Skip to content
Open
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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ make test
## Key Design Decisions

- 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)
- Pool identity is keyed off the repository's **common git dir** (`git.CommonGitDir`), which is shared by every linked worktree and the bare repo itself - NOT off the current working tree. So all worktrees of one repo map to a single shared pool instead of one pool per checkout. `config.ResolvePoolDir` resolves the common git dir from whatever directory it is handed, so `get`/`status`/`prune`/`return`/`destroy` all converge on the same pool name regardless of which worktree (or the bare repo) they run in. The pool name is `<repoName>-<hash>`: `repoName` comes from `git.RepoNameFromCommonDir` (a `.git`/`.bare` marker yields its parent dir's name; a standalone bare dir yields its own basename minus a `.git` suffix), and `hash` is the origin remote URL when present, falling back to `git.MainRootFromCommonDir` (the main repo root, stable across worktrees) for local-only repos. The pool *root* (parent dir) is still anchored on the passed-in dir, so relative `root` config stays repo-context dependent. NOTE on upgrade: this changed pool identity from the old per-worktree scheme. Single-checkout repos are unaffected and keep their existing pool - remote repos because the hash is still the remote URL, local-only repos because the main-root hash reproduces the old worktree-toplevel value. Repos used from multiple differently-named checkouts orphan their old per-checkout pools (clean up with `prune`/`destroy`)
- `get`/`status`/`prune` resolve their working directory via `git.ResolveWorkDir` (the working-tree root when one exists, else the common git dir), so they run from a bare repo or a gitdir-file parent (e.g. the `.bare` layout), not only from a checkout. Worktree-creating git ops (`AddWorktree`, `GetDefaultBranch`, `Fetch`) run fine from a bare dir. `init` still requires a working tree (it scaffolds repo-level config)
- Detached HEAD worktrees reset to whichever of local or origin default branch is further ahead (prefers origin on divergence). A new worktree always starts detached at the default branch tip - it never inherits the branch of the checkout `get` was invoked from
- In-use detection uses process scanning plus short-lived persisted owner reservations for lifecycle operations
- Durable leases are a separate, process-independent reservation: `WorktreeEntry.Leased`/`LeaseHolder`/`LeasedAt` persist in the state file (all `omitempty`, so pre-lease state files keep today's behavior). A lease is NOT derived from live processes, so it survives with zero processes inside the worktree and `healState` never clears it (it only clears dead owner reservations). Leased worktrees are skipped by `Acquire` and `prune`, classified `DestroyLeased` by destroy (removable only when the exact path is named with `--include-leased`, NEVER via `--all`), surfaced by `status` as `StatusLeased`, and cleared by `Release` (`return`)
- `destroy` is safe-by-default and mirrors `prune`: dry-run unless `--yes`, narrow explicit targets (`destroy <path>` for one worktree; `destroy <pool> --all` for that pool only - there is NO cross-pool/global destroy, and `--all` with no pool target is an error). The old blunt `--force` flag is REMOVED (this was the v2.0.0 breaking change); each risk class is its own opt-in: `--include-unlanded` (dirty, unmerged, or unverified), `--include-in-use` (running process or owner reservation; processes terminated cleanly first), `--include-leased` (leased, single named path only). A bare `--all --yes` removes only the disposable set (merged, clean, idle, unleased) and skips the rest with the flag that would include each. Bulk skips exit 0; a single-target skip exits non-zero. Entry points: `pool.DestroyWorktree` (single path, `allowLeased=true`) and `pool.DestroyPool` (bulk, `allowLeased=false`). Both share `classifyForDestroy` in `internal/pool/destroy.go`, which reuses prune's classification primitives (`ownerAlive`, `process.FindProcessesInWorktree`, `backingRepositoryMissing`, `git.IsDirty`, `git.IsHeadMergedIntoRef` against the `resolvePruneDefaultRef` ref) so destroy and prune agree on leased/in-use/unlanded/unverified/disposable. Removal keeps the same two-phase reservation as prune (reserve under flock, run `pre_destroy` hooks, remove only worktrees whose `sameDestroyReservation` still holds), so a worktree re-acquired during its hook is never deleted
Expand Down
2 changes: 1 addition & 1 deletion cmd/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func init() {
}

func getRunE(cmd *cobra.Command, args []string) error {
repoRoot, err := git.FindRepoRoot()
repoRoot, err := git.ResolveWorkDir("")
if err != nil {
return fmt.Errorf("not in a git repository: %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/prune.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ absolute.`,
return nil
}

repoRoot, err := git.FindRepoRoot()
repoRoot, err := git.ResolveWorkDir("")
if err != nil {
return fmt.Errorf("not in a git repository: %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ var statusCmd = &cobra.Command{
Use: "status",
Short: "Show the status of all worktrees in the pool",
RunE: func(cmd *cobra.Command, args []string) error {
repoRoot, err := git.FindRepoRoot()
repoRoot, err := git.ResolveWorkDir("")
if err != nil {
return fmt.Errorf("not in a git repository: %w", err)
}
Expand Down
30 changes: 20 additions & 10 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,19 +84,29 @@ func loadUser() (Config, bool, error) {
return cfg, false, nil
}

func ResolvePoolDir(repoRoot string, root string) (string, error) {
// Use remote URL for the hash when available; fall back to the
// absolute repo path for purely-local repositories.
hashInput, err := git.GetRemoteURL(repoRoot)
func ResolvePoolDir(repoDir string, root string) (string, error) {
// Identify the pool by the repository's common git dir, which is shared by
// every linked worktree (and the bare repo itself). This keeps all worktrees
// of one repository mapped to a single pool instead of one pool per checkout.
commonDir, err := git.CommonGitDir(repoDir)
if err != nil {
hashInput = repoRoot
return "", err
}
repoName := git.RepoNameFromCommonDir(commonDir)

// Use the remote URL for the hash when available; fall back to the main repo
// root for purely-local repositories. The main root is stable across worktrees
// and reproduces the pre-change worktree-toplevel value for a classic
// single-checkout repo, so such repos keep their existing pool on upgrade.
hashInput, err := git.GetRemoteURL(repoDir)
if err != nil || hashInput == "" {
hashInput = git.MainRootFromCommonDir(commonDir)
}
poolName := repoName + "-" + git.ShortHash(hashInput)

repoName := filepath.Base(repoRoot)
shortHash := git.ShortHash(hashInput)
poolName := repoName + "-" + shortHash

poolRoot, err := ResolvePoolRoot(repoRoot, root)
// Anchor the pool root on the passed-in repoDir (relative roots remain
// repo-context dependent, as before); only the pool name is repo-keyed.
poolRoot, err := ResolvePoolRoot(repoDir, root)
if err != nil {
return "", err
}
Expand Down
115 changes: 115 additions & 0 deletions internal/config/resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"path/filepath"
"strings"
"testing"

"github.com/kunchenguid/treehouse/internal/git"
)

func TestResolvePoolDir_EmptyRoot(t *testing.T) {
Expand Down Expand Up @@ -93,6 +95,119 @@ func TestResolvePoolDir_EnvVarExpansion(t *testing.T) {
}
}

// setupBareLayoutConfig builds a bare clone with two linked worktrees plus a
// gitdir-file parent, matching the layout treehouse must support.
func setupBareLayoutConfig(t *testing.T) (bareDir, wtMain, wtFeature, projDir string) {
t.Helper()
base := t.TempDir()
base, err := filepath.EvalSymlinks(base)
if err != nil {
t.Fatal(err)
}

originDir := filepath.Join(base, "origin.git")
seedDir := filepath.Join(base, "seed")
projDir = filepath.Join(base, "proj")
bareDir = filepath.Join(projDir, ".bare")
wtMain = filepath.Join(projDir, "main")
wtFeature = filepath.Join(projDir, "feature")

run(t, "", "git", "init", "--bare", "--initial-branch=main", originDir)
run(t, "", "git", "init", "--initial-branch=main", seedDir)
run(t, seedDir, "git", "config", "user.email", "test@test.com")
run(t, seedDir, "git", "config", "user.name", "Test")
if err := os.WriteFile(filepath.Join(seedDir, "README.md"), []byte("hi\n"), 0o644); err != nil {
t.Fatal(err)
}
run(t, seedDir, "git", "add", ".")
run(t, seedDir, "git", "commit", "-m", "initial")
run(t, seedDir, "git", "remote", "add", "origin", originDir)
run(t, seedDir, "git", "push", "-u", "origin", "main")

if err := os.MkdirAll(projDir, 0o755); err != nil {
t.Fatal(err)
}
run(t, "", "git", "clone", "--bare", originDir, bareDir)
run(t, bareDir, "git", "config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
run(t, bareDir, "git", "fetch", "origin")
if err := os.WriteFile(filepath.Join(projDir, ".git"), []byte("gitdir: ./.bare\n"), 0o644); err != nil {
t.Fatal(err)
}
run(t, projDir, "git", "worktree", "add", wtMain, "main")
run(t, projDir, "git", "worktree", "add", "-b", "feature", wtFeature, "main")
return bareDir, wtMain, wtFeature, projDir
}

// All worktrees of one repo - and the bare repo itself - must resolve to a
// single shared pool rather than one pool per checkout.
func TestResolvePoolDir_SharedAcrossWorktreesAndBare(t *testing.T) {
bareDir, wtMain, wtFeature, projDir := setupBareLayoutConfig(t)
root := t.TempDir() // absolute root => shared pool root regardless of caller dir

var want string
for i, dir := range []string{wtMain, wtFeature, bareDir, projDir} {
got, err := ResolvePoolDir(dir, root)
if err != nil {
t.Fatalf("ResolvePoolDir(%s): %v", dir, err)
}
if i == 0 {
want = got
continue
}
if got != want {
t.Errorf("ResolvePoolDir(%s) = %q, want shared %q", dir, got, want)
}
}

// The pool name is keyed on the project, not a per-worktree basename.
if name := filepath.Base(want); !strings.HasPrefix(name, "proj-") {
t.Errorf("pool name %q should start with project name %q", name, "proj-")
}
}

// A purely-local repo (no remote) must also share one pool across worktrees;
// the hash falls back to the common git dir, which is stable across worktrees.
func TestResolvePoolDir_LocalOnlySharedAcrossWorktrees(t *testing.T) {
base := t.TempDir()
base, err := filepath.EvalSymlinks(base)
if err != nil {
t.Fatal(err)
}
repoDir := filepath.Join(base, "repo")
wtPath := filepath.Join(base, "wt")

run(t, "", "git", "init", "--initial-branch=main", repoDir)
run(t, repoDir, "git", "config", "user.email", "test@test.com")
run(t, repoDir, "git", "config", "user.name", "Test")
if err := os.WriteFile(filepath.Join(repoDir, "README.md"), []byte("hi\n"), 0o644); err != nil {
t.Fatal(err)
}
run(t, repoDir, "git", "add", ".")
run(t, repoDir, "git", "commit", "-m", "initial")
run(t, repoDir, "git", "worktree", "add", "--detach", wtPath, "main")

root := t.TempDir()
fromRepo, err := ResolvePoolDir(repoDir, root)
if err != nil {
t.Fatalf("ResolvePoolDir(repo): %v", err)
}
fromWt, err := ResolvePoolDir(wtPath, root)
if err != nil {
t.Fatalf("ResolvePoolDir(worktree): %v", err)
}
if fromRepo != fromWt {
t.Errorf("local-only repo: main %q and worktree %q must share one pool", fromRepo, fromWt)
}

// The hash is keyed on the main repo root (not the common git dir), which
// reproduces the pre-change worktree-toplevel value so a classic
// single-checkout local-only repo keeps its existing pool on upgrade.
wantName := "repo-" + git.ShortHash(repoDir)
if got := filepath.Base(fromRepo); got != wantName {
t.Errorf("local-only pool name = %q, want %q (hash must key on main root)", got, wantName)
}
}

func TestResolvePoolRoot_EmptyRoot(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
Expand Down
79 changes: 79 additions & 0 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package git
import (
"crypto/sha256"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
Expand All @@ -16,6 +17,84 @@ func FindRepoRootFrom(dir string) (string, error) {
return runGit(dir, "rev-parse", "--show-toplevel")
}

// ResolveWorkDir returns a directory that git operations (worktree add, fetch,
// default-branch resolution) can run from for the repository containing dir.
// For a linked or main worktree it returns the working-tree root. For a bare
// repository - including a gitdir-file parent such as the `.bare` layout, where
// there is no working tree - it returns the common git dir, which git accepts as
// the repository for `worktree add`. dir may be empty to mean the current
// directory.
func ResolveWorkDir(dir string) (string, error) {
if top, err := runGit(dir, "rev-parse", "--show-toplevel"); err == nil && top != "" {
return top, nil
}
return CommonGitDir(dir)
}

// CommonGitDir returns the absolute common git dir shared by every linked
// worktree of the repository containing dir (e.g. `/path/repo/.git` or
// `/path/proj/.bare`). It is the stable identity anchor for a repository: all
// worktrees and the bare repo itself resolve to the same value. dir may be empty
// to mean the current directory.
func CommonGitDir(dir string) (string, error) {
if out, err := runGit(dir, "rev-parse", "--path-format=absolute", "--git-common-dir"); err == nil && out != "" {
return filepath.Clean(filepath.FromSlash(out)), nil
}
out, err := runGit(dir, "rev-parse", "--git-common-dir")
if err != nil {
return "", err
}
cleaned := filepath.Clean(filepath.FromSlash(out))
if !filepath.IsAbs(cleaned) {
base := dir
if base == "" {
if cwd, err := os.Getwd(); err == nil {
base = cwd
}
}
cleaned = filepath.Join(base, cleaned)
}
return cleaned, nil
}

// RepoNameFromCommonDir derives a stable, human-readable pool-name component from
// a repository's common git dir. A `.git` or `.bare` marker yields its parent
// directory's name (the project name shared by every worktree); any other name
// (a standalone bare repo such as `repo.git` or `repo`) yields its own basename
// with a trailing `.git` stripped.
func RepoNameFromCommonDir(commonDir string) string {
base := filepath.Base(commonDir)
if base == ".git" || base == ".bare" {
return filepath.Base(filepath.Dir(commonDir))
}
return strings.TrimSuffix(base, ".git")
}

// MainRootFromCommonDir returns the repository's main root from its common git
// dir: the parent directory of a `.git`/`.bare` marker (the project dir shared by
// every worktree), or the common dir itself for a standalone bare repo. It is the
// stable, worktree-independent identity used as the pool-name hash input for
// local-only repositories, and reproduces the old worktree-toplevel value for a
// classic single-checkout repo so such repos keep their existing pool on upgrade.
func MainRootFromCommonDir(commonDir string) string {
base := filepath.Base(commonDir)
if base == ".git" || base == ".bare" {
return filepath.Dir(commonDir)
}
return commonDir
}

// RepoName returns the stable repository name for the repo containing dir,
// resolved from its common git dir. dir may be empty to mean the current
// directory.
func RepoName(dir string) (string, error) {
commonDir, err := CommonGitDir(dir)
if err != nil {
return "", err
}
return RepoNameFromCommonDir(commonDir), nil
}

// FindMainRepoRootFrom returns the main repository root for dir.
// For linked worktrees, it resolves the worktree root back to the owning
// repository.
Expand Down
Loading