diff --git a/cmd/sync.go b/cmd/sync.go index 1b3d878..4e1d1a4 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -6,6 +6,8 @@ import ( "fmt" "io" "os" + "os/exec" + "path/filepath" "strings" "sync" @@ -929,12 +931,97 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient, syncRemo _ = gitClient.UnsetConfig(configSyncStashed) _ = gitClient.UnsetConfig(configSyncOriginalBranch) + // Run post-sync install if a package manager is detected + runPostSyncInstall(gitClient) + fmt.Println() fmt.Println(ui.Success("Sync complete!")) return nil } +// packageManager maps a lockfile name to its install command. +type packageManager struct { + lockfile string + command string + args []string +} + +var packageManagers = []packageManager{ + {"pnpm-lock.yaml", "pnpm", []string{"install"}}, + {"yarn.lock", "yarn", []string{"install"}}, + {"bun.lockb", "bun", []string{"install"}}, + {"bun.lock", "bun", []string{"install"}}, + {"package-lock.json", "npm", []string{"install"}}, +} + +// detectPackageManager checks for lockfiles in the given directory and returns +// the matching package manager, or nil if none found. +func detectPackageManager(repoRoot string) *packageManager { + for _, pm := range packageManagers { + if _, err := os.Stat(filepath.Join(repoRoot, pm.lockfile)); err == nil { + return &pm + } + } + return nil +} + +// runPostSyncInstall detects a package manager and runs install after sync. +// Configurable via git config stack.postSyncInstall: +// - not set / "auto": auto-detect from lockfiles +// - "false": disabled +// - any other value: treated as custom command +func runPostSyncInstall(gitClient git.GitClient) { + config := gitClient.GetConfig("stack.postSyncInstall") + if config == "false" { + return + } + + repoRoot, err := gitClient.GetRepoRoot() + if err != nil { + return + } + + var cmdName string + var cmdArgs []string + + if config != "" && config != "auto" { + // Custom command from config + parts := strings.Fields(config) + cmdName = parts[0] + cmdArgs = parts[1:] + } else { + // Auto-detect from lockfiles + if pm := detectPackageManager(repoRoot); pm != nil { + cmdName = pm.command + cmdArgs = pm.args + } + } + + if cmdName == "" { + return + } + + fullCmd := cmdName + " " + strings.Join(cmdArgs, " ") + + if git.DryRun { + fmt.Printf("\n [DRY RUN] %s\n", fullCmd) + return + } + + fmt.Println() + _ = spinner.WrapWithSuccess( + fmt.Sprintf("Running %s...", fullCmd), + fmt.Sprintf("Ran %s", fullCmd), + func() error { + cmd := exec.Command(cmdName, cmdArgs...) + cmd.Dir = repoRoot + cmd.Env = os.Environ() + return cmd.Run() + }, + ) +} + // displayStatusAfterSync shows the stack tree after a successful sync // It reuses the prCache from earlier to avoid a redundant API call func displayStatusAfterSync(gitClient git.GitClient, githubClient github.GitHubClient, prCache map[string]*github.PRInfo) error { diff --git a/cmd/sync_test.go b/cmd/sync_test.go index 2eeb696..42c78ea 100644 --- a/cmd/sync_test.go +++ b/cmd/sync_test.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "os" + "path/filepath" "strings" "testing" @@ -84,6 +85,7 @@ func TestRunSyncBasic(t *testing.T) { // Clean up sync state mockGit.On("UnsetConfig", "stack.sync.stashed").Return(nil) mockGit.On("UnsetConfig", "stack.sync.originalBranch").Return(nil) + mockGit.On("GetConfig", "stack.postSyncInstall").Return("false").Maybe() err := runSync(mockGit, mockGH, "origin") @@ -155,6 +157,7 @@ func TestRunSyncMergedParent(t *testing.T) { // Clean up sync state mockGit.On("UnsetConfig", "stack.sync.stashed").Return(nil) mockGit.On("UnsetConfig", "stack.sync.originalBranch").Return(nil) + mockGit.On("GetConfig", "stack.postSyncInstall").Return("false").Maybe() err := runSync(mockGit, mockGH, "origin") @@ -239,6 +242,7 @@ func TestRunSyncUpdatePRBase(t *testing.T) { // Clean up sync state mockGit.On("UnsetConfig", "stack.sync.stashed").Return(nil) mockGit.On("UnsetConfig", "stack.sync.originalBranch").Return(nil) + mockGit.On("GetConfig", "stack.postSyncInstall").Return("false").Maybe() err := runSync(mockGit, mockGH, "origin") @@ -307,6 +311,7 @@ func TestRunSyncStashHandling(t *testing.T) { mockGit.On("StashPop").Return(nil) mockGit.On("UnsetConfig", "stack.sync.stashed").Return(nil) mockGit.On("UnsetConfig", "stack.sync.originalBranch").Return(nil) + mockGit.On("GetConfig", "stack.postSyncInstall").Return("false").Maybe() err := runSync(mockGit, mockGH, "origin") @@ -561,6 +566,7 @@ func TestRunSyncResume(t *testing.T) { mockGit.On("StashPop").Return(nil) mockGit.On("UnsetConfig", "stack.sync.stashed").Return(nil) mockGit.On("UnsetConfig", "stack.sync.originalBranch").Return(nil) + mockGit.On("GetConfig", "stack.postSyncInstall").Return("false").Maybe() err := runSync(mockGit, mockGH, "origin") @@ -583,6 +589,7 @@ func TestRunSyncResume(t *testing.T) { // Clean up orphaned state (user confirmed) mockGit.On("UnsetConfig", "stack.sync.stashed").Return(nil) mockGit.On("UnsetConfig", "stack.sync.originalBranch").Return(nil) + mockGit.On("GetConfig", "stack.postSyncInstall").Return("false").Maybe() mockGit.On("GetCurrentBranch").Return("feature-a", nil) // Save original branch state @@ -624,6 +631,7 @@ func TestRunSyncResume(t *testing.T) { // Clean up sync state mockGit.On("UnsetConfig", "stack.sync.stashed").Return(nil) mockGit.On("UnsetConfig", "stack.sync.originalBranch").Return(nil) + mockGit.On("GetConfig", "stack.postSyncInstall").Return("false").Maybe() err := runSync(mockGit, mockGH, "origin") @@ -724,6 +732,7 @@ func TestRunSyncAutoConfiguresMissingStackparent(t *testing.T) { // Clean up sync state mockGit.On("UnsetConfig", "stack.sync.stashed").Return(nil) mockGit.On("UnsetConfig", "stack.sync.originalBranch").Return(nil) + mockGit.On("GetConfig", "stack.postSyncInstall").Return("false").Maybe() err := runSync(mockGit, mockGH, "origin") @@ -789,6 +798,7 @@ func TestRunSyncNoUniqueCommits(t *testing.T) { // Clean up sync state mockGit.On("UnsetConfig", "stack.sync.stashed").Return(nil) mockGit.On("UnsetConfig", "stack.sync.originalBranch").Return(nil) + mockGit.On("GetConfig", "stack.postSyncInstall").Return("false").Maybe() err := runSync(mockGit, mockGH, "origin") @@ -850,6 +860,7 @@ func TestRunSyncAbort(t *testing.T) { // Clean up sync state mockGit.On("UnsetConfig", "stack.sync.stashed").Return(nil) mockGit.On("UnsetConfig", "stack.sync.originalBranch").Return(nil) + mockGit.On("GetConfig", "stack.postSyncInstall").Return("false").Maybe() err := runSync(mockGit, mockGH, "origin") @@ -883,6 +894,7 @@ func TestRunSyncAbort(t *testing.T) { // Clean up sync state mockGit.On("UnsetConfig", "stack.sync.stashed").Return(nil) mockGit.On("UnsetConfig", "stack.sync.originalBranch").Return(nil) + mockGit.On("GetConfig", "stack.postSyncInstall").Return("false").Maybe() err := runSync(mockGit, mockGH, "origin") @@ -914,6 +926,7 @@ func TestRunSyncAbort(t *testing.T) { // Clean up sync state mockGit.On("UnsetConfig", "stack.sync.stashed").Return(nil) mockGit.On("UnsetConfig", "stack.sync.originalBranch").Return(nil) + mockGit.On("GetConfig", "stack.postSyncInstall").Return("false").Maybe() err := runSync(mockGit, mockGH, "origin") @@ -978,6 +991,7 @@ func TestRunSyncWithUpstreamRemote(t *testing.T) { // Clean up sync state mockGit.On("UnsetConfig", "stack.sync.stashed").Return(nil) mockGit.On("UnsetConfig", "stack.sync.originalBranch").Return(nil) + mockGit.On("GetConfig", "stack.postSyncInstall").Return("false").Maybe() err := runSync(mockGit, mockGH, "upstream") @@ -1090,6 +1104,7 @@ func TestRunSyncSkipsWorktreeBranches(t *testing.T) { // Clean up sync state mockGit.On("UnsetConfig", "stack.sync.stashed").Return(nil) mockGit.On("UnsetConfig", "stack.sync.originalBranch").Return(nil) + mockGit.On("GetConfig", "stack.postSyncInstall").Return("false").Maybe() err := runSync(mockGit, mockGH, "origin") @@ -1100,3 +1115,63 @@ func TestRunSyncSkipsWorktreeBranches(t *testing.T) { mockGH.AssertExpectations(t) }) } + +func TestRunPostSyncInstall(t *testing.T) { + testutil.SetupTest() + defer testutil.TeardownTest() + + t.Run("disabled via config", func(t *testing.T) { + mockGit := new(testutil.MockGitClient) + mockGit.On("GetConfig", "stack.postSyncInstall").Return("false") + + // Should return early without calling GetRepoRoot + runPostSyncInstall(mockGit) + + mockGit.AssertNotCalled(t, "GetRepoRoot") + mockGit.AssertExpectations(t) + }) + + t.Run("auto-detect with no lockfile", func(t *testing.T) { + mockGit := new(testutil.MockGitClient) + mockGit.On("GetConfig", "stack.postSyncInstall").Return("") + mockGit.On("GetRepoRoot").Return(t.TempDir(), nil) + + // Empty temp dir has no lockfiles, should be a no-op + runPostSyncInstall(mockGit) + + mockGit.AssertExpectations(t) + }) + + t.Run("auto-detect picks correct package manager", func(t *testing.T) { + for _, tc := range []struct { + lockfile string + expected string + }{ + {"pnpm-lock.yaml", "pnpm"}, + {"yarn.lock", "yarn"}, + {"bun.lockb", "bun"}, + {"bun.lock", "bun"}, + {"package-lock.json", "npm"}, + } { + t.Run(tc.lockfile, func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, tc.lockfile), []byte{}, 0644) + assert.NoError(t, err) + + detected := detectPackageManager(dir) + assert.NotNil(t, detected, "expected detection for %s", tc.lockfile) + assert.Equal(t, tc.expected, detected.command) + }) + } + }) + + t.Run("pnpm-lock.yaml wins over yarn.lock", func(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "pnpm-lock.yaml"), []byte{}, 0644) + _ = os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte{}, 0644) + + detected := detectPackageManager(dir) + assert.NotNil(t, detected) + assert.Equal(t, "pnpm", detected.command) + }) +}