Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
14 changes: 11 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Treehouse is a Go CLI tool that manages a pool of git worktrees for parallel AI
- `main.go` — entry point, calls `cmd.Execute()`
- `cmd/` — CLI commands (cobra): `get`, `return`, `status`, `destroy`
- `internal/config/` — config file loading (`treehouse.toml`)
- `internal/hooks/` — user-configured lifecycle hook command execution
- `internal/pool/` — pool manager (acquire, release, list, destroy) + state file
- `internal/git/` — git operations (shells out to `git` binary)
- `internal/process/` — in-use detection and lingering process termination for worktrees
Expand All @@ -35,8 +36,8 @@ make test

- No daemon — all operations are inline CLI commands
- Detached HEAD worktrees reset to whichever of local or origin default branch is further ahead (prefers origin on divergence)
- In-use detection is runtime-only (process scanning), never persisted
- State file only tracks pool membership, not usage status
- In-use detection uses process scanning plus short-lived persisted owner reservations for lifecycle operations
- State file tracks pool membership and temporary owner/destroy reservations, not long-term usage status
- Git operations shell out to `git` (go-git has incomplete worktree support)
- Self-healing: stale state entries are auto-removed

Expand All @@ -53,8 +54,15 @@ This project targets Linux, macOS, and Windows. All new code **must** work on Wi

## Config

Place `treehouse.toml` in repo root or `~/.config/treehouse/config.toml`:
Place repo-safe settings in repo root `treehouse.toml` or user-level `~/.config/treehouse/config.toml`:

```toml
max_trees = 16

# User-level config only:
[hooks]
post_create = ["./scripts/setup-venv.sh"]
pre_destroy = ["./scripts/teardown.sh"]
```

Hooks are ignored in repo-level config for safety.
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ Treehouse manages a **pool of git worktrees** per repository, stored under `~/.t

- **Detached HEAD** — worktrees use detached HEAD mode, reset to whichever of the local or remote default branch is further ahead, avoiding branch name conflicts entirely.
- **No daemon** — all operations are inline CLI commands. No background processes, no state to get corrupted.
- **In-use detection** — treehouse scans running processes to determine which worktrees are in-use. Usage state is never persisted, so it's always accurate.
- **In-use detection** — treehouse scans running processes and short-lived owner reservations to determine which worktrees are in-use. Reservations are persisted only while `get` and `destroy` lifecycle work is running.

## CLI Reference

Expand All @@ -154,7 +154,7 @@ Treehouse manages a **pool of git worktrees** per repository, stored under `~/.t

## Configuration

Create a config file with `treehouse init`, or add one manually:
Create a repo config file with `treehouse init`, or add one manually:

**Repo-level:** `treehouse.toml` in the repository root

