Skip to content

Commit 281b106

Browse files
committed
Better handling of remove when untracked changes, staged changes and commited but not pushed to remote
1 parent 5dcc806 commit 281b106

3 files changed

Lines changed: 276 additions & 10 deletions

File tree

cmd/git.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,42 @@ func getWorktreeStatus(path string) (*WorktreeStatus, error) {
277277
return status, nil
278278
}
279279

280+
// getGitStatus returns the git status output for display
281+
func getGitStatus(path string) (string, error) {
282+
cmd := exec.Command("git", "-C", path, "status", "--short")
283+
var out bytes.Buffer
284+
cmd.Stdout = &out
285+
if err := cmd.Run(); err != nil {
286+
return "", err
287+
}
288+
return out.String(), nil
289+
}
290+
291+
// stashChanges stashes all changes in the worktree (including untracked files)
292+
func stashChanges(path string) error {
293+
cmd := exec.Command("git", "-C", path, "stash", "push", "--include-untracked", "-m", "git-wt: stashed before remove")
294+
cmd.Stdout = os.Stdout
295+
cmd.Stderr = os.Stderr
296+
return cmd.Run()
297+
}
298+
299+
// stashAll stages everything and stashes it (for use after reset)
300+
func stashAll(path string) error {
301+
// First stage everything including untracked files
302+
cmd := exec.Command("git", "-C", path, "add", "-A")
303+
cmd.Stdout = os.Stdout
304+
cmd.Stderr = os.Stderr
305+
if err := cmd.Run(); err != nil {
306+
return fmt.Errorf("failed to stage changes: %w", err)
307+
}
308+
309+
// Now stash the staged changes
310+
cmd = exec.Command("git", "-C", path, "stash", "push", "-m", "git-wt: stashed all before remove")
311+
cmd.Stdout = os.Stdout
312+
cmd.Stderr = os.Stderr
313+
return cmd.Run()
314+
}
315+
280316
// AheadBehindCount represents commits ahead/behind remote
281317
type AheadBehindCount struct {
282318
Ahead int
@@ -313,3 +349,82 @@ func getAheadBehindCount(path, branch string) (*AheadBehindCount, error) {
313349

314350
return &count, nil
315351
}
352+
353+
// mixedResetToBase resets the branch to the base, putting all changes in working directory (unstaged)
354+
func mixedResetToBase(path, branch, baseBranch string) error {
355+
// Determine what to reset to: remote branch or base branch
356+
target := ""
357+
cmd := exec.Command("git", "-C", path, "rev-parse", "--verify", fmt.Sprintf("origin/%s", branch))
358+
if cmd.Run() == nil {
359+
target = fmt.Sprintf("origin/%s", branch)
360+
} else if baseBranch != "" {
361+
target = baseBranch
362+
} else {
363+
// Try to get default branch
364+
defaultBranch, err := getDefaultBranch()
365+
if err != nil {
366+
return fmt.Errorf("cannot determine base to reset to: %w", err)
367+
}
368+
target = defaultBranch
369+
}
370+
371+
// Use --mixed (default) to put all changes in working directory
372+
cmd = exec.Command("git", "-C", path, "reset", "--mixed", target)
373+
cmd.Stdout = os.Stdout
374+
cmd.Stderr = os.Stderr
375+
return cmd.Run()
376+
}
377+
378+
// pushToRemote pushes the branch to its remote tracking branch
379+
func pushToRemote(path, branch string) error {
380+
cmd := exec.Command("git", "-C", path, "push", "origin", branch)
381+
cmd.Stdout = os.Stdout
382+
cmd.Stderr = os.Stderr
383+
return cmd.Run()
384+
}
385+
386+
// pushToNewRemote pushes and sets upstream for a new remote branch
387+
func pushToNewRemote(path, localBranch, remoteBranch string) error {
388+
cmd := exec.Command("git", "-C", path, "push", "-u", "origin", fmt.Sprintf("%s:%s", localBranch, remoteBranch))
389+
cmd.Stdout = os.Stdout
390+
cmd.Stderr = os.Stderr
391+
return cmd.Run()
392+
}
393+
394+
// getUnpushedCommitCount returns the number of commits not on remote
395+
// For branches with a remote tracking branch: counts commits ahead
396+
// For new branches without remote: counts commits since base branch
397+
func getUnpushedCommitCount(path, branch, baseBranch string) (int, error) {
398+
// First try: check if remote tracking branch exists
399+
cmd := exec.Command("git", "-C", path, "rev-parse", "--verify", fmt.Sprintf("origin/%s", branch))
400+
if cmd.Run() == nil {
401+
// Has remote - count commits ahead
402+
cmd = exec.Command("git", "-C", path, "rev-list", "--count", fmt.Sprintf("origin/%s..%s", branch, branch))
403+
var out bytes.Buffer
404+
cmd.Stdout = &out
405+
if err := cmd.Run(); err != nil {
406+
return 0, err
407+
}
408+
var count int
409+
fmt.Sscanf(strings.TrimSpace(out.String()), "%d", &count)
410+
return count, nil
411+
}
412+
413+
// No remote - count commits since base branch
414+
if baseBranch == "" {
415+
baseBranch, _ = getDefaultBranch()
416+
}
417+
if baseBranch == "" {
418+
return 0, nil // Can't determine, assume 0
419+
}
420+
421+
cmd = exec.Command("git", "-C", path, "rev-list", "--count", fmt.Sprintf("%s..%s", baseBranch, branch))
422+
var out bytes.Buffer
423+
cmd.Stdout = &out
424+
if err := cmd.Run(); err != nil {
425+
return 0, err
426+
}
427+
var count int
428+
fmt.Sscanf(strings.TrimSpace(out.String()), "%d", &count)
429+
return count, nil
430+
}

cmd/input.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,3 +219,22 @@ func promptText(message string) (string, error) {
219219

220220
return strings.TrimSpace(input), nil
221221
}
222+
223+
// promptTextWithDefault displays a message with a grayed default, reads text input
224+
// If user just presses Enter, returns the default value
225+
func promptTextWithDefault(message, defaultValue string) (string, error) {
226+
grayDefault := color.New(color.Faint).Sprint(defaultValue)
227+
fmt.Printf("%s %s ", message, color.CyanString("[%s]:", grayDefault))
228+
229+
reader := bufio.NewReader(os.Stdin)
230+
input, err := reader.ReadString('\n')
231+
if err != nil {
232+
return "", fmt.Errorf("failed to read input: %w", err)
233+
}
234+
235+
input = strings.TrimSpace(input)
236+
if input == "" {
237+
return defaultValue, nil
238+
}
239+
return input, nil
240+
}

cmd/remove.go

Lines changed: 142 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,129 @@ var removeCmd = &cobra.Command{
4141
return fmt.Errorf("cannot remove worktree while inside it. Please navigate out first")
4242
}
4343

44+
// Load session early to get baseBranch for unpushed commit check
45+
session, _ := loadSession(wt.Path)
46+
baseBranch := ""
47+
if session != nil {
48+
baseBranch = session.BaseBranch
49+
}
50+
4451
// Check for uncommitted changes
4552
hasChanges, err := hasUncommittedChanges(wt.Path)
4653
if err != nil {
4754
return fmt.Errorf("failed to check for uncommitted changes: %w", err)
4855
}
4956

50-
if hasChanges && !forceRemove {
51-
return fmt.Errorf("worktree has uncommitted changes. Commit or stash them first, or use --force")
57+
// Check for unpushed commits (works for both pushed and unpushed branches)
58+
unpushedCount, _ := getUnpushedCommitCount(wt.Path, branch, baseBranch)
59+
60+
changesStashed := false
61+
if (hasChanges || unpushedCount > 0) && !forceRemove {
62+
// Show the changes if any
63+
if hasChanges {
64+
status, err := getGitStatus(wt.Path)
65+
if err != nil {
66+
color.Yellow("⚠ Failed to get status: %v", err)
67+
} else {
68+
color.Yellow("Uncommitted changes in worktree:")
69+
fmt.Println(status)
70+
}
71+
}
72+
73+
// Show unpushed commits if any
74+
if unpushedCount > 0 {
75+
color.Yellow("Branch has %d unpushed commit(s).", unpushedCount)
76+
fmt.Println()
77+
}
78+
79+
// Build prompt message
80+
var promptMsg string
81+
if hasChanges && unpushedCount > 0 {
82+
promptMsg = "Worktree has uncommitted changes and unpushed commits."
83+
} else if hasChanges {
84+
promptMsg = "Worktree has uncommitted changes."
85+
} else {
86+
promptMsg = "Worktree has unpushed commits."
87+
}
88+
89+
// Build options based on what issues exist
90+
var options []Option
91+
if hasChanges && unpushedCount > 0 {
92+
// Both issues: offer combined options
93+
options = []Option{
94+
{Option: "f", Description: "Force remove (discard all)"},
95+
{Option: "b", Description: "Stash and push before removing"},
96+
{Option: "a", Description: "Stash all (reset branch, stash everything)"},
97+
{Option: "n", Description: "Cancel", Default: true},
98+
}
99+
} else if hasChanges {
100+
options = []Option{
101+
{Option: "f", Description: "Force remove (discard changes)"},
102+
{Option: "s", Description: "Stash changes before removing"},
103+
{Option: "n", Description: "Cancel", Default: true},
104+
}
105+
} else {
106+
// Only unpushed commits
107+
options = []Option{
108+
{Option: "f", Description: "Force remove"},
109+
{Option: "p", Description: "Push to remote first"},
110+
{Option: "a", Description: "Stash all (reset branch, stash commits)"},
111+
{Option: "n", Description: "Cancel", Default: true},
112+
}
113+
}
114+
115+
choice, _, err := promptOption(promptMsg, options)
116+
if err != nil {
117+
return fmt.Errorf("failed to get user input: %w", err)
118+
}
119+
120+
switch choice {
121+
case "n":
122+
fmt.Println("Cancelled.")
123+
return nil
124+
case "s":
125+
// Stash only (no unpushed commits)
126+
color.Blue("Stashing changes...")
127+
if err := stashChanges(wt.Path); err != nil {
128+
return fmt.Errorf("failed to stash changes: %w", err)
129+
}
130+
color.Green("✓ Changes stashed")
131+
changesStashed = true
132+
case "p":
133+
// Push only (no uncommitted changes)
134+
if err := doPush(wt.Path, branch); err != nil {
135+
return err
136+
}
137+
case "b":
138+
// Stash and push (both uncommitted changes and unpushed commits)
139+
color.Blue("Stashing changes...")
140+
if err := stashChanges(wt.Path); err != nil {
141+
return fmt.Errorf("failed to stash changes: %w", err)
142+
}
143+
color.Green("✓ Changes stashed")
144+
changesStashed = true
145+
146+
if err := doPush(wt.Path, branch); err != nil {
147+
return err
148+
}
149+
case "a":
150+
// Stash all: mixed reset to base, then stage and stash everything
151+
color.Blue("Resetting branch to base...")
152+
if err := mixedResetToBase(wt.Path, branch, baseBranch); err != nil {
153+
return fmt.Errorf("failed to reset branch: %w", err)
154+
}
155+
color.Green("✓ Branch reset")
156+
157+
color.Blue("Staging and stashing all changes...")
158+
if err := stashAll(wt.Path); err != nil {
159+
return fmt.Errorf("failed to stash changes: %w", err)
160+
}
161+
color.Green("✓ All changes stashed")
162+
changesStashed = true
163+
forceRemove = true // Need force delete since branch appears unmerged after reset
164+
case "f":
165+
forceRemove = true
166+
}
52167
}
53168

54169
// Load config for remove actions
@@ -57,12 +172,6 @@ var removeCmd = &cobra.Command{
57172
color.Yellow("⚠ Failed to load config: %v", err)
58173
} else if len(config.Remove) > 0 {
59174
fmt.Println("Running remove actions...")
60-
// Load session for environment variables
61-
session, _ := loadSession(wt.Path)
62-
baseBranch := ""
63-
if session != nil {
64-
baseBranch = session.BaseBranch
65-
}
66175
if err := runActions(config.Remove, wt.Path, branch, baseBranch); err != nil {
67176
color.Yellow("⚠ Remove actions completed with errors")
68177
}
@@ -81,8 +190,8 @@ var removeCmd = &cobra.Command{
81190

82191
color.Green("✓ Worktree removed: %s", branch)
83192

84-
// Delete branch if worktree is clean or force is used
85-
if !hasChanges || forceRemove {
193+
// Delete branch if worktree is clean, force is used, or changes were stashed
194+
if !hasChanges || forceRemove || changesStashed {
86195
color.Blue("Deleting branch '%s'...", branch)
87196
if err := deleteBranch(branch, forceRemove); err != nil {
88197
color.Yellow("⚠ Failed to delete branch: %v", err)
@@ -95,6 +204,29 @@ var removeCmd = &cobra.Command{
95204
},
96205
}
97206

207+
// doPush handles pushing to remote, prompting for branch name if needed
208+
func doPush(path, branch string) error {
209+
if remoteBranchExists(branch) {
210+
color.Blue("Pushing to origin/%s...", branch)
211+
if err := pushToRemote(path, branch); err != nil {
212+
return fmt.Errorf("failed to push: %w", err)
213+
}
214+
color.Green("✓ Pushed to remote")
215+
} else {
216+
// Prompt for remote branch name with current branch as default
217+
remoteBranch, err := promptTextWithDefault("Remote branch name", branch)
218+
if err != nil {
219+
return fmt.Errorf("failed to get branch name: %w", err)
220+
}
221+
color.Blue("Pushing to origin/%s...", remoteBranch)
222+
if err := pushToNewRemote(path, branch, remoteBranch); err != nil {
223+
return fmt.Errorf("failed to push: %w", err)
224+
}
225+
color.Green("✓ Pushed to new remote branch: %s", remoteBranch)
226+
}
227+
return nil
228+
}
229+
98230
func init() {
99231
rootCmd.AddCommand(removeCmd)
100232
removeCmd.Flags().BoolVarP(&forceRemove, "force", "f", false, "Force removal even with uncommitted changes")

0 commit comments

Comments
 (0)