Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<git-shell> -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
Expand Down
4 changes: 2 additions & 2 deletions docs/migration-from-ruby-braid.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
31 changes: 31 additions & 0 deletions integration/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions internal/command/preflight.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 10 additions & 4 deletions internal/command/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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()
}
Expand Down Expand Up @@ -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
Expand Down
62 changes: 38 additions & 24 deletions internal/command/push_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"unicode/utf8"

Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
64 changes: 29 additions & 35 deletions internal/command/push_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"

"braid/internal/config"
"braid/internal/gitexec"
"braid/internal/testutil"
)

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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}")
Expand All @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -1050,17 +1030,18 @@ 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() != "" {
t.Fatalf("quiet trace = %q, want empty", quietTrace.String())
}

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)
}
Expand All @@ -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")
}

Expand Down Expand Up @@ -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)
}
}

Expand Down
Loading