Skip to content

Commit 4f40e96

Browse files
feat: add automatic stashing to sync command to protect all local files
Previously, running 'arbor sync' could result in untracked and ignored files being deleted when they conflicted with files in the upstream branch. This was particularly problematic for files like .env.testing and modified copies of tracked files like phpunit.parallel.xml. This commit adds automatic stashing of ALL changes (tracked modifications, untracked files, and ignored files) before sync operations, then automatically restores them after a successful sync. Changes: - Add git stash utilities (StashAll, PopStash, HasStash, HasChanges) - Integrate auto-stashing into sync command (enabled by default) - Add --no-auto-stash flag to opt out - Add sync.auto_stash config option - Handle stash conflicts gracefully with helpful error messages - Preserve stash on sync failure with recovery instructions - Add comprehensive tests for stash operations - Update documentation with auto-stash details Benefits: - No more lost untracked/ignored files during sync - No manual stashing required - Safe by default, with opt-out available - Clear error messages when conflicts occur
1 parent 412702f commit 4f40e96

5 files changed

Lines changed: 558 additions & 16 deletions

File tree

README.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,17 @@ See [AGENTS.md](./AGENTS.md) for development guide.
122122

123123
Synchronizes the current worktree branch with an upstream branch by fetching the latest changes and rebasing or merging.
124124

125+
**Auto-Stashing (Default):**
126+
127+
By default, `arbor sync` automatically stashes **all** changes before syncing, including:
128+
- Tracked modifications
129+
- Untracked files
130+
- Ignored files (like `.env`, `.env.testing`)
131+
132+
This ensures your local changes are preserved even if they conflict with files in the upstream branch. After a successful sync, the stashed changes are automatically restored.
133+
125134
```bash
126-
# Sync with default settings (upstream: main, strategy: rebase)
135+
# Sync with default settings (upstream: main, strategy: rebase, auto-stash: on)
127136
arbor sync
128137

129138
# Sync with a specific upstream branch
@@ -138,6 +147,9 @@ arbor sync -s merge
138147
arbor sync --remote upstream
139148
arbor sync -r upstream
140149

150+
# Disable auto-stashing (not recommended)
151+
arbor sync --no-auto-stash
152+
141153
# Skip all confirmations
142154
arbor sync --yes
143155
arbor sync -y
@@ -158,18 +170,20 @@ sync:
158170
upstream: main
159171
strategy: rebase
160172
remote: origin
173+
auto_stash: true # Default: true, set to false to disable
161174
```
162175
163176
The command resolves settings in this order:
164-
1. CLI flags (`--upstream`, `--strategy`, `--remote`)
177+
1. CLI flags (`--upstream`, `--strategy`, `--remote`, `--no-auto-stash`)
165178
2. Project config (`arbor.yaml`)
166179
3. Project `default_branch`
167180
4. Interactive selection (if in interactive mode)
168181

169182
**Notes:**
170183
- Must be run from within a worktree (not project root)
171184
- Fails if worktree is on detached HEAD
172-
- Warns if worktree has uncommitted changes
185+
- Auto-stashes all changes by default (can be disabled with `--no-auto-stash`)
186+
- If stash pop fails due to conflicts, the stash is preserved and instructions are provided
173187
- Detects and blocks if rebase or merge is already in progress
174188
- Provides guidance when conflicts occur
175189

