diff --git a/README.md b/README.md index 124f528..9a4c51c 100644 --- a/README.md +++ b/README.md @@ -62,12 +62,14 @@ no CGO or system SQLite is required). - `git` must be installed and on `PATH`. - Go 1.22 or newer (only for building from source). +- Project dependency managers must be installed when the acquired project uses + them: `go`, `npm`, or `bun`. ## Commands ### `-v, --version` -Prints the worktree-manager version (`2.0.3`). +Prints the worktree-manager version (`2.1.0`). ### `-d, --database ` @@ -156,11 +158,33 @@ Behavior: - `git fetch origin` - reset the worktree to the latest default branch (`origin/`) - remove untracked files (`git clean -xfd`) + - verify that `HEAD` matches the fetched default branch and the worktree is + clean + - detect and install project dependencies 6. Mark the worktree `ALLOCATED` with the branch name. 7. Print the worktree absolute path to stdout. -If a git operation fails, the worktree is marked `BROKEN` and the command -exits non-zero. +Dependency installation currently supports: + +| Detection | Command | +| --------- | ------- | +| `go.mod` | `go mod download` | +| `package.json` with `bun.lock`, `bun.lockb`, `bunfig.toml`, or a `packageManager` value beginning with `bun@` | `bun install` (frozen when a lockfile exists) | +| `package.json` without Bun markers | `npm ci` when a lockfile exists, otherwise `npm install --no-package-lock` | + +A dependency-manager command failure marks the worktree `BROKEN` and the +command exits non-zero. Projects with no supported manifest are left +unchanged. + +Dependency support uses a small strategy registry in +`internal/dependencies/dependencies.go`. Each strategy implements `Installer` +with a manifest detector and install method. To add another ecosystem, create +an installer there and add it to the `installers` slice. Keep detection +conservative and add detection and command tests in +`internal/dependencies/dependencies_test.go`. + +If a git or dependency-manager operation fails, the worktree is marked +`BROKEN` and the command exits non-zero. ### `release ` diff --git a/cmd/worktree-manager/main.go b/cmd/worktree-manager/main.go index 3ad634c..90f4b3f 100644 --- a/cmd/worktree-manager/main.go +++ b/cmd/worktree-manager/main.go @@ -10,7 +10,7 @@ import ( "github.com/bejo-dev/worktree-manager/internal/manager" ) -const version = "2.0.3" +const version = "2.1.0" const usage = `worktree-manager - manage a reusable pool of git worktrees diff --git a/internal/dependencies/dependencies.go b/internal/dependencies/dependencies.go new file mode 100644 index 0000000..65a05d5 --- /dev/null +++ b/internal/dependencies/dependencies.go @@ -0,0 +1,142 @@ +// Package dependencies detects and installs project dependencies. +// +// Add support for another ecosystem by implementing Installer, then adding an +// instance to the installers slice in Install. Keep detection conservative so +// a repository is not modified unless its project files clearly identify the +// ecosystem. +package dependencies + +import ( + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// CommandRunner runs a dependency-manager command in a project directory. +// It is injectable so installer behavior can be tested without invoking tools. +type CommandRunner func(projectDir, command string, args ...string) error + +// Installer detects and installs dependencies for one ecosystem. +type Installer interface { + Name() string + Detect(projectDir string) bool + Install(projectDir string, run CommandRunner) error +} + +// Install detects all supported dependency manifests in projectDir and +// installs their dependencies. Multiple installers may apply to one project, +// for example Go and npm in a full-stack repository. +func Install(projectDir string, logw io.Writer) error { + run := commandRunner(logw) + for _, installer := range installers { + if !installer.Detect(projectDir) { + continue + } + if logw != nil { + fmt.Fprintf(logw, "installing %s dependencies\n", installer.Name()) + } + if err := installer.Install(projectDir, run); err != nil { + return fmt.Errorf("install %s dependencies: %w", installer.Name(), err) + } + } + return nil +} + +// installers is intentionally ordered. Bun is checked before npm because a +// Bun project can also contain package.json, while npm is the fallback for +// JavaScript projects without Bun markers. +var installers = []Installer{ + goInstaller{}, + bunInstaller{}, + npmInstaller{}, +} + +type goInstaller struct{} + +func (goInstaller) Name() string { return "Go" } + +func (goInstaller) Detect(projectDir string) bool { + return fileExists(filepath.Join(projectDir, "go.mod")) +} + +func (goInstaller) Install(projectDir string, run CommandRunner) error { + return run(projectDir, "go", "mod", "download") +} + +type bunInstaller struct{} + +func (bunInstaller) Name() string { return "Bun" } + +func (bunInstaller) Detect(projectDir string) bool { + if !fileExists(filepath.Join(projectDir, "package.json")) { + return false + } + return fileExists(filepath.Join(projectDir, "bun.lock")) || + fileExists(filepath.Join(projectDir, "bun.lockb")) || + fileExists(filepath.Join(projectDir, "bunfig.toml")) || + packageManagerIsBun(projectDir) +} + +func (bunInstaller) Install(projectDir string, run CommandRunner) error { + args := []string{"install"} + if fileExists(filepath.Join(projectDir, "bun.lock")) || fileExists(filepath.Join(projectDir, "bun.lockb")) { + args = append(args, "--frozen-lockfile") + } + return run(projectDir, "bun", args...) +} + +type npmInstaller struct{} + +func (npmInstaller) Name() string { return "npm" } + +func (npmInstaller) Detect(projectDir string) bool { + if !fileExists(filepath.Join(projectDir, "package.json")) { + return false + } + return !bunInstaller{}.Detect(projectDir) +} + +func (npmInstaller) Install(projectDir string, run CommandRunner) error { + if fileExists(filepath.Join(projectDir, "package-lock.json")) || + fileExists(filepath.Join(projectDir, "npm-shrinkwrap.json")) { + return run(projectDir, "npm", "ci") + } + // Avoid creating a tracked package-lock.json as a side effect of acquire. + return run(projectDir, "npm", "install", "--no-package-lock") +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func packageManagerIsBun(projectDir string) bool { + data, err := os.ReadFile(filepath.Join(projectDir, "package.json")) + if err != nil { + return false + } + var manifest struct { + PackageManager string `json:"packageManager"` + } + if err := json.Unmarshal(data, &manifest); err != nil { + return false + } + return strings.HasPrefix(manifest.PackageManager, "bun@") +} + +func commandRunner(logw io.Writer) CommandRunner { + return func(projectDir, command string, args ...string) error { + cmd := exec.Command(command, args...) + cmd.Dir = projectDir + cmd.Stdout = logw + cmd.Stderr = logw + if err := cmd.Run(); err != nil { + return fmt.Errorf("run %s: %w", command, err) + } + return nil + } +} diff --git a/internal/dependencies/dependencies_test.go b/internal/dependencies/dependencies_test.go new file mode 100644 index 0000000..f013000 --- /dev/null +++ b/internal/dependencies/dependencies_test.go @@ -0,0 +1,102 @@ +package dependencies + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func touch(t *testing.T, dir, name string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), nil, 0o644); err != nil { + t.Fatal(err) + } +} + +func TestInstallersDetectProjectFiles(t *testing.T) { + tests := []struct { + name string + files []string + wantGo bool + wantBun bool + wantNPM bool + }{ + {name: "go", files: []string{"go.mod"}, wantGo: true}, + {name: "npm", files: []string{"package.json"}, wantNPM: true}, + {name: "bun lock", files: []string{"package.json", "bun.lock"}, wantBun: true}, + {name: "bun config", files: []string{"package.json", "bunfig.toml"}, wantBun: true}, + {name: "no manifest", files: []string{"README.md"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + for _, file := range tt.files { + touch(t, dir, file) + } + if got := (goInstaller{}).Detect(dir); got != tt.wantGo { + t.Errorf("Go Detect() = %v, want %v", got, tt.wantGo) + } + if got := (bunInstaller{}).Detect(dir); got != tt.wantBun { + t.Errorf("Bun Detect() = %v, want %v", got, tt.wantBun) + } + if got := (npmInstaller{}).Detect(dir); got != tt.wantNPM { + t.Errorf("npm Detect() = %v, want %v", got, tt.wantNPM) + } + }) + } +} + +func TestBunDetectsPackageManagerField(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"packageManager":"bun@1.1.0"}`), 0o644); err != nil { + t.Fatal(err) + } + if !(bunInstaller{}).Detect(dir) { + t.Fatal("Bun Detect() = false, want true") + } + if (npmInstaller{}).Detect(dir) { + t.Fatal("npm Detect() = true for a Bun project, want false") + } +} + +func TestInstallerCommands(t *testing.T) { + tests := []struct { + name string + installer Installer + files []string + command string + args []string + }{ + {name: "go", installer: goInstaller{}, files: []string{"go.mod"}, command: "go", args: []string{"mod", "download"}}, + {name: "bun", installer: bunInstaller{}, files: []string{"package.json", "bun.lock"}, command: "bun", args: []string{"install", "--frozen-lockfile"}}, + {name: "npm lock", installer: npmInstaller{}, files: []string{"package.json", "package-lock.json"}, command: "npm", args: []string{"ci"}}, + {name: "npm without lock", installer: npmInstaller{}, files: []string{"package.json"}, command: "npm", args: []string{"install", "--no-package-lock"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + for _, file := range tt.files { + touch(t, dir, file) + } + var gotCommand string + var gotArgs []string + run := func(projectDir, command string, args ...string) error { + if projectDir != dir { + t.Fatalf("project directory = %q, want %q", projectDir, dir) + } + gotCommand = command + gotArgs = args + return nil + } + if err := tt.installer.Install(dir, run); err != nil { + t.Fatal(err) + } + if gotCommand != tt.command || !reflect.DeepEqual(gotArgs, tt.args) { + t.Fatalf("command = %s %v, want %s %v", gotCommand, gotArgs, tt.command, tt.args) + } + }) + } +} diff --git a/internal/manager/manager.go b/internal/manager/manager.go index 8c81772..b906778 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -13,6 +13,7 @@ import ( "time" "github.com/bejo-dev/worktree-manager/internal/db" + "github.com/bejo-dev/worktree-manager/internal/dependencies" "github.com/bejo-dev/worktree-manager/internal/gitops" ) @@ -146,6 +147,10 @@ func (m *Manager) Acquire(repoPath string, branchName string) (*AcquireResult, e m.markBroken(wt.ID) return nil, fmt.Errorf("prepare worktree: %w", err) } + if err := dependencies.Install(wt.Path, m.logw); err != nil { + m.markBroken(wt.ID) + return nil, fmt.Errorf("install dependencies: %w", err) + } // Record base commit. if err := m.recordBaseCommit(wt.ID, gr, defaultBranch); err != nil { diff --git a/internal/manager/manager_test.go b/internal/manager/manager_test.go index d415c03..f3fdfa0 100644 --- a/internal/manager/manager_test.go +++ b/internal/manager/manager_test.go @@ -362,6 +362,28 @@ func TestAcquireFailsWhenFetchFails(t *testing.T) { } } +func TestAcquireMarksWorktreeBrokenWhenDependencyInstallFails(t *testing.T) { + repo := setupRepo(t) + writeFile(t, repo, "go.mod", "this is not a go module\n") + run(t, repo, "git", "add", "go.mod") + run(t, repo, "git", "commit", "-m", "add invalid module") + run(t, repo, "git", "push", "origin", "main") + + d := newManagerDB(t) + m := newTestManager(t, d) + if _, err := m.Acquire(repo, "task-1"); err == nil || !strings.Contains(err.Error(), "install dependencies") { + t.Fatalf("expected dependency installation failure, got %v", err) + } + + worktrees, err := d.ListAllWorktrees() + if err != nil { + t.Fatal(err) + } + if len(worktrees) != 1 || worktrees[0].Status != db.StatusBroken { + t.Fatalf("expected one BROKEN worktree, got %+v", worktrees) + } +} + func TestReleaseDoesNotReturnStaleWorktreeWhenFetchFails(t *testing.T) { repo := setupRepo(t) d := newManagerDB(t)