Skip to content

Commit c47ca16

Browse files
authored
fix(submit): only prompt to publish draft when base transitions to trunk (#100)
* fix(submit): only prompt to publish draft when base transitions to trunk Resolves #95. Previously, `gh stack submit` would prompt to publish a draft PR every time it ran, as long as the PR was a draft and targeted trunk. This got annoying fast. The fix adds a `stackPRBase` key to `.git/config` (per branch) that persists the last-known remote base after each successful submit run. The publish prompt now fires only when the stored base differs from trunk — i.e., the PR's base is genuinely moving to trunk for the first time since the last submit. Cleanup is consistent: `unlink`, `orphan`, and sync's merged-branch handlers all clear `stackPRBase` alongside the other per-branch config keys. Sync's retarget-to-trunk path also persists the new base so subsequent submit runs don't re-prompt. * fix(submit): check trunk transition before persisting new PR base The `prActionUpdate` path in `executePRDecisions` was calling `SetPRBase` before `maybeMarkPRReady`, which meant `isTransitionToTrunk` always read the value we had just written (trunk) and returned false. The publish-draft prompt was therefore silently suppressed for all existing PRs transitioning to trunk. Fix: call `maybeMarkPRReady` first, then persist the new base. While here, move `GenerateAndPostStackComment` inside the success branch so it doesn't run when `UpdatePRBase` fails. Add `TestIsTransitionToTrunkOrderingInvariant` to document and guard the required call order.
1 parent 709f2e3 commit c47ca16

9 files changed

Lines changed: 441 additions & 7 deletions

File tree

cmd/orphan.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ func runOrphan(cmd *cobra.Command, args []string) error {
7676
_ = cfg.RemoveParent(desc.Name) //nolint:errcheck // best effort cleanup
7777
_ = cfg.RemovePR(desc.Name) //nolint:errcheck // best effort cleanup
7878
_ = cfg.RemoveForkPoint(desc.Name) //nolint:errcheck // best effort cleanup
79+
_ = cfg.RemovePRBase(desc.Name) //nolint:errcheck // best effort cleanup
7980
fmt.Printf("%s Orphaned %s\n", s.SuccessIcon(), s.Branch(desc.Name))
8081
}
8182
}
@@ -84,6 +85,7 @@ func runOrphan(cmd *cobra.Command, args []string) error {
8485
_ = cfg.RemoveParent(branchName) //nolint:errcheck // best effort cleanup
8586
_ = cfg.RemovePR(branchName) //nolint:errcheck // best effort cleanup
8687
_ = cfg.RemoveForkPoint(branchName) //nolint:errcheck // best effort cleanup
88+
_ = cfg.RemovePRBase(branchName) //nolint:errcheck // best effort cleanup
8789
fmt.Printf("%s Orphaned %s\n", s.SuccessIcon(), s.Branch(branchName))
8890

8991
return nil

cmd/submit.go

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -468,14 +468,17 @@ func executePRDecisions(g *git.Git, cfg *config.Config, root *tree.Node, decisio
468468
fmt.Printf("%s failed to update PR #%d base: %v\n", s.WarningIcon(), d.prNum, err)
469469
} else {
470470
fmt.Println(s.Success("ok"))
471+
// Check for trunk transition BEFORE persisting the new base, so
472+
// isTransitionToTrunk compares against the previous stored value.
473+
maybeMarkPRReady(ghClient, cfg, d.prNum, b.Name, parent, trunk, s)
474+
_ = cfg.SetPRBase(b.Name, parent) //nolint:errcheck // best effort — used for transition detection only
475+
if err := ghClient.GenerateAndPostStackComment(root, b.Name, trunk, d.prNum, remoteBranches); err != nil {
476+
fmt.Printf("%s failed to update stack comment for PR #%d: %v\n", s.WarningIcon(), d.prNum, err)
477+
}
471478
if opts.OpenWeb {
472479
prURLs = append(prURLs, ghClient.PRURL(d.prNum))
473480
}
474481
}
475-
if err := ghClient.GenerateAndPostStackComment(root, b.Name, trunk, d.prNum, remoteBranches); err != nil {
476-
fmt.Printf("%s failed to update stack comment for PR #%d: %v\n", s.WarningIcon(), d.prNum, err)
477-
}
478-
maybeMarkPRReady(ghClient, d.prNum, b.Name, parent, trunk, s)
479482
}
480483
case prActionAdopt:
481484
if opts.DryRun {
@@ -561,6 +564,9 @@ func executePRCreate(pCtx prContext, branch, base, title, body string, draft boo
561564
return pr.Number, false, fmt.Errorf("PR created but failed to store number: %w", err)
562565
}
563566

567+
// Persist the base so future submit runs can detect trunk transitions.
568+
_ = pCtx.cfg.SetPRBase(branch, base) //nolint:errcheck // best effort
569+
564570
if node := tree.FindNode(pCtx.root, branch); node != nil {
565571
node.PR = pr.Number
566572
}
@@ -711,23 +717,51 @@ func adoptExistingPRDirect(pCtx prContext, branch, base string, existingPR *gith
711717
}
712718
}
713719

