Skip to content

Commit 6bcb0ea

Browse files
AJBcodingclaude
andcommitted
feat(sync): add gt sync merge-target to keep merge-target in sync with main (gt-mzi)
Refinery rebases polecat MRs onto merge-target before merging, but polecats branch off main. When a commit lands on main without propagating to merge-target (e.g. a hotfix cherry-pick), every MR rebase conflicts on the un-propagated content and the queue silently stops draining (incident 2026-05-03). Fix surface B — new command: - internal/git/git.go: PushRefspec(remote, refspec, force) — surgical remote-ref update that never checks out a branch, so the working tree is untouched and the town-root mutation guard is not triggered. Non-force = fast-forward-only safety net. - internal/cmd/sync_merge_target.go: `gt sync merge-target`. Fetches, then: missing target = silent ok; equal to main = silent ok; target ancestor of main = ff-push main onto merge-target; target has commits not on main = real divergence → diagnostic + non-zero exit + opt-in --escalate (HIGH, fingerprinted dedup). Flags: --dry-run --remote --source --target --escalate --severity. Fix surface A — process discipline docs: - docs/guides/merge-target-sync.md: operator runbook. - internal/templates/roles/refinery.md.tmpl: pointer in merge-push section. Tests: PushRefspec ff + non-ff rejection; missing/up-to-date/ff/dry-run/ divergence cases. All pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 262691a commit 6bcb0ea

6 files changed

Lines changed: 668 additions & 0 deletions

File tree

docs/guides/merge-target-sync.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Merge-target sync hygiene
2+
3+
> **TL;DR** — Any commit that lands on `main` without going through the merge
4+
> queue (a cherry-pick, a hotfix, an incident salvage) **must** be propagated to
5+
> `merge-target`. Run `gt sync merge-target` to do it safely.
6+
7+
## Why this matters
8+
9+
The refinery's merge queue is keyed off polecat branches:
10+
11+
- Polecats branch off **`main`**.
12+
- The refinery rebases each MR onto **`merge-target`** before merging.
13+
14+
When `main` and `merge-target` agree, that rebase is a no-op and merges fly
15+
through. When they **diverge** — which happens the moment a commit lands on
16+
`main` without also reaching `merge-target` — every MR is built on one parent
17+
state and rebased onto another. Result: **rebase conflicts on every MR**, and
18+
the queue silently stops draining.
19+
20+
This failure is deceptive. It presents as "queue not draining" or "claim logic
21+
dead," but the real cause is rebase-conflict-on-every-MR from divergence.
22+
23+
### How it bit us (2026-05-03)
24+
25+
An incident chain salvaged work via manual cherry-picks to `main`. Six commits
26+
were cherry-picked; **none propagated to `merge-target`**. Later polecats
27+
branched from `main` (with the cherry-picks) while the refinery rebased onto
28+
`merge-target` (without them). Every MR conflicted on the cherry-picked content.
29+
30+
The one-shot fix was a force resync of `merge-target` to `main`. After it, the
31+
refinery drained all five queued MRs immediately.
32+
33+
## Process discipline (do this every time)
34+
35+
**Whenever you put a commit on `main` outside the merge queue**, immediately
36+
propagate it:
37+
38+
```bash
39+
gt sync merge-target
40+
```
41+
42+
This is cheap, idempotent, and safe to run anytime — after a cherry-pick, on a
43+
schedule, or as a periodic operator check.
44+
45+
## What `gt sync merge-target` does
46+
47+
It fast-forwards `merge-target` up to `main` when (and only when) that is safe:
48+
49+
| Situation | Behavior |
50+
|-----------|----------|
51+
| `merge-target` does not exist | Nothing to sync — integration-branch refinery is off. Succeeds silently. |
52+
| `merge-target` already equals `main` | In sync. Succeeds silently. |
53+
| `merge-target` is strictly behind `main` (every merge-target commit is also on main) | **Fast-forwards** `merge-target` to `main` via a refspec push. The working tree is never touched. |
54+
| `merge-target` has commits **not** on `main` | **Real divergence.** Prints a diagnostic and exits non-zero. Will not discard work automatically. |
55+
56+
The fast-forward is a refspec push (`main:refs/heads/merge-target`), so it
57+
updates the remote branch without checking anything out — no working-tree
58+
mutation, no town-root guard tripped. The push is non-force, so git itself
59+
rejects anything that is not a true fast-forward as a final safety net.
60+
61+
### Useful flags
62+
63+
```bash
64+
gt sync merge-target --dry-run # Show what would change, push nothing
65+
gt sync merge-target --escalate # File a HIGH escalation on real divergence
66+
gt sync merge-target --source master # Non-default source branch
67+
gt sync merge-target --target staging # Non-default merge-target branch name
68+
gt sync merge-target --remote upstream
69+
```
70+
71+
## When divergence is real
72+
73+
If the command reports divergence, **a human must decide**. Inspect what is on
74+
`merge-target` but not on `main`:
75+
76+
```bash
77+
git log origin/merge-target ^origin/main --oneline
78+
```
79+
80+
If those commits are genuinely unwanted (e.g. salvage that already reached
81+
`main` another way), resync after tagging a backup so the old state is
82+
recoverable:
83+
84+
```bash
85+
git tag merge-target-pre-resync origin/merge-target
86+
git push origin origin/main:refs/heads/merge-target --force-with-lease
87+
```
88+
89+
If the commits are real work that belongs on `main`, get them onto `main`
90+
through the normal flow first, then re-run `gt sync merge-target`.