internal/cli/sync.go

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,13 @@ var syncCmd = &cobra.Command{
1717
fetching the latest changes and rebasing or merging.
1818
1919
The command will:
20-
1. Fetch updates from the remote
21-
2. Rebase (default) or merge the current branch with upstream changes
20+
1. Auto-stash all changes (tracked, untracked, and ignored files) by default
21+
2. Fetch updates from the remote
22+
3. Rebase (default) or merge the current branch with upstream changes
23+
4. Restore stashed changes after successful sync
24+
25+
Auto-stashing can be disabled with --no-auto-stash flag or by setting
26+
sync.auto_stash: false in arbor.yaml.
2227
2328
Configuration can be set via flags, project config (arbor.yaml), or interactively.`,
2429
RunE: func(cmd *cobra.Command, args []string) error {
@@ -40,6 +45,7 @@ Configuration can be set via flags, project config (arbor.yaml), or interactivel
4045
remoteFlag := mustGetString(cmd, "remote")
4146
saveFlag := mustGetBool(cmd, "save")
4247
yesFlag := mustGetBool(cmd, "yes")
48+
noAutoStashFlag := mustGetBool(cmd, "no-auto-stash")
4349

4450
// Get current branch
4551
currentBranch, err := git.GetCurrentBranch(pc.CWD)
@@ -64,17 +70,48 @@ Configuration can be set via flags, project config (arbor.yaml), or interactivel
6470
return fmt.Errorf("merge in progress - resolve conflicts, stage changes, and commit, or run 'git merge --abort' to cancel")
6571
}
6672

67-
// Check for dirty worktree
68-
isDirty, err := git.IsWorktreeDirty(pc.CWD)
73+
// Determine if auto-stash should be used
74+
// Priority: CLI flag > config > default (true)
75+
autoStash := true
76+
if noAutoStashFlag {
77+
autoStash = false
78+
} else if pc.Config.Sync.AutoStash != nil {
79+
autoStash = *pc.Config.Sync.AutoStash
80+
}
81+
82+
// Check for any changes (tracked, untracked, ignored)
83+
hasChanges, err := git.HasChanges(pc.CWD)
6984
if err != nil {
70-
return fmt.Errorf("checking worktree status: %w", err)
85+
return fmt.Errorf("checking for changes: %w", err)
7186
}
72-
if isDirty {
87+
88+
// Track whether we created a stash so we can pop it later
89+
var stashCreated bool
90+
91+
if hasChanges && autoStash {
7392
if !quiet {
74-
ui.PrintInfo("Warning: worktree has uncommitted changes")
93+
ui.PrintInfo("Auto-stashing all changes (tracked, untracked, and ignored files)...")
94+
}
95+
96+
if !dryRun {
97+
if err := git.StashAll(pc.CWD, "arbor sync auto-stash"); err != nil {
98+
return fmt.Errorf("failed to stash changes: %w", err)
99+
}
100+
stashCreated = true
101+
if !quiet {
102+
ui.PrintSuccess("Changes stashed successfully")
103+
}
104+
} else {
105+
ui.PrintInfo("[DRY RUN] Would stash all changes")
106+
}
107+
} else if hasChanges && !autoStash {
108+
// Auto-stash disabled but there are changes - warn the user
109+
if !quiet {
110+
ui.PrintWarning("Warning: worktree has changes (auto-stash is disabled)")
111+
ui.PrintInfo("Untracked files that conflict with upstream may be lost")
75112
}
76113
if !yesFlag && ui.IsInteractive() {
77-
confirmed, err := ui.Confirm("Continue with uncommitted changes?")
114+
confirmed, err := ui.Confirm("Continue without stashing changes?")
78115
if err != nil {
79116
return err
80117
}
@@ -204,13 +241,45 @@ Configuration can be set via flags, project config (arbor.yaml), or interactivel
204241
}
205242

206243
if syncErr != nil {
244+
// Leave stash intact on sync failure
245+
if stashCreated && !quiet {
246+
ui.PrintInfo("\nYour changes are preserved in the stash.")
247+
ui.PrintInfo("After fixing the issue, run 'git stash pop' to restore them.")
248+
}
207249
return syncErr
208250
}
209251

210252
if !quiet {
211253
ui.PrintSuccess(fmt.Sprintf("Successfully synced with %s/%s using %s", remote, upstream, strategy))
212254
}
213255

256+
// Pop the stash after successful sync
257+
if stashCreated && !dryRun {
258+
if verbose && !quiet {
259+
ui.PrintInfo("Restoring stashed changes...")
260+
}
261+
262+
popErr := git.PopStash(pc.CWD)
263+
if popErr != nil {
264+
// Check if it's a conflict error
265+
if _, isConflict := popErr.(*git.StashConflictError); isConflict {
266+
ui.PrintWarning("\nWarning: Could not automatically restore stashed changes due to conflicts")
267+
ui.PrintInfo("\nYour changes have been safely preserved in the stash.")
268+
ui.PrintInfo("To restore them, resolve conflicts and run:")
269+
ui.PrintInfo(" git stash pop")
270+
ui.PrintInfo("\nTo discard the stash:")
271+
ui.PrintInfo(" git stash drop")
272+
} else {
273+
ui.PrintWarning(fmt.Sprintf("\nWarning: Failed to restore stashed changes: %v", popErr))
274+
ui.PrintInfo("Your changes are still in the stash. Run 'git stash pop' to restore them manually.")
275+
}
276+
} else {
277+
if !quiet {
278+
ui.PrintSuccess("Stashed changes restored successfully")
279+
}
280+
}
281+
}
282+
214283
// Save config if requested
215284
shouldSave := saveFlag
216285
if !saveFlag && shouldPrompt {
@@ -250,4 +319,5 @@ func init() {
250319
syncCmd.Flags().StringP("remote", "r", "", "Remote name to fetch from (default: origin)")
251320
syncCmd.Flags().Bool("save", false, "Persist sync settings to arbor.yaml")
252321
syncCmd.Flags().BoolP("yes", "y", false, "Skip confirmations and run with chosen values")
322+
syncCmd.Flags().Bool("no-auto-stash", false, "Disable automatic stashing of all changes before sync")
253323
}

internal/config/config.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,10 @@ type Config struct {
4646

4747
// SyncConfig represents sync configuration for the sync command
4848
type SyncConfig struct {
49-
Upstream string `mapstructure:"upstream"`
50-
Strategy string `mapstructure:"strategy"`
51-
Remote string `mapstructure:"remote"`
49+
Upstream string `mapstructure:"upstream"`
50+
Strategy string `mapstructure:"strategy"`
51+
Remote string `mapstructure:"remote"`
52+
AutoStash *bool `mapstructure:"auto_stash"` // Pointer to distinguish between unset and false
5253
}
5354

5455
// PreFlight defines checks that run before scaffold execution.
@@ -374,7 +375,7 @@ func SaveProject(path string, config *Config) error {
374375
}
375376

376377
// Update sync config if any values are set
377-
if config.Sync.Upstream != "" || config.Sync.Strategy != "" || config.Sync.Remote != "" {
378+
if config.Sync.Upstream != "" || config.Sync.Strategy != "" || config.Sync.Remote != "" || config.Sync.AutoStash != nil {
378379
syncValues := make(map[string]interface{})
379380
if config.Sync.Upstream != "" {
380381
syncValues["upstream"] = config.Sync.Upstream
@@ -385,7 +386,10 @@ func SaveProject(path string, config *Config) error {
385386
if config.Sync.Remote != "" {
386387
syncValues["remote"] = config.Sync.Remote
387388
}
388-
setNestedValue("sync", syncValues, []string{"upstream", "strategy", "remote"})
389+
if config.Sync.AutoStash != nil {
390+
syncValues["auto_stash"] = *config.Sync.AutoStash
391+
}
392+
setNestedValue("sync", syncValues, []string{"upstream", "strategy", "remote", "auto_stash"})
389393
}
390394

391395
content, err := yaml.Marshal(doc)

internal/git/stash.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package git
2+
3+
import (
4+
"fmt"
5+
"os/exec"
6+
"strings"
7+
)
8+
9+
// StashAll creates a stash including untracked and ignored files
10+
// This captures tracked modifications, untracked files, and ignored files
11+
func StashAll(worktreePath string, message string) error {
12+
cmd := exec.Command("git", "-C", worktreePath, "stash", "push", "--include-untracked", "--all", "-m", message)
13+
output, err := cmd.CombinedOutput()
14+
if err != nil {
15+
outputStr := string(output)
16+
// Check if the error is because there's nothing to stash
17+
if strings.Contains(outputStr, "No local changes to save") {
18+
return nil // Not an error, just nothing to stash
19+
}
20+
return fmt.Errorf("git stash failed: %w\n%s", err, outputStr)
21+
}
22+
return nil
23+
}
24+
25+
// PopStash pops the most recent stash
26+
// Returns an error if there are conflicts or if the pop fails
27+
func PopStash(worktreePath string) error {
28+
cmd := exec.Command("git", "-C", worktreePath, "stash", "pop")
29+
output, err := cmd.CombinedOutput()
30+
if err != nil {
31+
outputStr := string(output)
32+
// Check if it's a conflict error
33+
if strings.Contains(outputStr, "CONFLICT") || strings.Contains(outputStr, "conflict") {
34+
return &StashConflictError{Output: outputStr}
35+
}
36+
return fmt.Errorf("git stash pop failed: %w\n%s", err, outputStr)
37+
}
38+
return nil
39+
}
40+
41+
// HasStash checks if there are any stashes in the repository
42+
func HasStash(worktreePath string) (bool, error) {
43+
cmd := exec.Command("git", "-C", worktreePath, "stash", "list")
44+
output, err := cmd.Output()
45+
if err != nil {
46+
return false, fmt.Errorf("checking stash list: %w", err)
47+
}
48+
return len(strings.TrimSpace(string(output))) > 0, nil
49+
}
50+
51+
// HasChanges checks if there are any changes that would be captured by stash
52+
// This includes tracked modifications, untracked files, and ignored files
53+
func HasChanges(worktreePath string) (bool, error) {
54+
// Check for any files in working tree (tracked, untracked, ignored)
55+
// Using --ignored to also check ignored files
56+
cmd := exec.Command("git", "-C", worktreePath, "status", "--porcelain", "--ignored")
57+
output, err := cmd.Output()
58+
if err != nil {
59+
return false, fmt.Errorf("checking for changes: %w", err)
60+
}
61+
return len(strings.TrimSpace(string(output))) > 0, nil
62+
}
63+
64+
// StashConflictError represents a stash pop that failed due to conflicts
65+
type StashConflictError struct {
66+
Output string
67+
}
68+
69+
func (e *StashConflictError) Error() string {
70+
return fmt.Sprintf("stash pop has conflicts:\n%s\n\nResolve the conflicts, stage the changes with 'git add', then run 'git stash drop' to remove the stash, or run 'git reset --hard && git stash pop' to try again", e.Output)
71+
}

0 commit comments

Comments
 (0)