Expand All @@ -165,7 +165,26 @@ Create a config file with `treehouse init`, or add one manually:
max_trees = 16
```

The repo-level config takes precedence. If no config is found, the default pool size is 16.
The repo-level config takes precedence for repo-safe settings.
If no config is found, the default pool size is 16.

### Hooks

You can run commands automatically at worktree lifecycle points by adding a `[hooks]` section to the user-level config at `~/.config/treehouse/config.toml`.
Hooks in repo-level `treehouse.toml` are ignored for safety.

```toml
[hooks]
post_create = ["./scripts/setup-venv.sh"]
pre_destroy = ["./scripts/teardown.sh"]
```

- `post_create` runs after a worktree is provisioned or reset and right before `treehouse get` hands it to you.
- `pre_destroy` runs before a worktree is removed by `treehouse destroy` (and `treehouse destroy --all`).

Commands in each list run sequentially in the worktree directory, via the OS shell (`/bin/sh -c` on Linux/macOS, `%COMSPEC% /c` on Windows).
If a command exits non-zero, treehouse logs the command, exit code, and stderr, then continues with the remaining commands.
A failing hook does not fail the overall `get` or `destroy` operation.

## Development

Expand Down
4 changes: 2 additions & 2 deletions cmd/destroy.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ var destroyCmd = &cobra.Command{
return nil
}
}
if err := pool.DestroyAll(repoRoot, poolDir, destroyForce); err != nil {
if err := pool.DestroyAll(repoRoot, poolDir, destroyForce, cfg.Hooks.PreDestroy); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "🌳 All worktrees destroyed.")
Expand All @@ -69,7 +69,7 @@ var destroyCmd = &cobra.Command{
}
}

if err := pool.Destroy(repoRoot, poolDir, wtPath, destroyForce); err != nil {
if err := pool.Destroy(repoRoot, poolDir, wtPath, destroyForce, cfg.Hooks.PreDestroy); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "🌳 Worktree destroyed.")
Expand Down
2 changes: 1 addition & 1 deletion cmd/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func getRunE(cmd *cobra.Command, args []string) error {
fmt.Fprintf(os.Stderr, "warning: failed to update .gitignore: %v\n", err)
}

wtPath, err := pool.Acquire(repoRoot, poolDir, cfg.MaxTrees)
wtPath, err := pool.Acquire(repoRoot, poolDir, cfg.MaxTrees, cfg.Hooks.PostCreate)
if err != nil {
return err
}
Expand Down
32 changes: 23 additions & 9 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import (
type Config struct {
MaxTrees int `toml:"max_trees"`
Root string `toml:"root"`
Hooks Hooks `toml:"hooks,omitempty"`
}

type Hooks struct {
PostCreate []string `toml:"post_create,omitempty"`
PreDestroy []string `toml:"pre_destroy,omitempty"`
}

func DefaultConfig() Config {
Expand All @@ -22,20 +28,28 @@ func DefaultConfig() Config {
func Load(repoRoot string) (Config, error) {
cfg := DefaultConfig()

paths := []string{
filepath.Join(repoRoot, "treehouse.toml"),
repoPath := filepath.Join(repoRoot, "treehouse.toml")
hasRepoConfig := false
if _, err := os.Stat(repoPath); err == nil {
hasRepoConfig = true
if _, err := toml.DecodeFile(repoPath, &cfg); err != nil {
return cfg, err
}
cfg.Hooks = Hooks{}
}

if home, err := os.UserHomeDir(); err == nil {
paths = append(paths, filepath.Join(home, ".config", "treehouse", "config.toml"))
}

for _, p := range paths {
if _, err := os.Stat(p); err == nil {
if _, err := toml.DecodeFile(p, &cfg); err != nil {
userPath := filepath.Join(home, ".config", "treehouse", "config.toml")
if _, err := os.Stat(userPath); err == nil {
userCfg := DefaultConfig()
if _, err := toml.DecodeFile(userPath, &userCfg); err != nil {
return cfg, err
}
return cfg, nil
if !hasRepoConfig {
cfg = userCfg
} else {
cfg.Hooks = userCfg.Hooks
}
}
}

Expand Down
96 changes: 96 additions & 0 deletions internal/config/hooks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package config

import (
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
)

func TestLoad_IgnoresRepoHooks(t *testing.T) {
repoDir := t.TempDir()
setUserHome(t, t.TempDir())

cfgTOML := `max_trees = 4