internal/cmd/sync_merge_target.go

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/spf13/cobra"
8+
gitpkg "github.com/steveyegge/gastown/internal/git"
9+
"github.com/steveyegge/gastown/internal/style"
10+
)
11+
12+
var (
13+
syncMTDryRun bool
14+
syncMTRemote string
15+
syncMTSource string
16+
syncMTTarget string
17+
syncMTEscalate bool
18+
syncMTSeverity string
19+
)
20+
21+
// syncCmd is the parent for branch/data synchronization tooling.
22+
var syncCmd = &cobra.Command{
23+
Use: "sync",
24+
GroupID: GroupWork,
25+
Short: "Synchronization tooling for branches and refs",
26+
Long: `Synchronization helpers that keep related git refs consistent.
27+
28+
Subcommands:
29+
gt sync merge-target Fast-forward the refinery merge-target onto main`,
30+
}
31+
32+
var syncMergeTargetCmd = &cobra.Command{
33+
Use: "merge-target",
34+
Short: "Fast-forward the refinery merge-target branch onto main",
35+
Args: cobra.NoArgs,
36+
RunE: runSyncMergeTarget,
37+
Long: `Keep the refinery's merge-target branch in sync with main.
38+
39+
The refinery rebases polecat MRs onto merge-target before merging. Polecats
40+
branch off main. If main and merge-target diverge — which happens whenever a
41+
commit lands on main without propagating to merge-target (e.g. a hotfix
42+
cherry-pick) — every MR rebase conflicts on the un-propagated content, and the
43+
queue silently stops draining.
44+
45+
This command resyncs merge-target to main when it is safe to do so:
46+
47+
- If merge-target is missing, there is nothing to sync (integration-branch
48+
refinery is not in use) — succeeds silently.
49+
- If merge-target already equals main, it is in sync — succeeds silently.
50+
- If merge-target is strictly behind main (every merge-target commit is also
51+
on main), it is fast-forwarded to main via a refspec push. The working tree
52+
is never touched.
53+
- If merge-target has commits that are NOT on main, that is real divergence
54+
requiring human attention. The command prints a diagnostic and exits
55+
non-zero (and, with --escalate, files a HIGH escalation).
56+
57+
Safe to run after any push to main, on a schedule, or as a manual operator tool.
58+
59+
EXAMPLES:
60+
gt sync merge-target # Fast-forward merge-target onto main
61+
gt sync merge-target --dry-run # Show what would change
62+
gt sync merge-target --escalate # File an escalation on real divergence
63+
gt sync merge-target --source master # Use a non-default source branch`,
64+
}
65+
66+
func init() {
67+
syncMergeTargetCmd.Flags().BoolVarP(&syncMTDryRun, "dry-run", "n", false, "Show what would change without pushing")
68+
syncMergeTargetCmd.Flags().StringVar(&syncMTRemote, "remote", "origin", "Remote to read from and push to")
69+
syncMergeTargetCmd.Flags().StringVar(&syncMTSource, "source", "", "Source branch (default: remote's default branch, e.g. main)")
70+
syncMergeTargetCmd.Flags().StringVar(&syncMTTarget, "target", "merge-target", "Merge-target branch to fast-forward")
71+
syncMergeTargetCmd.Flags().BoolVar(&syncMTEscalate, "escalate", false, "File a HIGH escalation if real divergence is detected")
72+
syncMergeTargetCmd.Flags().StringVar(&syncMTSeverity, "severity", "high", "Escalation severity when --escalate is set (critical, high, medium, low)")
73+
74+
syncCmd.AddCommand(syncMergeTargetCmd)
75+
rootCmd.AddCommand(syncCmd)
76+
}
77+
78+
func runSyncMergeTarget(cmd *cobra.Command, args []string) error {
79+
g := gitpkg.NewGit(".")
80+
if !g.IsRepo() {
81+
return fmt.Errorf("not a git repository")
82+
}
83+
84+
remote := syncMTRemote
85+
target := syncMTTarget
86+
source := syncMTSource
87+
if source == "" {
88+
source = g.RemoteDefaultBranch()
89+
}
90+
91+
// Refresh remote state so ancestry checks reflect what's actually pushed.
92+
fmt.Printf("Fetching from %s...\n", style.Dim.Render(remote))
93+
if err := g.Fetch(remote); err != nil {
94+
return fmt.Errorf("fetching %s: %w", remote, err)
95+
}
96+
97+
sourceRef := remote + "/" + source
98+
targetRef := remote + "/" + target
99+
100+
sourceSHA, err := g.Rev(sourceRef)
101+
if err != nil {
102+
return fmt.Errorf("source branch %s not found (is --source correct?): %w", sourceRef, err)
103+
}
104+
105+
// merge-target may legitimately not exist (integration-branch refinery off).
106+
targetExists, err := g.RemoteBranchExists(remote, target)
107+
if err != nil {
108+
return fmt.Errorf("checking %s: %w", targetRef, err)
109+
}
110+
if !targetExists {
111+
fmt.Printf("%s %s has no %q branch — nothing to sync.\n",
112+
style.Bold.Render("✓"), remote, target)
113+
return nil
114+
}
115+
116+
targetSHA, err := g.Rev(targetRef)
117+
if err != nil {
118+
return fmt.Errorf("resolving %s: %w", targetRef, err)
119+
}
120+
121+
if sourceSHA == targetSHA {
122+
fmt.Printf("%s %s is in sync with %s.\n",
123+
style.Bold.Render("✓"), target, source)
124+
return nil
125+
}
126+
127+
// Real divergence: any commit on merge-target that is not on main means a
128+
// fast-forward would discard work. That needs a human, not an auto-resync.
129+
targetIsAncestor, err := g.IsAncestor(targetRef, sourceRef)
130+
if err != nil {
131+
return fmt.Errorf("checking ancestry of %s: %w", targetRef, err)
132+
}
133+
if !targetIsAncestor {
134+
ahead, _ := g.CommitsAhead(sourceRef, targetRef) // commits on target not on source
135+
return reportMergeTargetDivergence(cmd, remote, source, target, ahead)
136+
}
137+
138+
// Safe fast-forward: every merge-target commit is already on main.
139+
behind, err := g.CommitsAhead(targetRef, sourceRef) // commits on source not on target
140+
if err != nil {
141+
return fmt.Errorf("counting commits: %w", err)
142+
}
143+
144+
if syncMTDryRun {
145+
fmt.Printf("%s Would fast-forward %s to %s (%d commit(s) behind).\n",
146+
style.Warning.Render("~"), target, source, behind)
147+
return nil
148+
}
149+
150+
// Push main's tip onto merge-target. Refspec push updates the remote branch
151+
// without checking it out, so the working tree is never mutated (and the
152+
// town-root mutation guard is not triggered). Non-force: git rejects this if
153+
// it is not a fast-forward, which is a final safety net behind the ancestry
154+
// check above.
155+
refspec := fmt.Sprintf("%s:refs/heads/%s", sourceSHA, target)
156+
if err := g.PushRefspec(remote, refspec, false); err != nil {
157+
return fmt.Errorf("fast-forwarding %s to %s: %w", target, source, err)
158+
}
159+
160+
fmt.Printf("%s Fast-forwarded %s to %s (%d commit(s)).\n",
161+
style.Bold.Render("✓"), target, source, behind)
162+
return nil
163+
}
164+
165+
// reportMergeTargetDivergence prints an operator diagnostic for real divergence
166+
// and, when --escalate is set, files an escalation. It always returns a non-zero
167+
// error so callers (operators, schedulers) can detect the unhandled condition.
168+
func reportMergeTargetDivergence(cmd *cobra.Command, remote, source, target string, ahead int) error {
169+
var b strings.Builder
170+
fmt.Fprintf(&b, "%s %s has diverged from %s\n\n",
171+
style.Warning.Render("⚠"), target, source)
172+
fmt.Fprintf(&b, " %s has %d commit(s) that are NOT on %s.\n", target, ahead, source)
173+
fmt.Fprintf(&b, " A fast-forward would discard them, so this needs a human.\n\n")
174+
fmt.Fprintf(&b, " Inspect the divergent commits:\n")
175+
fmt.Fprintf(&b, " git log %s/%s ^%s/%s --oneline\n\n", remote, target, remote, source)
176+
fmt.Fprintf(&b, " If those commits are genuinely unwanted (e.g. salvage that already\n")
177+
fmt.Fprintf(&b, " landed on %s another way), resync after tagging a backup:\n", source)
178+
fmt.Fprintf(&b, " git tag %s-pre-resync %s/%s\n", target, remote, target)
179+
fmt.Fprintf(&b, " git push %s %s/%s:refs/heads/%s --force-with-lease\n", remote, remote, source, target)
180+
fmt.Print(b.String())
181+
182+
if syncMTEscalate {
183+
description := fmt.Sprintf("merge-target sync: %s diverged from %s (%d unmerged commit(s))", target, source, ahead)
184+
if err := escalateMergeTargetDivergence(cmd, description); err != nil {
185+
style.PrintWarning("failed to file escalation: %v", err)
186+
}
187+
}
188+
189+
return fmt.Errorf("%s has diverged from %s — manual intervention required", target, source)
190+
}
191+
192+
// escalateMergeTargetDivergence reuses the standard escalation path (bead +
193+
// routed mail) by configuring the shared escalate flags and invoking runEscalate.
194+
// A stable fingerprint suppresses duplicate escalations across repeated runs.
195+
func escalateMergeTargetDivergence(cmd *cobra.Command, description string) error {
196+
severity := strings.ToLower(syncMTSeverity)
197+
if severity == "" {
198+
severity = "high"
199+
}
200+
201+
// Configure the shared escalate command state for a single create.
202+
escalateSeverity = severity
203+
escalateReason = "Refinery rebases MRs onto merge-target; while it has diverged from the source branch, every MR rebase conflicts and the queue stops draining. Resync merge-target (see `gt sync merge-target`)."
204+
escalateSource = "gt sync merge-target"
205+
escalateFingerprint = "sync-merge-target-divergence"
206+
escalateRelatedBead = ""
207+
escalateDryRun = false
208+
escalateStdin = false
209+
escalateJSON = false
210+
211+
return runEscalate(cmd, []string{description})
212+
}

0 commit comments

Comments
 (0)