From 8bfad3c346019a220e0b803635b249107436df4e Mon Sep 17 00:00:00 2001 From: abhinavgautam01 Date: Fri, 6 Nov 2026 17:48:31 +0530 Subject: [PATCH 1/3] Bound recursive submodule updates by depth and module count --- README.md | 2 +- ensure.go | 137 ++++++++++++++++++++++++++++++++++++++++++++++--- ensure_test.go | 130 ++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 259 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 048ea96..a4c2c43 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ fmt.Println(clone.Head(ctx, dst)) Pass `true` as the final argument for a full clone, including when an existing shallow checkout needs to be unshallowed. Clone and fetch errors are returned as `*clone.UnreachableError`, except when the context was canceled or reached its deadline. `errors.As` retrieves the URL and underlying Git error. `ValidateURL` accepts `https://` URLs, while `ValidateRef` rejects leading hyphens, `..`, and characters outside letters, digits, `.`, `_`, `/`, and `-`. -Use `EnsureWithOptions` to include submodules. Submodules are initialized and updated recursively with depth 1 after both a new clone and an existing checkout update. This step is best-effort because submodules may be large or refer to unavailable URLs; failures leave the parent checkout usable. Context cancellation is still returned. Submodule URLs come from the cloned repository's `.gitmodules`, so submodules are fetched with the ambient credential helper disabled. Other ambient Git credentials are not disabled: an unscoped `http.extraHeader`, `GIT_ASKPASS` or `core.askPass` can still authenticate a repository-chosen submodule URL. Callers that fetch untrusted repositories with `RecurseSubmodules` should scope those credentials to trusted URLs or remove them. Callers that need authenticated submodules can supply credentials through the environment. +Use `EnsureWithOptions` to include submodules. Submodules are initialized and updated recursively with depth 1 after both a new clone and an existing checkout update. This step is best-effort because submodules may be large or refer to unavailable URLs; failures leave the parent checkout usable. Context cancellation is still returned. Recursion is bounded: submodules are fetched one nesting level at a time, at most 8 levels deep and 256 submodules in total. When the next level would exceed either limit, the submodule step stops there; submodules already fetched are kept and `Submodules` reports the rest as unavailable. Submodule URLs come from the cloned repository's `.gitmodules`, so submodules are fetched with the ambient credential helper disabled. Other ambient Git credentials are not disabled: an unscoped `http.extraHeader`, `GIT_ASKPASS` or `core.askPass` can still authenticate a repository-chosen submodule URL. Callers that fetch untrusted repositories with `RecurseSubmodules` should scope those credentials to trusted URLs or remove them. Callers that need authenticated submodules can supply credentials through the environment. ```go err := clone.EnsureWithOptions(ctx, clone.Retry{}, url, dst, "main", diff --git a/ensure.go b/ensure.go index cd0cea6..20f9c33 100644 --- a/ensure.go +++ b/ensure.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" ) @@ -99,23 +100,88 @@ func ensure(ctx context.Context, retry Retry, url, dst, ref string, options Ensu return updateSubmodules(ctx, retry, dst, options.RecurseSubmodules) } -// Submodule URLs come from the cloned .gitmodules, so the ambient credential helper is disabled. +// Limits on recursive submodule updates, so nested or repeated gitlinks in a +// compact history cannot expand into an unbounded number of checkouts. +const ( + maxSubmoduleDepth = 8 + maxSubmodules = 256 +) + +type submoduleBudget struct { + maxDepth int + maxModules int +} + func updateSubmodules(ctx context.Context, retry Retry, dst string, enabled bool) error { if !enabled { return nil } + return updateSubmodulesWithin(ctx, retry, dst, submoduleBudget{ + maxDepth: maxSubmoduleDepth, + maxModules: maxSubmodules, + }) +} + +// updateSubmodulesWithin walks submodules breadth first, one level per Git +// call, instead of using --recursive. Each level's gitlinks are counted before +// it is fetched, and the walk stops before exceeding the budget, keeping the +// levels already fetched. Only context errors are returned. +func updateSubmodulesWithin(ctx context.Context, retry Retry, dst string, budget submoduleBudget) error { + type pending struct { + dir string + depth int // depth of the submodules declared in dir + } policy := retry.Resolved() - syncArgs := longPathArgs("-c", "credential.helper=", "submodule", "sync", "--recursive") - if _, err := policy.Run(ctx, dst, remoteEnv(), syncArgs...); err != nil { + queue := []pending{{dir: dst, depth: 1}} + fetched := 0 + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + if current.depth > budget.maxDepth { + return nil + } + paths, err := submoduleGitlinks(ctx, policy, current.dir) + if err != nil { + return err + } + if len(paths) == 0 { + continue + } + if fetched+len(paths) > budget.maxModules { + return nil + } + fetched += len(paths) + if err := updateSubmoduleLevel(ctx, policy, current.dir, paths); err != nil { + return err + } + for _, submodulePath := range paths { + child := pathFromGit(current.dir, submodulePath) + if initializedSubmodule(child) { + queue = append(queue, pending{dir: child, depth: current.depth + 1}) + } + } + } + return nil +} + +// updateSubmoduleLevel syncs and updates exactly the given submodules of dir. +// Submodule URLs come from the cloned .gitmodules, so the ambient credential +// helper is disabled. Literal pathspecs keep a path from matching more +// submodules than were counted. +func updateSubmoduleLevel(ctx context.Context, policy Retry, dir string, paths []string) error { + env := append(remoteEnv(), "GIT_LITERAL_PATHSPECS=1") + syncArgs := append([]string{"-c", "credential.helper=", "submodule", "sync", "--"}, paths...) + if _, err := policy.Run(ctx, dir, env, longPathArgs(syncArgs...)...); err != nil { if ctxErr := ctx.Err(); ctxErr != nil { return ctxErr } } + updateArgs := append([]string{"-c", "credential.helper=", "submodule", "update", "--init", "--depth", "1", "--"}, paths...) if _, err := policy.Do(ctx, Command{ Label: "submodule", - Dir: dst, - Env: remoteEnv(), - Args: longPathArgs("-c", "credential.helper=", "submodule", "update", "--init", "--recursive", "--depth", "1"), + Dir: dir, + Env: env, + Args: longPathArgs(updateArgs...), }); err != nil { if ctxErr := ctx.Err(); ctxErr != nil { return ctxErr @@ -124,6 +190,65 @@ func updateSubmodules(ctx context.Context, retry Retry, dst string, enabled bool return nil } +// submoduleGitlinks returns the sorted paths that dir's .gitmodules declares +// and HEAD records as gitlinks: the submodules one update of dir can fetch. +// A missing or unreadable .gitmodules yields no paths. +func submoduleGitlinks(ctx context.Context, policy Retry, dir string) ([]string, error) { + out, err := policy.Run(ctx, dir, nil, + "config", "-z", "--blob", gitmodulesBlob, "--get-regexp", `^submodule\..*\.path$`, + ) + if err != nil { + return nil, ctx.Err() + } + declared := map[string]bool{} + var candidates []string + for entry := range strings.SplitSeq(strings.TrimSuffix(out, "\x00"), "\x00") { + _, value, ok := strings.Cut(entry, "\n") + if !ok { + continue + } + submodulePath, ok := SanitizePath(value) + if !ok || declared[submodulePath] { + continue + } + declared[submodulePath] = true + candidates = append(candidates, submodulePath) + } + if len(candidates) == 0 { + return nil, nil + } + + env := []string{"GIT_LITERAL_PATHSPECS=1"} + out, err = policy.Run(ctx, dir, env, append([]string{"ls-tree", "-z", "HEAD", "--"}, candidates...)...) + if err != nil { + return nil, ctx.Err() + } + var paths []string + for entry := range strings.SplitSeq(strings.TrimSuffix(out, "\x00"), "\x00") { + metadata, treePath, ok := strings.Cut(entry, "\t") + if !ok || !declared[treePath] { + continue + } + fields := strings.Fields(metadata) + if len(fields) == 3 && fields[0] == "160000" && fields[1] == "commit" && ValidCommit(fields[2]) { + paths = append(paths, treePath) + } + } + sort.Strings(paths) + return paths, nil +} + +// initializedSubmodule reports whether dir is a real directory holding a +// submodule checkout, so the walk never follows a symlink out of the tree. +func initializedSubmodule(dir string) bool { + info, err := os.Lstat(dir) + if err != nil || !info.IsDir() { + return false + } + _, err = os.Lstat(filepath.Join(dir, ".git")) + return err == nil +} + // fetchRef takes url only so it can be pinned against ambient insteadOf // rewriting: the fetch addresses the remote by name, but Git resolves origin's // stored URL -- the validated one clone recorded -- through the same rules. diff --git a/ensure_test.go b/ensure_test.go index 4ae5cda..a666b68 100644 --- a/ensure_test.go +++ b/ensure_test.go @@ -251,6 +251,20 @@ func TestEnsureWithOptionsDoesNotUseCredentialHelperForSubmodules(t *testing.T) } } +const fakeGitlink = "0123456789abcdef0123456789abcdef01234567" + +// fakeSubmoduleListing answers the local Git queries updateSubmodulesWithin +// makes before fetching, declaring one gitlink at vendor/library. +func fakeSubmoduleListing(args []string) (string, bool) { + switch subcommand(args) { + case "config": + return "submodule.library.path\nvendor/library\x00", true + case "ls-tree": + return "160000 commit " + fakeGitlink + "\tvendor/library\x00", true + } + return "", false +} + func TestEnsureWithOptionsIgnoresSubmoduleFailure(t *testing.T) { dst := filepath.Join(t.TempDir(), "checkout") if err := os.MkdirAll(filepath.Join(dst, ".git"), 0o755); err != nil { @@ -264,6 +278,9 @@ func TestEnsureWithOptionsIgnoresSubmoduleFailure(t *testing.T) { retry := Retry{ Attempts: 1, Run: func(_ context.Context, dir string, env []string, args ...string) (string, error) { + if out, ok := fakeSubmoduleListing(args); ok { + return out, nil + } switch subcommand(args) { case "fetch", "reset": return "", nil @@ -292,20 +309,20 @@ func TestEnsureWithOptionsIgnoresSubmoduleFailure(t *testing.T) { t.Fatalf("EnsureWithOptions: %v", err) } wantSyncArgs := []string{ - "-c", "core.longpaths=true", "-c", "credential.helper=", "submodule", "sync", "--recursive", + "-c", "core.longpaths=true", "-c", "credential.helper=", "submodule", "sync", "--", "vendor/library", } if !slices.Equal(syncArgs, wantSyncArgs) { t.Errorf("submodule sync args = %v, want %v", syncArgs, wantSyncArgs) } wantUpdateArgs := []string{ "-c", "core.longpaths=true", "-c", "credential.helper=", - "submodule", "update", "--init", "--recursive", "--depth", "1", + "submodule", "update", "--init", "--depth", "1", "--", "vendor/library", } if !slices.Equal(updateArgs, wantUpdateArgs) { t.Errorf("submodule update args = %v, want %v", updateArgs, wantUpdateArgs) } for name, env := range map[string][]string{"sync": syncEnv, "update": updateEnv} { - if !slices.Contains(env, "GIT_PROTOCOL_FROM_USER=0") { + if !slices.Contains(env, "GIT_PROTOCOL_FROM_USER=0") || !slices.Contains(env, "GIT_LITERAL_PATHSPECS=1") { t.Errorf("submodule %s env = %v", name, env) } } @@ -321,6 +338,9 @@ func TestEnsureWithOptionsReturnsSubmoduleCancellation(t *testing.T) { retry := Retry{ Attempts: 1, Run: func(_ context.Context, _ string, _ []string, args ...string) (string, error) { + if out, ok := fakeSubmoduleListing(args); ok { + return out, nil + } if subcommand(args) == "submodule" { cancel() return "submodule canceled", errGitExit @@ -472,3 +492,107 @@ func TestHeadReturnsEmptyOutsideRepository(t *testing.T) { t.Errorf("Head = %q, want empty", got) } } + +// ensureWithoutSubmodules clones url into a fresh directory without touching +// its submodules, so a test can drive updateSubmodulesWithin directly. +func ensureWithoutSubmodules(t *testing.T, url string) string { + t.Helper() + dst := filepath.Join(t.TempDir(), "checkout") + if err := Ensure(context.Background(), Retry{}, url, dst, "", false); err != nil { + t.Fatalf("Ensure: %v", err) + } + return dst +} + +func assertCheckedOut(t *testing.T, dst string, want map[string]bool) { + t.Helper() + for name, present := range want { + _, err := os.Stat(filepath.Join(dst, filepath.FromSlash(name))) + if present && err != nil { + t.Errorf("%s missing: %v", name, err) + } + if !present && !os.IsNotExist(err) { + t.Errorf("%s exists beyond the submodule budget (err %v)", name, err) + } + } +} + +func addTestSubmodule(t *testing.T, parentDir, url, submodulePath string) { + t.Helper() + runGitTest(t, parentDir, "submodule", "add", "--quiet", url, submodulePath) + runGitTest(t, parentDir, "commit", "--quiet", "-m", "add "+submodulePath) +} + +func TestUpdateSubmodulesWithinStopsAtDepthLimit(t *testing.T) { + requireGit(t) + + // top -> a -> b -> c, each nested at "sub". + top := newTestRepository(t, "top.txt") + a := newTestRepository(t, "a.txt") + b := newTestRepository(t, "b.txt") + c := newTestRepository(t, "c.txt") + const ( + topURL = "https://clone.test/top.git" + aURL = "https://clone.test/a.git" + bURL = "https://clone.test/b.git" + cURL = "https://clone.test/c.git" + ) + configureTestURLs(t, map[string]string{topURL: top.dir, aURL: a.dir, bURL: b.dir, cURL: c.dir}) + addTestSubmodule(t, b.dir, cURL, "sub") + addTestSubmodule(t, a.dir, bURL, "sub") + addTestSubmodule(t, top.dir, aURL, "sub") + + for _, test := range []struct { + maxDepth int + want map[string]bool + }{ + {1, map[string]bool{"sub/a.txt": true, "sub/sub/b.txt": false, "sub/sub/sub/c.txt": false}}, + {2, map[string]bool{"sub/a.txt": true, "sub/sub/b.txt": true, "sub/sub/sub/c.txt": false}}, + {3, map[string]bool{"sub/a.txt": true, "sub/sub/b.txt": true, "sub/sub/sub/c.txt": true}}, + } { + dst := ensureWithoutSubmodules(t, topURL) + budget := submoduleBudget{maxDepth: test.maxDepth, maxModules: maxSubmodules} + if err := updateSubmodulesWithin(context.Background(), Retry{}, dst, budget); err != nil { + t.Fatalf("maxDepth %d: %v", test.maxDepth, err) + } + assertCheckedOut(t, dst, test.want) + } +} + +func TestUpdateSubmodulesWithinStopsAtModuleLimit(t *testing.T) { + requireGit(t) + + // top declares x and y; x declares z. + top := newTestRepository(t, "top.txt") + x := newTestRepository(t, "x.txt") + y := newTestRepository(t, "y.txt") + z := newTestRepository(t, "z.txt") + const ( + topURL = "https://clone.test/top.git" + xURL = "https://clone.test/x.git" + yURL = "https://clone.test/y.git" + zURL = "https://clone.test/z.git" + ) + configureTestURLs(t, map[string]string{topURL: top.dir, xURL: x.dir, yURL: y.dir, zURL: z.dir}) + addTestSubmodule(t, x.dir, zURL, "z") + addTestSubmodule(t, top.dir, xURL, "x") + addTestSubmodule(t, top.dir, yURL, "y") + + for _, test := range []struct { + maxModules int + want map[string]bool + }{ + // A level that would exceed the budget is not fetched at all. + {1, map[string]bool{"x/x.txt": false, "y/y.txt": false, "x/z/z.txt": false}}, + // Levels already fetched are kept when a later level would exceed it. + {2, map[string]bool{"x/x.txt": true, "y/y.txt": true, "x/z/z.txt": false}}, + {3, map[string]bool{"x/x.txt": true, "y/y.txt": true, "x/z/z.txt": true}}, + } { + dst := ensureWithoutSubmodules(t, topURL) + budget := submoduleBudget{maxDepth: maxSubmoduleDepth, maxModules: test.maxModules} + if err := updateSubmodulesWithin(context.Background(), Retry{}, dst, budget); err != nil { + t.Fatalf("maxModules %d: %v", test.maxModules, err) + } + assertCheckedOut(t, dst, test.want) + } +} From 72dcd3b5a3edfb94c07108d46bf6632449157ab0 Mon Sep 17 00:00:00 2001 From: abhinavgautam01 Date: Fri, 6 Nov 2026 20:50:46 +0530 Subject: [PATCH 2/3] Only descend into submodules checked out at their gitlink --- ensure.go | 73 +++++++++++++++++++++++++++++++++++--------------- ensure_test.go | 55 +++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 22 deletions(-) diff --git a/ensure.go b/ensure.go index 20f9c33..c212ec0 100644 --- a/ensure.go +++ b/ensure.go @@ -112,6 +112,7 @@ type submoduleBudget struct { maxModules int } +// updateSubmodules fetches dst's submodules within the default limits when enabled. func updateSubmodules(ctx context.Context, retry Retry, dst string, enabled bool) error { if !enabled { return nil @@ -125,7 +126,8 @@ func updateSubmodules(ctx context.Context, retry Retry, dst string, enabled bool // updateSubmodulesWithin walks submodules breadth first, one level per Git // call, instead of using --recursive. Each level's gitlinks are counted before // it is fetched, and the walk stops before exceeding the budget, keeping the -// levels already fetched. Only context errors are returned. +// levels already fetched. Like git's own --recursive, it descends only into +// submodules checked out at their gitlink. Only context errors are returned. func updateSubmodulesWithin(ctx context.Context, retry Retry, dst string, budget submoduleBudget) error { type pending struct { dir string @@ -140,23 +142,31 @@ func updateSubmodulesWithin(ctx context.Context, retry Retry, dst string, budget if current.depth > budget.maxDepth { return nil } - paths, err := submoduleGitlinks(ctx, policy, current.dir) + links, err := submoduleGitlinks(ctx, policy, current.dir) if err != nil { return err } - if len(paths) == 0 { + if len(links) == 0 { continue } - if fetched+len(paths) > budget.maxModules { + if fetched+len(links) > budget.maxModules { return nil } - fetched += len(paths) + fetched += len(links) + paths := make([]string, len(links)) + for i, link := range links { + paths[i] = link.path + } if err := updateSubmoduleLevel(ctx, policy, current.dir, paths); err != nil { return err } - for _, submodulePath := range paths { - child := pathFromGit(current.dir, submodulePath) - if initializedSubmodule(child) { + for _, link := range links { + child := pathFromGit(current.dir, link.path) + ok, err := checkedOutAt(ctx, policy, child, link.commit) + if err != nil { + return err + } + if ok { queue = append(queue, pending{dir: child, depth: current.depth + 1}) } } @@ -190,10 +200,15 @@ func updateSubmoduleLevel(ctx context.Context, policy Retry, dir string, paths [ return nil } -// submoduleGitlinks returns the sorted paths that dir's .gitmodules declares -// and HEAD records as gitlinks: the submodules one update of dir can fetch. -// A missing or unreadable .gitmodules yields no paths. -func submoduleGitlinks(ctx context.Context, policy Retry, dir string) ([]string, error) { +type gitlink struct { + path string + commit string +} + +// submoduleGitlinks returns the paths that dir's .gitmodules declares and HEAD +// records as gitlinks, sorted by path: the submodules one update of dir can +// fetch. A missing or unreadable .gitmodules yields none. +func submoduleGitlinks(ctx context.Context, policy Retry, dir string) ([]gitlink, error) { out, err := policy.Run(ctx, dir, nil, "config", "-z", "--blob", gitmodulesBlob, "--get-regexp", `^submodule\..*\.path$`, ) @@ -223,7 +238,7 @@ func submoduleGitlinks(ctx context.Context, policy Retry, dir string) ([]string, if err != nil { return nil, ctx.Err() } - var paths []string + var links []gitlink for entry := range strings.SplitSeq(strings.TrimSuffix(out, "\x00"), "\x00") { metadata, treePath, ok := strings.Cut(entry, "\t") if !ok || !declared[treePath] { @@ -231,22 +246,36 @@ func submoduleGitlinks(ctx context.Context, policy Retry, dir string) ([]string, } fields := strings.Fields(metadata) if len(fields) == 3 && fields[0] == "160000" && fields[1] == "commit" && ValidCommit(fields[2]) { - paths = append(paths, treePath) + links = append(links, gitlink{path: treePath, commit: fields[2]}) } } - sort.Strings(paths) - return paths, nil + sort.Slice(links, func(i, j int) bool { + return links[i].path < links[j].path + }) + return links, nil } -// initializedSubmodule reports whether dir is a real directory holding a -// submodule checkout, so the walk never follows a symlink out of the tree. -func initializedSubmodule(dir string) bool { +// checkedOutAt reports whether dir is a real directory holding a submodule +// checkout whose HEAD is commit. Rejecting symlinks keeps the walk inside the +// tree; the ceiling stops an uninitialized directory from resolving to its +// parent's HEAD. +func checkedOutAt(ctx context.Context, policy Retry, dir, commit string) (bool, error) { info, err := os.Lstat(dir) if err != nil || !info.IsDir() { - return false + return false, nil + } + if _, err := os.Lstat(filepath.Join(dir, ".git")); err != nil { + return false, nil + } + ceiling, err := filepath.Abs(filepath.Dir(dir)) + if err != nil { + return false, nil + } + out, err := policy.Run(ctx, dir, []string{"GIT_CEILING_DIRECTORIES=" + ceiling}, "rev-parse", "HEAD") + if err != nil { + return false, ctx.Err() } - _, err = os.Lstat(filepath.Join(dir, ".git")) - return err == nil + return strings.TrimSpace(out) == commit, nil } // fetchRef takes url only so it can be pinned against ambient insteadOf diff --git a/ensure_test.go b/ensure_test.go index a666b68..d222241 100644 --- a/ensure_test.go +++ b/ensure_test.go @@ -504,6 +504,7 @@ func ensureWithoutSubmodules(t *testing.T, url string) string { return dst } +// assertCheckedOut checks which files exist under dst, keyed by slash path. func assertCheckedOut(t *testing.T, dst string, want map[string]bool) { t.Helper() for name, present := range want { @@ -596,3 +597,57 @@ func TestUpdateSubmodulesWithinStopsAtModuleLimit(t *testing.T) { assertCheckedOut(t, dst, test.want) } } + +func TestUpdateSubmodulesWithinSkipsCheckoutNotAtGitlink(t *testing.T) { + requireGit(t) + + // top -> a -> b, each nested at "sub". + top := newTestRepository(t, "top.txt") + a := newTestRepository(t, "a.txt") + b := newTestRepository(t, "b.txt") + const ( + topURL = "https://clone.test/top.git" + aURL = "https://clone.test/a.git" + bURL = "https://clone.test/b.git" + ) + configureTestURLs(t, map[string]string{topURL: top.dir, aURL: a.dir, bURL: b.dir}) + addTestSubmodule(t, a.dir, bURL, "sub") + addTestSubmodule(t, top.dir, aURL, "sub") + + dst := filepath.Join(t.TempDir(), "checkout") + ctx := context.Background() + options := EnsureOptions{RecurseSubmodules: true} + if err := EnsureWithOptions(ctx, Retry{}, topURL, dst, "", options); err != nil { + t.Fatalf("first EnsureWithOptions: %v", err) + } + assertCheckedOut(t, dst, map[string]bool{"sub/a.txt": true, "sub/sub/b.txt": true}) + + // Point top's gitlink at a commit a's remote does not have, so updating + // the existing checkout of a fails and leaves it at its old HEAD. + missing := "0123456789abcdef0123456789abcdef01234567" + runGitTest(t, top.dir, "update-index", "--cacheinfo", "160000,"+missing+",sub") + runGitTest(t, top.dir, "commit", "--quiet", "-m", "point sub at a missing commit") + + stale := pathFromGit(dst, "sub") + var staleCommands [][]string + retry := Retry{ + Attempts: 1, + Run: func(ctx context.Context, dir string, env []string, args ...string) (string, error) { + if dir == stale { + staleCommands = append(staleCommands, args) + } + return Run(ctx, dir, env, args...) + }, + } + if err := EnsureWithOptions(ctx, retry, topURL, dst, "", options); err != nil { + t.Fatalf("second EnsureWithOptions: %v", err) + } + for _, args := range staleCommands { + if subcommand(args) != "rev-parse" { + t.Errorf("walked into checkout not at its gitlink: git %v", args) + } + } + if len(staleCommands) == 0 { + t.Error("stale checkout HEAD was never compared with its gitlink") + } +} From ca78b26866b066a507a21de97ce1c7c19f57a3eb Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sat, 7 Nov 2026 14:47:37 +0000 Subject: [PATCH 3/3] Describe the submodule count limit as per repository --- README.md | 2 +- ensure.go | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a4c2c43..5d8c6fc 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ fmt.Println(clone.Head(ctx, dst)) Pass `true` as the final argument for a full clone, including when an existing shallow checkout needs to be unshallowed. Clone and fetch errors are returned as `*clone.UnreachableError`, except when the context was canceled or reached its deadline. `errors.As` retrieves the URL and underlying Git error. `ValidateURL` accepts `https://` URLs, while `ValidateRef` rejects leading hyphens, `..`, and characters outside letters, digits, `.`, `_`, `/`, and `-`. -Use `EnsureWithOptions` to include submodules. Submodules are initialized and updated recursively with depth 1 after both a new clone and an existing checkout update. This step is best-effort because submodules may be large or refer to unavailable URLs; failures leave the parent checkout usable. Context cancellation is still returned. Recursion is bounded: submodules are fetched one nesting level at a time, at most 8 levels deep and 256 submodules in total. When the next level would exceed either limit, the submodule step stops there; submodules already fetched are kept and `Submodules` reports the rest as unavailable. Submodule URLs come from the cloned repository's `.gitmodules`, so submodules are fetched with the ambient credential helper disabled. Other ambient Git credentials are not disabled: an unscoped `http.extraHeader`, `GIT_ASKPASS` or `core.askPass` can still authenticate a repository-chosen submodule URL. Callers that fetch untrusted repositories with `RecurseSubmodules` should scope those credentials to trusted URLs or remove them. Callers that need authenticated submodules can supply credentials through the environment. +Use `EnsureWithOptions` to include submodules. Submodules are initialized and updated recursively with depth 1 after both a new clone and an existing checkout update. This step is best-effort because submodules may be large or refer to unavailable URLs; failures leave the parent checkout usable. Context cancellation is still returned. Recursion is bounded: submodules are fetched one nesting level at a time, at most 8 levels deep and 256 submodules in total. When fetching a repository's submodules would exceed either limit, the submodule step stops before fetching them; submodules already fetched are kept and `Submodules` reports the rest as unavailable. Submodule URLs come from the cloned repository's `.gitmodules`, so submodules are fetched with the ambient credential helper disabled. Other ambient Git credentials are not disabled: an unscoped `http.extraHeader`, `GIT_ASKPASS` or `core.askPass` can still authenticate a repository-chosen submodule URL. Callers that fetch untrusted repositories with `RecurseSubmodules` should scope those credentials to trusted URLs or remove them. Callers that need authenticated submodules can supply credentials through the environment. ```go err := clone.EnsureWithOptions(ctx, clone.Retry{}, url, dst, "main", diff --git a/ensure.go b/ensure.go index c212ec0..6a03968 100644 --- a/ensure.go +++ b/ensure.go @@ -123,11 +123,11 @@ func updateSubmodules(ctx context.Context, retry Retry, dst string, enabled bool }) } -// updateSubmodulesWithin walks submodules breadth first, one level per Git -// call, instead of using --recursive. Each level's gitlinks are counted before -// it is fetched, and the walk stops before exceeding the budget, keeping the -// levels already fetched. Like git's own --recursive, it descends only into -// submodules checked out at their gitlink. Only context errors are returned. +// updateSubmodulesWithin walks submodules breadth first, one repository's +// submodules per Git call. Each batch is counted before it is fetched, and the +// walk stops before exceeding the budget, keeping what was already fetched. +// Like git's own --recursive, it descends only into submodules checked out at +// their gitlink. Only context errors are returned. func updateSubmodulesWithin(ctx context.Context, retry Retry, dst string, budget submoduleBudget) error { type pending struct { dir string