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
30 changes: 27 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>`

Expand Down Expand Up @@ -156,11 +158,33 @@ Behavior:
- `git fetch origin`
- reset the worktree to the latest default branch (`origin/<default>`)
- 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 <worktree-path>`

Expand Down
2 changes: 1 addition & 1 deletion cmd/worktree-manager/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
142 changes: 142 additions & 0 deletions internal/dependencies/dependencies.go
Original file line number Diff line number Diff line change
@@ -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
}
}
102 changes: 102 additions & 0 deletions internal/dependencies/dependencies_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
5 changes: 5 additions & 0 deletions internal/manager/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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 {
Expand Down
22 changes: 22 additions & 0 deletions internal/manager/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading