@@ -9,9 +9,13 @@ package cmd
99import (
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+
322462func 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+ }
0 commit comments