Skip to content

Commit d2a3e98

Browse files
committed
fix(dotfiles): warn and confirm before git reset --hard when local changes exist
Before resetting the dotfiles repo to origin, check git status --porcelain. If uncommitted changes are present: warn the user, prompt for confirmation in interactive (TTY) mode, and skip the reset entirely in non-interactive mode — preventing silent data loss of local dotfile tweaks. Add two regression tests: NoTTY skips reset and preserves local changes, CleanRepo still syncs normally when the tree is clean.
1 parent 94336cf commit d2a3e98

2 files changed

Lines changed: 92 additions & 0 deletions

File tree

internal/dotfiles/dotfiles.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,20 @@ func Clone(repoURL string, dryRun bool) error {
9090
if branch == "" || branch == "HEAD" {
9191
branch = "main"
9292
}
93+
// Guard against silently discarding local uncommitted changes.
94+
if statusOut, err := exec.Command("git", "-C", dotfilesPath, "status", "--porcelain").Output(); err == nil && len(strings.TrimSpace(string(statusOut))) > 0 {
95+
ui.Warn(fmt.Sprintf("Local uncommitted changes detected in %s", dotfilesPath))
96+
if system.HasTTY() {
97+
proceed, confirmErr := ui.Confirm("Proceeding will discard all local changes in your dotfiles. Continue?", false)
98+
if confirmErr != nil || !proceed {
99+
fmt.Printf("Skipping dotfiles sync to avoid data loss. Run 'git reset --hard origin/%s' manually to force update.\n", branch)
100+
return nil
101+
}
102+
} else {
103+
fmt.Printf("Local changes detected in %s — skipping sync to avoid data loss. Run 'git reset --hard origin/%s' manually to force update.\n", dotfilesPath, branch)
104+
return nil
105+
}
106+
}
93107
resetCmd := exec.Command("git", "-C", dotfilesPath, "reset", "--hard", "origin/"+branch)
94108
resetCmd.Stdout = os.Stdout
95109
resetCmd.Stderr = os.Stderr

internal/dotfiles/dotfiles_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,3 +539,81 @@ func TestBackupConflicts_SkipsMissingTargets(t *testing.T) {
539539
require.NoError(t, err)
540540
assert.Len(t, backed, 0)
541541
}
542+
543+
// TestClone_LocalChanges_NoTTY verifies that when the dotfiles repo has
544+
// uncommitted local changes and there is no TTY (non-interactive), Clone
545+
// skips the reset and returns nil without modifying the working tree.
546+
func TestClone_LocalChanges_NoTTY(t *testing.T) {
547+
if _, err := os.Open("/dev/tty"); err == nil {
548+
// /dev/tty is available in this environment — the guard will prompt
549+
// interactively rather than taking the no-TTY path. Skip so the test
550+
// only runs in non-interactive CI environments where it is meaningful.
551+
t.Skip("skipping no-TTY path when /dev/tty is available")
552+
}
553+
554+
tmpHome := t.TempDir()
555+
t.Setenv("HOME", tmpHome)
556+
557+
bare := initBareAndClone(t, tmpHome)
558+
dotfilesPath := filepath.Join(tmpHome, defaultDotfilesDir)
559+
560+
// Introduce an uncommitted local change after cloning.
561+
localFile := filepath.Join(dotfilesPath, ".bashrc")
562+
require.NoError(t, os.WriteFile(localFile, []byte("# local tweak"), 0644))
563+
564+
// Push a new upstream commit so there is something to reset to.
565+
scratch := filepath.Join(tmpHome, "scratch")
566+
require.NoError(t, exec.Command("git", "clone", bare, scratch).Run())
567+
require.NoError(t, os.WriteFile(filepath.Join(scratch, ".vimrc"), []byte("\" vimrc"), 0644))
568+
runIn := func(dir string, args ...string) {
569+
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
570+
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test")
571+
require.NoError(t, cmd.Run())
572+
}
573+
runIn(scratch, "add", ".")
574+
runIn(scratch, "commit", "-m", "add vimrc")
575+
runIn(scratch, "push")
576+
577+
// Clone must not reset --hard when local changes exist and no TTY.
578+
err := Clone(bare, false)
579+
require.NoError(t, err)
580+
581+
// The local tweak must survive — reset was skipped.
582+
content, readErr := os.ReadFile(localFile)
583+
require.NoError(t, readErr)
584+
assert.Equal(t, "# local tweak", string(content), ".bashrc local change should be preserved when sync is skipped")
585+
586+
// The upstream-only file must NOT appear (reset was skipped).
587+
_, statErr := os.Stat(filepath.Join(dotfilesPath, ".vimrc"))
588+
assert.True(t, os.IsNotExist(statErr), ".vimrc must not appear when sync was skipped due to local changes")
589+
}
590+
591+
// TestClone_LocalChanges_CleanRepo verifies that when there are no local
592+
// changes the sync still runs normally (regression guard for the guard code).
593+
func TestClone_LocalChanges_CleanRepo(t *testing.T) {
594+
tmpHome := t.TempDir()
595+
t.Setenv("HOME", tmpHome)
596+
597+
bare := initBareAndClone(t, tmpHome)
598+
dotfilesPath := filepath.Join(tmpHome, defaultDotfilesDir)
599+
600+
// Push a new upstream commit.
601+
scratch := filepath.Join(tmpHome, "scratch")
602+
require.NoError(t, exec.Command("git", "clone", bare, scratch).Run())
603+
require.NoError(t, os.WriteFile(filepath.Join(scratch, ".vimrc"), []byte("\" vimrc"), 0644))
604+
runIn := func(dir string, args ...string) {
605+
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
606+
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test")
607+
require.NoError(t, cmd.Run())
608+
}
609+
runIn(scratch, "add", ".")
610+
runIn(scratch, "commit", "-m", "add vimrc")
611+
runIn(scratch, "push")
612+
613+
// No local changes — sync must proceed and pull in the new file.
614+
err := Clone(bare, false)
615+
require.NoError(t, err)
616+
617+
_, statErr := os.Stat(filepath.Join(dotfilesPath, ".vimrc"))
618+
assert.NoError(t, statErr, ".vimrc must appear after clean sync")
619+
}

0 commit comments

Comments
 (0)