720+
// Persist the new base for transition detection on future submit runs.
721+
_ = pCtx.cfg.SetPRBase(branch, base) //nolint:errcheck // best effort
722+
714723
// Add/update stack navigation comment
715724
if err := pCtx.ghClient.GenerateAndPostStackComment(pCtx.root, branch, pCtx.trunk, existingPR.Number, pCtx.remoteBranches); err != nil {
716725
fmt.Printf("%s failed to update stack comment: %v\n", pCtx.s.WarningIcon(), err)
717726
}
718727

719-
// If adopted PR is a draft and targets trunk, offer to publish
720-
if existingPR.Draft && base == pCtx.trunk {
728+
// Prompt to publish if the PR is a draft and its base is transitioning to trunk.
729+
// We use the live pre-adoption base (existingPR.Base.Ref) here since stored base
730+
// metadata may not exist yet for a freshly adopted PR.
731+
if existingPR.Draft && base == pCtx.trunk && existingPR.Base.Ref != pCtx.trunk {
721732
promptMarkPRReady(pCtx.ghClient, existingPR.Number, branch, pCtx.trunk, pCtx.s)
722733
}
723734

724735
return existingPR.Number, nil
725736
}
726737

738+
// isTransitionToTrunk reports whether a PR's base branch is moving to trunk for
739+
// the first time as tracked by gh-stack. It uses the stored last-known PR base
740+
// (persisted after each successful submit) to detect the transition, avoiding
741+
// any additional GitHub API calls.
742+
//
743+
// Returns true when:
744+
// - no stored base exists yet (first run after PR creation/adoption), or
745+
// - stored base is not trunk (base is changing from something else to trunk).
746+
//
747+
// Returns false when the stored base is already trunk, meaning the PR was
748+
// already targeting trunk on the previous submit run.
749+
func isTransitionToTrunk(cfg *config.Config, branch, trunk string) bool {
750+
storedBase, err := cfg.GetPRBase(branch)
751+
if err == nil && storedBase == trunk {
752+
return false
753+
}
754+
return true
755+
}
756+
727757
// maybeMarkPRReady checks if a PR is a draft targeting trunk and offers to publish it.
728758
// This handles the case where a PR was created as a draft (middle of stack) but now
729759
// targets trunk because its parent was merged.
730-
func maybeMarkPRReady(ghClient *github.Client, prNumber int, branch, base, trunk string, s *style.Style) {
760+
//
761+
// The prompt fires only when the base branch is transitioning to trunk for the first
762+
// time — i.e., the stored last-known base is not already trunk. This prevents the
763+
// prompt from appearing on every submit run once the PR already targets trunk.
764+
func maybeMarkPRReady(ghClient *github.Client, cfg *config.Config, prNumber int, branch, base, trunk string, s *style.Style) {
731765
// Only relevant if PR now targets trunk
732766
if base != trunk {
733767
return
@@ -739,6 +773,11 @@ func maybeMarkPRReady(ghClient *github.Client, prNumber int, branch, base, trunk
739773
return
740774
}
741775

776+
// Only prompt when the base is transitioning to trunk this run.
777+
if !isTransitionToTrunk(cfg, branch, trunk) {
778+
return
779+
}
780+
742781
promptMarkPRReady(ghClient, prNumber, branch, trunk, s)
743782
}
744783

cmd/submit_internal_test.go

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@ package cmd
99
import (
1010
"errors"
1111
"fmt"
12+
"os/exec"
1213
"testing"
1314

15+
"github.com/boneskull/gh-stack/internal/config"
16+
"github.com/boneskull/gh-stack/internal/git"
1417
"github.com/boneskull/gh-stack/internal/github"
18+
"github.com/boneskull/gh-stack/internal/style"
1519
"github.com/boneskull/gh-stack/internal/tree"
1620
)
1721

@@ -319,6 +323,142 @@ func TestIsBaseBranchInvalidError(t *testing.T) {
319323
}
320324
}
321325

326+
func setupTestRepo(t *testing.T) *config.Config {
327+
t.Helper()
328+
dir := t.TempDir()
329+
if err := exec.Command("git", "init", dir).Run(); err != nil {
330+
t.Fatalf("git init failed: %v", err)
331+
}
332+
exec.Command("git", "-C", dir, "config", "user.email", "test@test.com").Run() //nolint:errcheck
333+
exec.Command("git", "-C", dir, "config", "user.name", "Test").Run() //nolint:errcheck
334+
cfg, err := config.Load(dir)
335+
if err != nil {
336+
t.Fatalf("config.Load failed: %v", err)
337+
}
338+
return cfg
339+
}
340+
341+
// setupTestRepoWithDir is like setupTestRepo but also returns the directory path
342+
// for callers that need to run git commands directly or construct a git.Git instance.
343+
func setupTestRepoWithDir(t *testing.T) (*config.Config, string) {
344+
t.Helper()
345+
dir := t.TempDir()
346+
if err := exec.Command("git", "init", dir).Run(); err != nil {
347+
t.Fatalf("git init failed: %v", err)
348+
}
349+
exec.Command("git", "-C", dir, "config", "user.email", "test@test.com").Run() //nolint:errcheck
350+
exec.Command("git", "-C", dir, "config", "user.name", "Test").Run() //nolint:errcheck
351+
// Create an initial commit so the repo has a HEAD and we can create branches.
352+
exec.Command("git", "-C", dir, "commit", "--allow-empty", "-m", "init").Run() //nolint:errcheck
353+
cfg, err := config.Load(dir)
354+
if err != nil {
355+
t.Fatalf("config.Load failed: %v", err)
356+
}
357+
return cfg, dir
358+
}
359+
360+
func TestIsTransitionToTrunk(t *testing.T) {
361+
trunk := "main"
362+
363+
t.Run("no_stored_base_returns_true", func(t *testing.T) {
364+
cfg := setupTestRepo(t)
365+
// No stored base — first run after PR creation; should prompt.
366+
if !isTransitionToTrunk(cfg, "feat-a", trunk) {
367+
t.Error("expected true when no stored base exists")
368+
}
369+
})
370+
371+
t.Run("stored_base_is_not_trunk_returns_true", func(t *testing.T) {
372+
cfg := setupTestRepo(t)
373+
// Branch previously targeted a non-trunk parent; now it targets trunk.
374+
if err := cfg.SetPRBase("feat-a", "feat-parent"); err != nil {
375+
t.Fatalf("SetPRBase failed: %v", err)
376+
}
377+
if !isTransitionToTrunk(cfg, "feat-a", trunk) {
378+
t.Error("expected true when stored base is not trunk")
379+
}
380+
})
381+
382+
t.Run("stored_base_is_trunk_returns_false", func(t *testing.T) {
383+
cfg := setupTestRepo(t)
384+
// Branch was already targeting trunk on the previous run; don't re-prompt.
385+
if err := cfg.SetPRBase("feat-a", trunk); err != nil {
386+
t.Fatalf("SetPRBase failed: %v", err)
387+
}
388+
if isTransitionToTrunk(cfg, "feat-a", trunk) {
389+
t.Error("expected false when stored base is already trunk")
390+
}
391+
})
392+
393+
t.Run("different_branches_are_independent", func(t *testing.T) {
394+
cfg := setupTestRepo(t)
395+
// feat-a already targeting trunk; feat-b has no stored base.
396+
if err := cfg.SetPRBase("feat-a", trunk); err != nil {
397+
t.Fatalf("SetPRBase failed: %v", err)
398+
}
399+
if isTransitionToTrunk(cfg, "feat-a", trunk) {
400+
t.Error("feat-a: expected false when stored base is trunk")
401+
}
402+
if !isTransitionToTrunk(cfg, "feat-b", trunk) {
403+
t.Error("feat-b: expected true when no stored base exists")
404+
}
405+
})
406+
407+
t.Run("custom_trunk_name_works", func(t *testing.T) {
408+
cfg := setupTestRepo(t)
409+
customTrunk := "master"
410+
// Stored as "master"; should not prompt.
411+
if err := cfg.SetPRBase("feat-a", customTrunk); err != nil {
412+
t.Fatalf("SetPRBase failed: %v", err)
413+
}
414+
if isTransitionToTrunk(cfg, "feat-a", customTrunk) {
415+
t.Error("expected false with custom trunk name already stored")
416+
}
417+
// Stored as something else; should prompt.
418+
if err := cfg.SetPRBase("feat-b", "other-branch"); err != nil {
419+
t.Fatalf("SetPRBase failed: %v", err)
420+
}
421+
if !isTransitionToTrunk(cfg, "feat-b", customTrunk) {
422+
t.Error("expected true when stored base is not the custom trunk")
423+
}
424+
})
425+
}
426+
427+
// TestIsTransitionToTrunkOrderingInvariant verifies the property that
428+
// isTransitionToTrunk must be evaluated BEFORE SetPRBase is called with trunk,
429+
// because SetPRBase overwrites the stored value that the function reads.
430+
// This guards against the regression where SetPRBase was called before
431+
// maybeMarkPRReady in the prActionUpdate path, silently suppressing the prompt.
432+
func TestIsTransitionToTrunkOrderingInvariant(t *testing.T) {
433+
trunk := "main"
434+
435+
t.Run("returns_true_before_SetPRBase_when_parent_stored", func(t *testing.T) {
436+
cfg := setupTestRepo(t)
437+
if err := cfg.SetPRBase("feat-a", "feat-parent"); err != nil {
438+
t.Fatalf("SetPRBase failed: %v", err)
439+
}
440+
// Simulates the correct ordering: check transition BEFORE persisting trunk.
441+
transitionDetected := isTransitionToTrunk(cfg, "feat-a", trunk)
442+
_ = cfg.SetPRBase("feat-a", trunk)
443+
if !transitionDetected {
444+
t.Error("expected transition to be detected when checked before SetPRBase(trunk)")
445+
}
446+
})
447+
448+
t.Run("returns_false_after_SetPRBase_wrong_order", func(t *testing.T) {
449+
cfg := setupTestRepo(t)
450+
if err := cfg.SetPRBase("feat-a", "feat-parent"); err != nil {
451+
t.Fatalf("SetPRBase failed: %v", err)
452+
}
453+
// Simulates the buggy ordering: persist trunk BEFORE checking transition.
454+
_ = cfg.SetPRBase("feat-a", trunk)
455+
transitionDetected := isTransitionToTrunk(cfg, "feat-a", trunk)
456+
if transitionDetected {
457+
t.Error("demonstrates the bug: SetPRBase before check suppresses the prompt")
458+
}
459+
})
460+
}
461+
322462
func TestApplyMustPushForSkippedAncestors(t *testing.T) {
323463
main := &tree.Node{Name: "main"}
324464
featA := &tree.Node{Name: "feat-a", Parent: main}
@@ -392,3 +532,84 @@ func TestApplyMustPushForSkippedAncestors(t *testing.T) {
392532
}
393533
})
394534
}
535+
536+
func TestDeleteMergedBranchClearsPRBase(t *testing.T) {
537+
cfg, dir := setupTestRepoWithDir(t)
538+
g := git.New(dir)
539+
s := style.New()
540+
541+
trunk, err := g.CurrentBranch()
542+
if err != nil {
543+
t.Fatalf("CurrentBranch failed: %v", err)
544+
}
545+
if err := cfg.SetTrunk(trunk); err != nil {
546+
t.Fatalf("SetTrunk failed: %v", err)
547+
}
548+
549+
// Create feature-a with a commit so git can delete it later.
550+
if err := exec.Command("git", "-C", dir, "checkout", "-b", "feature-a").Run(); err != nil {
551+
t.Fatalf("create branch failed: %v", err)
552+
}
553+
if err := exec.Command("git", "-C", dir, "commit", "--allow-empty", "-m", "feat").Run(); err != nil {
554+
t.Fatalf("commit failed: %v", err)
555+
}
556+
if err := exec.Command("git", "-C", dir, "checkout", trunk).Run(); err != nil {
557+
t.Fatalf("checkout trunk failed: %v", err)
558+
}
559+
560+
if err := cfg.SetParent("feature-a", trunk); err != nil {
561+
t.Fatalf("SetParent failed: %v", err)
562+
}
563+
if err := cfg.SetPR("feature-a", 10); err != nil {
564+
t.Fatalf("SetPR failed: %v", err)
565+
}
566+
if err := cfg.SetPRBase("feature-a", trunk); err != nil {
567+
t.Fatalf("SetPRBase failed: %v", err)
568+
}
569+
570+
currentBranch := trunk
571+
deleteMergedBranch(g, cfg, "feature-a", trunk, &currentBranch, s)
572+
573+
if v, err := cfg.GetPRBase("feature-a"); err == nil {
574+
t.Errorf("expected stackPRBase to be removed after deleteMergedBranch, got %q", v)
575+
}
576+
if v, err := cfg.GetPR("feature-a"); err == nil {
577+
t.Errorf("expected stackPR to be removed after deleteMergedBranch, got %d", v)
578+
}
579+
}
580+
581+
func TestOrphanMergedBranchClearsPRBase(t *testing.T) {
582+
cfg, dir := setupTestRepoWithDir(t)
583+
g := git.New(dir)
584+
s := style.New()
585+
586+
trunk, err := g.CurrentBranch()
587+
if err != nil {
588+
t.Fatalf("CurrentBranch failed: %v", err)
589+
}
590+
if err := cfg.SetTrunk(trunk); err != nil {
591+
t.Fatalf("SetTrunk failed: %v", err)
592+
}
593+
594+
if err := cfg.SetParent("feature-a", trunk); err != nil {
595+
t.Fatalf("SetParent failed: %v", err)
596+
}
597+
if err := cfg.SetPR("feature-a", 11); err != nil {
598+
t.Fatalf("SetPR failed: %v", err)
599+
}
600+
if err := cfg.SetPRBase("feature-a", trunk); err != nil {
601+
t.Fatalf("SetPRBase failed: %v", err)
602+
}
603+
604+
orphanMergedBranch(cfg, "feature-a", s)
605+
606+
if v, err := cfg.GetPRBase("feature-a"); err == nil {
607+
t.Errorf("expected stackPRBase to be removed after orphanMergedBranch, got %q", v)
608+
}
609+
if v, err := cfg.GetPR("feature-a"); err == nil {
610+
t.Errorf("expected stackPR to be removed after orphanMergedBranch, got %d", v)
611+
}
612+
if v, err := cfg.GetParent("feature-a"); err == nil {
613+
t.Errorf("expected stackParent to be removed after orphanMergedBranch, got %q", v)
614+
}
615+
}

