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-20260602-160842.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Added
body: 'integration: Add ''regenerate'' merge driver that re-runs generators for derived files (mocks, CLI ref, shamhub fixtures, help text) when they conflict during merge, instead of picking one side'
time: 2026-06-02T16:08:42.565148-04:00
24 changes: 24 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,27 @@ doc/ export-ignore
# mise files that don't need to be seen in diffs by default.
mise.lock linguist-generated
/bin/mise lingust-generated

# Auto-resolve merge conflicts for files that are derived from source
# or test runs (CLI reference, help fixtures, mocks, recorded test
# fixtures). On conflict, the `regenerate` driver re-runs the
# appropriate generator against the merged source rather than picking
# a side. Picking a side is dangerous here because either side may
# carry structural changes (a new flag, a new test case, a new mock
# method) that the random/stochastic diff hides.
#
# To activate, run `mise run setup` once after cloning to register the
# `regenerate` merge driver and the .githooks path.

# Deterministically-generated CLI documentation and help fixtures.
doc/includes/cli-reference.md merge=regenerate
doc/includes/cli-shorthands.md merge=regenerate
testdata/help/*.txt merge=regenerate

# Generated mock files.
**/mocks_test.go merge=regenerate
**/mock_*.go merge=regenerate
**/mocks.go merge=regenerate

# Recorded ShamHub HTTP/test fixtures with stochastic IDs.
internal/forge/shamhub/testdata/** merge=regenerate
40 changes: 40 additions & 0 deletions .githooks/install-attributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/bin/sh
# install-attributes — install the regenerate merge driver into the
# per-clone .git/info directory so it works regardless of the
# currently-checked-out branch.
#
# We install BOTH the path mappings (.git/info/attributes) and the
# driver script itself (.git/info/merge-regenerate) into .git/info
# because both move when the worktree branch changes:
#
# - The tracked .gitattributes carrying the merge=regenerate lines
# only exists where this branch has been merged in. During
# `gs integration rebuild` the worktree is reset to trunk, so
# those lines wouldn't apply until they reach trunk.
# - The tracked .githooks/merge-regenerate script has the same
# problem: trunk's worktree doesn't carry the script, so git
# would not find the driver to invoke.
#
# .git/info is per-clone state untouched by branch checkout, so both
# the mappings and the script stay in scope. We re-copy on every
# setup invocation so updates take effect immediately.
set -eu

root="$(git rev-parse --show-toplevel)"
info="$root/.git/info"
mkdir -p "$info"

cat > "$info/attributes" <<'EOF'
# Managed by `.githooks/install-attributes` (invoked by `mise run setup`).
# Mirrors the regenerate-driver block in the tracked .gitattributes.
doc/includes/cli-reference.md merge=regenerate
doc/includes/cli-shorthands.md merge=regenerate
testdata/help/*.txt merge=regenerate
**/mocks_test.go merge=regenerate
**/mock_*.go merge=regenerate
**/mocks.go merge=regenerate
internal/forge/shamhub/testdata/** merge=regenerate
EOF

cp "$root/.githooks/merge-regenerate" "$info/merge-regenerate"
chmod +x "$info/merge-regenerate"
29 changes: 29 additions & 0 deletions .githooks/merge-regenerate
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# merge-regenerate — silent take-incoming merge driver for derived files.
#
# Files tagged `merge=regenerate` in .gitattributes have their merge
# conflicts silently resolved by taking the incoming version. Each
# invocation logs the conflicted path to $GS_INTEGRATION_REGEN_LOG (if
# set), so `gs integration rebuild` can invoke a project-level
# regenerator script after all tip merges complete.
#
# Why take-incoming + post-merge regen instead of regen-in-driver:
# running generators from inside the merge driver dirtied the worktree
# (`mise generate`, `go test -update` write to many files) and caused
# subsequent merge steps to abort with "your local changes would be
# overwritten." The git index is held by the merge for the duration,
# so `git add` from inside the driver also fails. The fix is to move
# regeneration out of the merge entirely; see
# `.gs/integration-regenerate` and the gs integration handler.
#
# Args (git merge driver protocol):
# $1 = %O ancestor version (base)
# $2 = %A current version (ours) — also where we must write the result
# $3 = %B incoming version (theirs)
# $4 = %P the path being merged
set -euo pipefail

cp -f "$3" "$2"
if [ -n "${GS_INTEGRATION_REGEN_LOG:-}" ]; then
printf '%s\n' "$4" >> "$GS_INTEGRATION_REGEN_LOG"
fi
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ If you don't have mise set up, you may:

See available tasks with `mise tasks` in the project directory.

Run `mise run setup` once after cloning to register the local
merge driver and post-merge hook used to auto-resolve and refresh
generated-file conflicts. This is optional but strongly recommended
when working with integration branches (see
[Integration branches](doc/src/guide/integration.md)).

Tasks you'll need to run regularly are:

```
Expand Down
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
23 changes: 23 additions & 0 deletions doc/src/guide/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,29 @@ pending-rebuild state remains; clear it by re-configuring the
integration with `gs integration delete` followed by `gs integration
create`.

### Generated-file conflicts

Some files in the repository are derived from source or test runs
(CLI reference, help fixtures, mocks, recorded ShamHub fixtures).
When two branches both touch them, the conflicts mix stochastic
noise (random IDs) with real structural changes (a new flag, a new
test case, a new mock method). Picking either side blindly silently
drops the structural change from the other branch.

The `.gitattributes` file declares a `regenerate` merge driver for
these paths. The driver re-runs the appropriate generator against
the merged source so the output reflects both branches' real
changes. To activate, run once after cloning:

```freeze language="terminal"
{green}${reset} mise run setup
```

After that, `gs intrb` (and any other `git merge` in the repo) will
auto-resolve generated-file conflicts by regenerating, not by
picking a side. Each generator runs at most once per merge even when
many files in its output set conflict.

## Switching to the integration branch

Use $$gs integration checkout$$ (shorthand: `gs intco`) to switch the
Expand Down
14 changes: 14 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@ golangci-lint = "latest"
changie = "latest"
requiredfield = "0.9.0"

[tasks.setup]
description = "One-time repo configuration (merge driver)"
run = [
# Copy the path mappings AND the driver script into .git/info so
# they apply regardless of branch state. The integration rebuild
# resets the worktree to trunk before merging tips; both the
# tracked .gitattributes lines and the tracked driver script
# otherwise disappear from view during that merge.
".githooks/install-attributes",
"git config merge.regenerate.name 'regenerate derived files on conflict'",
"git config merge.regenerate.driver \"$(git rev-parse --git-path info)/merge-regenerate %O %A %B %P\"",
"git config merge.regenerate.recursive binary",
]

[tasks.generate]
depends = ["tools"]
description = "Update generated code"
Expand Down
Loading