Skip to content

Commit db74d75

Browse files
fix: base fresh polecat branches on canonical refs (#3629)
Co-authored-by: fury <[email protected]>
1 parent 9536d09 commit db74d75

File tree

3 files changed

+312
-17
lines changed

3 files changed

+312
-17
lines changed

internal/polecat/manager_test.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,87 @@ esac
106106
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
107107
}
108108

109+
func setupCanonicalBranchManagerTest(t *testing.T) (*Manager, string) {
110+
t.Helper()
111+
installMockBd(t)
112+
113+
root := t.TempDir()
114+
mayorRig := filepath.Join(root, "mayor", "rig")
115+
if err := os.MkdirAll(mayorRig, 0755); err != nil {
116+
t.Fatalf("mkdir mayor/rig: %v", err)
117+
}
118+
119+
rigBeads := filepath.Join(root, ".beads")
120+
if err := os.MkdirAll(rigBeads, 0755); err != nil {
121+
t.Fatalf("mkdir rig .beads: %v", err)
122+
}
123+
mayorBeads := filepath.Join(mayorRig, ".beads")
124+
if err := os.MkdirAll(mayorBeads, 0755); err != nil {
125+
t.Fatalf("mkdir mayor/rig/.beads: %v", err)
126+
}
127+
if err := os.WriteFile(filepath.Join(rigBeads, "redirect"), []byte("mayor/rig/.beads\n"), 0644); err != nil {
128+
t.Fatalf("write rig redirect: %v", err)
129+
}
130+
131+
cmd := exec.Command("git", "init", "-b", "main")
132+
cmd.Dir = mayorRig
133+
if out, err := cmd.CombinedOutput(); err != nil {
134+
t.Fatalf("git init: %v\n%s", err, out)
135+
}
136+
137+
readmePath := filepath.Join(mayorRig, "README.md")
138+
if err := os.WriteFile(readmePath, []byte("# Test\n"), 0644); err != nil {
139+
t.Fatalf("write README.md: %v", err)
140+
}
141+
mayorGit := git.NewGit(mayorRig)
142+
if err := mayorGit.Add("README.md"); err != nil {
143+
t.Fatalf("git add: %v", err)
144+
}
145+
if err := mayorGit.Commit("Initial commit"); err != nil {
146+
t.Fatalf("git commit: %v", err)
147+
}
148+
149+
cmd = exec.Command("git", "remote", "add", "origin", mayorRig)
150+
cmd.Dir = mayorRig
151+
if out, err := cmd.CombinedOutput(); err != nil {
152+
t.Fatalf("git remote add: %v\n%s", err, out)
153+
}
154+
cmd = exec.Command("git", "update-ref", "refs/remotes/origin/main", "HEAD")
155+
cmd.Dir = mayorRig
156+
if out, err := cmd.CombinedOutput(); err != nil {
157+
t.Fatalf("git update-ref: %v\n%s", err, out)
158+
}
159+
160+
r := &rig.Rig{Name: "rig", Path: root}
161+
return NewManager(r, git.NewGit(root), nil), mayorRig
162+
}
163+
164+
func createStalePolecatCommit(t *testing.T, repoPath, startPoint, branchName string) string {
165+
t.Helper()
166+
167+
repoGit := git.NewGit(repoPath)
168+
if err := repoGit.CheckoutNewBranch(branchName, startPoint); err != nil {
169+
t.Fatalf("checkout stale branch %s from %s: %v", branchName, startPoint, err)
170+
}
171+
172+
fileName := strings.NewReplacer("/", "-", "@", "-").Replace(branchName) + ".txt"
173+
if err := os.WriteFile(filepath.Join(repoPath, fileName), []byte(branchName+"\n"), 0644); err != nil {
174+
t.Fatalf("write stale branch marker: %v", err)
175+
}
176+
if err := repoGit.Add(fileName); err != nil {
177+
t.Fatalf("git add stale branch marker: %v", err)
178+
}
179+
if err := repoGit.Commit("Create stale polecat branch"); err != nil {
180+
t.Fatalf("git commit stale branch marker: %v", err)
181+
}
182+
183+
sha, err := repoGit.Rev("HEAD")
184+
if err != nil {
185+
t.Fatalf("resolve stale branch commit: %v", err)
186+
}
187+
return sha
188+
}
189+
109190
func TestStateIsWorking(t *testing.T) {
110191
tests := []struct {
111192
state State
@@ -1071,6 +1152,78 @@ func TestAddWithOptions_NoPrimeMDCreatedLocally(t *testing.T) {
10711152
}
10721153
}
10731154

1155+
func TestAddWithOptions_UsesCanonicalOriginDefaultBranch(t *testing.T) {
1156+
mgr, mayorRig := setupCanonicalBranchManagerTest(t)
1157+
1158+
mayorGit := git.NewGit(mayorRig)
1159+
baseSHA, err := mayorGit.Rev("origin/main")
1160+
if err != nil {
1161+
t.Fatalf("resolve origin/main: %v", err)
1162+
}
1163+
staleSHA := createStalePolecatCommit(t, mayorRig, "main", "polecat/stale-source")
1164+
1165+
polecat, err := mgr.AddWithOptions("toast", AddOptions{})
1166+
if err != nil {
1167+
t.Fatalf("AddWithOptions: %v", err)
1168+
}
1169+
1170+
worktreeGit := git.NewGit(polecat.ClonePath)
1171+
staleAncestor, err := worktreeGit.IsAncestor(staleSHA, polecat.Branch)
1172+
if err != nil {
1173+
t.Fatalf("check stale ancestry: %v", err)
1174+
}
1175+
if staleAncestor {
1176+
t.Fatalf("new polecat branch %q unexpectedly includes stale local commit %s", polecat.Branch, staleSHA)
1177+
}
1178+
1179+
baseAncestor, err := worktreeGit.IsAncestor(baseSHA, polecat.Branch)
1180+
if err != nil {
1181+
t.Fatalf("check canonical ancestry: %v", err)
1182+
}
1183+
if !baseAncestor {
1184+
t.Fatalf("new polecat branch %q should descend from origin/main commit %s", polecat.Branch, baseSHA)
1185+
}
1186+
}
1187+
1188+
func TestReuseIdlePolecat_UsesCanonicalOriginDefaultBranch(t *testing.T) {
1189+
mgr, mayorRig := setupCanonicalBranchManagerTest(t)
1190+
1191+
mayorGit := git.NewGit(mayorRig)
1192+
baseSHA, err := mayorGit.Rev("origin/main")
1193+
if err != nil {
1194+
t.Fatalf("resolve origin/main: %v", err)
1195+
}
1196+
1197+
polecat, err := mgr.AddWithOptions("toast", AddOptions{})
1198+
if err != nil {
1199+
t.Fatalf("AddWithOptions: %v", err)
1200+
}
1201+
1202+
staleSHA := createStalePolecatCommit(t, polecat.ClonePath, "HEAD", "polecat/toast-stale")
1203+
1204+
reused, err := mgr.ReuseIdlePolecat("toast", AddOptions{HookBead: "gt-next"})
1205+
if err != nil {
1206+
t.Fatalf("ReuseIdlePolecat: %v", err)
1207+
}
1208+
1209+
worktreeGit := git.NewGit(reused.ClonePath)
1210+
staleAncestor, err := worktreeGit.IsAncestor(staleSHA, reused.Branch)
1211+
if err != nil {
1212+
t.Fatalf("check stale ancestry: %v", err)
1213+
}
1214+
if staleAncestor {
1215+
t.Fatalf("reused polecat branch %q unexpectedly includes stale local commit %s", reused.Branch, staleSHA)
1216+
}
1217+
1218+
baseAncestor, err := worktreeGit.IsAncestor(baseSHA, reused.Branch)
1219+
if err != nil {
1220+
t.Fatalf("check canonical ancestry: %v", err)
1221+
}
1222+
if !baseAncestor {
1223+
t.Fatalf("reused polecat branch %q should descend from origin/main commit %s", reused.Branch, baseSHA)
1224+
}
1225+
}
1226+
10741227
func TestAddWithOptions_NoFilesAddedToRepo(t *testing.T) {
10751228
// This test verifies the invariant that polecat creation does NOT add any
10761229
// TRACKED files to the repo's directory structure. The user's code should stay pure.

internal/polecat/session_manager.go

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,66 @@ func (m *SessionManager) freshBranchName(polecatName, issue string) string {
186186
return fmt.Sprintf("polecat/%s-%s", polecatName, ts)
187187
}
188188

189+
func (m *SessionManager) canonicalSessionStartPoint(g *git.Git) string {
190+
defaultBranch := ""
191+
if rigCfg, err := rig.LoadRigConfig(m.rig.Path); err == nil && rigCfg.DefaultBranch != "" {
192+
defaultBranch = rigCfg.DefaultBranch
193+
}
194+
if defaultBranch == "" {
195+
defaultBranch = g.RemoteDefaultBranch()
196+
}
197+
return fmt.Sprintf("origin/%s", defaultBranch)
198+
}
199+
200+
func shouldCreateFreshSessionBranch(currentBranch, issue, canonicalBranch string) bool {
201+
if issue != "" && strings.Contains(currentBranch, "/"+issue+"@") {
202+
return false
203+
}
204+
205+
if currentBranch == canonicalBranch || currentBranch == "main" || currentBranch == "master" {
206+
return true
207+
}
208+
209+
return issue != "" && strings.HasPrefix(currentBranch, "polecat/")
210+
}
211+
212+
func (m *SessionManager) ensureCanonicalSessionBranch(g *git.Git, polecat string, opts SessionStartOptions) string {
213+
currentBranch, err := g.CurrentBranch()
214+
if err != nil {
215+
return ""
216+
}
217+
218+
startPoint := m.canonicalSessionStartPoint(g)
219+
canonicalBranch := strings.TrimPrefix(startPoint, "origin/")
220+
if !shouldCreateFreshSessionBranch(currentBranch, opts.Issue, canonicalBranch) {
221+
return currentBranch
222+
}
223+
224+
// Refresh origin refs before branching so recovered sessions start from the
225+
// canonical remote base instead of any preserved local polecat branch.
226+
if err := g.Fetch("origin"); err != nil {
227+
debugSession("fetch origin for canonical session branch", err)
228+
}
229+
230+
exists, err := g.RefExists(startPoint)
231+
if err != nil {
232+
debugSession("check canonical session start point", err)
233+
return currentBranch
234+
}
235+
if !exists {
236+
debugSession("missing canonical session start point", fmt.Errorf("%s", startPoint))
237+
return currentBranch
238+
}
239+
240+
newBranch := m.freshBranchName(polecat, opts.Issue)
241+
if err := g.CheckoutNewBranch(newBranch, startPoint); err != nil {
242+
debugSession("auto-checkout fresh branch on canonical base", err)
243+
return currentBranch
244+
}
245+
246+
return newBranch
247+
}
248+
189249
// hasPolecat checks if the polecat exists in this rig.
190250
func (m *SessionManager) hasPolecat(polecat string) bool {
191251
polecatPath := m.polecatDir(polecat)
@@ -338,23 +398,7 @@ func (m *SessionManager) Start(polecat string, opts SessionStartOptions) error {
338398
// branch detection and path resolution without a working directory.
339399
polecatGitBranch := ""
340400
if g := git.NewGit(workDir); g != nil {
341-
if b, err := g.CurrentBranch(); err == nil {
342-
polecatGitBranch = b
343-
// Auto-checkout a fresh branch if the worktree is on the default branch.
344-
// After gt done merges a polecat's work, the worktree reverts to main/master.
345-
// Starting a new session on main triggers the PRIME.md branch guard, which
346-
// nukes the polecat — causing the zombie loop seen in production (hq-h01n8).
347-
defaultBranch := g.DefaultBranch()
348-
if polecatGitBranch == defaultBranch || polecatGitBranch == "master" || polecatGitBranch == "main" {
349-
newBranch := m.freshBranchName(polecat, opts.Issue)
350-
if err := g.CheckoutNewBranch(newBranch, defaultBranch); err != nil {
351-
// Non-fatal: PRIME.md guard remains as a fallback; log for debugging.
352-
debugSession("auto-checkout fresh branch on default", err)
353-
} else {
354-
polecatGitBranch = newBranch
355-
}
356-
}
357-
}
401+
polecatGitBranch = m.ensureCanonicalSessionBranch(g, polecat, opts)
358402
}
359403
// Generate the GASTA run ID — the root identifier for all telemetry emitted
360404
// by this polecat session and its subprocesses (bd, mail, …).

internal/polecat/session_manager_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"time"
1313

1414
"github.com/steveyegge/gastown/internal/config"
15+
"github.com/steveyegge/gastown/internal/git"
1516
"github.com/steveyegge/gastown/internal/rig"
1617
gtruntime "github.com/steveyegge/gastown/internal/runtime"
1718
"github.com/steveyegge/gastown/internal/session"
@@ -43,6 +44,41 @@ func requireTmux(t *testing.T) {
4344
}
4445
}
4546

47+
func setupSessionBranchTestRepo(t *testing.T) (string, *git.Git) {
48+
t.Helper()
49+
50+
workDir := t.TempDir()
51+
cmd := exec.Command("git", "init", "-b", "main")
52+
cmd.Dir = workDir
53+
if out, err := cmd.CombinedOutput(); err != nil {
54+
t.Fatalf("git init: %v\n%s", err, out)
55+
}
56+
57+
repoGit := git.NewGit(workDir)
58+
if err := os.WriteFile(filepath.Join(workDir, "README.md"), []byte("# Test\n"), 0644); err != nil {
59+
t.Fatalf("write README.md: %v", err)
60+
}
61+
if err := repoGit.Add("README.md"); err != nil {
62+
t.Fatalf("git add: %v", err)
63+
}
64+
if err := repoGit.Commit("Initial commit"); err != nil {
65+
t.Fatalf("git commit: %v", err)
66+
}
67+
68+
cmd = exec.Command("git", "remote", "add", "origin", workDir)
69+
cmd.Dir = workDir
70+
if out, err := cmd.CombinedOutput(); err != nil {
71+
t.Fatalf("git remote add: %v\n%s", err, out)
72+
}
73+
cmd = exec.Command("git", "update-ref", "refs/remotes/origin/main", "HEAD")
74+
cmd.Dir = workDir
75+
if out, err := cmd.CombinedOutput(); err != nil {
76+
t.Fatalf("git update-ref: %v\n%s", err, out)
77+
}
78+
79+
return workDir, repoGit
80+
}
81+
4682
func TestSessionName(t *testing.T) {
4783
setupTestRegistryForSession(t)
4884

@@ -300,6 +336,68 @@ func TestPolecatStartInjectsFallbackEnvVars(t *testing.T) {
300336
}
301337
}
302338

339+
func TestEnsureCanonicalSessionBranch_UsesOriginDefaultBranch(t *testing.T) {
340+
workDir, repoGit := setupSessionBranchTestRepo(t)
341+
342+
baseSHA, err := repoGit.Rev("origin/main")
343+
if err != nil {
344+
t.Fatalf("resolve origin/main: %v", err)
345+
}
346+
if err := repoGit.CheckoutNewBranch("polecat/toast-old", "main"); err != nil {
347+
t.Fatalf("checkout stale polecat branch: %v", err)
348+
}
349+
if err := os.WriteFile(filepath.Join(workDir, "stale.txt"), []byte("stale\n"), 0644); err != nil {
350+
t.Fatalf("write stale.txt: %v", err)
351+
}
352+
if err := repoGit.Add("stale.txt"); err != nil {
353+
t.Fatalf("git add stale.txt: %v", err)
354+
}
355+
if err := repoGit.Commit("stale local polecat commit"); err != nil {
356+
t.Fatalf("git commit stale.txt: %v", err)
357+
}
358+
staleSHA, err := repoGit.Rev("HEAD")
359+
if err != nil {
360+
t.Fatalf("resolve stale HEAD: %v", err)
361+
}
362+
363+
sm := NewSessionManager(tmux.NewTmux(), &rig.Rig{Name: "gastown", Path: workDir})
364+
branch := sm.ensureCanonicalSessionBranch(repoGit, "toast", SessionStartOptions{Issue: "gt-9qb"})
365+
if !strings.Contains(branch, "/gt-9qb@") {
366+
t.Fatalf("fresh session branch = %q, want issue-scoped branch", branch)
367+
}
368+
369+
staleAncestor, err := repoGit.IsAncestor(staleSHA, branch)
370+
if err != nil {
371+
t.Fatalf("check stale ancestry: %v", err)
372+
}
373+
if staleAncestor {
374+
t.Fatalf("fresh session branch %q unexpectedly includes stale local commit %s", branch, staleSHA)
375+
}
376+
377+
baseAncestor, err := repoGit.IsAncestor(baseSHA, branch)
378+
if err != nil {
379+
t.Fatalf("check canonical ancestry: %v", err)
380+
}
381+
if !baseAncestor {
382+
t.Fatalf("fresh session branch %q should descend from origin/main commit %s", branch, baseSHA)
383+
}
384+
}
385+
386+
func TestEnsureCanonicalSessionBranch_KeepsCurrentIssueBranch(t *testing.T) {
387+
workDir, repoGit := setupSessionBranchTestRepo(t)
388+
389+
currentBranch := "polecat/toast/gt-9qb@seed"
390+
if err := repoGit.CheckoutNewBranch(currentBranch, "main"); err != nil {
391+
t.Fatalf("checkout current issue branch: %v", err)
392+
}
393+
394+
sm := NewSessionManager(tmux.NewTmux(), &rig.Rig{Name: "gastown", Path: workDir})
395+
branch := sm.ensureCanonicalSessionBranch(repoGit, "toast", SessionStartOptions{Issue: "gt-9qb"})
396+
if branch != currentBranch {
397+
t.Fatalf("ensureCanonicalSessionBranch changed active issue branch: got %q want %q", branch, currentBranch)
398+
}
399+
}
400+
303401
// TestSessionManager_resolveBeadsDir verifies that SessionManager correctly
304402
// resolves the beads directory for cross-rig issues via routes.jsonl.
305403
// This is a regression test for GitHub issue #1056.

0 commit comments

Comments
 (0)