diff --git a/.changes/unreleased/Added-20260602-160842.yaml b/.changes/unreleased/Added-20260602-160842.yaml new file mode 100644 index 000000000..020d1d32e --- /dev/null +++ b/.changes/unreleased/Added-20260602-160842.yaml @@ -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 diff --git a/.gitattributes b/.gitattributes index 4e1bff220..3912006b5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/.githooks/install-attributes b/.githooks/install-attributes new file mode 100755 index 000000000..8266ec45a --- /dev/null +++ b/.githooks/install-attributes @@ -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" diff --git a/.githooks/merge-regenerate b/.githooks/merge-regenerate new file mode 100755 index 000000000..90a3259f9 --- /dev/null +++ b/.githooks/merge-regenerate @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 88c2540be..5179cdddc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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: ``` diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index 898fc4542..796a421e8 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 a871a6042..3bb90a49a 100644 --- a/doc/src/guide/integration.md +++ b/doc/src/guide/integration.md @@ -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 diff --git a/mise.toml b/mise.toml index 80f53dae9..40126ccf5 100644 --- a/mise.toml +++ b/mise.toml @@ -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"