diff --git a/internal/archtest/baseline/no-direct-exec.txt b/internal/archtest/baseline/no-direct-exec.txt index f4e286e..919b3b9 100644 --- a/internal/archtest/baseline/no-direct-exec.txt +++ b/internal/archtest/baseline/no-direct-exec.txt @@ -9,8 +9,8 @@ internal/diff/compare.go:253 internal/dotfiles/dotfiles.go:23 internal/dotfiles/dotfiles.go:32 internal/dotfiles/dotfiles.go:66 -internal/dotfiles/dotfiles.go:318 -internal/dotfiles/dotfiles.go:416 +internal/dotfiles/dotfiles.go:351 +internal/dotfiles/dotfiles.go:449 internal/installer/step_system.go:85 internal/npm/npm.go:22 internal/permissions/screen_recording_cgo.go:21 diff --git a/internal/dotfiles/dotfiles.go b/internal/dotfiles/dotfiles.go index 2238692..6372ebf 100644 --- a/internal/dotfiles/dotfiles.go +++ b/internal/dotfiles/dotfiles.go @@ -254,9 +254,42 @@ func linkWithMake(dotfilesPath string, dryRun bool) error { ui.DryRunMsg("Would run make install in %s", dotfilesPath) return nil } + + home, err := system.HomeDir() + if err != nil { + return fmt.Errorf("make install home: %w", err) + } + + // Back up files that would conflict with stow-style Makefiles before + // running make install — the Makefile commonly delegates to stow, which + // aborts on pre-existing regular files. + var allBacked [][2]string + if entries, rdErr := os.ReadDir(dotfilesPath); rdErr == nil { + for _, entry := range entries { + if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + continue + } + pkgDir := filepath.Join(dotfilesPath, entry.Name()) + backed, backupErr := backupConflicts(pkgDir, home, false) + if backupErr != nil { + return fmt.Errorf("backup conflicts for %s: %w", entry.Name(), backupErr) + } + allBacked = append(allBacked, backed...) + } + } + if err := system.RunCommandInDir(dotfilesPath, "make", "install"); err != nil { + for _, pair := range allBacked { + restoreFile(pair[0], pair[1], false) + } return fmt.Errorf("make install: %w", err) } + + for _, pair := range allBacked { + if rmErr := os.Remove(pair[0]); rmErr != nil { + ui.Warn(fmt.Sprintf("could not remove backup %s: %v", pair[0], rmErr)) + } + } return nil } diff --git a/internal/dotfiles/dotfiles_test.go b/internal/dotfiles/dotfiles_test.go index 1dff75c..40ad1a6 100644 --- a/internal/dotfiles/dotfiles_test.go +++ b/internal/dotfiles/dotfiles_test.go @@ -153,6 +153,60 @@ func TestLinkWithMake_DryRun(t *testing.T) { assert.NoError(t, err) } +func TestLinkWithMake_BacksUpConflictsBeforeRunning(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + dotfilesPath := filepath.Join(tmpHome, defaultDotfilesDir) + pkgDir := filepath.Join(dotfilesPath, "git") + require.NoError(t, os.MkdirAll(pkgDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(pkgDir, ".gitconfig"), []byte("new"), 0644)) + + // Pre-existing file that would conflict with stow. + originalContent := "# original gitconfig\n" + require.NoError(t, os.WriteFile(filepath.Join(tmpHome, ".gitconfig"), []byte(originalContent), 0644)) + + // Makefile just touches a sentinel file — no real stow needed. + require.NoError(t, os.WriteFile(filepath.Join(dotfilesPath, "Makefile"), + []byte("install:\n\ttouch \"$(HOME)/.make_ran\"\n"), 0644)) + + err := linkWithMake(dotfilesPath, false) + require.NoError(t, err) + + // Sentinel confirms make ran. + _, statErr := os.Stat(filepath.Join(tmpHome, ".make_ran")) + assert.NoError(t, statErr, "make install should have run") + + // Backup should be cleaned up after success. + _, backupErr := os.Stat(filepath.Join(tmpHome, ".gitconfig.openboot.bak")) + assert.True(t, os.IsNotExist(backupErr), "backup should be removed after success") +} + +func TestLinkWithMake_RestoresConflictsOnMakeFailure(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + dotfilesPath := filepath.Join(tmpHome, defaultDotfilesDir) + pkgDir := filepath.Join(dotfilesPath, "git") + require.NoError(t, os.MkdirAll(pkgDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(pkgDir, ".gitconfig"), []byte("new"), 0644)) + + originalContent := "# original gitconfig\n" + require.NoError(t, os.WriteFile(filepath.Join(tmpHome, ".gitconfig"), []byte(originalContent), 0644)) + + // Makefile that always fails. + require.NoError(t, os.WriteFile(filepath.Join(dotfilesPath, "Makefile"), + []byte("install:\n\texit 1\n"), 0644)) + + err := linkWithMake(dotfilesPath, false) + assert.Error(t, err) + + // Original file should be restored. + content, readErr := os.ReadFile(filepath.Join(tmpHome, ".gitconfig")) + require.NoError(t, readErr) + assert.Equal(t, originalContent, string(content), ".gitconfig should be restored after make failure") +} + func TestLink_UsesMakefileOverStow(t *testing.T) { tmpHome := t.TempDir() t.Setenv("HOME", tmpHome)