Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cmd/adopt.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ func runAdopt(cmd *cobra.Command, args []string) error {
return err
}

// Store fork point
forkPoint, fpErr := g.GetMergeBase(branchName, parent)
if fpErr == nil {
_ = cfg.SetForkPoint(branchName, forkPoint) //nolint:errcheck // best effort
}

fmt.Printf("Adopted branch %q with parent %q\n", branchName, parent)
return nil
}
35 changes: 35 additions & 0 deletions cmd/adopt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,38 @@ func TestAdoptDetectsCycle(t *testing.T) {
t.Error("expected feature-a to be ancestor of feature-b")
}
}

func TestAdoptStoresForkPoint(t *testing.T) {
dir := setupTestRepo(t)

cfg, _ := config.Load(dir)
g := git.New(dir)

trunk, _ := g.CurrentBranch()
cfg.SetTrunk(trunk)

// Get trunk tip before creating branch
trunkTip, _ := g.GetTip(trunk)

// Create an untracked branch
g.CreateBranch("untracked-feature")

// Simulate adopt: set parent and fork point
cfg.SetParent("untracked-feature", trunk)

// Store fork point (what adopt command should now do)
forkPoint, fpErr := g.GetMergeBase("untracked-feature", trunk)
if fpErr != nil {
t.Fatalf("GetMergeBase failed: %v", fpErr)
}
cfg.SetForkPoint("untracked-feature", forkPoint)

// Verify fork point was stored
storedFP, err := cfg.GetForkPoint("untracked-feature")
if err != nil {
t.Fatalf("GetForkPoint failed: %v", err)
}
if storedFP != trunkTip {
t.Errorf("fork point = %s, want %s", storedFP, trunkTip)
}
}
35 changes: 33 additions & 2 deletions cmd/cascade.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,39 @@ func doCascade(g *git.Git, cfg *config.Config, branches []*tree.Node, dryRun boo
continue
}

fmt.Printf("Cascading %s onto %s...\n", b.Name, parent)
// Check if we should use --onto rebase
// This is needed when parent has been rebased/amended since child was created
storedForkPoint, fpErr := cfg.GetForkPoint(b.Name)
useOnto := false

if fpErr == nil && g.CommitExists(storedForkPoint) {
// We have a valid stored fork point
// Use --onto if the stored fork point differs from merge-base
currentMergeBase, mbErr := g.GetMergeBase(b.Name, parent)
if mbErr == nil && currentMergeBase != storedForkPoint {
useOnto = true
}
}

if useOnto {
fmt.Printf("Cascading %s onto %s (using fork point)...\n", b.Name, parent)
} else {
fmt.Printf("Cascading %s onto %s...\n", b.Name, parent)
}

// Checkout and rebase
if err := g.Checkout(b.Name); err != nil {
return err
}

if err := g.Rebase(parent); err != nil {
var rebaseErr error
if useOnto {
rebaseErr = g.RebaseOnto(parent, storedForkPoint, b.Name)
} else {
rebaseErr = g.Rebase(parent)
}

if rebaseErr != nil {
// Rebase conflict - save state
remaining := make([]string, 0, len(branches)-i-1)
for _, r := range branches[i+1:] {
Expand All @@ -146,6 +171,12 @@ func doCascade(g *git.Git, cfg *config.Config, branches []*tree.Node, dryRun boo
}

fmt.Printf("Cascading %s... ok\n", b.Name)

// Update fork point to current parent tip
parentTip, tipErr := g.GetTip(parent)
if tipErr == nil {
_ = cfg.SetForkPoint(b.Name, parentTip) //nolint:errcheck // best effort
}
}

// Return to original branch
Expand Down
6 changes: 6 additions & 0 deletions cmd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ func runCreate(cmd *cobra.Command, args []string) error {
return err
}

// Store fork point (where this branch diverges from parent)
forkPoint, fpErr := g.GetMergeBase(branchName, currentBranch)
if fpErr == nil {
_ = cfg.SetForkPoint(branchName, forkPoint) //nolint:errcheck // best effort
}

fmt.Printf("Created branch %q stacked on %q\n", branchName, currentBranch)
return nil
}
33 changes: 33 additions & 0 deletions cmd/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,36 @@ func TestBranchAlreadyExists(t *testing.T) {
t.Error("BranchExists should return true")
}
}

