From e7c3ff4ac97fe9d2baff84c2ca218550fe83a33e Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Thu, 28 May 2026 07:27:55 -0400 Subject: [PATCH 1/5] integration: Move regeneration out of merge driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regenerate merge driver previously ran generators (mise generate, go test -update) from inside git's merge process. Those generators side-effect on many files in one pass, and git aborts subsequent merge steps with 'your local changes would be overwritten' when it sees a worktree dirtied by an earlier driver invocation. The existing stage_outputs workaround called git add from inside the driver to stage the side effects, but git holds the index lock for the duration of the merge — git add silently failed with rc=128 every time. This commit moves regeneration out of the merge driver entirely: - The driver becomes take-incoming + log: cp the incoming version into place, append the path to the file named by $GS_INTEGRATION_REGEN_LOG. No side effects. - gs runs all tip merges with that env var pointing at a per-merge temp file, accumulates the deduplicated path list across the rebuild, and invokes .gs/integration-regenerate (a project-level executable at the repo root) with the list on stdin. The script chooses what to regenerate based on which categories of files appear in its input — so a rebuild with no derived conflicts pays one process spawn and exits. - Whatever the script wrote to the worktree gets folded into the last merge commit via git add -A && git commit --amend --no-edit --allow-empty. The contract is well-orthogonalized: gs hands the script an allow-listed list of paths (only files tagged merge=regenerate in .gitattributes can appear there), the script returns by writing to the worktree, gs takes care of the commit-amend wrap-up. Project generator knowledge stays in the project's script; gs doesn't carry a dispatch table. --- .../unreleased/Added-20260528-072610.yaml | 3 + .gs/integration-regenerate | 46 ++++ doc/includes/cli-reference.md | 164 +------------ doc/src/guide/integration.md | 97 ++++++++ internal/git/merge_wt.go | 28 +++ internal/git/merge_wt_test.go | 161 +++++++++++++ internal/handler/integration/handler.go | 91 ++++++- internal/handler/integration/handler_test.go | 228 ++++++++++++++++++ internal/handler/integration/mocks_test.go | 189 ++++++++------- internal/handler/integration/regenerator.go | 110 +++++++++ .../handler/integration/regenerator_test.go | 147 +++++++++++ main.go | 8 + mise.toml | 8 +- .../script/integration_regenerate_absent.txt | 55 +++++ .../script/integration_regenerate_failure.txt | 64 +++++ .../script/integration_regenerate_invoked.txt | 68 ++++++ .../integration_regenerate_no_conflicts.txt | 51 ++++ 17 files changed, 1279 insertions(+), 239 deletions(-) create mode 100644 .changes/unreleased/Added-20260528-072610.yaml create mode 100755 .gs/integration-regenerate create mode 100644 internal/handler/integration/regenerator.go create mode 100644 internal/handler/integration/regenerator_test.go create mode 100644 testdata/script/integration_regenerate_absent.txt create mode 100644 testdata/script/integration_regenerate_failure.txt create mode 100644 testdata/script/integration_regenerate_invoked.txt create mode 100644 testdata/script/integration_regenerate_no_conflicts.txt 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..05a80d83d 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -12,7 +12,7 @@ git-spice is a command line tool for stacking Git branches. * `-C`, `--dir=DIR`: Change to DIR before doing anything * `--[no-]prompt`: Whether to prompt for missing information -**Configuration**: [spice.forge.bitbucket.apiURL](/cli/config.md#spiceforgebitbucketapiurl), [spice.forge.bitbucket.url](/cli/config.md#spiceforgebitbucketurl), [spice.forge.github.apiUrl](/cli/config.md#spiceforgegithubapiurl), [spice.forge.github.url](/cli/config.md#spiceforgegithuburl), [spice.forge.gitlab.apiURL](/cli/config.md#spiceforgegitlabapiurl), [spice.forge.gitlab.oauth.clientID](/cli/config.md#spiceforgegitlaboauthclientid), [spice.forge.gitlab.removeSourceBranch](/cli/config.md#spiceforgegitlabremovesourcebranch), [spice.forge.gitlab.url](/cli/config.md#spiceforgegitlaburl), [spice.forge.kind](/cli/config.md#spiceforgekind), [spice.git.indexLockTimeout](/cli/config.md#spicegitindexlocktimeout), [spice.secret.backend](/cli/config.md#spicesecretbackend) +**Configuration**: [spice.forge.bitbucket.apiURL](/cli/config.md#spiceforgebitbucketapiurl), [spice.forge.bitbucket.url](/cli/config.md#spiceforgebitbucketurl), [spice.forge.github.apiUrl](/cli/config.md#spiceforgegithubapiurl), [spice.forge.github.url](/cli/config.md#spiceforgegithuburl), [spice.forge.gitlab.apiURL](/cli/config.md#spiceforgegitlabapiurl), [spice.forge.gitlab.oauth.clientID](/cli/config.md#spiceforgegitlaboauthclientid), [spice.forge.gitlab.removeSourceBranch](/cli/config.md#spiceforgegitlabremovesourcebranch), [spice.forge.gitlab.url](/cli/config.md#spiceforgegitlaburl), [spice.git.indexLockTimeout](/cli/config.md#spicegitindexlocktimeout), [spice.secret.backend](/cli/config.md#spicesecretbackend) ## Shell @@ -156,17 +156,11 @@ The repository must have a remote associated for syncing. A prompt will ask for one if the repository was not initialized with a remote. -Branches above merged and deleted branches -are retargeted to the trunk branch. -Run with --restack to also restack them and their upstacks. -Run with --restack=aboves to only restack direct upstacks -of deleted branches, leaving higher branches in place. - **Flags** -* `--restack` ([:material-wrench:{ .middle title="spice.repoSync.restack" }](/cli/config.md#spicereposyncrestack)): How to restack branches above deleted branches. One of 'none', 'aboves', and 'upstack'. +* `--restack`: Restack the current stack after syncing -**Configuration**: [spice.repoSync.closedChanges](/cli/config.md#spicereposyncclosedchanges), [spice.repoSync.restack](/cli/config.md#spicereposyncrestack) +**Configuration**: [spice.repoSync.closedChanges](/cli/config.md#spicereposyncclosedchanges) ### git-spice repo restack {#gs-repo-restack} @@ -284,49 +278,7 @@ only if there are multiple CRs in the stack. * `-a`, `--assign=ASSIGNEE,...`: Assign the change request to these users. Pass multiple times or separate with commas. :material-tag:{ title="Released in version" }[v0.21.0](/changelog.md#v0.21.0) * `--no-web`: Alias for --web=false. -**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) - -### git-spice stack merge {#gs-stack-merge} - -``` -gs stack (s) merge (m) [flags] -``` - -:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) - -Merge a stack - -Merges the CRs for the current branch's stack into trunk. -Use --branch to merge a different branch's stack. - -The stack includes the selected branch, -its downstack branches down to trunk, -and every upstack branch. - -Already-merged branches are skipped automatically. -Branches must have an open Change Request to be merged. - -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. - -By default, a branch failure skips that branch's upstack descendants, -but independent sibling branches continue. -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. -* `--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.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.label.addWhen](/cli/config.md#spicesubmitlabeladdwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) ### git-spice stack restack {#gs-stack-restack} @@ -449,7 +401,7 @@ only if there are multiple CRs in the stack. * `--no-web`: Alias for --web=false. * `--branch=NAME`: Branch to start at -**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) +**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.label.addWhen](/cli/config.md#spicesubmitlabeladdwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) ### git-spice upstack restack {#gs-upstack-restack} @@ -604,66 +556,7 @@ only if there are multiple CRs in the stack. * `--no-web`: Alias for --web=false. * `--branch=NAME`: Branch to start at -**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) - -### git-spice downstack merge {#gs-downstack-merge} - -``` -gs downstack (ds) merge (m) [flags] -``` - -:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) - -Merge a branch and those below it - -Merges the current branch and all branches below it -into trunk via the forge API, bottom-up. -Use --branch to start at a different branch. - -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, -and then repeats the process. - -For a stack like this: - - main <- feature1 <- feature2 <- feature3 - -Running from feature3 merges in this order: - - feature1, feature2, feature3 - -Already-merged branches are skipped automatically. -Branches must have an open Change Request to be merged. - -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 -(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, -and syncs merged branch cleanup. - -Use --no-wait for single branch merging -when you don't want to wait for the merge to propagate. ---no-wait is rejected for multi-branch merges. - -**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. -* `--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.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.label.addWhen](/cli/config.md#spicesubmitlabeladdwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) ### git-spice downstack edit {#gs-downstack-edit} @@ -871,13 +764,10 @@ gs branch (b) delete (d,rm) [ ...] [flags] Delete branches The deleted branches and their commits are removed from the stack. -Branches above the deleted branches are retargeted onto +Branches above the deleted branches are first rebased onto the next branches available downstack, or onto trunk if there are no branches available below. -Use --restack to rebase those branches and their upstacks -immediately after retargeting. - Without any arguments, a prompt will allow selecting the branch to delete. @@ -892,9 +782,8 @@ Use --force to delete the branch regardless of unmerged changes. **Flags** * `--force`: Force deletion of the branch -* `--restack` ([:material-wrench:{ .middle title="spice.branchDelete.restack" }](/cli/config.md#spicebranchdeleterestack)): How to restack branches above deleted branches. One of 'none', 'aboves', and 'upstack'. -**Configuration**: [spice.branchDelete.restack](/cli/config.md#spicebranchdeleterestack), [spice.branchPrompt.sort](/cli/config.md#spicebranchpromptsort) +**Configuration**: [spice.branchPrompt.sort](/cli/config.md#spicebranchpromptsort) ### git-spice branch fold {#gs-branch-fold} @@ -1064,12 +953,9 @@ Commits of the current branch are transplanted onto another branch while leaving the rest of the stack intact. That is, branches above the current branch -are retargeted onto its original base, +are first rebased onto its original base, and then the current branch is moved onto the new base. -Use --restack to rebase those branches and their upstacks -immediately after retargeting. - A prompt will allow selecting the new base for the branch. Provide an argument to skip the prompt. Use the --branch flag to target a different branch for the move. @@ -1094,9 +980,8 @@ Use 'gs upstack onto' to also move the upstack branches. **Flags** * `--branch=NAME`: Branch to move -* `--restack` ([:material-wrench:{ .middle title="spice.branchOnto.restack" }](/cli/config.md#spicebranchontorestack)): How to restack branches above the moved branch. One of 'none', 'aboves', and 'upstack'. -**Configuration**: [spice.branchOnto.restack](/cli/config.md#spicebranchontorestack), [spice.branchPrompt.sort](/cli/config.md#spicebranchpromptsort) +**Configuration**: [spice.branchPrompt.sort](/cli/config.md#spicebranchpromptsort) ### git-spice branch diff {#gs-branch-diff} @@ -1117,33 +1002,6 @@ Use --branch to target a different branch. * `--branch=NAME`: Branch to diff -### git-spice branch merge {#gs-branch-merge} - -``` -gs branch (b) merge (m) [flags] -``` - -:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) - -Merge a branch into trunk - -Merges the CR for the current branch into trunk. -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. - -**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. -* `--branch=NAME`: Branch to merge - -**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) - ### git-spice branch submit {#gs-branch-submit} ``` @@ -1197,7 +1055,7 @@ only if there are multiple CRs in the stack. * `--body=BODY`: Body of the change request * `--branch=NAME`: Branch to submit -**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.web](/cli/config.md#spicesubmitweb) +**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.label.addWhen](/cli/config.md#spicesubmitlabeladdwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.web](/cli/config.md#spicesubmitweb) ## Commit 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..753102ea3 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,228 @@ 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(), + } + 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..690ec8119 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 @@ -14,7 +14,6 @@ import ( reflect "reflect" git "go.abhg.dev/gs/internal/git" - scriptrun "go.abhg.dev/gs/internal/scriptrun" spice "go.abhg.dev/gs/internal/spice" state "go.abhg.dev/gs/internal/spice/state" gomock "go.uber.org/mock/gomock" @@ -121,68 +120,68 @@ func (c *MockGitRepositoryPeelToCommitCall) DoAndReturn(f func(context.Context, return c } -// Worktrees mocks base method. -func (m *MockGitRepository) Worktrees(ctx context.Context) iter.Seq2[*git.WorktreeListItem, error] { +// MockGitWorktree is a mock of GitWorktree interface. +type MockGitWorktree struct { + ctrl *gomock.Controller + recorder *MockGitWorktreeMockRecorder + isgomock struct{} +} + +// MockGitWorktreeMockRecorder is the mock recorder for MockGitWorktree. +type MockGitWorktreeMockRecorder struct { + mock *MockGitWorktree +} + +// NewMockGitWorktree creates a new mock instance. +func NewMockGitWorktree(ctrl *gomock.Controller) *MockGitWorktree { + mock := &MockGitWorktree{ctrl: ctrl} + mock.recorder = &MockGitWorktreeMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +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, "Worktrees", ctx) - ret0, _ := ret[0].(iter.Seq2[*git.WorktreeListItem, error]) + ret := m.ctrl.Call(m, "AmendCommitAll", ctx) + ret0, _ := ret[0].(error) return ret0 } -// Worktrees indicates an expected call of Worktrees. -func (mr *MockGitRepositoryMockRecorder) Worktrees(ctx any) *MockGitRepositoryWorktreesCall { +// 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, "Worktrees", reflect.TypeOf((*MockGitRepository)(nil).Worktrees), ctx) - return &MockGitRepositoryWorktreesCall{Call: call} + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AmendCommitAll", reflect.TypeOf((*MockGitWorktree)(nil).AmendCommitAll), ctx) + return &MockGitWorktreeAmendCommitAllCall{Call: call} } -// MockGitRepositoryWorktreesCall wrap *gomock.Call -type MockGitRepositoryWorktreesCall struct { +// MockGitWorktreeAmendCommitAllCall wrap *gomock.Call +type MockGitWorktreeAmendCommitAllCall struct { *gomock.Call } // Return rewrite *gomock.Call.Return -func (c *MockGitRepositoryWorktreesCall) Return(arg0 iter.Seq2[*git.WorktreeListItem, error]) *MockGitRepositoryWorktreesCall { +func (c *MockGitWorktreeAmendCommitAllCall) Return(arg0 error) *MockGitWorktreeAmendCommitAllCall { c.Call = c.Call.Return(arg0) return c } // Do rewrite *gomock.Call.Do -func (c *MockGitRepositoryWorktreesCall) Do(f func(context.Context) iter.Seq2[*git.WorktreeListItem, error]) *MockGitRepositoryWorktreesCall { +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 *MockGitRepositoryWorktreesCall) DoAndReturn(f func(context.Context) iter.Seq2[*git.WorktreeListItem, error]) *MockGitRepositoryWorktreesCall { +func (c *MockGitWorktreeAmendCommitAllCall) DoAndReturn(f func(context.Context) error) *MockGitWorktreeAmendCommitAllCall { c.Call = c.Call.DoAndReturn(f) return c } -// MockGitWorktree is a mock of GitWorktree interface. -type MockGitWorktree struct { - ctrl *gomock.Controller - recorder *MockGitWorktreeMockRecorder - isgomock struct{} -} - -// MockGitWorktreeMockRecorder is the mock recorder for MockGitWorktree. -type MockGitWorktreeMockRecorder struct { - mock *MockGitWorktree -} - -// NewMockGitWorktree creates a new mock instance. -func NewMockGitWorktree(ctrl *gomock.Controller) *MockGitWorktree { - mock := &MockGitWorktree{ctrl: ctrl} - mock.recorder = &MockGitWorktreeMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockGitWorktree) EXPECT() *MockGitWorktreeMockRecorder { - return m.recorder -} - // CheckoutBranch mocks base method. func (m *MockGitWorktree) CheckoutBranch(ctx context.Context, branch string) error { m.ctrl.T.Helper() @@ -451,44 +450,6 @@ func (c *MockGitWorktreePushCall) DoAndReturn(f func(context.Context, git.PushOp return c } -// RootDir mocks base method. -func (m *MockGitWorktree) RootDir() string { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RootDir") - ret0, _ := ret[0].(string) - return ret0 -} - -// RootDir indicates an expected call of RootDir. -func (mr *MockGitWorktreeMockRecorder) RootDir() *MockGitWorktreeRootDirCall { - mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RootDir", reflect.TypeOf((*MockGitWorktree)(nil).RootDir)) - return &MockGitWorktreeRootDirCall{Call: call} -} - -// MockGitWorktreeRootDirCall wrap *gomock.Call -type MockGitWorktreeRootDirCall struct { - *gomock.Call -} - -// Return rewrite *gomock.Call.Return -func (c *MockGitWorktreeRootDirCall) Return(arg0 string) *MockGitWorktreeRootDirCall { - c.Call = c.Call.Return(arg0) - return c -} - -// Do rewrite *gomock.Call.Do -func (c *MockGitWorktreeRootDirCall) Do(f func() string) *MockGitWorktreeRootDirCall { - c.Call = c.Call.Do(f) - return c -} - -// DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockGitWorktreeRootDirCall) DoAndReturn(f func() string) *MockGitWorktreeRootDirCall { - c.Call = c.Call.DoAndReturn(f) - return c -} - // MockStore is a mock of Store interface. type MockStore struct { ctrl *gomock.Controller @@ -870,10 +831,10 @@ func (m *MockResolver) EXPECT() *MockResolverMockRecorder { } // Resolve mocks base method. -func (m *MockResolver) Resolve(ctx context.Context, req *ResolveRequest) (*scriptrun.ResolveResponse, error) { +func (m *MockResolver) Resolve(ctx context.Context, req *ResolveRequest) (*ResolveResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Resolve", ctx, req) - ret0, _ := ret[0].(*scriptrun.ResolveResponse) + ret0, _ := ret[0].(*ResolveResponse) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -891,19 +852,19 @@ type MockResolverResolveCall struct { } // Return rewrite *gomock.Call.Return -func (c *MockResolverResolveCall) Return(arg0 *scriptrun.ResolveResponse, arg1 error) *MockResolverResolveCall { +func (c *MockResolverResolveCall) Return(arg0 *ResolveResponse, arg1 error) *MockResolverResolveCall { c.Call = c.Call.Return(arg0, arg1) return c } // Do rewrite *gomock.Call.Do -func (c *MockResolverResolveCall) Do(f func(context.Context, *ResolveRequest) (*scriptrun.ResolveResponse, error)) *MockResolverResolveCall { +func (c *MockResolverResolveCall) Do(f func(context.Context, *ResolveRequest) (*ResolveResponse, error)) *MockResolverResolveCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockResolverResolveCall) DoAndReturn(f func(context.Context, *ResolveRequest) (*scriptrun.ResolveResponse, error)) *MockResolverResolveCall { +func (c *MockResolverResolveCall) DoAndReturn(f func(context.Context, *ResolveRequest) (*ResolveResponse, error)) *MockResolverResolveCall { c.Call = c.Call.DoAndReturn(f) return c } @@ -970,3 +931,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 From 4e91eab0502efe0d52d0b3c307a9da3a87241cc1 Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Mon, 8 Jun 2026 07:08:45 -0400 Subject: [PATCH 2/5] integration regenerate: Regenerate CLI reference --- doc/includes/cli-reference.md | 95 +++++++++++++++++++++++++++++++---- 1 file changed, 84 insertions(+), 11 deletions(-) diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index 05a80d83d..0b9055946 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -12,7 +12,7 @@ git-spice is a command line tool for stacking Git branches. * `-C`, `--dir=DIR`: Change to DIR before doing anything * `--[no-]prompt`: Whether to prompt for missing information -**Configuration**: [spice.forge.bitbucket.apiURL](/cli/config.md#spiceforgebitbucketapiurl), [spice.forge.bitbucket.url](/cli/config.md#spiceforgebitbucketurl), [spice.forge.github.apiUrl](/cli/config.md#spiceforgegithubapiurl), [spice.forge.github.url](/cli/config.md#spiceforgegithuburl), [spice.forge.gitlab.apiURL](/cli/config.md#spiceforgegitlabapiurl), [spice.forge.gitlab.oauth.clientID](/cli/config.md#spiceforgegitlaboauthclientid), [spice.forge.gitlab.removeSourceBranch](/cli/config.md#spiceforgegitlabremovesourcebranch), [spice.forge.gitlab.url](/cli/config.md#spiceforgegitlaburl), [spice.git.indexLockTimeout](/cli/config.md#spicegitindexlocktimeout), [spice.secret.backend](/cli/config.md#spicesecretbackend) +**Configuration**: [spice.forge.bitbucket.apiURL](/cli/config.md#spiceforgebitbucketapiurl), [spice.forge.bitbucket.url](/cli/config.md#spiceforgebitbucketurl), [spice.forge.github.apiUrl](/cli/config.md#spiceforgegithubapiurl), [spice.forge.github.url](/cli/config.md#spiceforgegithuburl), [spice.forge.gitlab.apiURL](/cli/config.md#spiceforgegitlabapiurl), [spice.forge.gitlab.oauth.clientID](/cli/config.md#spiceforgegitlaboauthclientid), [spice.forge.gitlab.removeSourceBranch](/cli/config.md#spiceforgegitlabremovesourcebranch), [spice.forge.gitlab.url](/cli/config.md#spiceforgegitlaburl), [spice.forge.kind](/cli/config.md#spiceforgekind), [spice.git.indexLockTimeout](/cli/config.md#spicegitindexlocktimeout), [spice.secret.backend](/cli/config.md#spicesecretbackend) ## Shell @@ -156,11 +156,17 @@ The repository must have a remote associated for syncing. A prompt will ask for one if the repository was not initialized with a remote. +Branches above merged and deleted branches +are retargeted to the trunk branch. +Run with --restack to also restack them and their upstacks. +Run with --restack=aboves to only restack direct upstacks +of deleted branches, leaving higher branches in place. + **Flags** -* `--restack`: Restack the current stack after syncing +* `--restack` ([:material-wrench:{ .middle title="spice.repoSync.restack" }](/cli/config.md#spicereposyncrestack)): How to restack branches above deleted branches. One of 'none', 'aboves', and 'upstack'. -**Configuration**: [spice.repoSync.closedChanges](/cli/config.md#spicereposyncclosedchanges) +**Configuration**: [spice.repoSync.closedChanges](/cli/config.md#spicereposyncclosedchanges), [spice.repoSync.restack](/cli/config.md#spicereposyncrestack) ### git-spice repo restack {#gs-repo-restack} @@ -278,7 +284,7 @@ only if there are multiple CRs in the stack. * `-a`, `--assign=ASSIGNEE,...`: Assign the change request to these users. Pass multiple times or separate with commas. :material-tag:{ title="Released in version" }[v0.21.0](/changelog.md#v0.21.0) * `--no-web`: Alias for --web=false. -**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.label.addWhen](/cli/config.md#spicesubmitlabeladdwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) +**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) ### git-spice stack restack {#gs-stack-restack} @@ -401,7 +407,7 @@ only if there are multiple CRs in the stack. * `--no-web`: Alias for --web=false. * `--branch=NAME`: Branch to start at -**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.label.addWhen](/cli/config.md#spicesubmitlabeladdwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) +**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) ### git-spice upstack restack {#gs-upstack-restack} @@ -556,7 +562,66 @@ only if there are multiple CRs in the stack. * `--no-web`: Alias for --web=false. * `--branch=NAME`: Branch to start at -**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.label.addWhen](/cli/config.md#spicesubmitlabeladdwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) +**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) + +### git-spice downstack merge {#gs-downstack-merge} + +``` +gs downstack (ds) merge (m) [flags] +``` + +:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) + +Merge a branch and those below it + +Merges the current branch and all branches below it +into trunk via the forge API, bottom-up. +Use --branch to start at a different branch. + +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, +and then repeats the process. + +For a stack like this: + + main <- feature1 <- feature2 <- feature3 + +Running from feature3 merges in this order: + + feature1, feature2, feature3 + +Already-merged branches are skipped automatically. +Branches must have an open Change Request to be merged. + +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 +(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, +and syncs merged branch cleanup. + +Use --no-wait for single branch merging +when you don't want to wait for the merge to propagate. +--no-wait is rejected for multi-branch merges. + +**Flags** + +* `--branch=NAME`: Branch to start merging from +* `--no-wait`: Skip polling for a single branch merge to propagate. +* `--no-branch-check`: Skip stale base validation before merging. +* `--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. + +**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) ### git-spice downstack edit {#gs-downstack-edit} @@ -764,10 +829,13 @@ gs branch (b) delete (d,rm) [ ...] [flags] Delete branches The deleted branches and their commits are removed from the stack. -Branches above the deleted branches are first rebased onto +Branches above the deleted branches are retargeted onto the next branches available downstack, or onto trunk if there are no branches available below. +Use --restack to rebase those branches and their upstacks +immediately after retargeting. + Without any arguments, a prompt will allow selecting the branch to delete. @@ -782,8 +850,9 @@ Use --force to delete the branch regardless of unmerged changes. **Flags** * `--force`: Force deletion of the branch +* `--restack` ([:material-wrench:{ .middle title="spice.branchDelete.restack" }](/cli/config.md#spicebranchdeleterestack)): How to restack branches above deleted branches. One of 'none', 'aboves', and 'upstack'. -**Configuration**: [spice.branchPrompt.sort](/cli/config.md#spicebranchpromptsort) +**Configuration**: [spice.branchDelete.restack](/cli/config.md#spicebranchdeleterestack), [spice.branchPrompt.sort](/cli/config.md#spicebranchpromptsort) ### git-spice branch fold {#gs-branch-fold} @@ -953,9 +1022,12 @@ Commits of the current branch are transplanted onto another branch while leaving the rest of the stack intact. That is, branches above the current branch -are first rebased onto its original base, +are retargeted onto its original base, and then the current branch is moved onto the new base. +Use --restack to rebase those branches and their upstacks +immediately after retargeting. + A prompt will allow selecting the new base for the branch. Provide an argument to skip the prompt. Use the --branch flag to target a different branch for the move. @@ -980,8 +1052,9 @@ Use 'gs upstack onto' to also move the upstack branches. **Flags** * `--branch=NAME`: Branch to move +* `--restack` ([:material-wrench:{ .middle title="spice.branchOnto.restack" }](/cli/config.md#spicebranchontorestack)): How to restack branches above the moved branch. One of 'none', 'aboves', and 'upstack'. -**Configuration**: [spice.branchPrompt.sort](/cli/config.md#spicebranchpromptsort) +**Configuration**: [spice.branchOnto.restack](/cli/config.md#spicebranchontorestack), [spice.branchPrompt.sort](/cli/config.md#spicebranchpromptsort) ### git-spice branch diff {#gs-branch-diff} @@ -1055,7 +1128,7 @@ only if there are multiple CRs in the stack. * `--body=BODY`: Body of the change request * `--branch=NAME`: Branch to submit -**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.label.addWhen](/cli/config.md#spicesubmitlabeladdwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.web](/cli/config.md#spicesubmitweb) +**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.web](/cli/config.md#spicesubmitweb) ## Commit From dddf689251c354d347d6462a60c1a57f808ab0c6 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:15:52 +0000 Subject: [PATCH 3/5] [autofix.ci] apply automated fixes --- internal/handler/integration/handler_test.go | 1 + internal/handler/integration/mocks_test.go | 87 ++++++++++++++++++-- 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/internal/handler/integration/handler_test.go b/internal/handler/integration/handler_test.go index 753102ea3..f0451648e 100644 --- a/internal/handler/integration/handler_test.go +++ b/internal/handler/integration/handler_test.go @@ -1373,6 +1373,7 @@ func newHandlerWithRegenerator(t *testing.T) (*Handler, *handlerMocks, *MockRege Regenerator: regenerator, RepoRoot: t.TempDir(), } + expectBorrowableWorktree(mocks) return h, mocks, regenerator } diff --git a/internal/handler/integration/mocks_test.go b/internal/handler/integration/mocks_test.go index 690ec8119..66ee3b7fb 100644 --- a/internal/handler/integration/mocks_test.go +++ b/internal/handler/integration/mocks_test.go @@ -14,6 +14,7 @@ import ( reflect "reflect" git "go.abhg.dev/gs/internal/git" + scriptrun "go.abhg.dev/gs/internal/scriptrun" spice "go.abhg.dev/gs/internal/spice" state "go.abhg.dev/gs/internal/spice/state" gomock "go.uber.org/mock/gomock" @@ -120,6 +121,44 @@ func (c *MockGitRepositoryPeelToCommitCall) DoAndReturn(f func(context.Context, return c } +// Worktrees mocks base method. +func (m *MockGitRepository) Worktrees(ctx context.Context) iter.Seq2[*git.WorktreeListItem, error] { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Worktrees", ctx) + ret0, _ := ret[0].(iter.Seq2[*git.WorktreeListItem, error]) + return ret0 +} + +// Worktrees indicates an expected call of Worktrees. +func (mr *MockGitRepositoryMockRecorder) Worktrees(ctx any) *MockGitRepositoryWorktreesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Worktrees", reflect.TypeOf((*MockGitRepository)(nil).Worktrees), ctx) + return &MockGitRepositoryWorktreesCall{Call: call} +} + +// MockGitRepositoryWorktreesCall wrap *gomock.Call +type MockGitRepositoryWorktreesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockGitRepositoryWorktreesCall) Return(arg0 iter.Seq2[*git.WorktreeListItem, error]) *MockGitRepositoryWorktreesCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockGitRepositoryWorktreesCall) Do(f func(context.Context) iter.Seq2[*git.WorktreeListItem, error]) *MockGitRepositoryWorktreesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockGitRepositoryWorktreesCall) DoAndReturn(f func(context.Context) iter.Seq2[*git.WorktreeListItem, error]) *MockGitRepositoryWorktreesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // MockGitWorktree is a mock of GitWorktree interface. type MockGitWorktree struct { ctrl *gomock.Controller @@ -450,6 +489,44 @@ func (c *MockGitWorktreePushCall) DoAndReturn(f func(context.Context, git.PushOp return c } +// RootDir mocks base method. +func (m *MockGitWorktree) RootDir() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RootDir") + ret0, _ := ret[0].(string) + return ret0 +} + +// RootDir indicates an expected call of RootDir. +func (mr *MockGitWorktreeMockRecorder) RootDir() *MockGitWorktreeRootDirCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RootDir", reflect.TypeOf((*MockGitWorktree)(nil).RootDir)) + return &MockGitWorktreeRootDirCall{Call: call} +} + +// MockGitWorktreeRootDirCall wrap *gomock.Call +type MockGitWorktreeRootDirCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockGitWorktreeRootDirCall) Return(arg0 string) *MockGitWorktreeRootDirCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockGitWorktreeRootDirCall) Do(f func() string) *MockGitWorktreeRootDirCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockGitWorktreeRootDirCall) DoAndReturn(f func() string) *MockGitWorktreeRootDirCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // MockStore is a mock of Store interface. type MockStore struct { ctrl *gomock.Controller @@ -831,10 +908,10 @@ func (m *MockResolver) EXPECT() *MockResolverMockRecorder { } // Resolve mocks base method. -func (m *MockResolver) Resolve(ctx context.Context, req *ResolveRequest) (*ResolveResponse, error) { +func (m *MockResolver) Resolve(ctx context.Context, req *ResolveRequest) (*scriptrun.ResolveResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Resolve", ctx, req) - ret0, _ := ret[0].(*ResolveResponse) + ret0, _ := ret[0].(*scriptrun.ResolveResponse) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -852,19 +929,19 @@ type MockResolverResolveCall struct { } // Return rewrite *gomock.Call.Return -func (c *MockResolverResolveCall) Return(arg0 *ResolveResponse, arg1 error) *MockResolverResolveCall { +func (c *MockResolverResolveCall) Return(arg0 *scriptrun.ResolveResponse, arg1 error) *MockResolverResolveCall { c.Call = c.Call.Return(arg0, arg1) return c } // Do rewrite *gomock.Call.Do -func (c *MockResolverResolveCall) Do(f func(context.Context, *ResolveRequest) (*ResolveResponse, error)) *MockResolverResolveCall { +func (c *MockResolverResolveCall) Do(f func(context.Context, *ResolveRequest) (*scriptrun.ResolveResponse, error)) *MockResolverResolveCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockResolverResolveCall) DoAndReturn(f func(context.Context, *ResolveRequest) (*ResolveResponse, error)) *MockResolverResolveCall { +func (c *MockResolverResolveCall) DoAndReturn(f func(context.Context, *ResolveRequest) (*scriptrun.ResolveResponse, error)) *MockResolverResolveCall { c.Call = c.Call.DoAndReturn(f) return c } From 9f7d7a8c76b538fdfb750bfa8bb859920a5d46c4 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:03:38 +0000 Subject: [PATCH 4/5] [autofix.ci] apply automated fixes --- doc/includes/cli-reference.md | 75 +++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index 0b9055946..71e913973 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -286,6 +286,48 @@ only if there are multiple CRs in the stack. **Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) +### git-spice stack merge {#gs-stack-merge} + +``` +gs stack (s) merge (m) [flags] +``` + +:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) + +Merge a stack + +Merges the CRs for the current branch's stack into trunk. +Use --branch to merge a different branch's stack. + +The stack includes the selected branch, +its downstack branches down to trunk, +and every upstack branch. + +Already-merged branches are skipped automatically. +Branches must have an open Change Request to be merged. + +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. + +By default, a branch failure skips that branch's upstack descendants, +but independent sibling branches continue. +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. +* `--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) + ### git-spice stack restack {#gs-stack-restack} ``` @@ -615,11 +657,11 @@ when you don't want to wait for the merge to propagate. **Flags** -* `--branch=NAME`: Branch to start merging from -* `--no-wait`: Skip polling for a single branch merge to propagate. -* `--no-branch-check`: Skip stale base validation before merging. * `--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. +* `--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) @@ -1075,6 +1117,33 @@ Use --branch to target a different branch. * `--branch=NAME`: Branch to diff +### git-spice branch merge {#gs-branch-merge} + +``` +gs branch (b) merge (m) [flags] +``` + +:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) + +Merge a branch into trunk + +Merges the CR for the current branch into trunk. +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. + +**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. +* `--branch=NAME`: Branch to merge + +**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) + ### git-spice branch submit {#gs-branch-submit} ``` From 3ef23d0867146bd9e437e6abc9afba50152519a6 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:37:09 +0000 Subject: [PATCH 5/5] [autofix.ci] apply automated fixes --- doc/includes/cli-reference.md | 36 ++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) 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}