From 2c2707cf24c1fbe1e0e7ce860ba83128d7a7943c Mon Sep 17 00:00:00 2001 From: Peter Donald Date: Wed, 15 Jul 2026 16:09:20 +1000 Subject: [PATCH] feat(push): support generated messages on Windows Resolve the POSIX shell through Git, probe it before pushing, and reuse it across sync push plans. Add Windows-capable unit and integration coverage. # Conflicts: # internal/command/push.go # internal/command/push_message.go # internal/command/push_test.go # internal/command/sync.go --- README.md | 10 ++--- docs/migration-from-ruby-braid.md | 4 +- integration/sync_test.go | 31 +++++++++++++++ internal/command/preflight.go | 1 + internal/command/push.go | 14 +++++-- internal/command/push_message.go | 62 ++++++++++++++++++------------ internal/command/push_test.go | 64 ++++++++++++++----------------- internal/command/sync.go | 10 ++++- internal/command/sync_test.go | 9 ----- internal/gitexec/gitexec.go | 4 ++ internal/gitexec/gitexec_test.go | 11 ++++++ 11 files changed, 140 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 96fb4d5..8d60173 100644 --- a/README.md +++ b/README.md @@ -539,14 +539,14 @@ Example: BRAID_PUSH_COMMIT_MESSAGE_COMMAND='codex exec -C {REPO_DIR} --add-dir {CONTEXT_DIR} --model gpt-5.5 -c '\''model_reasoning_effort="low"'\'' -o {MESSAGE_FILE} < {PROMPT_FILE}' ``` -The command runs as `/bin/sh -c` from the downstream repository root with the -current process environment. The prompt includes mirror metadata, downstream +The command runs as ` -c` from the downstream repository root with +the current process environment. Braid obtains the POSIX shell path from +`git var GIT_SHELL_PATH` and verifies that it starts before pushing; this also +supports Git for Windows. The prompt includes mirror metadata, downstream commit provenance when it can be collected, and the staged upstream diff. Diffs up to 5 KiB are included inline; larger diffs are written under `{CONTEXT_DIR}` and referenced from the prompt. The configured command is -trusted local shell code and is not sandboxed by Braid. On Windows, configured -generation is not supported; leave the environment variable unset or empty to -use the normal editor flow. +trusted local shell code and is not sandboxed by Braid. When generation succeeds, Git's editor opens with the generated message followed by commented provenance guidance when available. The editor-reviewed content is diff --git a/docs/migration-from-ruby-braid.md b/docs/migration-from-ruby-braid.md index 981d405..c177adc 100644 --- a/docs/migration-from-ruby-braid.md +++ b/docs/migration-from-ruby-braid.md @@ -150,8 +150,8 @@ tool keeps that model but tightens the user-facing behavior: without opening the editor or running the generated-message command. - `BRAID_PUSH_COMMIT_MESSAGE_COMMAND` can optionally run a trusted local POSIX shell command to generate a draft upstream commit message. The editor still - opens for review. This generator is disabled by default and is not supported - on Windows. + opens for review. This generator is disabled by default. Braid obtains the + shell from `git var GIT_SHELL_PATH`, including on Git for Windows. Migration impact: diff --git a/integration/sync_test.go b/integration/sync_test.go index d51ffee..0d455c7 100644 --- a/integration/sync_test.go +++ b/integration/sync_test.go @@ -93,6 +93,37 @@ func TestExecutablePushProvenanceTemplateTouchesGitDefaultTemplate(t *testing.T) assertNotContains(t, template, "BRAID_COMMIT_TEMPLATE") } +func TestExecutablePushGeneratesMessageWithGitPOSIXShell(t *testing.T) { + root := t.TempDir() + env := newProcessEnv(t, root) + braid := braidBinary(t) + + upstream := filepath.Join(root, "upstream") + initRepo(t, env, upstream) + writeFile(t, upstream, "README.md", "base\n") + commitAll(t, env, upstream, "seed upstream") + gitOK(t, env, upstream, "config", "--local", "receive.denyCurrentBranch", "updateInstead") + + downstream := filepath.Join(root, "downstream") + initRepo(t, env, downstream) + writeFile(t, downstream, "README.md", "downstream\n") + commitAll(t, env, downstream, "seed downstream") + add := runBraid(t, env, downstream, braid, "--quiet", "add", upstream, "vendor/basic") + assertResult(t, add, 0, "", "") + + writeFile(t, downstream, "vendor/basic/README.md", "local\n") + commitAll(t, env, downstream, "local mirror change") + capture, editor := capturingEditorCommand(t, root, "Reviewed generated message") + pushEnv := env. + with("BRAID_PUSH_COMMIT_MESSAGE_COMMAND", "printf 'Generated by Git POSIX shell\\n' > {MESSAGE_FILE}"). + with("GIT_EDITOR", editor) + + push := runBraid(t, pushEnv, downstream, braid, "push", "vendor/basic") + assertExit(t, push, 0) + assertContains(t, readFile(t, root, filepath.Base(capture)), "Generated by Git POSIX shell") + assertLatestCommit(t, env, upstream, defaultName+" <"+defaultEmail+">", "Reviewed generated message") +} + func TestExecutableSyncPullOnlyUpdatesWithoutEditor(t *testing.T) { root := t.TempDir() env := newProcessEnv(t, root) diff --git a/internal/command/preflight.go b/internal/command/preflight.go index 06887b7..42315cc 100644 --- a/internal/command/preflight.go +++ b/internal/command/preflight.go @@ -108,6 +108,7 @@ type PushGit interface { UpdateGit ConfigGet(context.Context, ...string) (string, bool, error) CoreCommentChar(context.Context) (string, bool, error) + ShellPath(context.Context) (string, error) FirstParentCommits(context.Context, string) ([]string, error) LogCommitsTouchingPath(context.Context, string, string) ([]gitexec.Commit, error) ShowFile(context.Context, string, string) ([]byte, bool, error) diff --git a/internal/command/push.go b/internal/command/push.go index 7229d91..fbfb041 100644 --- a/internal/command/push.go +++ b/internal/command/push.go @@ -129,7 +129,11 @@ func (h PushHandler) Run(inv cli.Invocation, stdout, stderr io.Writer) error { if err != nil { return err } - result, err := h.push(ctx, repo, git, selection.Source.WithMirror(selection.Mirrors[0]), inv.Push.Branch, inv.Push.Keep, inv.Push.Message, inv.Global, stdout, stderr) + messageGeneration := pushMessageGeneration{} + if inv.Push.Message == "" { + messageGeneration = configuredPushMessageGeneration() + } + result, err := h.push(ctx, repo, git, selection.Source.WithMirror(selection.Mirrors[0]), inv.Push.Branch, inv.Push.Keep, inv.Push.Message, messageGeneration, inv.Global, stdout, stderr) if err != nil { return err } @@ -152,7 +156,7 @@ func (h PushHandler) pushGit(repo RepoContext, inv cli.Invocation, trace io.Writ return gitexec.New(repo.GitWorkTreeRoot, inv.Global.Verbose, trace) } -func (h PushHandler) push(ctx context.Context, repo RepoContext, git PushGit, m source.SourceMirror, branch string, keep bool, commitMessage string, global cli.GlobalOptions, stdout, stderr io.Writer) (result pushResult, err error) { +func (h PushHandler) push(ctx context.Context, repo RepoContext, git PushGit, m source.SourceMirror, branch string, keep bool, commitMessage string, messageGeneration pushMessageGeneration, global cli.GlobalOptions, stdout, stderr io.Writer) (result pushResult, err error) { if branch == "" { branch = m.Branch() } @@ -255,13 +259,15 @@ func (h PushHandler) push(ctx context.Context, repo RepoContext, git PushGit, m var provenance pushProvenance var provenanceOK bool var provenanceErr error - messageGeneration := pushMessageGeneration{} if commitMessage == "" { provenance, provenanceOK, provenanceErr = buildPushProvenance(ctx, git, m) if provenanceErr != nil { warnPushProvenance(stderr, provenanceErr) } - messageGeneration = configuredPushMessageGeneration() + messageGeneration, err = resolvePushMessageGeneration(ctx, git, messageGeneration) + if err != nil { + return pushResult{}, err + } } pushCompleted := false diff --git a/internal/command/push_message.go b/internal/command/push_message.go index 8b16bdb..4d10020 100644 --- a/internal/command/push_message.go +++ b/internal/command/push_message.go @@ -8,7 +8,6 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "strings" "unicode/utf8" @@ -17,20 +16,20 @@ import ( ) const ( - pushMessageCommandEnv = "BRAID_PUSH_COMMIT_MESSAGE_COMMAND" - pushMessageInlineDiffLimit = 5 * 1024 - pushMessageGeneratorOutputLimit = 4 * 1024 - pushMessageTruncationMarker = "[truncated after 4096 bytes]" - pushMessagePromptFileName = "prompt.txt" - pushMessageOutputFileName = "message.txt" - pushMessageSeedFileName = "commit-message-seed.txt" - pushMessageLargeDiffFileName = "upstream.diff" - pushMessageUnsupportedWindowsMsg = "AI push commit-message generation requires POSIX /bin/sh support and is not supported on Windows; unset BRAID_PUSH_COMMIT_MESSAGE_COMMAND to push without generation" + pushMessageCommandEnv = "BRAID_PUSH_COMMIT_MESSAGE_COMMAND" + pushMessageInlineDiffLimit = 5 * 1024 + pushMessageGeneratorOutputLimit = 4 * 1024 + pushMessageTruncationMarker = "[truncated after 4096 bytes]" + pushMessagePromptFileName = "prompt.txt" + pushMessageOutputFileName = "message.txt" + pushMessageSeedFileName = "commit-message-seed.txt" + pushMessageLargeDiffFileName = "upstream.diff" ) type pushMessageGeneration struct { Enabled bool CommandTemplate string + ShellPath string } type pushMessageCommandValues struct { @@ -79,9 +78,6 @@ func configuredPushMessageGeneration() pushMessageGeneration { } func preparePushMessageSeed(ctx context.Context, repo RepoContext, source PushGit, tempGit gitexec.Git, m source.SourceMirror, branch, baseRevision, newTree, contextDir string, generation pushMessageGeneration, verbose bool, trace io.Writer, provenance pushProvenance, provenanceOK bool, provenanceErr error) (string, error) { - if err := validatePushMessageGeneratorPlatform(runtime.GOOS); err != nil { - return "", err - } commentChar, err := pushMessageSeedCommentChar(ctx, source) if err != nil { return "", err @@ -124,7 +120,7 @@ func preparePushMessageSeed(ctx context.Context, repo RepoContext, source PushGi if _, err := fmt.Fprintf(progress, "Braid: generating push commit message for %s using external tool\n", m.LocalPath); err != nil { return "", err } - generated, failure, err := runPushMessageGenerator(ctx, generation.CommandTemplate, pushMessageCommandValues{ + generated, failure, err := runPushMessageGenerator(ctx, generation.ShellPath, generation.CommandTemplate, pushMessageCommandValues{ RepoDir: repo.GitWorkTreeRoot, ContextDir: contextDir, PromptFile: promptPath, @@ -146,13 +142,6 @@ func preparePushMessageSeed(ctx context.Context, repo RepoContext, source PushGi return seedPath, nil } -func validatePushMessageGeneratorPlatform(goos string) error { - if goos == "windows" { - return errors.New(pushMessageUnsupportedWindowsMsg) - } - return nil -} - func pushMessageSeedCommentChar(ctx context.Context, git PushGit) (string, error) { value, ok, err := git.CoreCommentChar(ctx) if err != nil { @@ -259,17 +248,42 @@ func formatPushMessagePromptProvenance(provenance pushProvenance, ok bool, err e return b.String() } -func runPushMessageGenerator(ctx context.Context, commandTemplate string, values pushMessageCommandValues, verbose bool, trace io.Writer) (string, *pushMessageGeneratorFailure, error) { +func resolvePushMessageGeneration(ctx context.Context, git PushGit, generation pushMessageGeneration) (pushMessageGeneration, error) { + if !generation.Enabled || generation.ShellPath != "" { + return generation, nil + } + shellPath, err := git.ShellPath(ctx) + if err != nil { + return pushMessageGeneration{}, fmt.Errorf("resolve POSIX shell with git var GIT_SHELL_PATH: %w", err) + } + if shellPath == "" { + return pushMessageGeneration{}, errors.New("resolve POSIX shell with git var GIT_SHELL_PATH: Git returned an empty path") + } + if err := probePushMessageShell(ctx, shellPath); err != nil { + return pushMessageGeneration{}, err + } + generation.ShellPath = shellPath + return generation, nil +} + +func probePushMessageShell(ctx context.Context, shellPath string) error { + if err := exec.CommandContext(ctx, shellPath, "-c", ":").Run(); err != nil { + return fmt.Errorf("start Git POSIX shell %q: %w", shellPath, err) + } + return nil +} + +func runPushMessageGenerator(ctx context.Context, shellPath, commandTemplate string, values pushMessageCommandValues, verbose bool, trace io.Writer) (string, *pushMessageGeneratorFailure, error) { command := expandPushMessageCommand(commandTemplate, values) if verbose { if trace == nil { trace = io.Discard } - if _, err := fmt.Fprintf(trace, "Braid: Executing %s in %s\n", gitexec.FormatArgv([]string{"/bin/sh", "-c", command}), values.RepoDir); err != nil { + if _, err := fmt.Fprintf(trace, "Braid: Executing %s in %s\n", gitexec.FormatArgv([]string{shellPath, "-c", command}), values.RepoDir); err != nil { return "", nil, err } } - cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command) + cmd := exec.CommandContext(ctx, shellPath, "-c", command) cmd.Dir = values.RepoDir var stdout, stderr limitedOutput stdout.limit = pushMessageGeneratorOutputLimit diff --git a/internal/command/push_test.go b/internal/command/push_test.go index c002cf8..f731d77 100644 --- a/internal/command/push_test.go +++ b/internal/command/push_test.go @@ -6,11 +6,11 @@ import ( "fmt" "os" "path/filepath" - "runtime" "strings" "testing" "braid/internal/config" + "braid/internal/gitexec" "braid/internal/testutil" ) @@ -365,10 +365,6 @@ func TestPushCommandDoesNotPushWhenEditorFails(t *testing.T) { } func TestPushCommandGeneratedMessagePromptAndReview(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("push message generation requires POSIX /bin/sh") - } - upstream := testutil.InitRepo(t) testutil.WriteFile(t, upstream, "README.md", "base\n") testutil.CommitAll(t, upstream, "base") @@ -405,7 +401,7 @@ func TestPushCommandGeneratedMessagePromptAndReview(t *testing.T) { if err != nil { t.Fatalf("resolve repo path: %v", err) } - if got := strings.TrimSpace(readTestFile(t, repoCapture)); got != expectedRepo { + if got := filepath.Clean(filepath.FromSlash(strings.TrimSpace(readTestFile(t, repoCapture)))); got != expectedRepo { t.Fatalf("generator repo arg = %q, want %q", got, expectedRepo) } prompt := readTestFile(t, promptCapture) @@ -439,10 +435,6 @@ func TestPushCommandGeneratedMessagePromptAndReview(t *testing.T) { } func TestPushCommandGeneratorFailuresOpenEditorWithDiagnostics(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("push message generation requires POSIX /bin/sh") - } - tests := []struct { name string body string @@ -510,10 +502,6 @@ func TestPushCommandGeneratorFailuresOpenEditorWithDiagnostics(t *testing.T) { } func TestPushCommandGeneratorReadsLargeDiffFromContextDir(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("push message generation requires POSIX /bin/sh") - } - upstream := testutil.InitRepo(t) testutil.WriteFile(t, upstream, "README.md", "base\n") testutil.CommitAll(t, upstream, "base") @@ -528,7 +516,7 @@ func TestPushCommandGeneratorReadsLargeDiffFromContextDir(t *testing.T) { captureDir := t.TempDir() promptCapture := filepath.Join(captureDir, "prompt.txt") diffCapture := filepath.Join(captureDir, "upstream.diff") - generator := writeGenerator(t, "#!/bin/sh\nprompt=$1\nmessage=$2\ncontext=$3\ndiff_file=$(sed -n 's/^Full diff file: //p' \"$prompt\")\n[ -n \"$diff_file\" ] || exit 12\n[ -f \"$diff_file\" ] || exit 13\ncase \"$diff_file\" in \"$context\"/*) ;; *) exit 14 ;; esac\ncp \"$prompt\" \"$BRAID_GENERATOR_PROMPT\" || exit 1\ncp \"$diff_file\" \"$BRAID_GENERATOR_DIFF\" || exit 1\nprintf 'Generated large diff\\n' > \"$message\"\n") + generator := writeGenerator(t, "#!/bin/sh\nprompt=$1\nmessage=$2\ncontext=$3\ndiff_file=$(sed -n 's/^Full diff file: //p' \"$prompt\")\n[ -n \"$diff_file\" ] || exit 12\n[ -f \"$diff_file\" ] || exit 13\nif command -v cygpath >/dev/null 2>&1; then\n diff_file=$(cygpath -u \"$diff_file\")\n context=$(cygpath -u \"$context\")\nfi\ncase \"$diff_file\" in \"$context\"/*) ;; *) exit 14 ;; esac\ncp \"$prompt\" \"$BRAID_GENERATOR_PROMPT\" || exit 1\ncp \"$diff_file\" \"$BRAID_GENERATOR_DIFF\" || exit 1\nprintf 'Generated large diff\\n' > \"$message\"\n") t.Setenv("BRAID_GENERATOR_PROMPT", promptCapture) t.Setenv("BRAID_GENERATOR_DIFF", diffCapture) t.Setenv(pushMessageCommandEnv, shellQuote(generator)+" {PROMPT_FILE} {MESSAGE_FILE} {CONTEXT_DIR}") @@ -549,10 +537,6 @@ func TestPushCommandGeneratorReadsLargeDiffFromContextDir(t *testing.T) { } func TestPushCommandGenerationContinuesWhenProvenanceFails(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("push message generation requires POSIX /bin/sh") - } - upstream := testutil.InitRepo(t) testutil.WriteFile(t, upstream, "README.md", "base\n") testutil.CommitAll(t, upstream, "base") @@ -1031,10 +1015,6 @@ func TestPushMessageCommandSubstitutionQuotesDocumentedPlaceholders(t *testing.T } func TestPushMessageGeneratorVerboseTrace(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("push message generation requires POSIX /bin/sh") - } - repoDir := t.TempDir() contextDir := t.TempDir() promptPath := filepath.Join(contextDir, pushMessagePromptFileName) @@ -1050,9 +1030,10 @@ func TestPushMessageGeneratorVerboseTrace(t *testing.T) { PromptFile: promptPath, MessageFile: messagePath, } + shellPath := strings.TrimSpace(testutil.Git(t, repoDir, "var", "GIT_SHELL_PATH").Stdout) var quietTrace bytes.Buffer - if _, _, err := runPushMessageGenerator(context.Background(), template, values, false, &quietTrace); err != nil { + if _, _, err := runPushMessageGenerator(context.Background(), shellPath, template, values, false, &quietTrace); err != nil { t.Fatalf("runPushMessageGenerator quiet returned error: %v", err) } if quietTrace.String() != "" { @@ -1060,7 +1041,7 @@ func TestPushMessageGeneratorVerboseTrace(t *testing.T) { } var trace bytes.Buffer - message, failure, err := runPushMessageGenerator(context.Background(), template, values, true, &trace) + message, failure, err := runPushMessageGenerator(context.Background(), shellPath, template, values, true, &trace) if err != nil { t.Fatalf("runPushMessageGenerator verbose returned error: %v", err) } @@ -1070,10 +1051,11 @@ func TestPushMessageGeneratorVerboseTrace(t *testing.T) { if message != "generated" { t.Fatalf("generated message = %q, want generated", message) } - assertContains(t, trace.String(), "Braid: Executing [\"/bin/sh\", \"-c\", ") - assertContains(t, trace.String(), shellQuote(generator)) - assertContains(t, trace.String(), shellQuote(promptPath)) - assertContains(t, trace.String(), shellQuote(messagePath)) + assertContains(t, trace.String(), "Braid: Executing") + assertContains(t, trace.String(), shellPath) + assertContains(t, trace.String(), filepath.Base(generator)) + assertContains(t, trace.String(), filepath.Base(promptPath)) + assertContains(t, trace.String(), filepath.Base(messagePath)) assertContains(t, trace.String(), " in "+repoDir+"\n") } @@ -1111,13 +1093,25 @@ func TestPushMessageDiffContextCutoff(t *testing.T) { } } -func TestPushMessageGeneratorPlatformSupport(t *testing.T) { - if err := validatePushMessageGeneratorPlatform("linux"); err != nil { - t.Fatalf("linux platform validation returned error: %v", err) +func TestResolvePushMessageGenerationUsesGitShell(t *testing.T) { + git := gitexec.New(t.TempDir(), false, nil) + want, err := git.ShellPath(context.Background()) + if err != nil { + t.Fatalf("ShellPath returned error: %v", err) + } + generation, err := resolvePushMessageGeneration(context.Background(), git, pushMessageGeneration{Enabled: true, CommandTemplate: "generate"}) + if err != nil { + t.Fatalf("resolvePushMessageGeneration returned error: %v", err) + } + if generation.ShellPath != want { + t.Fatalf("resolved shell = %q, want %q", generation.ShellPath, want) } - err := validatePushMessageGeneratorPlatform("windows") - if err == nil || !strings.Contains(err.Error(), "/bin/sh") || !strings.Contains(err.Error(), pushMessageCommandEnv) { - t.Fatalf("windows platform validation error = %v, want clear unsupported message", err) +} + +func TestProbePushMessageShellRejectsMissingExecutable(t *testing.T) { + err := probePushMessageShell(context.Background(), filepath.Join(t.TempDir(), "missing-sh")) + if err == nil || !strings.Contains(err.Error(), "start Git POSIX shell") { + t.Fatalf("probePushMessageShell error = %v, want launch failure", err) } } diff --git a/internal/command/sync.go b/internal/command/sync.go index 325c16e..c2b73ed 100644 --- a/internal/command/sync.go +++ b/internal/command/sync.go @@ -385,8 +385,16 @@ func (h SyncHandler) withFetchedMirrorForPlanning(ctx context.Context, git PushG func (h SyncHandler) runPushPlan(ctx context.Context, repo RepoContext, git PushGit, plan syncPushPlan, inv cli.Invocation, stdout, stderr io.Writer) ([]string, error) { push := PushHandler(h) var completed []string + messageGeneration := configuredPushMessageGeneration() + var err error + if len(plan.Actions) > 0 { + messageGeneration, err = resolvePushMessageGeneration(ctx, git, messageGeneration) + if err != nil { + return nil, err + } + } for _, action := range plan.Actions { - result, err := push.push(ctx, repo, git, action.Target.Mirror, action.Target.Mirror.Branch(), inv.Sync.Keep, "", inv.Global, stdout, stderr) + result, err := push.push(ctx, repo, git, action.Target.Mirror, action.Target.Mirror.Branch(), inv.Sync.Keep, "", messageGeneration, inv.Global, stdout, stderr) if err != nil { if result.Status == pushStatusPushed { completed = append(completed, ":"+action.Target.Mirror.Name) diff --git a/internal/command/sync_test.go b/internal/command/sync_test.go index 096d37a..9c73deb 100644 --- a/internal/command/sync_test.go +++ b/internal/command/sync_test.go @@ -5,7 +5,6 @@ import ( "errors" "os" "path/filepath" - "runtime" "strings" "testing" @@ -132,10 +131,6 @@ func TestSyncCommandProvenanceGuidanceIsPerPushedMirror(t *testing.T) { } func TestSyncCommandGeneratedMessagesArePerPushedMirror(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("push message generation requires POSIX /bin/sh") - } - upstreamA := testutil.InitRepo(t) testutil.WriteFile(t, upstreamA, "README.md", "a base\n") testutil.CommitAll(t, upstreamA, "a base") @@ -856,10 +851,6 @@ func TestSyncCommandStopsBeforePullPhaseWhenPushFails(t *testing.T) { } func TestSyncCommandLaterEditorFailureLeavesEarlierGeneratedPushComplete(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("push message generation requires POSIX /bin/sh") - } - upstreamA := testutil.InitRepo(t) testutil.WriteFile(t, upstreamA, "README.md", "a base\n") aBase := testutil.CommitAll(t, upstreamA, "a base") diff --git a/internal/gitexec/gitexec.go b/internal/gitexec/gitexec.go index b8e11e1..bd4e02a 100644 --- a/internal/gitexec/gitexec.go +++ b/internal/gitexec/gitexec.go @@ -280,6 +280,10 @@ func (g Git) CoreCommentChar(ctx context.Context) (string, bool, error) { return strings.TrimRight(result.Stdout, "\r\n"), true, nil } +func (g Git) ShellPath(ctx context.Context) (string, error) { + return g.Output(ctx, "var", "GIT_SHELL_PATH") +} + func (g Git) RevParse(ctx context.Context, rev string) (string, error) { return g.Output(ctx, "rev-parse", rev) } diff --git a/internal/gitexec/gitexec_test.go b/internal/gitexec/gitexec_test.go index 81f42e8..fbe5277 100644 --- a/internal/gitexec/gitexec_test.go +++ b/internal/gitexec/gitexec_test.go @@ -338,6 +338,17 @@ func TestCoreCommentChar(t *testing.T) { } } +func TestShellPath(t *testing.T) { + git := New(t.TempDir(), false, nil) + path, err := git.ShellPath(context.Background()) + if err != nil { + t.Fatalf("ShellPath returned error: %v", err) + } + if strings.TrimSpace(path) == "" { + t.Fatal("ShellPath returned an empty path") + } +} + func TestHistoryHelpersReadCommitsFilesAndTrees(t *testing.T) { repo := initRealRepo(t) writeRealFile(t, repo, "mirror/file.txt", "base\n")