diff --git a/.changes/unreleased/Added-20260528-072610.yaml b/.changes/unreleased/Added-20260528-072610.yaml new file mode 100644 index 000000000..a8ede9869 --- /dev/null +++ b/.changes/unreleased/Added-20260528-072610.yaml @@ -0,0 +1,3 @@ +kind: Added +body: 'integration: Add post-merge regenerator. When .gs/integration-regenerate exists and is executable, gs invokes it after a successful integration rebuild with the list of derived-file conflicts the merge driver auto-resolved, then folds the script''s output into the final merge commit.' +time: 2026-05-28T07:26:10.749008-04:00 diff --git a/.gs/integration-regenerate b/.gs/integration-regenerate new file mode 100755 index 000000000..bd207d467 --- /dev/null +++ b/.gs/integration-regenerate @@ -0,0 +1,46 @@ +#!/bin/sh +# .gs/integration-regenerate — project-level regenerator for +# git-spice's integration-branch rebuild. +# +# Invoked by `gs integration rebuild` after all tip merges succeed, +# with the newline-separated list of paths whose conflicts the +# `regenerate` git merge driver resolved via take-incoming. We +# dispatch the project's actual generators based on which categories +# of derived files appear in the input list. Generators that touch +# many files are only invoked when at least one file in their output +# set appeared in the conflict list — so a rebuild with no derived +# conflicts pays nothing, and rebuilds that conflict only on (say) +# CLI docs don't gratuitously rewrite mocks. +# +# The contract: +# - stdin: newline-separated, deduplicated file paths (allow-listed +# to those tagged `merge=regenerate` in .gitattributes). +# - cwd: repo root. +# - exit 0: success. +# - exit non-zero: warning logged by gs; rebuild still succeeds, but +# derived files may be stale until the next rebuild. +# +# When changing the dispatch logic here, consider keeping the +# steady-state cost low: every condition that runs a generator should +# be guarded by a path-match check. +set -eu + +files=$(cat) + +# Mocks (mockgen output) — regenerate via go generate. Same task also +# rebuilds CLI docs (cli-reference.md, cli-shorthands.md), so we use +# this single check for both categories. +if printf '%s\n' "$files" | grep -qE 'mocks?(_test)?\.go|mock_.*\.go|^doc/includes/cli-'; then + mise run generate +fi + +# Help fixtures (testdata/help/*.txt) — TestHelp regenerates them all +# in one pass. +if printf '%s\n' "$files" | grep -q '^testdata/help/'; then + go test -run TestHelp . -update +fi + +# ShamHub integration test fixtures. +if printf '%s\n' "$files" | grep -q '^internal/forge/shamhub/testdata/'; then + go test -run TestIntegration ./internal/forge/shamhub/ -update +fi diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index 71e913973..6a7b34cc1 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -310,9 +310,11 @@ Before merging, the stack is checked for branches whose base PR was already merged on the forge. Use --no-branch-check to skip this validation. -Before each merge, waits for CI checks to pass. -Use --build-timeout to configure the maximum wait -before failing if checks are not ready. +Before each merge, waits for merge readiness: +the forge must observe the pushed head +and report that the CR is ready to merge. +Use --ready-timeout to configure the maximum wait +before failing if merge readiness is not reached. By default, a branch failure skips that branch's upstack descendants, but independent sibling branches continue. @@ -321,12 +323,12 @@ Use --fail-fast to stop the queue after the first branch failure. **Flags** * `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. -* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once. +* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once. * `--no-branch-check`: Skip stale base validation before merging. * `--fail-fast`: Stop the merge queue after the first branch failure. * `--branch=NAME`: Branch whose stack to merge -**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) +**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout) ### git-spice stack restack {#gs-stack-restack} @@ -624,7 +626,7 @@ This command acts as a local merge queue: it merges one Change Request, waits for that merge to finish, restacks and updates the next Change Request, -waits for its CI checks to pass, +waits for merge readiness on the updated Change Request, and then repeats the process. For a stack like this: @@ -642,13 +644,15 @@ Before merging, the downstack is checked for branches whose base PR was already merged on the forge. Use --no-branch-check to skip this validation. -Before each merge, waits for CI checks to pass. -Use --build-timeout to configure the maximum wait +Before each merge, waits for merge readiness: +the forge must observe the pushed head +and report that the CR is ready to merge. +Use --ready-timeout to configure the maximum wait (default: 30m, 0 means fail immediately if not ready). Between merges, the command waits for each merge to complete, restacks and updates the next PR, -waits for CI checks on the updated PR, +waits for merge readiness on the updated PR, and syncs merged branch cleanup. Use --no-wait for single branch merging @@ -658,12 +662,12 @@ when you don't want to wait for the merge to propagate. **Flags** * `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. -* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once. +* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once. * `--no-wait`: Skip polling for a single branch merge to propagate. * `--no-branch-check`: Skip stale base validation before merging. * `--branch=NAME`: Branch to start merging from -**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) +**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout) ### git-spice downstack edit {#gs-downstack-edit} @@ -1133,16 +1137,18 @@ Use --branch to merge a different branch. The branch must be based directly on trunk. To merge a stacked branch, use 'gs downstack merge'. -Before merging, waits for CI checks to pass. -Use --build-timeout to configure the maximum wait. +Before merging, waits for merge readiness: +the forge must observe the pushed head +and report that the CR is ready to merge. +Use --ready-timeout to configure the maximum wait. **Flags** * `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. -* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once. +* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once. * `--branch=NAME`: Branch to merge -**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) +**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout) ### git-spice branch submit {#gs-branch-submit} diff --git a/doc/src/guide/integration.md b/doc/src/guide/integration.md index c5cf7df84..3e154db39 100644 --- a/doc/src/guide/integration.md +++ b/doc/src/guide/integration.md @@ -276,6 +276,103 @@ then run $$gs integration rebuild$$ again. If `rerere` has recorded an incorrect resolution from an earlier run, `git rerere clear` wipes the cache. +## Regenerating derived files + + + +Repositories often have files that are *derived* from source — CLI +documentation, mockgen output, recorded test fixtures — where two +integration tips' changes might conflict not because of meaningful +disagreement but because the same generator was re-run on each +branch with different stochastic IDs or with different inputs. A +classic merge driver can't handle these correctly because regenerating +during a merge would side-effect the worktree, and `git add` from +inside a merge driver fails (the index is locked). + +git-spice handles this with a two-piece arrangement: + +1. **A take-incoming git merge driver** registered for the path + patterns you mark in `.gitattributes`. On conflict, it silently + copies the incoming version into place AND appends the path to + a log file whose location it reads from the + `GS_INTEGRATION_REGEN_LOG` environment variable. +2. **A project-level regenerator script** at + `.gs/integration-regenerate` (relative to repo root). After every + successful $$gs integration rebuild$$, git-spice invokes this + script with the deduplicated list of paths the merge driver + handled, then folds the script's worktree output into the final + merge commit. + +### Setting it up in a new repo + +Tag the derived paths in `.gitattributes`: + +```gitattributes +doc/includes/cli-reference.md merge=regenerate +testdata/help/*.txt merge=regenerate +**/mocks_test.go merge=regenerate +``` + +Register the merge driver. The driver itself is trivially small: + +```sh +git config merge.regenerate.driver \ + "$(git rev-parse --git-path info)/merge-regenerate %O %A %B %P" +cat > "$(git rev-parse --git-path info)/merge-regenerate" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +cp -f "$3" "$2" +if [ -n "${GS_INTEGRATION_REGEN_LOG:-}" ]; then + printf '%s\n' "$4" >> "$GS_INTEGRATION_REGEN_LOG" +fi +EOF +chmod +x "$(git rev-parse --git-path info)/merge-regenerate" +``` + +Add `.gs/integration-regenerate` to your repo (committed, +executable) with project-specific dispatch logic: + +```sh +#!/bin/sh +# .gs/integration-regenerate +set -eu + +# stdin is a deduplicated newline-separated list of paths whose +# conflicts the merge driver auto-resolved. +files=$(cat) + +# Only run the slow mockgen pass if a mock was actually in the list. +if printf '%s\n' "$files" | grep -qE 'mocks?(_test)?\.go'; then + go generate ./... +fi + +# Only update help fixtures if a help file was in the list. +if printf '%s\n' "$files" | grep -q '^testdata/help/'; then + go test -run TestHelp . -update +fi +``` + +The conditional dispatch keeps the steady-state cost low — a rebuild +with no derived-file conflicts pays one process spawn and exits. + +### Contract + +| Aspect | Value | +|--------|-------| +| Script path | `.gs/integration-regenerate` relative to repo root | +| Executable bit | required (`chmod +x`) | +| Input | newline-separated deduplicated paths on stdin | +| Working directory | repo root | +| Exit 0 | success; worktree changes folded into the last merge commit | +| Exit non-zero | warning logged; rebuild still considered successful | +| Absent | silently skipped (no-op) | + +The path list is automatically allow-listed: only files whose +conflicts the `regenerate` merge driver actually handled appear in +the list. Conflicts on un-tagged files (regular source code) are +resolved through the usual channels and do *not* leak into the +regenerator. + ## Removing the configuration Use $$gs integration delete$$ to remove the configuration. The diff --git a/internal/git/merge_wt.go b/internal/git/merge_wt.go index 2f5e06ee0..7e82775e3 100644 --- a/internal/git/merge_wt.go +++ b/internal/git/merge_wt.go @@ -32,6 +32,12 @@ type MergeOptions struct { // worktree (with unmerged paths) rather than aborting it. The // caller is then responsible for resolving or aborting the merge. LeaveConflict bool + + // Env lists additional environment variables to set on the git + // merge process. Each entry is "KEY=VALUE". These are inherited + // by any merge drivers git invokes. Useful for passing per-merge + // state to a custom driver (e.g., a log file path). + Env []string } // MergeConflictError indicates that a [Worktree.Merge] could not be @@ -86,6 +92,9 @@ func (w *Worktree) Merge(ctx context.Context, opts MergeOptions) error { } cmd = cmd.WithArgs(append(prefix, cmd.Args()...)...) } + if len(opts.Env) > 0 { + cmd = cmd.AppendEnv(opts.Env...) + } err := w.runGitWithIndexLockRetry(ctx, func() *gitCmd { return cmd }) if err == nil { @@ -170,3 +179,22 @@ func (w *Worktree) MergeContinue( } return nil } + +// AmendCommitAll stages all worktree changes (additions, modifications, +// deletions) and amends HEAD with --no-edit. Preserves merge-commit +// parentage. +// +// Used after a post-merge step (e.g., a regenerator) writes additional +// content that should be folded into the just-made commit, rather than +// added as a separate commit on top. +func (w *Worktree) AmendCommitAll(ctx context.Context) error { + if err := w.gitCmd(ctx, "add", "-A").Run(); err != nil { + return fmt.Errorf("git add: %w", err) + } + if err := w.gitCmd(ctx, + "commit", "--amend", "--no-edit", "--allow-empty", + ).Run(); err != nil { + return fmt.Errorf("git commit --amend: %w", err) + } + return nil +} diff --git a/internal/git/merge_wt_test.go b/internal/git/merge_wt_test.go index 0eb9b39c4..15154dc53 100644 --- a/internal/git/merge_wt_test.go +++ b/internal/git/merge_wt_test.go @@ -3,6 +3,7 @@ package git_test import ( "errors" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -323,3 +324,163 @@ func TestWorktree_Merge_noRefs(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "at least one ref") } + +func TestWorktree_Merge_envPropagatesToDriver(t *testing.T) { + t.Parallel() + + // Use a stub merge driver that writes its $GIT_PROBE_OUT env value + // to a file when invoked. The driver is invoked by registering it + // in the local git config and tagging shared.txt with merge=stub. + dir := t.TempDir() + probeOut := filepath.Join(dir, "probe.out") + driverScript := filepath.Join(dir, "stub-driver.sh") + require.NoError(t, os.WriteFile(driverScript, []byte(strings.Join([]string{ + "#!/bin/sh", + "# stub merge driver: take incoming + record env probe value", + "cp -f \"$3\" \"$2\"", + "if [ -n \"${GIT_PROBE_OUT:-}\" ]; then", + " echo recorded > \"$GIT_PROBE_OUT\"", + "fi", + }, "\n")), 0o700)) + + fixture, err := gittest.LoadFixtureScript([]byte(text.Dedent(` + as 'Test ' + at '2025-01-01T00:00:00Z' + + git init + git add shared.txt .gitattributes + git commit -m 'Initial commit' + + git checkout -b feature + cp feature.txt shared.txt + git add shared.txt + git commit -m 'feature edits shared.txt' + + git checkout main + cp main.txt shared.txt + git add shared.txt + git commit -m 'main edits shared.txt' + + -- shared.txt -- + base + -- feature.txt -- + feature + -- main.txt -- + main + -- .gitattributes -- + shared.txt merge=stub + `))) + require.NoError(t, err) + t.Cleanup(fixture.Cleanup) + + // Register the driver in this repo's local config. + require.NoError(t, exec.Command("git", "-C", fixture.Dir(), + "config", "merge.stub.driver", + driverScript+" %O %A %B %P").Run()) + require.NoError(t, exec.Command("git", "-C", fixture.Dir(), + "config", "merge.stub.name", "stub take-incoming driver").Run()) + + wt, err := git.OpenWorktree(t.Context(), fixture.Dir(), git.OpenOptions{ + Log: silogtest.New(t), + }) + require.NoError(t, err) + + require.NoError(t, wt.Merge(t.Context(), git.MergeOptions{ + Refs: []string{"feature"}, + NoFF: true, + Message: "Merge feature", + Env: []string{"GIT_PROBE_OUT=" + probeOut}, + })) + + // Driver should have observed the env var and written to probeOut. + data, err := os.ReadFile(probeOut) + require.NoError(t, err, "driver did not write to probe output") + assert.Equal(t, "recorded\n", string(data)) +} + +func TestWorktree_AmendCommitAll(t *testing.T) { + t.Parallel() + + fixture, err := gittest.LoadFixtureScript([]byte(text.Dedent(` + as 'Test ' + at '2025-01-01T00:00:00Z' + + git init + git add file.txt + git commit -m 'Initial commit' + + -- file.txt -- + base + `))) + require.NoError(t, err) + t.Cleanup(fixture.Cleanup) + + wt, err := git.OpenWorktree(t.Context(), fixture.Dir(), git.OpenOptions{ + Log: silogtest.New(t), + }) + require.NoError(t, err) + + // Modify a tracked file. + require.NoError(t, + os.WriteFile(filepath.Join(fixture.Dir(), "file.txt"), + []byte("changed\n"), 0o600)) + + require.NoError(t, wt.AmendCommitAll(t.Context())) + + clean, err := wt.IsClean(t.Context()) + require.NoError(t, err) + assert.True(t, clean, "worktree should be clean after amend") +} + +func TestWorktree_AmendCommitAll_preservesMergeParents(t *testing.T) { + t.Parallel() + + fixture, err := gittest.LoadFixtureScript([]byte(text.Dedent(` + as 'Test ' + at '2025-01-01T00:00:00Z' + + git init + git add base.txt + git commit -m 'Initial commit' + + git checkout -b feature + git add feature.txt + git commit -m 'Add feature' + + git checkout main + + -- base.txt -- + base + -- feature.txt -- + feature + `))) + require.NoError(t, err) + t.Cleanup(fixture.Cleanup) + + wt, err := git.OpenWorktree(t.Context(), fixture.Dir(), git.OpenOptions{ + Log: silogtest.New(t), + }) + require.NoError(t, err) + + require.NoError(t, wt.Merge(t.Context(), git.MergeOptions{ + Refs: []string{"feature"}, + NoFF: true, + Message: "Merge feature", + })) + + // Add a new file and amend the merge commit. + require.NoError(t, + os.WriteFile(filepath.Join(fixture.Dir(), "extra.txt"), + []byte("extra\n"), 0o600)) + + require.NoError(t, wt.AmendCommitAll(t.Context())) + + // Verify the amended HEAD still has two parents (merge commit). + parents, err := exec.Command("git", "-C", fixture.Dir(), + "rev-list", "--parents", "-n1", "HEAD").Output() + require.NoError(t, err) + fields := strings.Fields(string(parents)) + assert.Len(t, fields, 3, + "amended merge commit should still have 2 parents (got %d): %s", + len(fields)-1, string(parents)) +} diff --git a/internal/handler/integration/handler.go b/internal/handler/integration/handler.go index f18abb162..59512b67d 100644 --- a/internal/handler/integration/handler.go +++ b/internal/handler/integration/handler.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "iter" + "os" "slices" "strings" @@ -22,7 +23,7 @@ import ( "go.abhg.dev/gs/internal/spice/state" ) -//go:generate mockgen -typed -destination mocks_test.go -package integration -write_package_comment=false . GitRepository,GitWorktree,Store,Service,Resolver,QuestionPrompter +//go:generate mockgen -typed -destination mocks_test.go -package integration -write_package_comment=false . GitRepository,GitWorktree,Store,Service,Resolver,QuestionPrompter,Regenerator // GitRepository is the subset of [git.Repository] used by the handler. type GitRepository interface { @@ -41,6 +42,7 @@ type GitWorktree interface { CheckoutNewBranch(ctx context.Context, req git.CheckoutNewBranchRequest) error Merge(ctx context.Context, opts git.MergeOptions) error MergeContinue(ctx context.Context, paths []string, message string) error + AmendCommitAll(ctx context.Context) error IsClean(ctx context.Context) (bool, error) Push(ctx context.Context, opts git.PushOptions) error } @@ -99,6 +101,13 @@ type Handler struct { // Config.ScriptResolveMaxIterations. A non-positive value falls // back to the package default. MaxResolveIterations int + + // Regenerator, if non-nil, is invoked after all tip merges in a + // rebuild succeed AND at least one path was logged via the + // regenerate merge driver. It re-derives project-specific files + // (mocks, CLI docs, test fixtures) that the take-incoming driver + // could not merge correctly. nil disables the step entirely. + Regenerator Regenerator } // defaultMaxResolveIterations is the fallback when @@ -569,16 +578,39 @@ func (h *Handler) runMerges( originalBranch string, opts *RebuildOptions, ) (*RebuildResult, error) { + // Accumulate paths whose conflicts the regenerate merge driver + // resolved across all tip merges in this rebuild. After the loop + // succeeds, gs hands the deduped list to the Regenerator (if any). + var regenPaths []string + for i := start; i < len(tips); i++ { tip := tips[i] mergeMsg := fmt.Sprintf("Merge %s into %s", tip.Name, info.Name) - err := h.Worktree.Merge(ctx, git.MergeOptions{ + + // Set up a per-merge log file. The merge driver writes one + // line per take-incoming resolution it performs. + logFile, err := os.CreateTemp("", "gs-regen-log-*") + if err != nil { + return nil, fmt.Errorf("create regen log: %w", err) + } + logPath := logFile.Name() + _ = logFile.Close() + mergeEnv := []string{regenLogEnvVar + "=" + logPath} + + err = h.Worktree.Merge(ctx, git.MergeOptions{ Refs: []string{tip.Hash.String()}, NoFF: true, Message: mergeMsg, EnableRerere: true, LeaveConflict: true, + Env: mergeEnv, }) + + // Always drain + clean up the log file, regardless of merge + // outcome. + regenPaths = appendRegenLog(regenPaths, logPath) + _ = os.Remove(logPath) + if err == nil { continue } @@ -612,6 +644,23 @@ func (h *Handler) runMerges( return nil, &ConflictError{Tip: tip.Name, Paths: conflict.ConflictPaths} } + // All tip merges succeeded. If any derived files were take-incoming + // resolved AND a regenerator is configured, run it and fold any + // resulting worktree changes into the last merge commit. AmendCommitAll + // runs even if the regenerator exits non-zero: a partial run may have + // written real updates that must not be left dirty in the worktree, and + // `git commit --amend --no-edit --allow-empty` is a safe no-op when no + // changes are present. + deduped := dedupStrings(regenPaths) + if h.Regenerator != nil && len(deduped) > 0 { + if err := h.Regenerator.Regenerate(ctx, deduped); err != nil { + h.Log.Warnf("regenerator: %v", err) + } + if err := h.Worktree.AmendCommitAll(ctx); err != nil { + return nil, fmt.Errorf("amend with regen output: %w", err) + } + } + info.Tips = tips if err := h.Store.SetIntegration(ctx, info); err != nil { return nil, fmt.Errorf("save integration state: %w", err) @@ -636,6 +685,44 @@ func (h *Handler) runMerges( }, nil } +// regenLogEnvVar is the environment variable name the regenerate merge +// driver reads to find its append-only log file. +const regenLogEnvVar = "GS_INTEGRATION_REGEN_LOG" + +// appendRegenLog reads each newline-separated path from the given log +// file (if any) and appends to dst. Missing or unreadable files are +// silently treated as empty. +func appendRegenLog(dst []string, logPath string) []string { + data, err := os.ReadFile(logPath) + if err != nil { + return dst + } + for line := range strings.SplitSeq(string(data), "\n") { + if line = strings.TrimSpace(line); line != "" { + dst = append(dst, line) + } + } + return dst +} + +// dedupStrings returns ss without duplicates, preserving first +// occurrence order. +func dedupStrings(ss []string) []string { + if len(ss) == 0 { + return nil + } + seen := make(map[string]struct{}, len(ss)) + out := make([]string, 0, len(ss)) + for _, s := range ss { + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + return out +} + // PushRejectedError indicates that [Handler.Submit] could not push // because the remote integration branch already exists at a hash that // the local state does not recognize as previously-pushed. diff --git a/internal/handler/integration/handler_test.go b/internal/handler/integration/handler_test.go index aaff20fae..f0451648e 100644 --- a/internal/handler/integration/handler_test.go +++ b/internal/handler/integration/handler_test.go @@ -1,8 +1,11 @@ package integration import ( + "context" "errors" "iter" + "os" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -1319,3 +1322,229 @@ func singleRemoteRef(ref git.RemoteRef) iter.Seq2[git.RemoteRef, error] { yield(ref, nil) } } + +// regenLogPathFrom finds GS_INTEGRATION_REGEN_LOG= in opts.Env. +// Used by mock Merge callbacks to simulate the merge driver writing +// to the log file. +func regenLogPathFrom(opts git.MergeOptions) string { + const prefix = regenLogEnvVar + "=" + for _, e := range opts.Env { + if after, ok := strings.CutPrefix(e, prefix); ok { + return after + } + } + return "" +} + +// writeRegenLog simulates merge-driver invocations by appending the +// given paths to the log file gs prepared for this merge. +func writeRegenLog(t *testing.T, opts git.MergeOptions, paths ...string) { + t.Helper() + logPath := regenLogPathFrom(opts) + require.NotEmpty(t, logPath, "merge options missing %s", regenLogEnvVar) + f, err := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY, 0) + require.NoError(t, err) + defer func() { assert.NoError(t, f.Close()) }() + for _, p := range paths { + _, err := f.WriteString(p + "\n") + require.NoError(t, err) + } +} + +// newHandlerWithRegenerator returns a handler with a Regenerator mock +// plus the standard handler mocks. The handler's RepoRoot is a fresh +// temp dir. +func newHandlerWithRegenerator(t *testing.T) (*Handler, *handlerMocks, *MockRegenerator) { + t.Helper() + mockCtrl := gomock.NewController(t) + mocks := &handlerMocks{ + Repository: NewMockGitRepository(mockCtrl), + Worktree: NewMockGitWorktree(mockCtrl), + Store: NewMockStore(mockCtrl), + Service: NewMockService(mockCtrl), + } + regenerator := NewMockRegenerator(mockCtrl) + h := &Handler{ + Log: silogtest.New(t), + Repository: mocks.Repository, + Worktree: mocks.Worktree, + Store: mocks.Store, + Service: mocks.Service, + Regenerator: regenerator, + RepoRoot: t.TempDir(), + } + expectBorrowableWorktree(mocks) + return h, mocks, regenerator +} + +// setupSuccessfulRebuild primes the mocks for a fresh rebuild with one +// tip that merges cleanly. Returns the gomock matcher for Merge so +// callers can inject additional behavior (like writing to the regen +// log) via Do. +func setupSuccessfulRebuild(t *testing.T, mocks *handlerMocks) { + t.Helper() + info := &state.IntegrationInfo{ + Name: "preview", + Tips: []state.IntegrationTip{{Name: "feat-a"}}, + } + mocks.Store.EXPECT().Integration(gomock.Any()).Return(info, nil) + mocks.Store.EXPECT(). + PendingIntegrationRebuild(gomock.Any()). + Return(nil, state.ErrNotExist) + mocks.Worktree.EXPECT().CurrentBranch(gomock.Any()).Return("main", nil) + mocks.Worktree.EXPECT().IsClean(gomock.Any()).Return(true, nil) + mocks.Store.EXPECT().Trunk().Return("main").AnyTimes() + mocks.Repository.EXPECT(). + PeelToCommit(gomock.Any(), "main"). + Return(git.Hash("trunk-hash"), nil) + mocks.Service.EXPECT(). + LookupBranch(gomock.Any(), "feat-a"). + Return(&spice.LookupBranchResponse{}, nil) + mocks.Repository.EXPECT(). + PeelToCommit(gomock.Any(), "feat-a"). + Return(git.Hash("hash-a"), nil) + mocks.Worktree.EXPECT(). + CheckoutNewBranch(gomock.Any(), gomock.Any()). + Return(nil) + // Final state save + cleanup mocks expected by the happy path. + mocks.Store.EXPECT(). + SetIntegration(gomock.Any(), gomock.Any()). + Return(nil) + mocks.Store.EXPECT(). + ClearPendingIntegrationRebuild(gomock.Any()). + Return(nil) + mocks.Worktree.EXPECT(). + CheckoutBranch(gomock.Any(), "main"). + Return(nil) +} + +func TestHandler_Rebuild_regenerateInvokedWithLoggedPaths(t *testing.T) { + h, mocks, regenerator := newHandlerWithRegenerator(t) + setupSuccessfulRebuild(t, mocks) + + // Simulate the take-incoming merge driver writing two paths to + // the regen log during the merge. + mocks.Worktree.EXPECT(). + Merge(gomock.Any(), gomock.Any()). + Do(func(_ context.Context, opts git.MergeOptions) error { + writeRegenLog(t, opts, + "doc/includes/cli-shorthands.md", + "testdata/help/foo.txt") + return nil + }). + Return(nil) + + regenerator.EXPECT(). + Regenerate(gomock.Any(), []string{ + "doc/includes/cli-shorthands.md", + "testdata/help/foo.txt", + }). + Return(nil) + mocks.Worktree.EXPECT().AmendCommitAll(gomock.Any()).Return(nil) + + _, err := h.Rebuild(t.Context(), nil) + require.NoError(t, err) +} + +func TestHandler_Rebuild_regenerateDedupesPaths(t *testing.T) { + h, mocks, regenerator := newHandlerWithRegenerator(t) + setupSuccessfulRebuild(t, mocks) + + mocks.Worktree.EXPECT(). + Merge(gomock.Any(), gomock.Any()). + Do(func(_ context.Context, opts git.MergeOptions) error { + writeRegenLog(t, opts, "a", "b", "a", "b", "c") + return nil + }). + Return(nil) + + regenerator.EXPECT(). + Regenerate(gomock.Any(), []string{"a", "b", "c"}). + Return(nil) + mocks.Worktree.EXPECT().AmendCommitAll(gomock.Any()).Return(nil) + + _, err := h.Rebuild(t.Context(), nil) + require.NoError(t, err) +} + +func TestHandler_Rebuild_regenerateSkippedWhenLogEmpty(t *testing.T) { + h, mocks, _ := newHandlerWithRegenerator(t) + setupSuccessfulRebuild(t, mocks) + + // No log writes → no Regenerator.Regenerate call expected. + mocks.Worktree.EXPECT(). + Merge(gomock.Any(), gomock.Any()). + Return(nil) + + _, err := h.Rebuild(t.Context(), nil) + require.NoError(t, err) +} + +func TestHandler_Rebuild_regenerateErrorIsWarning(t *testing.T) { + h, mocks, regenerator := newHandlerWithRegenerator(t) + setupSuccessfulRebuild(t, mocks) + + mocks.Worktree.EXPECT(). + Merge(gomock.Any(), gomock.Any()). + Do(func(_ context.Context, opts git.MergeOptions) error { + writeRegenLog(t, opts, "foo.go") + return nil + }). + Return(nil) + + regenerator.EXPECT(). + Regenerate(gomock.Any(), gomock.Any()). + Return(errors.New("script blew up")) + // AmendCommitAll is called even when the regenerator failed: a + // partial run may have written real updates that must not be + // left dirty in the worktree. + mocks.Worktree.EXPECT().AmendCommitAll(gomock.Any()).Return(nil) + + res, err := h.Rebuild(t.Context(), nil) + require.NoError(t, err, "regen failure must not fail the rebuild") + require.NotNil(t, res) +} + +func TestHandler_Rebuild_regenerateAmendIsAlwaysCalledOnSuccess(t *testing.T) { + // On successful Regenerator.Regenerate, AmendCommitAll is always + // called. AmendCommitAll itself uses --allow-empty, so the case + // where the regenerator produced no changes is handled by git + // without gs needing a worktree-state check (which would miss + // untracked files anyway, see internal/git/files_wt.go IsClean). + h, mocks, regenerator := newHandlerWithRegenerator(t) + setupSuccessfulRebuild(t, mocks) + + mocks.Worktree.EXPECT(). + Merge(gomock.Any(), gomock.Any()). + Do(func(_ context.Context, opts git.MergeOptions) error { + writeRegenLog(t, opts, "foo.go") + return nil + }). + Return(nil) + + regenerator.EXPECT(). + Regenerate(gomock.Any(), []string{"foo.go"}). + Return(nil) + mocks.Worktree.EXPECT().AmendCommitAll(gomock.Any()).Return(nil) + + _, err := h.Rebuild(t.Context(), nil) + require.NoError(t, err) +} + +func TestHandler_Rebuild_regenerateNilSkipped(t *testing.T) { + // Default newHandler has nil Regenerator; even when paths get + // logged, no panic and no amend. + h, mocks := newHandler(t) + setupSuccessfulRebuild(t, mocks) + + mocks.Worktree.EXPECT(). + Merge(gomock.Any(), gomock.Any()). + Do(func(_ context.Context, opts git.MergeOptions) error { + writeRegenLog(t, opts, "foo.go") + return nil + }). + Return(nil) + + _, err := h.Rebuild(t.Context(), nil) + require.NoError(t, err) +} diff --git a/internal/handler/integration/mocks_test.go b/internal/handler/integration/mocks_test.go index a63c33d11..66ee3b7fb 100644 --- a/internal/handler/integration/mocks_test.go +++ b/internal/handler/integration/mocks_test.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: go.abhg.dev/gs/internal/handler/integration (interfaces: GitRepository,GitWorktree,Store,Service,Resolver,QuestionPrompter) +// Source: go.abhg.dev/gs/internal/handler/integration (interfaces: GitRepository,GitWorktree,Store,Service,Resolver,QuestionPrompter,Regenerator) // // Generated by this command: // -// mockgen -typed -destination mocks_test.go -package integration -write_package_comment=false . GitRepository,GitWorktree,Store,Service,Resolver,QuestionPrompter +// mockgen -typed -destination mocks_test.go -package integration -write_package_comment=false . GitRepository,GitWorktree,Store,Service,Resolver,QuestionPrompter,Regenerator // package integration @@ -183,6 +183,44 @@ func (m *MockGitWorktree) EXPECT() *MockGitWorktreeMockRecorder { return m.recorder } +// AmendCommitAll mocks base method. +func (m *MockGitWorktree) AmendCommitAll(ctx context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AmendCommitAll", ctx) + ret0, _ := ret[0].(error) + return ret0 +} + +// AmendCommitAll indicates an expected call of AmendCommitAll. +func (mr *MockGitWorktreeMockRecorder) AmendCommitAll(ctx any) *MockGitWorktreeAmendCommitAllCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AmendCommitAll", reflect.TypeOf((*MockGitWorktree)(nil).AmendCommitAll), ctx) + return &MockGitWorktreeAmendCommitAllCall{Call: call} +} + +// MockGitWorktreeAmendCommitAllCall wrap *gomock.Call +type MockGitWorktreeAmendCommitAllCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockGitWorktreeAmendCommitAllCall) Return(arg0 error) *MockGitWorktreeAmendCommitAllCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockGitWorktreeAmendCommitAllCall) Do(f func(context.Context) error) *MockGitWorktreeAmendCommitAllCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockGitWorktreeAmendCommitAllCall) DoAndReturn(f func(context.Context) error) *MockGitWorktreeAmendCommitAllCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // CheckoutBranch mocks base method. func (m *MockGitWorktree) CheckoutBranch(ctx context.Context, branch string) error { m.ctrl.T.Helper() @@ -970,3 +1008,65 @@ func (c *MockQuestionPrompterAskAnswersCall) DoAndReturn(f func(context.Context, c.Call = c.Call.DoAndReturn(f) return c } + +// MockRegenerator is a mock of Regenerator interface. +type MockRegenerator struct { + ctrl *gomock.Controller + recorder *MockRegeneratorMockRecorder + isgomock struct{} +} + +// MockRegeneratorMockRecorder is the mock recorder for MockRegenerator. +type MockRegeneratorMockRecorder struct { + mock *MockRegenerator +} + +// NewMockRegenerator creates a new mock instance. +func NewMockRegenerator(ctrl *gomock.Controller) *MockRegenerator { + mock := &MockRegenerator{ctrl: ctrl} + mock.recorder = &MockRegeneratorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockRegenerator) EXPECT() *MockRegeneratorMockRecorder { + return m.recorder +} + +// Regenerate mocks base method. +func (m *MockRegenerator) Regenerate(ctx context.Context, paths []string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Regenerate", ctx, paths) + ret0, _ := ret[0].(error) + return ret0 +} + +// Regenerate indicates an expected call of Regenerate. +func (mr *MockRegeneratorMockRecorder) Regenerate(ctx, paths any) *MockRegeneratorRegenerateCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Regenerate", reflect.TypeOf((*MockRegenerator)(nil).Regenerate), ctx, paths) + return &MockRegeneratorRegenerateCall{Call: call} +} + +// MockRegeneratorRegenerateCall wrap *gomock.Call +type MockRegeneratorRegenerateCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockRegeneratorRegenerateCall) Return(arg0 error) *MockRegeneratorRegenerateCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockRegeneratorRegenerateCall) Do(f func(context.Context, []string) error) *MockRegeneratorRegenerateCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockRegeneratorRegenerateCall) DoAndReturn(f func(context.Context, []string) error) *MockRegeneratorRegenerateCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/handler/integration/regenerator.go b/internal/handler/integration/regenerator.go new file mode 100644 index 000000000..f925526f8 --- /dev/null +++ b/internal/handler/integration/regenerator.go @@ -0,0 +1,110 @@ +package integration + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "go.abhg.dev/gs/internal/scriptrun" + "go.abhg.dev/gs/internal/silog" +) + +// RegenerateFileName is the well-known path (relative to the repo +// root) of the post-merge regenerator script invoked by gs after a +// successful integration rebuild. If absent or non-executable, gs +// silently skips the regeneration step. +const RegenerateFileName = ".gs/integration-regenerate" + +// Regenerator runs a project-level script with a list of paths whose +// conflicts the regenerate merge driver resolved by taking incoming. +// +// The script is expected to re-derive any of those paths that need it +// (for example, by re-running mockgen if a mock file was in the list). +// gs hands the script the list on stdin, in repo-root cwd; the +// script's output (worktree modifications) is folded into the last +// merge commit by the caller via Worktree.AmendCommitAll. +type Regenerator interface { + Regenerate(ctx context.Context, paths []string) error +} + +// ScriptRunner is the subset of [scriptrun.Runner] used by +// [FileRegenerator]. It is named here so tests can supply a fake +// without taking a dependency on scriptrun's concrete type. +// +// [ScriptResolver] (in resolver.go) declares its own ScriptRunner +// alias; both refer to the same shape. The interface lives in this +// file too to avoid coupling regenerator.go to resolver.go. +type regeneratorScriptRunner interface { + Run(ctx context.Context, req *scriptrun.RunRequest) (*scriptrun.RunResult, error) +} + +// FileRegenerator looks for [RegenerateFileName] in the repo root. +// If it exists and is executable, FileRegenerator reads its contents +// and invokes the script via [scriptrun.Runner] with the list of +// paths on stdin. +// +// If the file does not exist, [FileRegenerator.Regenerate] returns +// nil (no-op). This is intentional: projects without a regenerator +// script get the take-incoming merge driver behavior with no extra +// overhead. +type FileRegenerator struct { + Log *silog.Logger + Runner regeneratorScriptRunner + RepoRoot string +} + +// Regenerate reads the project regenerator script and invokes it with +// the deduped path list on stdin. +// +// Returns nil if the script does not exist. Returns an error if the +// script exists but is not executable, if it cannot be read, or if it +// exits with a non-zero code. The caller decides whether to treat +// the error as fatal or a warning. +func (r *FileRegenerator) Regenerate( + ctx context.Context, paths []string, +) error { + scriptPath := filepath.Join(r.RepoRoot, RegenerateFileName) + info, err := os.Stat(scriptPath) + switch { + case errors.Is(err, fs.ErrNotExist): + return nil + case err != nil: + return fmt.Errorf("stat %s: %w", RegenerateFileName, err) + } + + if info.Mode()&0o111 == 0 { + return fmt.Errorf( + "%s exists but is not executable; chmod +x to enable", + RegenerateFileName) + } + + body, err := os.ReadFile(scriptPath) + if err != nil { + return fmt.Errorf("read %s: %w", RegenerateFileName, err) + } + + var stdin bytes.Buffer + for _, p := range paths { + stdin.WriteString(p) + stdin.WriteByte('\n') + } + + res, err := r.Runner.Run(ctx, &scriptrun.RunRequest{ + Script: string(body), + Dir: r.RepoRoot, + Stdin: &stdin, + }) + if err != nil { + return fmt.Errorf("run %s: %w", RegenerateFileName, err) + } + if res.ExitCode != 0 { + return fmt.Errorf("%s exited with code %d: %s", + RegenerateFileName, res.ExitCode, + bytes.TrimSpace(res.Stderr)) + } + return nil +} diff --git a/internal/handler/integration/regenerator_test.go b/internal/handler/integration/regenerator_test.go new file mode 100644 index 000000000..e447b1b02 --- /dev/null +++ b/internal/handler/integration/regenerator_test.go @@ -0,0 +1,147 @@ +package integration_test + +import ( + "context" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.abhg.dev/gs/internal/handler/integration" + "go.abhg.dev/gs/internal/scriptrun" + "go.abhg.dev/gs/internal/silog" +) + +// fakeRegenRunner records the most recent script invocation and +// returns a canned result. Tests use it to inspect what the +// regenerator sent to the script. +type fakeRegenRunner struct { + lastScript string + lastDir string + lastStdin string + result *scriptrun.RunResult + err error +} + +func (f *fakeRegenRunner) Run( + _ context.Context, req *scriptrun.RunRequest, +) (*scriptrun.RunResult, error) { + f.lastScript = req.Script + f.lastDir = req.Dir + if req.Stdin != nil { + data, _ := io.ReadAll(req.Stdin) + f.lastStdin = string(data) + } + if f.err != nil { + return nil, f.err + } + return f.result, nil +} + +func writeExecutable(t *testing.T, path, body string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(body), 0o700)) +} + +func TestFileRegenerator_absent(t *testing.T) { + dir := t.TempDir() + runner := &fakeRegenRunner{} + r := &integration.FileRegenerator{ + Log: silog.Nop(), + Runner: runner, + RepoRoot: dir, + } + + require.NoError(t, r.Regenerate(t.Context(), []string{"a", "b"})) + assert.Empty(t, runner.lastScript, + "runner must not be invoked when script is absent") +} + +func TestFileRegenerator_notExecutable(t *testing.T) { + dir := t.TempDir() + scriptPath := filepath.Join(dir, integration.RegenerateFileName) + require.NoError(t, os.MkdirAll(filepath.Dir(scriptPath), 0o755)) + require.NoError(t, os.WriteFile(scriptPath, []byte("#!/bin/sh\n"), 0o600)) + + r := &integration.FileRegenerator{ + Log: silog.Nop(), + Runner: &fakeRegenRunner{}, + RepoRoot: dir, + } + + err := r.Regenerate(t.Context(), []string{"a"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not executable") +} + +func TestFileRegenerator_invokesScript(t *testing.T) { + dir := t.TempDir() + scriptBody := "#!/bin/sh\necho stub\n" + writeExecutable(t, + filepath.Join(dir, integration.RegenerateFileName), scriptBody) + + runner := &fakeRegenRunner{ + result: &scriptrun.RunResult{ExitCode: 0}, + } + r := &integration.FileRegenerator{ + Log: silog.Nop(), + Runner: runner, + RepoRoot: dir, + } + + require.NoError(t, + r.Regenerate(t.Context(), []string{"foo.go", "bar.go"})) + + assert.Equal(t, scriptBody, runner.lastScript) + assert.Equal(t, dir, runner.lastDir) + assert.Equal(t, "foo.go\nbar.go\n", runner.lastStdin) +} + +func TestFileRegenerator_emptyPaths(t *testing.T) { + dir := t.TempDir() + writeExecutable(t, + filepath.Join(dir, integration.RegenerateFileName), + "#!/bin/sh\n") + + runner := &fakeRegenRunner{ + result: &scriptrun.RunResult{ExitCode: 0}, + } + r := &integration.FileRegenerator{ + Log: silog.Nop(), + Runner: runner, + RepoRoot: dir, + } + + // Empty path list: the script is still invoked (the handler + // decides whether to call us; we don't second-guess) but stdin + // is empty. + require.NoError(t, r.Regenerate(t.Context(), nil)) + assert.Empty(t, runner.lastStdin) +} + +func TestFileRegenerator_nonZeroExit(t *testing.T) { + dir := t.TempDir() + writeExecutable(t, + filepath.Join(dir, integration.RegenerateFileName), + "#!/bin/sh\n") + + runner := &fakeRegenRunner{ + result: &scriptrun.RunResult{ + ExitCode: 3, + Stderr: []byte("regen blew up"), + }, + } + r := &integration.FileRegenerator{ + Log: silog.Nop(), + Runner: runner, + RepoRoot: dir, + } + + err := r.Regenerate(t.Context(), []string{"foo"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "exited with code 3") + assert.Contains(t, err.Error(), "regen blew up") +} diff --git a/main.go b/main.go index 00f0e0b4e..702602006 100644 --- a/main.go +++ b/main.go @@ -733,6 +733,14 @@ func (cmd *mainCmd) AfterApply( DefaultAutoResolve: cfg.IntegrationAutoResolve(), RepoRoot: repoRoot, MaxResolveIterations: cfg.ScriptResolveMaxIterations(), + Regenerator: &integration.FileRegenerator{ + Log: log, + Runner: &scriptrun.Runner{ + Log: log, + Args: os.Args, + }, + RepoRoot: repoRoot, + }, }, nil }), ) diff --git a/mise.toml b/mise.toml index 40126ccf5..6f0b88f44 100644 --- a/mise.toml +++ b/mise.toml @@ -44,8 +44,14 @@ run = [ [tasks.generate] depends = ["tools"] description = "Update generated code" +# Run mock generation before any other directive that needs to compile +# tests (e.g. help_test.go's `go test -run TestHelp -update`). All +# //go:generate mockgen directives live under internal/, so generating +# that subtree first lets the root-package directives see fresh mocks +# when their build runs. run = [ - "go generate -x ./...", + "go generate -x ./internal/...", + "go generate -x .", "(cd doc && mise run generate)", ] diff --git a/testdata/script/integration_regenerate_absent.txt b/testdata/script/integration_regenerate_absent.txt new file mode 100644 index 000000000..055e26070 --- /dev/null +++ b/testdata/script/integration_regenerate_absent.txt @@ -0,0 +1,55 @@ +# When .gs/integration-regenerate does not exist, gs silently skips +# the post-merge regenerator step. The merge driver still resolves +# conflicts via take-incoming. + +as 'Test ' +at '2025-01-01T00:00:00Z' + +chmod 700 $WORK/driver.sh + +cd repo +git init + +git config merge.regenerate.driver $WORK/driver.sh' %O %A %B %P' +git config merge.regenerate.name 'take incoming + log' + +git add derived.txt .gitattributes +git commit -m 'Initial commit' +gs repo init + +cp feat-a-version.txt derived.txt +git add derived.txt +gs bc feat-a -m 'feat-a edits derived.txt' + +git checkout main +cp feat-b-version.txt derived.txt +git add derived.txt +gs bc feat-b -m 'feat-b edits derived.txt' + +git checkout main +gs integration create preview --tip feat-a --tip feat-b + +# .gs/integration-regenerate intentionally NOT created. + +gs integration rebuild +stderr 'rebuilt with 2 tip\(s\)' + +# Final commit should reflect the take-incoming resolution +# (feat-b is the second tip merged). +git show preview:derived.txt +stdout 'feat-b content' + +-- repo/derived.txt -- +base content +-- repo/feat-a-version.txt -- +feat-a content +-- repo/feat-b-version.txt -- +feat-b content +-- repo/.gitattributes -- +derived.txt merge=regenerate +-- driver.sh -- +#!/bin/sh +cp -f "$3" "$2" +if [ -n "${GS_INTEGRATION_REGEN_LOG:-}" ]; then + printf '%s\n' "$4" >> "$GS_INTEGRATION_REGEN_LOG" +fi diff --git a/testdata/script/integration_regenerate_failure.txt b/testdata/script/integration_regenerate_failure.txt new file mode 100644 index 000000000..201fece75 --- /dev/null +++ b/testdata/script/integration_regenerate_failure.txt @@ -0,0 +1,64 @@ +# When .gs/integration-regenerate exits non-zero, gs treats it as a +# warning: the rebuild still succeeds with the take-incoming +# resolution, integration state is recorded, and the warning is +# logged. + +as 'Test ' +at '2025-01-01T00:00:00Z' + +chmod 700 $WORK/driver.sh +chmod 700 $WORK/regenerate.sh + +cd repo +git init + +git config merge.regenerate.driver $WORK/driver.sh' %O %A %B %P' +git config merge.regenerate.name 'take incoming + log' + +git add derived.txt .gitattributes +git commit -m 'Initial commit' +gs repo init + +cp feat-a-version.txt derived.txt +git add derived.txt +gs bc feat-a -m 'feat-a edits derived.txt' + +git checkout main +cp feat-b-version.txt derived.txt +git add derived.txt +gs bc feat-b -m 'feat-b edits derived.txt' + +git checkout main +gs integration create preview --tip feat-a --tip feat-b + +mkdir .gs +cp $WORK/regenerate.sh .gs/integration-regenerate +chmod 700 .gs/integration-regenerate + +# Rebuild succeeds despite the regenerator failing. +gs integration rebuild +stderr 'regenerator:' +stderr 'rebuilt with 2 tip\(s\)' + +# Final commit still reflects the take-incoming resolution. +git show preview:derived.txt +stdout 'feat-b content' + +-- repo/derived.txt -- +base content +-- repo/feat-a-version.txt -- +feat-a content +-- repo/feat-b-version.txt -- +feat-b content +-- repo/.gitattributes -- +derived.txt merge=regenerate +-- driver.sh -- +#!/bin/sh +cp -f "$3" "$2" +if [ -n "${GS_INTEGRATION_REGEN_LOG:-}" ]; then + printf '%s\n' "$4" >> "$GS_INTEGRATION_REGEN_LOG" +fi +-- regenerate.sh -- +#!/bin/sh +echo 'broken regenerator' >&2 +exit 1 diff --git a/testdata/script/integration_regenerate_invoked.txt b/testdata/script/integration_regenerate_invoked.txt new file mode 100644 index 000000000..4b33f6c25 --- /dev/null +++ b/testdata/script/integration_regenerate_invoked.txt @@ -0,0 +1,68 @@ +# Verifies the post-merge regenerator step: when two tips conflict on +# a path tagged `merge=regenerate` and `.gs/integration-regenerate` +# exists, gs hands the script the conflict list and folds its output +# into the final merge commit. + +as 'Test ' +at '2025-01-01T00:00:00Z' + +chmod 700 $WORK/driver.sh +chmod 700 $WORK/regenerate.sh + +cd repo +git init + +# Configure the regenerate merge driver pointing at our take-incoming +# driver, and tag derived.txt as merge=regenerate. +env DRIVER_CMD=$WORK/driver.sh' %O %A %B %P' +git config merge.regenerate.driver $DRIVER_CMD +git config merge.regenerate.name 'take incoming + log' + +git add derived.txt .gitattributes +git commit -m 'Initial commit' +gs repo init + +cp feat-a-version.txt derived.txt +git add derived.txt +gs bc feat-a -m 'feat-a edits derived.txt' + +git checkout main +cp feat-b-version.txt derived.txt +git add derived.txt +gs bc feat-b -m 'feat-b edits derived.txt' + +git checkout main +gs integration create preview --tip feat-a --tip feat-b + +# Make .gs/integration-regenerate executable. It writes a marker file +# (which gs will fold into the merge commit via AmendCommitAll). +mkdir .gs +cp $WORK/regenerate.sh .gs/integration-regenerate +chmod 700 .gs/integration-regenerate + +gs integration rebuild +stderr 'rebuilt with 2 tip\(s\)' + +# The marker file should be in HEAD (folded into the last merge +# commit via amend), and it should record the path the driver logged. +git show preview:marker.txt +stdout 'regenerated:derived\.txt' + +-- repo/derived.txt -- +base content +-- repo/feat-a-version.txt -- +feat-a content +-- repo/feat-b-version.txt -- +feat-b content +-- repo/.gitattributes -- +derived.txt merge=regenerate +-- driver.sh -- +#!/bin/sh +cp -f "$3" "$2" +if [ -n "${GS_INTEGRATION_REGEN_LOG:-}" ]; then + printf '%s\n' "$4" >> "$GS_INTEGRATION_REGEN_LOG" +fi +-- regenerate.sh -- +#!/bin/sh +files=$(cat) +printf 'regenerated:%s\n' "$files" > marker.txt diff --git a/testdata/script/integration_regenerate_no_conflicts.txt b/testdata/script/integration_regenerate_no_conflicts.txt new file mode 100644 index 000000000..517028deb --- /dev/null +++ b/testdata/script/integration_regenerate_no_conflicts.txt @@ -0,0 +1,51 @@ +# When a rebuild produces no derived-file conflicts, gs must NOT +# invoke .gs/integration-regenerate even if it exists. Verified via a +# sentinel file the script would touch. + +as 'Test ' +at '2025-01-01T00:00:00Z' + +chmod 700 $WORK/regenerate.sh + +cd repo +git init +git add file.txt +git commit -m 'Initial commit' +gs repo init + +# Two non-conflicting tips: each touches a distinct file. No merges +# will conflict and therefore the regenerate driver is never invoked +# and nothing gets logged. +git checkout -b feat-a +git add a.txt +gs branch track --base main +git commit -m 'feat-a adds a.txt' + +git checkout main +git checkout -b feat-b +git add b.txt +gs branch track --base main +git commit -m 'feat-b adds b.txt' + +git checkout main +gs integration create preview --tip feat-a --tip feat-b + +mkdir .gs +cp $WORK/regenerate.sh .gs/integration-regenerate +chmod 700 .gs/integration-regenerate + +gs integration rebuild +stderr 'rebuilt with 2 tip\(s\)' + +# Sentinel must NOT exist — regenerator should not have been invoked. +! exists $WORK/repo/sentinel + +-- repo/file.txt -- +base +-- repo/a.txt -- +a content +-- repo/b.txt -- +b content +-- regenerate.sh -- +#!/bin/sh +touch sentinel