func TestCreateStoresForkPoint(t *testing.T) {
dir := setupTestRepo(t)

cfg, _ := config.Load(dir)
g := git.New(dir)

trunk, _ := g.CurrentBranch()
cfg.SetTrunk(trunk)

// Get the tip of trunk before creating branch
trunkTip, _ := g.GetTip(trunk)

// Simulate create command: create branch and set parent + fork point
g.CreateAndCheckout("feature")
cfg.SetParent("feature", trunk)

// Store fork point (what create command should now do)
forkPoint, fpErr := g.GetMergeBase("feature", trunk)
if fpErr != nil {
t.Fatalf("GetMergeBase failed: %v", fpErr)
}
cfg.SetForkPoint("feature", forkPoint)

// Verify fork point was stored and equals trunk tip
storedFP, err := cfg.GetForkPoint("feature")
if err != nil {
t.Fatalf("GetForkPoint failed: %v", err)
}
if storedFP != trunkTip {
t.Errorf("fork point = %s, want %s (trunk tip at creation)", storedFP, trunkTip)
}
}
10 changes: 6 additions & 4 deletions cmd/orphan.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,17 @@ func runOrphan(cmd *cobra.Command, args []string) error {
if orphanForceFlag {
descendants := tree.GetDescendants(node)
for _, desc := range descendants {
_ = cfg.RemoveParent(desc.Name) //nolint:errcheck // best effort cleanup
_ = cfg.RemovePR(desc.Name) //nolint:errcheck // best effort cleanup
_ = cfg.RemoveParent(desc.Name) //nolint:errcheck // best effort cleanup
_ = cfg.RemovePR(desc.Name) //nolint:errcheck // best effort cleanup
_ = cfg.RemoveForkPoint(desc.Name) //nolint:errcheck // best effort cleanup
fmt.Printf("Orphaned %q\n", desc.Name)
}
}

// Orphan the branch
_ = cfg.RemoveParent(branchName) //nolint:errcheck // best effort cleanup
_ = cfg.RemovePR(branchName) //nolint:errcheck // best effort cleanup
_ = cfg.RemoveParent(branchName) //nolint:errcheck // best effort cleanup
_ = cfg.RemovePR(branchName) //nolint:errcheck // best effort cleanup
_ = cfg.RemoveForkPoint(branchName) //nolint:errcheck // best effort cleanup
fmt.Printf("Orphaned %q\n", branchName)

return nil
Expand Down
33 changes: 33 additions & 0 deletions cmd/orphan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,36 @@ func TestOrphanRemovesPR(t *testing.T) {
t.Error("PR should be removed after orphan")
}
}

