Archived
A restored submodule whose pin is a branch tip ends on that branch instead of a detached HEAD: the local branch tracking the ref, else the ref created as a tracking branch, else another local branch, else another remote branch at the pin. No branch is moved; in-sync submodules are included (D28). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
619 lines
19 KiB
Go
619 lines
19 KiB
Go
package git
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"submarine/internal/submodule"
|
|
)
|
|
|
|
// restoreArgs are prepended to every git command of a restore: the user's
|
|
// submodule.recurse must not make checkout and reset recurse behind our back,
|
|
// the restore walks the submodules itself.
|
|
var restoreArgs = []string{"-c", "submodule.recurse=false"}
|
|
|
|
// maxWalk bounds the entries visited when looking for repositories inside an
|
|
// untracked directory.
|
|
const maxWalk = 20000
|
|
|
|
// gitlink is a submodule entry of a tree or the index.
|
|
type gitlink struct{ path, sha string }
|
|
|
|
// RestoreLoss reports what Restore(path) would discard. It walks the
|
|
// repositories on disk, not the loaded tree.
|
|
func (p *Provider) RestoreLoss(ctx context.Context, path string, to submodule.RestoreTarget) (submodule.Loss, error) {
|
|
dir, target, err := p.restoreTarget(ctx, path, to)
|
|
if err != nil {
|
|
return submodule.Loss{}, err
|
|
}
|
|
if dir == "" { // not initialized: nothing on disk to lose
|
|
return submodule.Loss{}, nil
|
|
}
|
|
return p.lossOf(ctx, dir, target), nil
|
|
}
|
|
|
|
// Restore makes path ("" is the superproject) and all submodules below it
|
|
// exactly what git records, see docs/actions.md. Staged pins are kept. With
|
|
// RestoreCommitted, a submodule's own staged pin is unstaged first. A missing
|
|
// submodule is cloned and missing commits are fetched; git never prompts.
|
|
func (p *Provider) Restore(ctx context.Context, path string, to submodule.RestoreTarget) error {
|
|
if path == "" {
|
|
return p.restoreRepo(ctx, p.root, "HEAD", nil)
|
|
}
|
|
p.mu.RLock()
|
|
in, ok := p.mods[path]
|
|
p.mu.RUnlock()
|
|
if !ok {
|
|
return fmt.Errorf("unknown submodule %q", path)
|
|
}
|
|
pin, err := pinOf(ctx, in, to)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if to == submodule.RestoreCommitted {
|
|
if _, err := p.exec(ctx, in.superDir, nil, "update-index", "--cacheinfo", "160000,"+pin+","+in.relPath); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if !initialized(in.dir) {
|
|
if err := p.initSubmodules(ctx, in.superDir, []string{in.relPath}); err != nil {
|
|
return fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
}
|
|
return p.restoreRepo(ctx, in.dir, pin, &in)
|
|
}
|
|
|
|
// restoreTarget returns the directory and the commit path is restored to:
|
|
// HEAD for the superproject, else the pin to. The directory is "" if the
|
|
// submodule is not initialized.
|
|
func (p *Provider) restoreTarget(ctx context.Context, path string, to submodule.RestoreTarget) (dir, target string, err error) {
|
|
if path == "" {
|
|
return p.root, "HEAD", nil
|
|
}
|
|
p.mu.RLock()
|
|
in, ok := p.mods[path]
|
|
p.mu.RUnlock()
|
|
if !ok {
|
|
return "", "", fmt.Errorf("unknown submodule %q", path)
|
|
}
|
|
pin, err := pinOf(ctx, in, to)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if !initialized(in.dir) {
|
|
return "", pin, nil
|
|
}
|
|
return in.dir, pin, nil
|
|
}
|
|
|
|
// pinOf returns the gitlink of a submodule in its parent's index (staged)
|
|
// or HEAD (committed).
|
|
func pinOf(ctx context.Context, in info, to submodule.RestoreTarget) (string, error) {
|
|
if to == submodule.RestoreCommitted {
|
|
out, _, err := try(ctx, in.superDir, "ls-tree", "HEAD", "--", in.relPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if pin := parseGitlink(out); pin != "" {
|
|
return pin, nil
|
|
}
|
|
return "", errors.New("has no committed pin (added, but not committed yet)")
|
|
}
|
|
out, err := run(ctx, in.superDir, "ls-files", "-s", "--", in.relPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
pin := parseGitlink(out)
|
|
if pin == "" {
|
|
return "", errors.New("has no gitlink in the superproject index (stale .gitmodules entry?)")
|
|
}
|
|
return pin, nil
|
|
}
|
|
|
|
// --- restore -----------------------------------------------------------------
|
|
|
|
// restoreRepo restores the initialized repository at dir to target (a commit
|
|
// or "HEAD") and then every submodule its index records. Branches are never
|
|
// moved: a checkout that is not on target is detached first. If the checkout
|
|
// stays on target, its staged pins are kept and are the submodules' targets.
|
|
// For a submodule (in is not nil) a detached HEAD is then put on a branch at
|
|
// target if there is one, see attachBranch; the submodules below always are.
|
|
func (p *Provider) restoreRepo(ctx context.Context, dir, target string, in *info) error {
|
|
env := p.networkEnv()
|
|
g := func(args ...string) (string, error) {
|
|
return p.exec(ctx, dir, env, append(append([]string{}, restoreArgs...), args...)...)
|
|
}
|
|
|
|
sha, err := g("rev-parse", "--verify", "-q", target+"^{commit}")
|
|
if err != nil {
|
|
if sha, err = p.fetchMissing(ctx, dir, target); err != nil {
|
|
return fmt.Errorf("%s: %w", dir, err)
|
|
}
|
|
}
|
|
if gitDir, _, err := gitDirs(dir); err == nil {
|
|
p.clearOperations(ctx, dir, gitDir)
|
|
}
|
|
prev, _, _ := try(ctx, dir, "symbolic-ref", "--short", "-q", "HEAD") // for the ref, see attachBranch
|
|
var keep []gitlink
|
|
if head, err := g("rev-parse", "--verify", "-q", "HEAD"); err != nil || head != sha {
|
|
if _, err := g("checkout", "--force", "--detach", sha); err != nil {
|
|
return err
|
|
}
|
|
} else if keep, err = stagedPins(ctx, dir); err != nil {
|
|
return err
|
|
}
|
|
if _, err := g("reset", "--hard", "-q"); err != nil {
|
|
return err
|
|
}
|
|
for _, l := range keep {
|
|
if _, err := g("update-index", "--cacheinfo", "160000,"+l.sha+","+l.path); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// -ff also removes untracked repositories; ignored files stay
|
|
if _, err := g("clean", "-ffdq"); err != nil {
|
|
return err
|
|
}
|
|
if _, err := g("submodule", "sync", "-q"); err != nil {
|
|
return err
|
|
}
|
|
if in != nil {
|
|
if err := p.attachBranch(ctx, *in, sha, prev); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
links, err := indexGitlinks(ctx, dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var missing []string
|
|
for _, l := range links {
|
|
if !initialized(filepath.Join(dir, filepath.FromSlash(l.path))) {
|
|
missing = append(missing, l.path)
|
|
}
|
|
}
|
|
if len(missing) > 0 {
|
|
if err := p.initSubmodules(ctx, dir, missing); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// the .gitmodules branch of each child, for its ref
|
|
branches := map[string]string{}
|
|
if out, ok, err := try(ctx, dir, "config", "-f", ".gitmodules", "--get-regexp", `^submodule\..*\.(path|branch)$`); err != nil {
|
|
return err
|
|
} else if ok {
|
|
for _, m := range parseGitmodules(out) {
|
|
branches[path.Clean(m.path)] = m.branch
|
|
}
|
|
}
|
|
|
|
// the children are independent repositories
|
|
var wg sync.WaitGroup
|
|
errs := make([]error, len(links))
|
|
for i, l := range links {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
child := info{superDir: dir, dir: filepath.Join(dir, filepath.FromSlash(l.path)), relPath: l.path, branch: branches[l.path]}
|
|
if err := p.restoreRepo(ctx, child.dir, l.sha, &child); err != nil {
|
|
errs[i] = err
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// attachBranch puts the detached HEAD of the restored submodule in on a
|
|
// branch whose tip is sha, the first that git accepts of, in this order:
|
|
//
|
|
// 1. a local branch tracking the ref the UI shows (see resolve), or named
|
|
// like it;
|
|
// 2. the ref itself, as a new local branch tracking it, if no local branch
|
|
// has its name;
|
|
// 3. any other local branch, alphabetically;
|
|
// 4. any other remote branch whose name no local branch has, as a new
|
|
// tracking branch; the ref's remote first, then alphabetically.
|
|
//
|
|
// A HEAD that is on a branch stays there. No branch is moved: a local branch
|
|
// elsewhere is no candidate, and its remote branch neither. If nothing fits,
|
|
// HEAD stays detached; only a context error is returned. prev is the branch
|
|
// checked out before the restore; the ref depends on it.
|
|
func (p *Provider) attachBranch(ctx context.Context, in info, sha, prev string) error {
|
|
if _, onBranch, err := try(ctx, in.dir, "symbolic-ref", "-q", "HEAD"); err != nil || onBranch {
|
|
return err
|
|
}
|
|
out, ok, err := try(ctx, in.dir, "for-each-ref", "--points-at="+sha,
|
|
"--format=%(refname)%00%(upstream)%00%(symref)", "refs/heads", "refs/remotes")
|
|
if err != nil || !ok {
|
|
return err
|
|
}
|
|
var locals, remotes []string // alphabetical, as for-each-ref sorts them
|
|
upstreams := map[string]string{}
|
|
for _, line := range strings.Split(out, "\n") {
|
|
f := strings.Split(line, "\x00")
|
|
if len(f) != 3 || f[2] != "" { // a symref is origin/HEAD
|
|
continue
|
|
}
|
|
if name, ok := strings.CutPrefix(f[0], "refs/heads/"); ok {
|
|
locals = append(locals, name)
|
|
upstreams[name] = strings.TrimPrefix(f[1], "refs/remotes/")
|
|
} else if name, ok := strings.CutPrefix(f[0], "refs/remotes/"); ok {
|
|
remotes = append(remotes, name)
|
|
}
|
|
}
|
|
if len(locals)+len(remotes) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// the ref, if it is a remote branch
|
|
var ref string
|
|
containing, err := remoteContaining(ctx, in.dir, sha)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if r, source, _, err := resolve(ctx, in, sha, prev, containing); err != nil {
|
|
return err
|
|
} else if source != submodule.RefFromTag && source != submodule.RefFromSHA {
|
|
ref = r
|
|
}
|
|
|
|
localOf := func(remote string) string { _, l, _ := strings.Cut(remote, "/"); return l }
|
|
hasLocal := func(name string) (bool, error) {
|
|
_, ok, err := try(ctx, in.dir, "rev-parse", "--verify", "-q", "refs/heads/"+name)
|
|
return ok, err
|
|
}
|
|
type candidate struct{ local, remote string } // remote: create local, tracking it
|
|
var cands []candidate
|
|
addRemote := func(r string) error {
|
|
l := localOf(r)
|
|
if l == "" {
|
|
return nil
|
|
}
|
|
exists, err := hasLocal(l)
|
|
if err == nil && !exists {
|
|
cands = append(cands, candidate{l, r})
|
|
}
|
|
return err
|
|
}
|
|
if ref != "" {
|
|
for _, l := range locals {
|
|
if upstreams[l] == ref {
|
|
cands = append(cands, candidate{local: l})
|
|
}
|
|
}
|
|
if l := localOf(ref); slices.Contains(locals, l) {
|
|
cands = append(cands, candidate{local: l})
|
|
}
|
|
if slices.Contains(remotes, ref) {
|
|
if err := addRemote(ref); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
for _, l := range locals {
|
|
cands = append(cands, candidate{local: l})
|
|
}
|
|
preferred, _, _ := strings.Cut(ref, "/")
|
|
if preferred == "" {
|
|
if preferred, err = remoteName(ctx, in.dir); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
slices.SortStableFunc(remotes, func(a, b string) int {
|
|
ap, bp := strings.HasPrefix(a, preferred+"/"), strings.HasPrefix(b, preferred+"/")
|
|
switch {
|
|
case ap && !bp:
|
|
return -1
|
|
case bp && !ap:
|
|
return 1
|
|
}
|
|
return 0
|
|
})
|
|
for _, r := range remotes {
|
|
if r != ref {
|
|
if err := addRemote(r); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, c := range cands {
|
|
args := append(append([]string{}, restoreArgs...), "switch", "-q", c.local)
|
|
if c.remote != "" {
|
|
args = append(append([]string{}, restoreArgs...), "switch", "-q", "-c", c.local, "--track", c.remote)
|
|
}
|
|
if _, err := p.exec(ctx, in.dir, nil, args...); err == nil {
|
|
return nil
|
|
} else if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
// e.g. checked out in another worktree: try the next one
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// networkEnv is the environment of commands that may clone or fetch.
|
|
func (p *Provider) networkEnv() []string {
|
|
return append(append([]string{}, p.sshEnv...), p.extraEnv...)
|
|
}
|
|
|
|
// initSubmodules clones or checks out the given submodules of the repository
|
|
// at dir, whatever their active and update settings say (--checkout overrides
|
|
// update = none/rebase/merge, the explicit paths override inactive).
|
|
func (p *Provider) initSubmodules(ctx context.Context, dir string, rels []string) error {
|
|
ctx, cancel := context.WithTimeout(ctx, fetchTimeout*time.Duration(len(rels)))
|
|
defer cancel()
|
|
args := append(append([]string{}, restoreArgs...), "submodule", "update", "--init", "--checkout", "--force", "-q", "--")
|
|
_, err := p.exec(ctx, dir, p.networkEnv(), append(args, rels...)...)
|
|
return err
|
|
}
|
|
|
|
// exec runs git like runEnv, but counts against the concurrency limit. Hold
|
|
// it only for the command, never while recursing.
|
|
func (p *Provider) exec(ctx context.Context, dir string, env []string, args ...string) (string, error) {
|
|
select {
|
|
case p.sem <- struct{}{}:
|
|
defer func() { <-p.sem }()
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
}
|
|
return runEnv(ctx, dir, env, args...)
|
|
}
|
|
|
|
// fetchMissing fetches all remotes of dir to get target, and returns its SHA.
|
|
func (p *Provider) fetchMissing(ctx context.Context, dir, target string) (string, error) {
|
|
fctx, cancel := context.WithTimeout(ctx, fetchTimeout)
|
|
defer cancel()
|
|
// a failing fetch is reported with the missing commit below
|
|
_, ferr := runEnv(fctx, dir, p.networkEnv(), "fetch", "--all", "--no-recurse-submodules", "--quiet")
|
|
sha, ok, err := try(ctx, dir, "rev-parse", "--verify", "-q", target+"^{commit}")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !ok {
|
|
msg := fmt.Sprintf("commit %.7s is not available", target)
|
|
if ferr != nil {
|
|
msg += " (fetch failed: " + firstLine(ferr) + ")"
|
|
} else {
|
|
msg += " on any remote"
|
|
}
|
|
return "", errors.New(msg)
|
|
}
|
|
return sha, nil
|
|
}
|
|
|
|
func firstLine(err error) string {
|
|
var ge *gitError
|
|
msg := err.Error()
|
|
if errors.As(err, &ge) && ge.Stderr != "" {
|
|
msg = ge.Stderr
|
|
}
|
|
line, _, _ := strings.Cut(msg, "\n")
|
|
return strings.TrimPrefix(line, "fatal: ")
|
|
}
|
|
|
|
// clearOperations ends a merge, rebase, cherry-pick or revert in progress
|
|
// without moving any ref (--quit, not --abort).
|
|
func (p *Provider) clearOperations(ctx context.Context, dir, gitDir string) {
|
|
exists := func(name string) bool { _, err := os.Stat(filepath.Join(gitDir, name)); return err == nil }
|
|
if exists("rebase-merge") || exists("rebase-apply") {
|
|
_, _, _ = try(ctx, dir, "rebase", "--quit")
|
|
}
|
|
if exists("CHERRY_PICK_HEAD") || exists("sequencer") {
|
|
_, _, _ = try(ctx, dir, "cherry-pick", "--quit")
|
|
}
|
|
if exists("REVERT_HEAD") {
|
|
_, _, _ = try(ctx, dir, "revert", "--quit")
|
|
}
|
|
// a merge is ended by the reset --hard that follows
|
|
}
|
|
|
|
// treeGitlinks lists the submodules recorded in commit's tree, at any depth
|
|
// of directories.
|
|
func treeGitlinks(ctx context.Context, dir, commit string) ([]gitlink, error) {
|
|
out, err := run(ctx, dir, "ls-tree", "-r", "-z", commit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var links []gitlink
|
|
for _, rec := range strings.Split(out, "\x00") {
|
|
meta, path, ok := strings.Cut(rec, "\t")
|
|
if f := strings.Fields(meta); ok && len(f) == 3 && f[0] == "160000" {
|
|
links = append(links, gitlink{path: path, sha: f[2]})
|
|
}
|
|
}
|
|
return links, nil
|
|
}
|
|
|
|
// indexGitlinks lists the submodules in the index of the repository at dir.
|
|
func indexGitlinks(ctx context.Context, dir string) ([]gitlink, error) {
|
|
out, err := run(ctx, dir, "ls-files", "-s", "-z")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var links []gitlink
|
|
for _, rec := range strings.Split(out, "\x00") {
|
|
meta, path, ok := strings.Cut(rec, "\t")
|
|
if f := strings.Fields(meta); ok && len(f) == 3 && f[0] == "160000" && f[2] == "0" {
|
|
links = append(links, gitlink{path: path, sha: f[1]})
|
|
}
|
|
}
|
|
return links, nil
|
|
}
|
|
|
|
// stagedPins lists the gitlinks of the index of dir that differ from HEAD's,
|
|
// i.e. staged pins of submodules that HEAD has. A submodule that was added
|
|
// but not committed is not one: its .gitmodules entry is not kept either.
|
|
func stagedPins(ctx context.Context, dir string) ([]gitlink, error) {
|
|
index, err := indexGitlinks(ctx, dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tree, err := treeGitlinks(ctx, dir, "HEAD")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
committed := map[string]string{}
|
|
for _, l := range tree {
|
|
committed[l.path] = l.sha
|
|
}
|
|
var staged []gitlink
|
|
for _, l := range index {
|
|
if c, ok := committed[l.path]; ok && c != l.sha {
|
|
staged = append(staged, l)
|
|
}
|
|
}
|
|
return staged, nil
|
|
}
|
|
|
|
// --- loss --------------------------------------------------------------------
|
|
|
|
// lossOf adds up what a restore of the repository at dir to target
|
|
// discards, including the submodules on disk below it. target is "" if it is
|
|
// not known; then commits are only judged by the refs that reach them.
|
|
func (p *Provider) lossOf(ctx context.Context, dir, target string) submodule.Loss {
|
|
var own submodule.Loss
|
|
var children []gitlink
|
|
var targets map[string]string
|
|
unknown := func() { own.Unknown++ }
|
|
|
|
func() {
|
|
select {
|
|
case p.sem <- struct{}{}:
|
|
defer func() { <-p.sem }()
|
|
case <-ctx.Done():
|
|
unknown()
|
|
return
|
|
}
|
|
|
|
out, err := run(ctx, dir, "status", "--porcelain=v2", "-z", "--untracked-files=normal", "--ignore-submodules=all")
|
|
if err != nil {
|
|
unknown()
|
|
return
|
|
}
|
|
for _, e := range parseStatus(out) {
|
|
if e.submodule {
|
|
continue // restored without loss
|
|
}
|
|
own.Files++
|
|
if e.kind == submodule.ChangeUntracked && strings.HasSuffix(e.path, "/") {
|
|
own.Repositories += standaloneRepos(ctx, filepath.Join(dir, filepath.FromSlash(e.path)))
|
|
}
|
|
}
|
|
|
|
if gitDir, _, err := gitDirs(dir); err == nil {
|
|
for _, name := range []string{"rebase-merge", "rebase-apply", "MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "sequencer"} {
|
|
if _, err := os.Stat(filepath.Join(gitDir, name)); err == nil {
|
|
own.Operations++
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// commits only this checkout reaches are lost if the checkout moves
|
|
head, err := run(ctx, dir, "rev-parse", "--verify", "-q", "HEAD")
|
|
if err != nil {
|
|
unknown()
|
|
} else {
|
|
moves := target == ""
|
|
if !moves {
|
|
if sha, ok, _ := try(ctx, dir, "rev-parse", "--verify", "-q", target+"^{commit}"); ok {
|
|
moves = sha != head
|
|
} else {
|
|
moves = true
|
|
}
|
|
}
|
|
if moves {
|
|
n, ok, _ := try(ctx, dir, "rev-list", "--count", "HEAD", "--not", "--branches", "--tags", "--remotes")
|
|
if ok {
|
|
fmt.Sscan(n, &own.Commits)
|
|
} else {
|
|
unknown()
|
|
}
|
|
}
|
|
|
|
// staged pins are kept, unless the checkout moves away from
|
|
// the commit they were staged on; then the submodules follow
|
|
// the target's tree
|
|
staged, err := stagedPins(ctx, dir)
|
|
if err != nil {
|
|
unknown()
|
|
}
|
|
var links []gitlink
|
|
switch {
|
|
case !moves:
|
|
links, err = indexGitlinks(ctx, dir)
|
|
case target != "":
|
|
own.StagedPins = len(staged)
|
|
links, err = treeGitlinks(ctx, dir, target)
|
|
default:
|
|
own.StagedPins = len(staged)
|
|
}
|
|
if err == nil && links != nil {
|
|
targets = map[string]string{}
|
|
for _, l := range links {
|
|
targets[l.path] = l.sha
|
|
}
|
|
}
|
|
}
|
|
|
|
if children, err = indexGitlinks(ctx, dir); err != nil {
|
|
unknown()
|
|
}
|
|
}()
|
|
|
|
if own.Files+own.Operations+own.Commits+own.Repositories+own.StagedPins+own.Unknown > 0 {
|
|
own.Modules = 1
|
|
}
|
|
|
|
// the children on disk, concurrently
|
|
results := make([]submodule.Loss, len(children))
|
|
var wg sync.WaitGroup
|
|
for i, c := range children {
|
|
sub := filepath.Join(dir, filepath.FromSlash(c.path))
|
|
if !initialized(sub) {
|
|
continue
|
|
}
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
results[i] = p.lossOf(ctx, sub, targets[c.path])
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
for _, r := range results {
|
|
own = own.Add(r)
|
|
}
|
|
return own
|
|
}
|
|
|
|
// standaloneRepos counts the repositories with their own .git directory in
|
|
// the untracked directory dir (or dir itself). An absorbed submodule leaves
|
|
// only a .git file; its commits live on in .git/modules.
|
|
func standaloneRepos(ctx context.Context, dir string) int {
|
|
n, seen := 0, 0
|
|
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
|
if seen++; err != nil || seen > maxWalk || ctx.Err() != nil {
|
|
return filepath.SkipAll
|
|
}
|
|
if d.IsDir() && d.Name() == ".git" {
|
|
n++
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
})
|
|
return n
|
|
}
|