Skip to content

Commit 0ed9ca7

Browse files
committed
fix(git): address R2 review feedback on submodule support
- Fix swapped git.Open arguments at lines 112 and 136. The storer was backed by the worktree root and the worktree arg was the .git/ filesystem. This made every workspace restart fail to detect the existing repo, fall through to CloneContext, and hit ErrRepositoryAlreadyExists. (AMREM-14) - Clean up orphaned .git directory when CloneContext fails in cloneSubmodule. Without this, one failed clone permanently poisons the submodule directory: the next Stat(".git") takes the already-cloned fast path and tries to Open a broken repo. (AMREM-16) - Use openSubmoduleRepo in the already-cloned branch of cloneSubmodule, removing the duplicate Chroot/NewStorage/Open sequence. (AMREM-25) - Rewrite extractHost to delegate to transport.NewEndpoint instead of reimplementing SCP URL parsing with a separate regex. (AMREM-24) - Include fetchErr in the "Direct fetch failed" log message so operators see why the targeted fetch was rejected. (AMREM-22) - Track warnings in initSubmodules and adjust the final log line so it does not claim all submodules initialized successfully when warnings were emitted. (AMREM-23) - Correct the SameHost doc comment: port is ignored as a simplification, not to match git credential helpers (which do include port). (AMREM-27) - Change "positive integer" to "non-negative integer (0 disables)" in SubmoduleDepth docs and error messages, matching the actual guard (n < 0). (AMREM-28) - Cap SubmoduleDepth at 100 to bound runaway recursion. (AMREM-31) - Document cross-protocol auth forwarding in submoduleAuthFor as intentional. go-git rejects incompatible auth at the transport layer. (AMREM-15) - Fix test logf to use fmt.Fprintf so format args are expanded and the warning check is not vacuously true. (AMREM-20) - Fix TestCloneRepo/AlreadyCloned to produce a repo in the layout CloneRepo expects (.git/ subdirectory) by cloning first, rather than using gittest.NewRepo which writes objects at the root. Skip the subtest when the test case expects the initial clone to fail. (AMREM-14 test fix)
1 parent 044c616 commit 0ed9ca7

6 files changed

Lines changed: 61 additions & 40 deletions

File tree

docs/env-variables.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
| `--git-clone-depth` | `ENVBUILDER_GIT_CLONE_DEPTH` | | The depth to use when cloning the Git repository. |
2929
| `--git-clone-single-branch` | `ENVBUILDER_GIT_CLONE_SINGLE_BRANCH` | | Clone only a single branch of the Git repository. |
3030
| `--git-clone-thinpack` | `ENVBUILDER_GIT_CLONE_THINPACK` | `true` | Git clone with thin pack compatibility enabled, ensuring that even when thin pack compatibility is activated,it will not be turned on for the domain dev.azure.com. |
31-
| `--git-clone-submodules` | `ENVBUILDER_GIT_CLONE_SUBMODULES` | | Clone Git submodules after cloning the repository. Accepts 'true' (max depth 10), 'false' (disabled), or a positive integer for max recursion depth. |
31+
| `--git-clone-submodules` | `ENVBUILDER_GIT_CLONE_SUBMODULES` | | Clone Git submodules after cloning the repository. Accepts 'true' (max depth 10), 'false' (disabled), or a non-negative integer for max recursion depth (0 disables, max 100). |
3232
| `--git-username` | `ENVBUILDER_GIT_USERNAME` | | The username to use for Git authentication. This is optional. |
3333
| `--git-password` | `ENVBUILDER_GIT_PASSWORD` | | The password to use for Git authentication. This is optional. |
3434
| `--git-ssh-private-key-path` | `ENVBUILDER_GIT_SSH_PRIVATE_KEY_PATH` | | Path to an SSH private key to be used for Git authentication. If this is set, then GIT_SSH_PRIVATE_KEY_BASE64 cannot be set. |

git/git.go

Lines changed: 27 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/coder/envbuilder/options"
1818

