From 2def278f55e3472ed1da11abd7a3b8d9bf6ce368 Mon Sep 17 00:00:00 2001 From: Jonatan Dahl Date: Fri, 6 Feb 2026 14:52:03 -0500 Subject: [PATCH] feat: consolidate reparent into parent command Merge `stack reparent ` into `stack parent [branch]`: - `stack parent` (no args) shows current parent - `stack parent ` changes parent to specified branch This provides a more intuitive UX where `parent` handles both showing and setting the parent relationship. Co-Authored-By: Claude Opus 4.6 --- cmd/parent.go | 152 +++++++++++++++++++++++++++++++++++++++++++--- cmd/reparent.go | 155 ----------------------------------------------- cmd/root.go | 1 - docs/commands.md | 27 ++++----- 4 files changed, 153 insertions(+), 182 deletions(-) delete mode 100644 cmd/reparent.go diff --git a/cmd/parent.go b/cmd/parent.go index d2d7694..2a3e668 100644 --- a/cmd/parent.go +++ b/cmd/parent.go @@ -5,30 +5,54 @@ import ( "os" "github.com/javoire/stackinator/internal/git" + "github.com/javoire/stackinator/internal/github" "github.com/javoire/stackinator/internal/ui" "github.com/spf13/cobra" ) var parentCmd = &cobra.Command{ - Use: "parent", - Short: "Show the parent of the current branch", - Long: `Display the parent branch of the current branch in the stack. + Use: "parent [new-parent]", + Short: "Show or change the parent of the current branch", + Long: `Show or change the parent branch of the current branch in the stack. -If the current branch has no parent set, it will show that the branch -is not part of a stack.`, +Without arguments, displays the current parent branch. + +With a branch argument, changes the parent to the specified branch. This updates +the stack parent relationship in git config and, if a PR exists for the current +branch, automatically updates the PR base to match the new parent.`, Example: ` # Show parent of current branch - stack parent`, + stack parent + + # Change current branch to be based on a different parent + stack parent feature-auth + + # Preview what would happen + stack parent main --dry-run`, + Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { gitClient := git.NewGitClient() - if err := runParent(gitClient); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + if len(args) == 0 { + // Show parent + if err := runShowParent(gitClient); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + } else { + // Reparent + newParent := args[0] + repo := github.ParseRepoFromURL(gitClient.GetRemoteURL("origin")) + githubClient := github.NewGitHubClient(repo) + + if err := runReparent(gitClient, githubClient, newParent); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } } }, } -func runParent(gitClient git.GitClient) error { +func runShowParent(gitClient git.GitClient) error { // Get current branch currentBranch, err := gitClient.GetCurrentBranch() if err != nil { @@ -47,3 +71,111 @@ func runParent(gitClient git.GitClient) error { return nil } +func runReparent(gitClient git.GitClient, githubClient github.GitHubClient, newParent string) error { + // Get current branch + currentBranch, err := gitClient.GetCurrentBranch() + if err != nil { + return fmt.Errorf("failed to get current branch: %w", err) + } + + // Get current parent (may be empty if not in a stack) + currentParent := gitClient.GetConfig(fmt.Sprintf("branch.%s.stackparent", currentBranch)) + + // Check if new parent is the same as current parent + if currentParent != "" && newParent == currentParent { + fmt.Printf("Branch %s is already parented to %s\n", ui.Branch(currentBranch), ui.Branch(newParent)) + return nil + } + + // Verify new parent branch exists + if !gitClient.BranchExists(newParent) { + return fmt.Errorf("new parent branch %s does not exist", newParent) + } + + // Check if this would create a cycle + if newParent == currentBranch { + return fmt.Errorf("cannot set branch as its own parent") + } + + // Check if new parent is a descendant of current branch (would create cycle) + if isDescendant(gitClient, currentBranch, newParent) { + return fmt.Errorf("cannot reparent to %s: it is a descendant of %s (would create a cycle)", newParent, currentBranch) + } + + // Print appropriate message based on whether we're adding to stack or reparenting + if currentParent == "" { + fmt.Printf("Adding %s to stack with parent %s\n", ui.Branch(currentBranch), ui.Branch(newParent)) + } else { + fmt.Printf("Reparenting %s: %s -> %s\n", ui.Branch(currentBranch), ui.Branch(currentParent), ui.Branch(newParent)) + } + + // Update git config + configKey := fmt.Sprintf("branch.%s.stackparent", currentBranch) + if err := gitClient.SetConfig(configKey, newParent); err != nil { + return fmt.Errorf("failed to update parent config: %w", err) + } + + // Check if there's a PR for this branch + pr, err := githubClient.GetPRForBranch(currentBranch) + if err != nil { + // Error fetching PR info, but config was updated successfully + fmt.Println(ui.Success(fmt.Sprintf("Updated parent to %s", ui.Branch(newParent)))) + fmt.Printf("Warning: failed to check for PR: %v\n", err) + return nil + } + + if pr != nil { + // PR exists, update its base + fmt.Printf("Updating PR #%d base: %s -> %s\n", pr.Number, ui.Branch(pr.Base), ui.Branch(newParent)) + + if err := githubClient.UpdatePRBase(pr.Number, newParent); err != nil { + // Config was updated but PR base update failed + fmt.Println(ui.Success(fmt.Sprintf("Updated parent to %s", ui.Branch(newParent)))) + return fmt.Errorf("failed to update PR base: %w", err) + } + + if !dryRun { + fmt.Println(ui.Success(fmt.Sprintf("Updated parent to %s", ui.Branch(newParent)))) + fmt.Println(ui.Success(fmt.Sprintf("Updated PR #%d base to %s", pr.Number, ui.Branch(newParent)))) + } + } else { + // No PR exists + if !dryRun { + fmt.Println(ui.Success(fmt.Sprintf("Updated parent to %s", ui.Branch(newParent)))) + fmt.Println(" (no PR found for this branch)") + } + } + + return nil +} + +// isDescendant checks if possibleDescendant is a descendant of ancestor in the stack +func isDescendant(gitClient git.GitClient, ancestor, possibleDescendant string) bool { + // Walk up from possibleDescendant to see if we reach ancestor + current := possibleDescendant + visited := make(map[string]bool) + + for current != "" { + // Prevent infinite loops + if visited[current] { + return false + } + visited[current] = true + + // Get parent of current + parent := gitClient.GetConfig(fmt.Sprintf("branch.%s.stackparent", current)) + if parent == "" { + // Reached the top of the stack without finding ancestor + return false + } + + if parent == ancestor { + // Found ancestor in the chain + return true + } + + current = parent + } + + return false +} diff --git a/cmd/reparent.go b/cmd/reparent.go deleted file mode 100644 index 23a82a3..0000000 --- a/cmd/reparent.go +++ /dev/null @@ -1,155 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - - "github.com/javoire/stackinator/internal/git" - "github.com/javoire/stackinator/internal/github" - "github.com/javoire/stackinator/internal/ui" - "github.com/spf13/cobra" -) - -var reparentCmd = &cobra.Command{ - Use: "reparent ", - Short: "Change the parent of the current branch", - Long: `Change or set the parent branch of the current branch. - -This command updates the stack parent relationship in git config and, if a PR -exists for the current branch, automatically updates the PR base to match the -new parent. - -This is useful for: -- Adding an existing branch to a stack (when no parent is currently set) -- Reorganizing your stack when you want to change which branch a feature is based on`, - Example: ` # Change current branch to be based on a different parent - stack reparent feature-auth - - # Preview what would happen - stack reparent main --dry-run - - # See all git/gh commands - stack reparent feature-base --verbose`, - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - newParent := args[0] - - gitClient := git.NewGitClient() - repo := github.ParseRepoFromURL(gitClient.GetRemoteURL("origin")) - githubClient := github.NewGitHubClient(repo) - - if err := runReparent(gitClient, githubClient, newParent); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - }, -} - -func runReparent(gitClient git.GitClient, githubClient github.GitHubClient, newParent string) error { - // Get current branch - currentBranch, err := gitClient.GetCurrentBranch() - if err != nil { - return fmt.Errorf("failed to get current branch: %w", err) - } - - // Get current parent (may be empty if not in a stack) - currentParent := gitClient.GetConfig(fmt.Sprintf("branch.%s.stackparent", currentBranch)) - - // Check if new parent is the same as current parent - if currentParent != "" && newParent == currentParent { - fmt.Printf("Branch %s is already parented to %s\n", ui.Branch(currentBranch), ui.Branch(newParent)) - return nil - } - - // Verify new parent branch exists - if !gitClient.BranchExists(newParent) { - return fmt.Errorf("new parent branch %s does not exist", newParent) - } - - // Check if this would create a cycle - if newParent == currentBranch { - return fmt.Errorf("cannot set branch as its own parent") - } - - // Check if new parent is a descendant of current branch (would create cycle) - if isDescendant(gitClient, currentBranch, newParent) { - return fmt.Errorf("cannot reparent to %s: it is a descendant of %s (would create a cycle)", newParent, currentBranch) - } - - // Print appropriate message based on whether we're adding to stack or reparenting - if currentParent == "" { - fmt.Printf("Adding %s to stack with parent %s\n", ui.Branch(currentBranch), ui.Branch(newParent)) - } else { - fmt.Printf("Reparenting %s: %s -> %s\n", ui.Branch(currentBranch), ui.Branch(currentParent), ui.Branch(newParent)) - } - - // Update git config - configKey := fmt.Sprintf("branch.%s.stackparent", currentBranch) - if err := gitClient.SetConfig(configKey, newParent); err != nil { - return fmt.Errorf("failed to update parent config: %w", err) - } - - // Check if there's a PR for this branch - pr, err := githubClient.GetPRForBranch(currentBranch) - if err != nil { - // Error fetching PR info, but config was updated successfully - fmt.Println(ui.Success(fmt.Sprintf("Updated parent to %s", ui.Branch(newParent)))) - fmt.Printf("Warning: failed to check for PR: %v\n", err) - return nil - } - - if pr != nil { - // PR exists, update its base - fmt.Printf("Updating PR #%d base: %s -> %s\n", pr.Number, ui.Branch(pr.Base), ui.Branch(newParent)) - - if err := githubClient.UpdatePRBase(pr.Number, newParent); err != nil { - // Config was updated but PR base update failed - fmt.Println(ui.Success(fmt.Sprintf("Updated parent to %s", ui.Branch(newParent)))) - return fmt.Errorf("failed to update PR base: %w", err) - } - - if !dryRun { - fmt.Println(ui.Success(fmt.Sprintf("Updated parent to %s", ui.Branch(newParent)))) - fmt.Println(ui.Success(fmt.Sprintf("Updated PR #%d base to %s", pr.Number, ui.Branch(newParent)))) - } - } else { - // No PR exists - if !dryRun { - fmt.Println(ui.Success(fmt.Sprintf("Updated parent to %s", ui.Branch(newParent)))) - fmt.Println(" (no PR found for this branch)") - } - } - - return nil -} - -// isDescendant checks if possibleDescendant is a descendant of ancestor in the stack -func isDescendant(gitClient git.GitClient, ancestor, possibleDescendant string) bool { - // Walk up from possibleDescendant to see if we reach ancestor - current := possibleDescendant - visited := make(map[string]bool) - - for current != "" { - // Prevent infinite loops - if visited[current] { - return false - } - visited[current] = true - - // Get parent of current - parent := gitClient.GetConfig(fmt.Sprintf("branch.%s.stackparent", current)) - if parent == "" { - // Reached the top of the stack without finding ancestor - return false - } - - if parent == ancestor { - // Found ancestor in the chain - return true - } - - current = parent - } - - return false -} diff --git a/cmd/root.go b/cmd/root.go index d849fab..9fc9c5b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -76,7 +76,6 @@ func init() { rootCmd.AddCommand(pruneCmd) rootCmd.AddCommand(parentCmd) rootCmd.AddCommand(renameCmd) - rootCmd.AddCommand(reparentCmd) rootCmd.AddCommand(worktreeCmd) rootCmd.AddCommand(configCmd) rootCmd.AddCommand(upCmd) diff --git a/docs/commands.md b/docs/commands.md index 6686d2c..9739d07 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -55,12 +55,21 @@ Flags: - `--force`, `-f` - Use `--force` instead of `--force-with-lease` for push (bypasses safety checks) -## `stack parent` +## `stack parent [new-parent]` -Display the parent branch of the current branch in the stack. +Show or change the parent branch of the current branch in the stack. + +Without arguments, displays the current parent. With a branch argument, changes the parent to the specified branch and updates the PR base if a PR exists. ```bash +# Show parent of current branch stack parent + +# Change current branch to be based on a different parent +stack parent feature-auth + +# Preview what would happen +stack parent main --dry-run ``` ## `stack prune` @@ -100,20 +109,6 @@ stack rename feature-improved-name stack rename feature-improved-name --dry-run ``` -## `stack reparent ` - -Change the parent branch of the current branch in the stack. - -Updates the stack parent relationship and, if a PR exists, automatically updates the PR base to match the new parent. - -```bash -# Change current branch to be based on a different parent -stack reparent feature-auth - -# Preview what would happen -stack reparent main --dry-run -``` - ## `stack worktree [base-branch]` Create a git worktree in the configured worktrees directory for the specified branch.