Skip to content
Open
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
3 changes: 3 additions & 0 deletions .changes/unreleased/Added-20260528-072610.yaml
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions .gs/integration-regenerate
Original file line number Diff line number Diff line change
@@ -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
36 changes: 21 additions & 15 deletions doc/includes/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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}

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

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

Expand Down
97 changes: 97 additions & 0 deletions doc/src/guide/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<!-- gs:version unreleased -->

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
Expand Down
28 changes: 28 additions & 0 deletions internal/git/merge_wt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Loading
Loading