From 0dd4b48540457cb77f9a2a810bc9108e3fe61b07 Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Wed, 18 Mar 2026 12:45:46 +0100 Subject: [PATCH] git: add opt-in command tracing Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- client/llb/git_test.go | 11 ++++ client/llb/source.go | 12 +++- frontend/dockerfile/docs/reference.md | 21 +++--- frontend/dockerui/build_test.go | 66 +++++++++++++++++++ frontend/dockerui/config.go | 1 + frontend/dockerui/context.go | 11 +++- frontend/dockerui/namedcontext.go | 4 +- solver/pb/attr.go | 1 + solver/pb/caps.go | 7 ++ source/git/identifier.go | 1 + source/git/source.go | 10 ++- source/git/source_test.go | 93 +++++++++++++++++++++++++++ util/gitutil/git_cli.go | 74 ++++++++++++++++++--- util/gitutil/git_cli_test.go | 46 +++++++++++++ 14 files changed, 331 insertions(+), 27 deletions(-) create mode 100644 util/gitutil/git_cli_test.go diff --git a/client/llb/git_test.go b/client/llb/git_test.go index 38724981928e..9a740e47c20d 100644 --- a/client/llb/git_test.go +++ b/client/llb/git_test.go @@ -69,6 +69,17 @@ func TestGit(t *testing.T) { "git.fullurl": "https://github.com/foo/bar.git", }, }, + { + name: "debug commands", + st: Git("github.com/foo/bar.git", "ref", GitDebugCommands()), + identifier: "git://github.com/foo/bar.git#ref", + attrs: map[string]string{ + "git.authheadersecret": "GIT_AUTH_HEADER", + "git.authtokensecret": "GIT_AUTH_TOKEN", + "git.debugcommands": "true", + "git.fullurl": "https://github.com/foo/bar.git", + }, + }, } for _, tc := range tcases { diff --git a/client/llb/source.go b/client/llb/source.go index 0ffeddace4c0..d04d46a39795 100644 --- a/client/llb/source.go +++ b/client/llb/source.go @@ -470,11 +470,14 @@ func Git(url, fragment string, opts ...GitOption) State { attrs[pb.AttrGitChecksum] = checksum addCap(&gi.Constraints, pb.CapSourceGitChecksum) } - if gi.SkipSubmodules { attrs[pb.AttrGitSkipSubmodules] = "true" addCap(&gi.Constraints, pb.CapSourceGitSkipSubmodules) } + if gi.DebugCommands { + attrs[pb.AttrGitDebugCommands] = "true" + addCap(&gi.Constraints, pb.CapSourceGitDebugCommands) + } addCap(&gi.Constraints, pb.CapSourceGit) @@ -503,6 +506,7 @@ type GitInfo struct { Ref string SubDir string SkipSubmodules bool + DebugCommands bool } func GitRef(v string) GitOption { @@ -529,6 +533,12 @@ func KeepGitDir() GitOption { }) } +func GitDebugCommands() GitOption { + return gitOptionFunc(func(gi *GitInfo) { + gi.DebugCommands = true + }) +} + func AuthTokenSecret(v string) GitOption { return gitOptionFunc(func(gi *GitInfo) { gi.AuthTokenSecret = v diff --git a/frontend/dockerfile/docs/reference.md b/frontend/dockerfile/docs/reference.md index 997e8a6abf72..73bd21d0e20f 100644 --- a/frontend/dockerfile/docs/reference.md +++ b/frontend/dockerfile/docs/reference.md @@ -2715,16 +2715,17 @@ RUN echo "I'm building for $TARGETPLATFORM" ### BuildKit built-in build args -| Arg | Type | Description | -|----------------------------------|--------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `BUILDKIT_BUILD_NAME` | String | Override the build name shown in [`buildx history` command](https://docs.docker.com/reference/cli/docker/buildx/history/) and [Docker Desktop Builds view](https://docs.docker.com/desktop/use-desktop/builds/). | -| `BUILDKIT_CACHE_MOUNT_NS` | String | Set optional cache ID namespace. | -| `BUILDKIT_CONTEXT_KEEP_GIT_DIR` | Bool | Trigger Git context to keep the `.git` directory. | -| `BUILDKIT_INLINE_CACHE`[^2] | Bool | Inline cache metadata to image config or not. | -| `BUILDKIT_MULTI_PLATFORM` | Bool | Opt into deterministic output regardless of multi-platform output or not. | -| `BUILDKIT_SANDBOX_HOSTNAME` | String | Set the hostname (default `buildkitsandbox`) | -| `BUILDKIT_SYNTAX` | String | Set frontend image | -| `SOURCE_DATE_EPOCH` | Int | Set the Unix timestamp for created image and layers. More info from [reproducible builds](https://reproducible-builds.org/docs/source-date-epoch/). Supported since Dockerfile 1.5, BuildKit 0.11 | +| Arg | Type | Description | +|---------------------------------|--------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `BUILDKIT_BUILD_NAME` | String | Override the build name shown in [`buildx history` command](https://docs.docker.com/reference/cli/docker/buildx/history/) and [Docker Desktop Builds view](https://docs.docker.com/desktop/use-desktop/builds/). | +| `BUILDKIT_CACHE_MOUNT_NS` | String | Set optional cache ID namespace. | +| `BUILDKIT_CONTEXT_KEEP_GIT_DIR` | Bool | Trigger Git context to keep the `.git` directory. | +| `BUILDKIT_DEBUG_GIT_COMMANDS` | Bool | Trigger Git context to print executed Git commands in the build log. | +| `BUILDKIT_INLINE_CACHE`[^2] | Bool | Inline cache metadata to image config or not. | +| `BUILDKIT_MULTI_PLATFORM` | Bool | Opt into deterministic output regardless of multi-platform output or not. | +| `BUILDKIT_SANDBOX_HOSTNAME` | String | Set the hostname (default `buildkitsandbox`) | +| `BUILDKIT_SYNTAX` | String | Set frontend image | +| `SOURCE_DATE_EPOCH` | Int | Set the Unix timestamp for created image and layers. More info from [reproducible builds](https://reproducible-builds.org/docs/source-date-epoch/). Supported since Dockerfile 1.5, BuildKit 0.11 | #### Example: keep `.git` dir diff --git a/frontend/dockerui/build_test.go b/frontend/dockerui/build_test.go index 378f209b285f..a8d81b1c1068 100644 --- a/frontend/dockerui/build_test.go +++ b/frontend/dockerui/build_test.go @@ -1,10 +1,14 @@ package dockerui import ( + "context" "testing" "github.com/containerd/platforms" + "github.com/moby/buildkit/client/llb" "github.com/moby/buildkit/exporter/containerimage/exptypes" + "github.com/moby/buildkit/solver/pb" + digest "github.com/opencontainers/go-digest" ocispecs "github.com/opencontainers/image-spec/specs-go/v1" "github.com/stretchr/testify/require" ) @@ -98,3 +102,65 @@ func TestNormalizePlatform(t *testing.T) { require.Equal(t, platforms.FormatAll(platforms.Normalize(tc.p)), tc.expected.ID) } } + +func TestDetectGitContextForwardsDebugCommands(t *testing.T) { + t.Parallel() + + enabled := true + st, ok, err := DetectGitContext("https://github.com/docker/buildx.git?ref=refs/pull/3732/merge", nil, &enabled) + require.True(t, ok) + require.NoError(t, err) + + g := marshalGitContext(t, st) + require.Equal(t, "git://github.com/docker/buildx.git#refs/pull/3732/merge", g.Identifier) + require.Equal(t, map[string]string{ + "git.authheadersecret": "GIT_AUTH_HEADER", + "git.authtokensecret": "GIT_AUTH_TOKEN", + "git.debugcommands": "true", + "git.fullurl": "https://github.com/docker/buildx.git", + }, g.Attrs) +} + +func marshalGitContext(t *testing.T, st *llb.State) *pb.SourceOp { + t.Helper() + + def, err := st.Marshal(context.TODO()) + require.NoError(t, err) + + m, arr := parseDef(t, def.Def) + require.Equal(t, 2, len(arr)) + + dgst, idx := last(t, arr) + require.Equal(t, 0, idx) + require.Equal(t, m[dgst], arr[0]) + + return arr[0].Op.(*pb.Op_Source).Source +} + +func parseDef(t *testing.T, def [][]byte) (map[string]*pb.Op, []*pb.Op) { + t.Helper() + + m := map[string]*pb.Op{} + arr := make([]*pb.Op, 0, len(def)) + + for _, dt := range def { + var op pb.Op + err := op.Unmarshal(dt) + require.NoError(t, err) + dgst := digest.FromBytes(dt) + m[string(dgst)] = &op + arr = append(arr, &op) + } + + return m, arr +} + +func last(t *testing.T, arr []*pb.Op) (string, int) { + t.Helper() + + require.Greater(t, len(arr), 1) + + op := arr[len(arr)-1] + require.Equal(t, 1, len(op.Inputs)) + return op.Inputs[0].Digest, int(op.Inputs[0].Index) +} diff --git a/frontend/dockerui/config.go b/frontend/dockerui/config.go index 619b8345730f..83063cdc6692 100644 --- a/frontend/dockerui/config.go +++ b/frontend/dockerui/config.go @@ -51,6 +51,7 @@ const ( keyHostnameArg = "build-arg:BUILDKIT_SANDBOX_HOSTNAME" keyDockerfileLintArg = "build-arg:BUILDKIT_DOCKERFILE_CHECK" keyContextKeepGitDirArg = "build-arg:BUILDKIT_CONTEXT_KEEP_GIT_DIR" + keyDebugGitCommandsArg = "build-arg:BUILDKIT_DEBUG_GIT_COMMANDS" keySourceDateEpoch = "build-arg:SOURCE_DATE_EPOCH" ) diff --git a/frontend/dockerui/context.go b/frontend/dockerui/context.go index 88d68569b4fc..e3ca8abadc95 100644 --- a/frontend/dockerui/context.go +++ b/frontend/dockerui/context.go @@ -73,7 +73,11 @@ func (bc *Client) initContext(ctx context.Context) (*buildContext, error) { if v, err := strconv.ParseBool(opts[keyContextKeepGitDirArg]); err == nil { keepGit = &v } - if st, ok, err := DetectGitContext(opts[localNameContext], keepGit); ok { + var debugGitCommands *bool + if v, err := strconv.ParseBool(opts[keyDebugGitCommandsArg]); err == nil { + debugGitCommands = &v + } + if st, ok, err := DetectGitContext(opts[localNameContext], keepGit, debugGitCommands); ok { if err != nil { return nil, err } @@ -143,7 +147,7 @@ func (bc *Client) initContext(ctx context.Context) (*buildContext, error) { return bctx, nil } -func DetectGitContext(ref string, keepGit *bool) (*llb.State, bool, error) { +func DetectGitContext(ref string, keepGit *bool, debugGitCommands *bool) (*llb.State, bool, error) { g, isGit, err := dfgitutil.ParseGitRef(ref) if err != nil { return nil, isGit, err @@ -158,6 +162,9 @@ func DetectGitContext(ref string, keepGit *bool) (*llb.State, bool, error) { if keepGit != nil && *keepGit { gitOpts = append(gitOpts, llb.KeepGitDir()) } + if debugGitCommands != nil && *debugGitCommands { + gitOpts = append(gitOpts, llb.GitDebugCommands()) + } if g.SubDir != "" { gitOpts = append(gitOpts, llb.GitSubDir(g.SubDir)) } diff --git a/frontend/dockerui/namedcontext.go b/frontend/dockerui/namedcontext.go index ccda59c0ec09..0887a86fc764 100644 --- a/frontend/dockerui/namedcontext.go +++ b/frontend/dockerui/namedcontext.go @@ -141,7 +141,7 @@ func (nc *NamedContext) load(ctx context.Context, count int) (*llb.State, *docke } return &st, &img, nil case "git": - st, ok, err := DetectGitContext(nc.input, nil) + st, ok, err := DetectGitContext(nc.input, nil, nil) if !ok { return nil, nil, errors.Errorf("invalid git context %s", nc.input) } @@ -150,7 +150,7 @@ func (nc *NamedContext) load(ctx context.Context, count int) (*llb.State, *docke } return st, nil, nil case "http", "https": - st, ok, err := DetectGitContext(nc.input, nil) + st, ok, err := DetectGitContext(nc.input, nil, nil) if !ok { httpst := llb.HTTP(nc.input, llb.WithCustomName("[context "+nc.nameWithPlatform+"] "+nc.input)) st = &httpst diff --git a/solver/pb/attr.go b/solver/pb/attr.go index 0db767341bd3..25077b366bf0 100644 --- a/solver/pb/attr.go +++ b/solver/pb/attr.go @@ -8,6 +8,7 @@ const AttrKnownSSHHosts = "git.knownsshhosts" const AttrMountSSHSock = "git.mountsshsock" const AttrGitChecksum = "git.checksum" const AttrGitSkipSubmodules = "git.skipsubmodules" +const AttrGitDebugCommands = "git.debugcommands" const AttrGitSignatureVerifyPubKey = "git.sig.pubkey" const AttrGitSignatureVerifyRejectExpired = "git.sig.rejectexpired" diff --git a/solver/pb/caps.go b/solver/pb/caps.go index d869b9f8152b..640d8bc910d8 100644 --- a/solver/pb/caps.go +++ b/solver/pb/caps.go @@ -33,6 +33,7 @@ const ( CapSourceGitSubdir apicaps.CapID = "source.git.subdir" CapSourceGitChecksum apicaps.CapID = "source.git.checksum" CapSourceGitSkipSubmodules apicaps.CapID = "source.git.skipsubmodules" + CapSourceGitDebugCommands apicaps.CapID = "source.git.debugcommands" CapSourceGitSignatureVerify apicaps.CapID = "source.git.signatureverify" CapSourceHTTP apicaps.CapID = "source.http" @@ -249,6 +250,12 @@ func init() { Status: apicaps.CapStatusExperimental, }) + Caps.Init(apicaps.Cap{ + ID: CapSourceGitDebugCommands, + Enabled: true, + Status: apicaps.CapStatusExperimental, + }) + Caps.Init(apicaps.Cap{ ID: CapSourceGitSignatureVerify, Enabled: true, diff --git a/source/git/identifier.go b/source/git/identifier.go index 02f490185973..9c7f7bed2a54 100644 --- a/source/git/identifier.go +++ b/source/git/identifier.go @@ -21,6 +21,7 @@ type GitIdentifier struct { MountSSHSock string KnownSSHHosts string SkipSubmodules bool + DebugCommands bool VerifySignature *GitSignatureVerifyOptions } diff --git a/source/git/source.go b/source/git/source.go index fe35e0f522be..cd93fba3b229 100644 --- a/source/git/source.go +++ b/source/git/source.go @@ -114,6 +114,10 @@ func (gs *Source) Identifier(scheme, ref string, attrs map[string]string, platfo if v == "true" { id.SkipSubmodules = true } + case pb.AttrGitDebugCommands: + if v == "true" { + id.DebugCommands = true + } case pb.AttrGitSignatureVerifyPubKey: if id.VerifySignature == nil { id.VerifySignature = &GitSignatureVerifyOptions{} @@ -141,7 +145,7 @@ func (gs *Source) Identifier(scheme, ref string, attrs map[string]string, platfo } // needs to be called with repo lock -func (gs *Source) mountRemote(ctx context.Context, remote string, authArgs []string, sha256 bool, reset bool, g session.Group) (target string, release func() error, retErr error) { +func (gs *Source) mountRemote(ctx context.Context, remote string, authArgs []string, debugCommands bool, sha256 bool, reset bool, g session.Group) (target string, release func() error, retErr error) { sis, err := searchGitRemote(ctx, gs.cache, remote) if err != nil { return "", nil, errors.Wrapf(err, "failed to search metadata for %s", urlutil.RedactCredentials(remote)) @@ -206,6 +210,7 @@ func (gs *Source) mountRemote(ctx context.Context, remote string, authArgs []str git := gitCLI( gitutil.WithGitDir(dir), gitutil.WithArgs(authArgs...), + gitutil.WithDebugCommands(debugCommands), ) if initializeRepo { @@ -844,7 +849,7 @@ func (gs *gitSourceHandler) tryRemoteFetch(ctx context.Context, g session.Group, } repo.releasers = append(repo.releasers, cleanup) - gitDir, unmountGitDir, err := gs.mountRemote(ctx, gs.src.Remote, gs.authArgs, gs.sha256, reset, g) + gitDir, unmountGitDir, err := gs.mountRemote(ctx, gs.src.Remote, gs.authArgs, gs.src.DebugCommands, gs.sha256, reset, g) if err != nil { return nil, err } @@ -1207,6 +1212,7 @@ func (gs *gitSourceHandler) emptyGitCli(ctx context.Context, g session.Group, op opts = append([]gitutil.Option{ gitutil.WithArgs(gs.authArgs...), + gitutil.WithDebugCommands(gs.src.DebugCommands), gitutil.WithSSHAuthSock(sock), gitutil.WithSSHKnownHosts(knownHosts), }, opts...) diff --git a/source/git/source_test.go b/source/git/source_test.go index b0e2e6360282..f2191a3e010e 100644 --- a/source/git/source_test.go +++ b/source/git/source_test.go @@ -14,6 +14,7 @@ import ( "runtime" "strconv" "strings" + "sync" "testing" "time" @@ -57,6 +58,14 @@ func TestRepeatedFetchKeepGitDirSHA256(t *testing.T) { testRepeatedFetch(t, true, "sha256") } +func TestDebugCommandsSHA1(t *testing.T) { + testDebugCommands(t, "sha1") +} + +func TestDebugCommandsSHA256(t *testing.T) { + testDebugCommands(t, "sha256") +} + func testRepeatedFetch(t *testing.T, keepGitDir bool, format string) { if runtime.GOOS == "windows" { t.Skip("Depends on unimplemented containerd bind-mount support on Windows") @@ -175,6 +184,48 @@ func testRepeatedFetch(t *testing.T, keepGitDir bool, format string) { require.Equal(t, pin3, pin4) } +func testDebugCommands(t *testing.T, format string) { + if runtime.GOOS == "windows" { + t.Skip("Depends on unimplemented containerd bind-mount support on Windows") + } + + t.Parallel() + ctx, logs := captureProgressLogs(context.Background(), t) + + gs := setupGitSource(t, t.TempDir()) + repo := setupGitRepo(t, format) + + id := &GitIdentifier{Remote: repo.mainURL, Ref: "feature", KeepGitDir: true, DebugCommands: true} + g, err := gs.Resolve(ctx, id, nil, nil) + require.NoError(t, err) + + ref, err := g.Snapshot(ctx, nil) + require.NoError(t, err) + defer ref.Release(context.TODO()) + + _ = mountRef(ctx, t, ref) + + require.Eventually(t, func() bool { + out := logs.String() + return strings.Contains(out, "+ git ") && strings.Contains(out, " fetch ") && strings.Contains(out, " checkout ") + }, 5*time.Second, 50*time.Millisecond) +} + +func mountRef(ctx context.Context, t *testing.T, ref cache.ImmutableRef) string { + t.Helper() + + mount, err := ref.Mount(ctx, true, nil) + require.NoError(t, err) + + lm := snapshot.LocalMounter(mount) + dir, err := lm.Mount() + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, lm.Unmount()) + }) + return dir +} + func TestFetchBySHA1(t *testing.T) { testFetchBySHA(t, "sha1", false) } @@ -2365,3 +2416,45 @@ func logProgressStreams(ctx context.Context, t *testing.T) context.Context { }() return ctx } + +type progressLogBuffer struct { + mu sync.Mutex + sb strings.Builder +} + +func (b *progressLogBuffer) Write(dt []byte) { + b.mu.Lock() + defer b.mu.Unlock() + b.sb.Write(dt) +} + +func (b *progressLogBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.sb.String() +} + +func captureProgressLogs(ctx context.Context, t *testing.T) (context.Context, *progressLogBuffer) { + pr, ctx, cancel := progress.NewContext(ctx) + buf := &progressLogBuffer{} + done := make(chan struct{}) + t.Cleanup(func() { + cancel(errors.WithStack(context.Canceled)) + <-done + }) + go func() { + defer close(done) + for { + prog, err := pr.Read(context.Background()) + if err != nil { + return + } + for _, log := range prog { + if lsys, ok := log.Sys.(client.VertexLog); ok { + buf.Write(lsys.Data) + } + } + } + }() + return ctx, buf +} diff --git a/util/gitutil/git_cli.go b/util/gitutil/git_cli.go index 966e18ac45b2..00ac5556d724 100644 --- a/util/gitutil/git_cli.go +++ b/util/gitutil/git_cli.go @@ -7,8 +7,10 @@ import ( "os" "os/exec" "slices" + "strconv" "strings" + "github.com/moby/buildkit/util/urlutil" "github.com/pkg/errors" ) @@ -18,9 +20,10 @@ type GitCLI struct { git string exec func(context.Context, *exec.Cmd) error - args []string - dir string - streams StreamFunc + args []string + dir string + streams StreamFunc + debugCommands bool workTree string gitDir string @@ -83,6 +86,12 @@ func WithGitDir(gitDir string) Option { } } +func WithDebugCommands(debugCommands bool) Option { + return func(b *GitCLI) { + b.debugCommands = debugCommands + } +} + // WithSSHAuthSock sets the ssh auth sock. func WithSSHAuthSock(sshAuthSock string) Option { return func(b *GitCLI) { @@ -169,22 +178,28 @@ func (cli *GitCLI) Run(ctx context.Context, args ...string) (_ []byte, err error cmd.Stdin = nil cmd.Stdout = buf cmd.Stderr = errbuf + var stdoutStream io.WriteCloser + var stderrStream io.WriteCloser + flush := func() {} if cli.streams != nil { - stdout, stderr, flush := cli.streams(ctx) - if stdout != nil { - cmd.Stdout = io.MultiWriter(stdout, cmd.Stdout) + stdoutStream, stderrStream, flush = cli.streams(ctx) + if stdoutStream != nil { + cmd.Stdout = io.MultiWriter(stdoutStream, cmd.Stdout) } - if stderr != nil { - cmd.Stderr = io.MultiWriter(stderr, cmd.Stderr) + if stderrStream != nil { + cmd.Stderr = io.MultiWriter(stderrStream, cmd.Stderr) } - defer stdout.Close() - defer stderr.Close() + defer stdoutStream.Close() + defer stderrStream.Close() defer func() { if err != nil { flush() } }() } + if cli.debugCommands && stderrStream != nil { + _, _ = io.WriteString(stderrStream, "+ "+formatDebugCommand(cmd.Args)+"\n") + } cmd.Env = []string{ "PATH=" + os.Getenv("PATH"), @@ -243,6 +258,45 @@ func (cli *GitCLI) Run(ctx context.Context, args ...string) (_ []byte, err error } } +func formatDebugCommand(args []string) string { + redacted := make([]string, 0, len(args)) + for i := 0; i < len(args); i++ { + arg := args[i] + if arg == "-c" && i+1 < len(args) { + cfg := args[i+1] + if key, _, ok := strings.Cut(cfg, "="); ok && strings.HasSuffix(strings.ToLower(key), ".extraheader") { + cfg = key + "=xxxxx" + } else if strings.Contains(cfg, "://") { + if redactedURL := urlutil.RedactCredentials(cfg); redactedURL != cfg { + cfg = redactedURL + } + } + redacted = append(redacted, arg, cfg) + i++ + continue + } + if strings.Contains(arg, "://") { + if redactedURL := urlutil.RedactCredentials(arg); redactedURL != arg { + arg = redactedURL + } + } + redacted = append(redacted, arg) + } + quoted := make([]string, 0, len(redacted)) + for _, arg := range redacted { + if arg == "" { + quoted = append(quoted, `""`) + continue + } + if strings.ContainsAny(arg, " \t\n\r\"'`$&|;<>()[]{}*?!#\\") { + quoted = append(quoted, strconv.Quote(arg)) + continue + } + quoted = append(quoted, arg) + } + return strings.Join(quoted, " ") +} + func getGitSSHCommand(knownHosts string) string { gitSSHCommand := "ssh -F /dev/null" if knownHosts != "" { diff --git a/util/gitutil/git_cli_test.go b/util/gitutil/git_cli_test.go new file mode 100644 index 000000000000..42f88493be64 --- /dev/null +++ b/util/gitutil/git_cli_test.go @@ -0,0 +1,46 @@ +package gitutil + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFormatDebugCommand(t *testing.T) { + tests := []struct { + name string + args []string + expected string + }{ + { + name: "quotes and redacts urls and config", + args: []string{ + "git", + "-c", "protocol.file.allow=user", + "--work-tree", "/tmp/work tree", + "--git-dir", "/tmp/work tree/.git", + "-c", "http.https://github.com/.extraheader=Authorization: Basic c2VjcmV0", + "-c", "core.abbrev=12", + "fetch", + "https://user:pass@example.com/repo.git", + "refs/heads/main", + }, + expected: `git -c protocol.file.allow=user --work-tree "/tmp/work tree" --git-dir "/tmp/work tree/.git" -c http.https://github.com/.extraheader=xxxxx -c core.abbrev=12 fetch https://xxxxx:xxxxx@example.com/repo.git refs/heads/main`, + }, + { + name: "redacts extraheader config only", + args: []string{ + "git", + "-c", "http.https://github.com/.extraheader=Authorization: Basic dXNlcjp0b2tlbg==", + "-c", "core.abbrev=12", + "fetch", + }, + expected: `git -c http.https://github.com/.extraheader=xxxxx -c core.abbrev=12 fetch`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, formatDebugCommand(tt.args)) + }) + } +}