[hooks]
post_create = ["echo a", "echo b"]
pre_destroy = ["echo c"]
`
if err := os.WriteFile(filepath.Join(repoDir, "treehouse.toml"), []byte(cfgTOML), 0o644); err != nil {
t.Fatal(err)
}

cfg, err := Load(repoDir)
if err != nil {
t.Fatalf("Load failed: %v", err)
}

if cfg.MaxTrees != 4 {
t.Errorf("MaxTrees: got %d, want 4", cfg.MaxTrees)
}
if len(cfg.Hooks.PostCreate) != 0 {
t.Errorf("expected repo post_create hooks to be ignored, got %v", cfg.Hooks.PostCreate)
}
if len(cfg.Hooks.PreDestroy) != 0 {
t.Errorf("expected repo pre_destroy hooks to be ignored, got %v", cfg.Hooks.PreDestroy)
}
}

func TestLoad_UserHooks(t *testing.T) {
repoDir := t.TempDir()
userHome := t.TempDir()
setUserHome(t, userHome)

configDir := filepath.Join(userHome, ".config", "treehouse")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatal(err)
}
cfgTOML := `[hooks]
post_create = ["echo a", "echo b"]
pre_destroy = ["echo c"]
`
if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(cfgTOML), 0o644); err != nil {
t.Fatal(err)
}

cfg, err := Load(repoDir)
if err != nil {
t.Fatalf("Load failed: %v", err)
}

wantPost := []string{"echo a", "echo b"}
wantPre := []string{"echo c"}
if !reflect.DeepEqual(cfg.Hooks.PostCreate, wantPost) {
t.Errorf("PostCreate: got %v, want %v", cfg.Hooks.PostCreate, wantPost)
}
if !reflect.DeepEqual(cfg.Hooks.PreDestroy, wantPre) {
t.Errorf("PreDestroy: got %v, want %v", cfg.Hooks.PreDestroy, wantPre)
}
}

func TestLoad_HooksDefaultEmpty(t *testing.T) {
repoDir := t.TempDir()
setUserHome(t, t.TempDir())

cfg, err := Load(repoDir)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if len(cfg.Hooks.PostCreate) != 0 {
t.Errorf("expected empty PostCreate, got %v", cfg.Hooks.PostCreate)
}
if len(cfg.Hooks.PreDestroy) != 0 {
t.Errorf("expected empty PreDestroy, got %v", cfg.Hooks.PreDestroy)
}
}

func setUserHome(t *testing.T, home string) {
t.Helper()
if runtime.GOOS == "windows" {
t.Setenv("USERPROFILE", home)
} else {
t.Setenv("HOME", home)
}
}
9 changes: 9 additions & 0 deletions internal/hooks/command_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build !windows

package hooks

import "os/exec"

func newHookCommand(command string) *exec.Cmd {
return exec.Command("/bin/sh", "-c", command)
}
20 changes: 20 additions & 0 deletions internal/hooks/command_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//go:build windows

package hooks

import (
"os"
"os/exec"
"syscall"
)

func newHookCommand(command string) *exec.Cmd {
shell := os.Getenv("COMSPEC")
if shell == "" {
shell = "cmd.exe"
}

cmd := exec.Command(shell)
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: windowsShellCommandLine(shell, command)}
return cmd
}
39 changes: 39 additions & 0 deletions internal/hooks/hooks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Package hooks runs user-configured shell commands at worktree lifecycle
// points. Commands run sequentially in a given working directory. A failing
// command is logged but does not stop later commands or fail the caller.
package hooks

import (
"fmt"
"io"
"os/exec"
)

// Run executes each command in commands sequentially in workDir. Each command
// is passed to the OS shell (/bin/sh -c on Unix, %COMSPEC% /d /s /c on Windows).
// Stdout and stderr from the commands are streamed to the given writers.
// Failures are logged to stderr and do not stop subsequent commands.
func Run(commands []string, workDir string, stdout, stderr io.Writer) {
for _, command := range commands {
runOne(command, workDir, stdout, stderr)
}
}

func runOne(command, workDir string, stdout, stderr io.Writer) {
cmd := newHookCommand(command)
cmd.Dir = workDir
cmd.Stdout = stdout
cmd.Stderr = stderr

if err := cmd.Run(); err != nil {
exitCode := -1
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
}
fmt.Fprintf(stderr, "🌳 hook command failed: %q (exit %d): %v\n", command, exitCode, err)
}
}

func windowsShellCommandLine(shell, command string) string {
return `"` + shell + `" /d /s /c "` + command + `"`
}
Loading
Loading