Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"

Expand Down Expand Up @@ -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 {
Expand Down
75 changes: 75 additions & 0 deletions cmd/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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")

Expand All @@ -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
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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")

Expand All @@ -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)
})
}