cmd/sync.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,10 @@ func runSync(cmd *cobra.Command, args []string) error {
318318
if rt.childPR > 0 {
319319
if updateErr := gh.UpdatePRBase(rt.childPR, trunk); updateErr != nil {
320320
fmt.Printf("%s failed to update PR #%d base: %v\n", s.WarningIcon(), rt.childPR, updateErr)
321+
} else {
322+
// Persist the new base so the submit publish-prompt knows this
323+
// PR is already targeting trunk on its next run.
324+
_ = cfg.SetPRBase(rt.childName, trunk) //nolint:errcheck // best effort
321325
}
322326
}
323327

@@ -473,6 +477,7 @@ func deleteMergedBranch(g *git.Git, cfg *config.Config, branch, trunk string, cu
473477
_ = cfg.RemoveParent(branch) //nolint:errcheck // best effort cleanup
474478
_ = cfg.RemovePR(branch) //nolint:errcheck // best effort cleanup
475479
_ = cfg.RemoveForkPoint(branch) //nolint:errcheck // best effort cleanup
480+
_ = cfg.RemovePRBase(branch) //nolint:errcheck // best effort cleanup
476481
if err := g.DeleteBranch(branch); err != nil {
477482
fmt.Printf("%s could not delete branch %s: %v\n", s.WarningIcon(), s.Branch(branch), err)
478483
}
@@ -485,5 +490,6 @@ func orphanMergedBranch(cfg *config.Config, branch string, s *style.Style) merge
485490
_ = cfg.RemoveParent(branch) //nolint:errcheck // best effort cleanup
486491
_ = cfg.RemovePR(branch) //nolint:errcheck // best effort cleanup
487492
_ = cfg.RemoveForkPoint(branch) //nolint:errcheck // best effort cleanup
493+
_ = cfg.RemovePRBase(branch) //nolint:errcheck // best effort cleanup
488494
return mergedActionOrphan
489495
}

cmd/unlink.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ func runUnlink(cmd *cobra.Command, args []string) error {
4242
if err := cfg.RemovePR(branch); err != nil {
4343
return err
4444
}
45+
_ = cfg.RemovePRBase(branch) //nolint:errcheck // best effort cleanup
4546

4647
s := style.New()
4748
fmt.Printf("%s Unlinked PR from branch %s\n", s.SuccessIcon(), s.Branch(branch))

0 commit comments

Comments
 (0)