From f739756389d1fa7ebca2a10d84c97c294bfea4c5 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 1 Nov 2026 02:32:27 -0800 Subject: [PATCH] v1.92.25.0 fix(ship): Step 3 stops by name on an unfinished merge, cherry-pick or revert (GST-732) (#3785) * test(ship): an unfinished merge or cherry-pick stops Step 3 by name (GST-732, failing first) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): an unfinished merge, cherry-pick or revert stops Step 3 by name before any git merge (GST-732) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(ship): Step 3 names the MERGE: up to date outcome (GST-732) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(ship): regenerate ship goldens for the Step 3 MERGE: up to date wording (GST-732) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (GST-732: cover the unfinished revert stop) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (GST-732: only a real operation state file stops Step 3; unresolved conflicts with none in progress stop by name) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): scope GST-732 to repositories that merge the base in /ship Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (GST-732: a failed --git-path probe is not an operation in progress) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(ship): shorten Step 3's MERGE: up to date note so the ship skeleton fits its cap on current main (GST-732) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 4 ++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- lib/ship-preflight.ts | 19 ++++++-- package.json | 2 +- ship/SKILL.md | 2 +- ship/SKILL.md.tmpl | 2 +- test/fixtures/golden/agy-ship-SKILL.md | 2 +- test/fixtures/golden/claude-ship-SKILL.md | 2 +- test/fixtures/golden/codex-ship-SKILL.md | 2 +- test/fixtures/golden/factory-ship-SKILL.md | 2 +- test/ship-preflight-base-ancestor.test.ts | 47 ++++++++++++++----- .../ship/gst-732-preflight-unfinished-op.json | 4 ++ 13 files changed, 69 insertions(+), 23 deletions(-) create mode 100644 test/size-ledger/ship/gst-732-preflight-unfinished-op.json diff --git a/CHANGELOG.md b/CHANGELOG.md index d86a59838..56e4c4786 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [1.92.25.0] - 2026-11-01 + +- In a repository that merges the base during `/ship` (any `queue_mode` but `stamp-at-merge`), Step 3 now stops by name when an earlier merge, cherry-pick or revert is still unfinished (`STOP: an unfinished cherry-pick is in progress (CHERRY_PICK_HEAD): …`), with any conflicted files listed, and runs no `git merge`. Conflicted files left with no operation in progress, as after a conflicted `git stash pop`, stop with those files named instead of a merge message. Before, both fell through to `git merge`, so on a machine whose shared-tree guard refuses `git merge`, the guard's refusal was the only message (GST-732). A branch or tag named `MERGE_HEAD`, `CHERRY_PICK_HEAD` or `REVERT_HEAD` is not mistaken for one. The ship skill's Step 3 now names the `MERGE: up to date` outcome. + ## [1.92.24.0] - 2026-11-01 - `/ship` can finish after the owner accepts a Step 14.5 documentation risk. `gstack-docs-candidate snapshot --exception .gstack/tmp/docs-audit.json` writes the post-audit snapshot from the blocked record, naming who accepted it, the date, how and the base SHA, and `gstack-ship-release finish` prints that exception on its `DOCS:` line. Finish still stops on a blocked audit with no accepted exception, and on an exception snapshot whose docs-audit record no longer carries it (GST-713). diff --git a/VERSION b/VERSION index 0718912ee..798807a15 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.24.0 +1.92.25.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 3b982c202..c5c0f56f7 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.24.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.25.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/lib/ship-preflight.ts b/lib/ship-preflight.ts index 487dc0a74..dd1f51d5e 100644 --- a/lib/ship-preflight.ts +++ b/lib/ship-preflight.ts @@ -67,9 +67,22 @@ const forkPoint = (cwd: string, base: string) => git(cwd, ['merge-base', `origin function merge(cwd: string, base: string, r: Report): boolean { const policy = shipPolicy(cwd, base); - if (policy?.queue_mode === 'stamp-at-merge') r.add('MERGE: skipped (queue_mode stamp-at-merge: the slot stamp merges the base; docs/ship-policy.md#ship-before-ready)'); - else if (!git(cwd, ['ls-files', '-u']).stdout.trim() && ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD'].every(ref => git(cwd, ['rev-parse', '-q', '--verify', ref]).status !== 0) - && git(cwd, ['merge-base', '--is-ancestor', `origin/${base}`, 'HEAD']).status === 0) r.add('MERGE: up to date'); + const stampAtMerge = policy?.queue_mode === 'stamp-at-merge'; + const pending = stampAtMerge ? undefined : ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD'].find(ref => { + const p = git(cwd, ['rev-parse', '--git-path', ref]); + return p.status === 0 && !!p.stdout.trim() && fs.existsSync(path.resolve(cwd, p.stdout.trim())); + }); + const unmerged = stampAtMerge ? [] : git(cwd, ['diff', '--name-only', '--diff-filter=U']).stdout.split('\n').filter(Boolean); + if (pending) { + r.stop(`an unfinished ${pending.replace(/_HEAD$/, '').toLowerCase().replace('_', '-')} is in progress (${pending}${unmerged.length ? `, conflicts in ${short(unmerged, 8)}` : ''}): resolve and commit it, or abort it, then rerun (Step 3)`); + return false; + } + if (unmerged.length) { + r.stop(`unresolved conflicts in ${short(unmerged, 8)} with no merge, cherry-pick or revert in progress: resolve them (git add) or restore the files, then rerun (Step 3)`); + return false; + } + if (stampAtMerge) r.add('MERGE: skipped (queue_mode stamp-at-merge: the slot stamp merges the base; docs/ship-policy.md#ship-before-ready)'); + else if (git(cwd, ['merge-base', '--is-ancestor', `origin/${base}`, 'HEAD']).status === 0) r.add('MERGE: up to date'); else { const before = git(cwd, ['rev-parse', 'HEAD']).stdout.trim(); const m = git(cwd, ['merge', `origin/${base}`, '--no-edit'], 300_000); diff --git a/package.json b/package.json index fb5050636..61d7434c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.24", + "version": "1.92.25", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/ship/SKILL.md b/ship/SKILL.md index 76c60c15c..32de83e7d 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -455,7 +455,7 @@ not web services already deployed. ## Step 3: Merge the base branch (BEFORE tests) -The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `skipped` under stamp-at-merge). +The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `up to date` if nothing new; `skipped` under stamp-at-merge). **Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight. diff --git a/ship/SKILL.md.tmpl b/ship/SKILL.md.tmpl index f1dbb34ae..4b9491940 100644 --- a/ship/SKILL.md.tmpl +++ b/ship/SKILL.md.tmpl @@ -185,7 +185,7 @@ not web services already deployed. ## Step 3: Merge the base branch (BEFORE tests) -The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `skipped` under stamp-at-merge). +The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `up to date` if nothing new; `skipped` under stamp-at-merge). **Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight. diff --git a/test/fixtures/golden/agy-ship-SKILL.md b/test/fixtures/golden/agy-ship-SKILL.md index df7239269..b3aa51ef4 100644 --- a/test/fixtures/golden/agy-ship-SKILL.md +++ b/test/fixtures/golden/agy-ship-SKILL.md @@ -455,7 +455,7 @@ not web services already deployed. ## Step 3: Merge the base branch (BEFORE tests) -The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `skipped` under stamp-at-merge). +The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `up to date` if nothing new; `skipped` under stamp-at-merge). **Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight. diff --git a/test/fixtures/golden/claude-ship-SKILL.md b/test/fixtures/golden/claude-ship-SKILL.md index 76c60c15c..32de83e7d 100644 --- a/test/fixtures/golden/claude-ship-SKILL.md +++ b/test/fixtures/golden/claude-ship-SKILL.md @@ -455,7 +455,7 @@ not web services already deployed. ## Step 3: Merge the base branch (BEFORE tests) -The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `skipped` under stamp-at-merge). +The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `up to date` if nothing new; `skipped` under stamp-at-merge). **Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight. diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index 4fdb09da6..e3b16338d 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -454,7 +454,7 @@ not web services already deployed. ## Step 3: Merge the base branch (BEFORE tests) -The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `skipped` under stamp-at-merge). +The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `up to date` if nothing new; `skipped` under stamp-at-merge). **Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight. diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index bc3b84109..148a7b6fb 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -456,7 +456,7 @@ not web services already deployed. ## Step 3: Merge the base branch (BEFORE tests) -The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `skipped` under stamp-at-merge). +The preflight merges the fetched base (`git merge`) so tests and reviews cover the integrated code (`MERGE:`; `up to date` if nothing new; `skipped` under stamp-at-merge). **Merge conflicts:** `MERGE:` names them. Auto-resolve simple ones (VERSION, schema.rb, CHANGELOG ordering). For complex or ambiguous conflicts, **STOP**, show the conflicting choices, use AskUserQuestion for the resolution, and wait for the answer before editing or continuing; then commit and rerun the preflight. diff --git a/test/ship-preflight-base-ancestor.test.ts b/test/ship-preflight-base-ancestor.test.ts index 1e7fdd3c5..8920c63e9 100644 --- a/test/ship-preflight-base-ancestor.test.ts +++ b/test/ship-preflight-base-ancestor.test.ts @@ -44,20 +44,45 @@ describe('preflight merge when the base is already in HEAD (GST-691)', () => { expect(r.stdout).toContain('STOP: git merge origin/main failed: shared-tree guard: git merge is not allowed in this checkout'); }); - // Value: protects=an unfinished merge of another branch still stops at Step 3 with its conflicted files named, though the base is in HEAD; - // fails_when=the ancestor shortcut reports up to date over an index with unmerged paths and the run tests conflict markers; - // why_new=GST-691 /ship adversarial finding 1; seam=git ls-files -u; no_claim=how the agent resolves it - test('an unfinished merge with the base already in HEAD still stops, conflicted or resolved and staged', () => { - const { f } = guarded(); + // Value: protects=an unfinished merge, cherry-pick or revert stops at Step 3 with its name and conflicted files, though the base is in HEAD, and no git merge runs; + // fails_when=the ancestor shortcut reports up to date over one, or the stop is a guard's refusal that names no cause; + // why_new=GST-691 /ship adversarial findings; seam=MERGE_HEAD/CHERRY_PICK_HEAD/REVERT_HEAD; no_claim=how the agent resolves it + test('an unfinished merge, cherry-pick or revert with the base already in HEAD stops and names it, conflicted or resolved', () => { + const { f, merges } = guarded(); f.git('checkout', '-q', '-b', 'other', 'main'); f.write({ 'src/a.ts': 'export const a = 5;\n' }); f.commit('other edits a'); f.git('checkout', '-q', 'feat'); f.write({ 'src/a.ts': 'export const a = 6;\n' }); f.commit('feat edits a'); expect(f.git('merge', '--no-edit', 'other').status).not.toBe(0); - const r = f.run(PREFLIGHT, ['--base', 'main']); - expect([r.status, lineOf(r.stdout, 'MERGE')]).toEqual([3, '']); - expect(r.stdout).toMatch(/STOP: merge conflicts in src\/a\.ts/); + const conflicted = f.run(PREFLIGHT, ['--base', 'main']); + expect([conflicted.status, lineOf(conflicted.stdout, 'MERGE')]).toEqual([3, '']); + expect(conflicted.stdout).toContain('STOP: an unfinished merge is in progress (MERGE_HEAD, conflicts in src/a.ts)'); f.write({ 'src/a.ts': 'export const a = 7;\n' }); f.git('add', 'src/a.ts'); - const staged = f.run(PREFLIGHT, ['--base', 'main']); - expect([staged.status, lineOf(staged.stdout, 'MERGE')]).toEqual([3, '']); - expect(staged.stdout).toContain('STOP: git merge origin/main failed:'); + expect(f.run(PREFLIGHT, ['--base', 'main']).stdout).toContain('STOP: an unfinished merge is in progress (MERGE_HEAD):'); + f.git('merge', '--abort'); + expect(f.git('cherry-pick', 'other').status).not.toBe(0); + f.write({ 'src/a.ts': 'export const a = 8;\n' }); f.git('add', 'src/a.ts'); + expect(f.run(PREFLIGHT, ['--base', 'main']).stdout).toContain('STOP: an unfinished cherry-pick is in progress (CHERRY_PICK_HEAD):'); + f.git('cherry-pick', '--abort'); + f.write({ 'src/a.ts': 'export const a = 9;\n' }); f.commit('feat edits a again'); + expect(f.git('revert', '--no-edit', 'HEAD~1').status).not.toBe(0); + f.write({ 'src/a.ts': 'export const a = 10;\n' }); f.git('add', 'src/a.ts'); + expect(f.run(PREFLIGHT, ['--base', 'main']).stdout).toContain('STOP: an unfinished revert is in progress (REVERT_HEAD):'); + expect(merges()).toEqual([]); + }); + + // Value: protects=only a real in-progress operation stops Step 3, and unresolved index entries with none in progress stop with their files named before any git merge; + // fails_when=a branch or tag named like MERGE_HEAD/CHERRY_PICK_HEAD/REVERT_HEAD gives a stop no abort can clear, or a conflicted stash pop reaches git merge and gets a merge-specific stop; + // why_new=GST-732 /ship adversarial findings 2 and 4; seam=git rev-parse --git-path, git diff --diff-filter=U; no_claim=git am or sequencer state + test('a ref named REVERT_HEAD is not an unfinished revert, and a conflicted stash pop stops with its files named', () => { + const { f, merges } = guarded(); + f.git('branch', 'REVERT_HEAD'); f.git('tag', 'CHERRY_PICK_HEAD'); + const named = f.run(PREFLIGHT, ['--base', 'main']); + expect([named.status, lineOf(named.stdout, 'MERGE')]).toEqual([0, 'MERGE: up to date']); + f.write({ 'src/a.ts': 'export const a = 6;\n' }); f.git('stash'); + f.write({ 'src/a.ts': 'export const a = 7;\n' }); f.commit('feat edits a'); + expect(f.git('stash', 'pop').status).not.toBe(0); + const r = f.run(PREFLIGHT, ['--base', 'main']); + expect(r.status).toBe(3); + expect(r.stdout).toContain('STOP: unresolved conflicts in src/a.ts with no merge, cherry-pick or revert in progress'); + expect(merges()).toEqual([]); }); }); diff --git a/test/size-ledger/ship/gst-732-preflight-unfinished-op.json b/test/size-ledger/ship/gst-732-preflight-unfinished-op.json new file mode 100644 index 000000000..94358e552 --- /dev/null +++ b/test/size-ledger/ship/gst-732-preflight-unfinished-op.json @@ -0,0 +1,4 @@ +{ + "bytes": 29, + "why": "GST-732: Step 3 names the MERGE: up to date outcome" +}