1919
"github.com/go-git/go-billy/v5"
20+
billyutil "github.com/go-git/go-billy/v5/util"
2021
"github.com/go-git/go-git/v5"
2122
"github.com/go-git/go-git/v5/config"
2223
"github.com/go-git/go-git/v5/plumbing"
@@ -108,8 +109,7 @@ func CloneRepo(ctx context.Context, logf func(string, ...any), opts CloneRepoOpt
108109
return false, fmt.Errorf("chroot .git: %w", err)
109110
}
110111
gitStorage := filesystem.NewStorage(gitDir, cache.NewObjectLRU(cache.DefaultMaxSize*10))
111-
fsStorage := filesystem.NewStorage(fs, cache.NewObjectLRU(cache.DefaultMaxSize*10))
112-
repo, err := git.Open(fsStorage, gitDir)
112+
repo, err := git.Open(gitStorage, fs)
113113
if errors.Is(err, git.ErrRepositoryNotExists) {
114114
err = nil
115115
}
@@ -133,7 +133,7 @@ func CloneRepo(ctx context.Context, logf func(string, ...any), opts CloneRepoOpt
133133
if errors.Is(err, git.ErrRepositoryAlreadyExists) {
134134
// The repository was created between our Open and CloneContext
135135
// calls. Reopen it so submodule initialization can still run.
136-
repo, err = git.Open(fsStorage, gitDir)
136+
repo, err = git.Open(gitStorage, fs)
137137
if err != nil {
138138
return false, fmt.Errorf("reopen existing %q: %w", opts.RepoURL, err)
139139
}
@@ -453,25 +453,17 @@ var scpLikeURLRegex = regexp.MustCompile(`^([^@]+)@([^:]+):(.+)$`)
453453
// extractHost extracts the host from a URL, handling both standard URLs and SCP-like URLs.
454454
// Returns empty string if host cannot be determined.
455455
func extractHost(u string) string {
456-
// Try standard URL parsing first
457-
if parsed, err := url.Parse(u); err == nil && parsed.Host != "" {
458-
// Remove port if present
459-
host := parsed.Hostname()
460-
return strings.ToLower(host)
456+
ep, err := transport.NewEndpoint(u)
457+
if err != nil || ep.Host == "" {
458+
return ""
461459
}
462-
463-
// Handle SCP-like URLs: user@host:path
464-
if matches := scpLikeURLRegex.FindStringSubmatch(u); matches != nil {
465-
return strings.ToLower(matches[2])
466-
}
467-
468-
return ""
460+
return strings.ToLower(ep.Host)
469461
}
470462

471463
// SameHost checks if two URLs point to the same host.
472464
// Used to determine if credentials should be forwarded to submodules.
473-
// The comparison is hostname-only. Port is ignored to match git's
474-
// credential-helper convention, which keys credentials on the host alone.
465+
// The comparison is hostname-only. Port is ignored as a simplification;
466+
// submodules on the same host at different ports are uncommon.
475467
func SameHost(url1, url2 string) bool {
476468
host1 := extractHost(url1)
477469
host2 := extractHost(url2)
@@ -605,6 +597,7 @@ func initSubmodules(ctx context.Context, logf func(string, ...any), repo *git.Re
605597
}
606598
logf("Parent repository URL: %s", RedactURL(effectiveParentURL))
607599

600+
var warnings int
608601
for _, sub := range subs {
609602
select {
610603
case <-ctx.Done():
@@ -650,11 +643,13 @@ func initSubmodules(ctx context.Context, logf func(string, ...any), repo *git.Re
650643
subRepo, subWorktree, err := openSubmoduleRepo(parentWorktree, subConfig.Path)
651644
if err != nil {
652645
logf(" ⚠ Could not open submodule repository %s for nested traversal: %v", subConfig.Name, err)
646+
warnings++
653647
continue
654648
}
655649
nestedSubs, err := subWorktree.Submodules()
656650
if err != nil {
657651
logf(" ⚠ Could not list nested submodules in %s: %v", subConfig.Name, err)
652+
warnings++
658653
continue
659654
}
660655
if len(nestedSubs) == 0 {
@@ -666,14 +661,25 @@ func initSubmodules(ctx context.Context, logf func(string, ...any), repo *git.Re
666661
}
667662
}
668663

669-
logf("✓ All submodules initialized successfully")
664+
if warnings > 0 {
665+
logf("⚠ Submodule initialization finished with %d warning(s)", warnings)
666+
} else {
667+
logf("✓ All submodules initialized successfully")
668+
}
670669
return nil
671670
}
672671

673672
// submoduleAuthFor returns the auth to use when fetching a submodule. It
674673
// returns parentAuth if the submodule shares the parent's host, and nil
675674
// otherwise. A warning is logged when parent auth is set but withheld
676675
// because the hosts differ.
676+
//
677+
// The check is host-only and does not compare transport protocols. If
678+
// the parent uses SSH auth and a submodule on the same host uses HTTPS,
679+
// the SSH auth is forwarded and go-git rejects it at the transport
680+
// layer. This is intentional: returning nil here would silently skip
681+
// auth for a submodule that legitimately needs it under a different
682+
// protocol.
677683
func submoduleAuthFor(logf func(string, ...any), parentURL, submoduleURL string, parentAuth transport.AuthMethod) transport.AuthMethod {
678684
if parentAuth == nil {
679685
return nil
@@ -723,15 +729,7 @@ func cloneSubmodule(ctx context.Context, logf func(string, ...any), parentWorktr
723729
// Check if already cloned
724730
if _, statErr := subFS.Stat(".git"); statErr == nil {
725731
logf(" Submodule already cloned, checking out expected commit...")
726-
// Open the existing repository
727-
subGitDir, err := subFS.Chroot(".git")
728-
if err != nil {
729-
return fmt.Errorf("chroot to existing .git: %w", err)
730-
}
731-
subRepo, err := git.Open(
732-
filesystem.NewStorage(subGitDir, cache.NewObjectLRU(cache.DefaultMaxSize*10)),
733-
subFS,
734-
)
732+
subRepo, _, err := openSubmoduleRepo(parentWorktree, subConfig.Path)
735733
if err != nil {
736734
return fmt.Errorf("open existing submodule: %w", err)
737735
}
@@ -768,6 +766,7 @@ func cloneSubmodule(ctx context.Context, logf func(string, ...any), parentWorktr
768766
NoCheckout: true,
769767
})
770768
if err != nil {
769+
_ = billyutil.RemoveAll(subFS, ".git")
771770
return fmt.Errorf("clone submodule repository: %w", err)
772771
}
773772

@@ -796,7 +795,7 @@ func checkoutSubmoduleCommit(ctx context.Context, logf func(string, ...any), sub
796795
})
797796
if fetchErr != nil && !errors.Is(fetchErr, git.NoErrAlreadyUpToDate) {
798797
// If that fails, try fetching all refs
799-
logf(" Direct fetch failed, fetching all refs...")
798+
logf(" Direct fetch failed (%v), fetching all refs...", fetchErr)
800799
fetchAllErr := subRepo.FetchContext(ctx, &git.FetchOptions{
801800
RemoteName: "origin",
802801
Auth: submoduleAuth,

git/git_test.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ func TestCloneRepo(t *testing.T) {
9494

9595
// We do not overwrite a repo if one is already present.
9696
t.Run("AlreadyCloned", func(t *testing.T) {
97+
if !tc.expectClone {
98+
t.Skip("cannot test already-cloned when the initial clone is expected to fail")
99+
}
97100
srvFS := memfs.New()
98101
_ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "README.md", "Hello, world!", "Wow!"))
99102
authMW := mwtest.BasicAuthMW(tc.srvUsername, tc.srvPassword)
@@ -102,12 +105,24 @@ func TestCloneRepo(t *testing.T) {
102105
tc.mungeURL(&srv.URL)
103106
}
104107
clientFS := memfs.New()
105-
// A repo already exists!
106-
_ = gittest.NewRepo(t, clientFS)
108+
// Clone once so the repo exists in the layout CloneRepo expects.
107109
cloned, err := git.CloneRepo(context.Background(), t.Logf, git.CloneRepoOptions{
108110
Path: "/",
109111
RepoURL: srv.URL,
110112
Storage: clientFS,
113+
RepoAuth: &githttp.BasicAuth{
114+
Username: tc.username,
115+
Password: tc.password,
116+
},
117+
})
118+
require.NoError(t, err)
119+
require.True(t, cloned)
120+
121+
// Second call: repo already exists, must be a no-op.
122+
cloned, err = git.CloneRepo(context.Background(), t.Logf, git.CloneRepoOptions{
123+
Path: "/",
124+
RepoURL: srv.URL,
125+
Storage: clientFS,
111126
})
112127
require.NoError(t, err)
113128
require.False(t, cloned)

git/submodule_auth_internal_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package git
22

33
import (
44
"bytes"
5+
"fmt"
56
"testing"
67

78
"github.com/go-git/go-git/v5/plumbing/transport"
@@ -59,7 +60,7 @@ func TestSubmoduleAuthFor(t *testing.T) {
5960
t.Run(c.name, func(t *testing.T) {
6061
t.Parallel()
6162
var buf bytes.Buffer
62-
logf := func(format string, _ ...any) { _, _ = buf.WriteString(format) }
63+
logf := func(format string, args ...any) { fmt.Fprintf(&buf, format, args...) }
6364
got := submoduleAuthFor(logf, c.parentURL, c.submoduleURL, c.parentAuth)
6465
require.Equal(t, c.wantAuth, got)
6566
if c.wantWarn {

options/options.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,13 @@ import (
1414
)
1515

1616
// SubmoduleDepth is a custom type for handling submodule depth that accepts
17-
// "true" (defaults to 10), "false" (0), or a positive integer.
17+
// "true" (defaults to 10), "false" (0), or a non-negative integer (0 disables).
1818
type SubmoduleDepth int
1919

20-
const DefaultSubmoduleDepth = 10
20+
const (
21+
DefaultSubmoduleDepth SubmoduleDepth = 10
22+
MaxSubmoduleDepth SubmoduleDepth = 100
23+
)
2124

2225
func (s *SubmoduleDepth) Set(val string) error {
2326
lower := strings.ToLower(strings.TrimSpace(val))
@@ -31,11 +34,14 @@ func (s *SubmoduleDepth) Set(val string) error {
3134
}
3235
n, err := strconv.Atoi(lower)
3336
if err != nil {
34-
return fmt.Errorf("invalid submodule depth %q: must be true, false, or a positive integer", val)
37+
return fmt.Errorf("invalid submodule depth %q: must be true, false, or a non-negative integer", val)
3538
}
3639
if n < 0 {
3740
return fmt.Errorf("submodule depth must be non-negative, got %d", n)
3841
}
42+
if n > int(MaxSubmoduleDepth) {
43+
return fmt.Errorf("submodule depth must be at most %d, got %d", MaxSubmoduleDepth, n)
44+
}
3945
*s = SubmoduleDepth(n)
4046
return nil
4147
}
@@ -149,9 +155,9 @@ type Options struct {
149155
// GitCloneThinPack clone with thin pack compabilities. This is optional.
150156
GitCloneThinPack bool
151157
// GitCloneSubmoduleDepth controls submodule initialization after cloning.
152-
// 0 = disabled (default), positive integer = max recursion depth.
158+
// 0 = disabled (default), >0 = max recursion depth (capped at MaxSubmoduleDepth).
153159
// The flag accepts "true" (defaults to DefaultSubmoduleDepth), "false"
154-
// (0), or a positive integer for the max recursion depth.
160+
// (0), or a non-negative integer (0 disables).
155161
GitCloneSubmoduleDepth int
156162
// GitUsername is the username to use for Git authentication. This is
157163
// optional.
@@ -436,7 +442,7 @@ func (o *Options) CLI() serpent.OptionSet {
436442
Env: WithEnvPrefix("GIT_CLONE_SUBMODULES"),
437443
Value: SubmoduleDepthOf(&o.GitCloneSubmoduleDepth),
438444
Description: "Clone Git submodules after cloning the repository. " +
439-
"Accepts 'true' (max depth 10), 'false' (disabled), or a positive integer for max recursion depth.",
445+
"Accepts 'true' (max depth 10), 'false' (disabled), or a non-negative integer for max recursion depth (0 disables, max 100).",
440446
},
441447
{
442448
Flag: "git-username",

options/testdata/options.golden

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,8 @@ OPTIONS:
101101

102102
--git-clone-submodules submodule-depth, $ENVBUILDER_GIT_CLONE_SUBMODULES
103103
Clone Git submodules after cloning the repository. Accepts 'true' (max
104-
depth 10), 'false' (disabled), or a positive integer for max recursion
105-
depth.
104+
depth 10), 'false' (disabled), or a non-negative integer for max
105+
recursion depth (0 disables, max 100).
106106

107107
--git-clone-thinpack bool, $ENVBUILDER_GIT_CLONE_THINPACK (default: true)
108108
Git clone with thin pack compatibility enabled, ensuring that even

0 commit comments

Comments
 (0)