func TestOrphanRemovesForkPoint(t *testing.T) {
dir := setupTestRepo(t)

cfg, _ := config.Load(dir)
g := git.New(dir)

trunk, _ := g.CurrentBranch()
cfg.SetTrunk(trunk)

// Create branch with fork point
g.CreateBranch("feature-a")
cfg.SetParent("feature-a", trunk)
trunkTip, _ := g.GetTip(trunk)
cfg.SetForkPoint("feature-a", trunkTip)

// Verify fork point is set
fp, err := cfg.GetForkPoint("feature-a")
if err != nil || fp != trunkTip {
t.Fatal("fork point should be set")
}

// Orphan (which should also remove fork point)
cfg.RemoveParent("feature-a")
cfg.RemovePR("feature-a")
cfg.RemoveForkPoint("feature-a")

// Verify fork point is gone
_, err = cfg.GetForkPoint("feature-a")
if err == nil {
t.Error("fork point should be removed after orphan")
}
}
119 changes: 87 additions & 32 deletions cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,49 +143,42 @@ func runSync(cmd *cobra.Command, args []string) error {

// Handle merged branches
root, _ := tree.Build(cfg) //nolint:errcheck // nil root is fine, FindNode handles it

// Collect fork points BEFORE deleting merged branches
type retargetInfo struct {
childName string
forkPoint string
childPR int
}
var retargets []retargetInfo

for _, branch := range merged {
node := tree.FindNode(root, branch)
if node == nil {
continue
}

// Retarget children to trunk
// For each child, get fork point - prefer stored, fall back to calculated
for _, child := range node.Children {
if syncDryRunFlag {
fmt.Printf("Would retarget %s from %s to %s\n", child.Name, branch, trunk)
} else {
fmt.Printf("Retargeting %s from %s to %s\n", child.Name, branch, trunk)
_ = cfg.SetParent(child.Name, trunk) //nolint:errcheck // best effort

// Update PR base on GitHub
childPR, _ := cfg.GetPR(child.Name) //nolint:errcheck // 0 is fine
if childPR > 0 {
if updateErr := gh.UpdatePRBase(childPR, trunk); updateErr != nil {
fmt.Printf("Warning: failed to update PR #%d base: %v\n", childPR, updateErr)
}

// Check if this was a draft and now targets trunk
pr, getPRErr := gh.GetPR(childPR)
if getPRErr == nil && pr.Draft {
fmt.Printf("PR #%d (%s) now targets %s.\n", childPR, child.Name, trunk)
fmt.Print("Mark as ready for review? [y/N]: ")

var response string
if _, scanErr := fmt.Scanln(&response); scanErr == nil {
if strings.ToLower(strings.TrimSpace(response)) == "y" {
if readyErr := gh.MarkPRReady(childPR); readyErr != nil {
fmt.Printf("Warning: failed to mark PR ready: %v\n", readyErr)
} else {
fmt.Printf("PR #%d marked as ready for review.\n", childPR)
}
}
}
}
// Try stored fork point first
forkPoint, fpErr := cfg.GetForkPoint(child.Name)
if fpErr != nil || !g.CommitExists(forkPoint) {
// Fall back to calculating from parent (before it's deleted)
forkPoint, fpErr = g.GetMergeBase(child.Name, branch)
if fpErr != nil {
fmt.Printf("Warning: could not get fork point for %s: %v\n", child.Name, fpErr)
forkPoint = "" // Will fall back to simple rebase
}
}
childPR, _ := cfg.GetPR(child.Name) //nolint:errcheck // 0 is fine
retargets = append(retargets, retargetInfo{
childName: child.Name,
forkPoint: forkPoint,
childPR: childPR,
})
}

// Prompt to delete merged branch
// Now safe to delete the merged branch
if syncDryRunFlag {
fmt.Printf("Would delete merged branch %s\n", branch)
} else {
Expand All @@ -196,6 +189,68 @@ func runSync(cmd *cobra.Command, args []string) error {
}
}

// Retarget children to trunk
for _, rt := range retargets {
if syncDryRunFlag {
fmt.Printf("Would retarget %s to %s (fork point: %s)\n", rt.childName, trunk, rt.forkPoint)
continue
}

fmt.Printf("Retargeting %s to %s\n", rt.childName, trunk)
_ = cfg.SetParent(rt.childName, trunk) //nolint:errcheck // best effort

Comment thread
boneskull marked this conversation as resolved.
// Update PR base on GitHub
if rt.childPR > 0 {
if updateErr := gh.UpdatePRBase(rt.childPR, trunk); updateErr != nil {
fmt.Printf("Warning: failed to update PR #%d base: %v\n", rt.childPR, updateErr)
}

// Check if this was a draft and now targets trunk
pr, getPRErr := gh.GetPR(rt.childPR)
if getPRErr == nil && pr.Draft {
fmt.Printf("PR #%d (%s) now targets %s.\n", rt.childPR, rt.childName, trunk)
fmt.Print("Mark as ready for review? [y/N]: ")

var response string
if _, scanErr := fmt.Scanln(&response); scanErr == nil {
if strings.ToLower(strings.TrimSpace(response)) == "y" {
if readyErr := gh.MarkPRReady(rt.childPR); readyErr != nil {
fmt.Printf("Warning: failed to mark PR ready: %v\n", readyErr)
} else {
fmt.Printf("PR #%d marked as ready for review.\n", rt.childPR)
}
}
}
}
}

// Rebase using --onto if we have a fork point
if rt.forkPoint != "" && g.CommitExists(rt.forkPoint) {
displayForkPoint := rt.forkPoint
if len(displayForkPoint) > 8 {
displayForkPoint = displayForkPoint[:8]
}
fmt.Printf("Rebasing %s onto %s (from fork point %s)...\n", rt.childName, trunk, displayForkPoint)
if rebaseErr := g.RebaseOnto(trunk, rt.forkPoint, rt.childName); rebaseErr != nil {
fmt.Printf("Warning: --onto rebase failed, will try normal cascade: %v\n", rebaseErr)
// Don't return error - let cascade try
} else {
fmt.Printf("Rebased %s successfully\n", rt.childName)
Comment thread
boneskull marked this conversation as resolved.

// Update fork point to new parent tip after successful rebase
trunkTip, tipErr := g.GetTip(trunk)
if tipErr == nil {
_ = cfg.SetForkPoint(rt.childName, trunkTip) //nolint:errcheck // best effort
}
}
}
}

// Return to original branch after retargeting
if !syncDryRunFlag && currentBranch != "" {
_ = g.Checkout(currentBranch) //nolint:errcheck // best effort
}

// Cascade all (if not disabled)
if !syncNoCascadeFlag {
fmt.Println("\nCascading all branches...")
Expand Down
Loading