// Package project answers one question: which directory is the project. // // Everything that is a fact about the project — the issue store, the request // payload scratchpad, the tracker the issues belong to — is resolved from the // answer, and the answer is found by one walk written once. The guard, the // transport and the store used to each have their own copy of that walk in // Python, and they disagreed: in a linked worktree `tea` worked while every // script reported no login pinned. // // This package depends on nothing but the standard library, and nothing in it // resolves from the binary's own location. Where an installation keeps its // files is a fact about the installation; which issues a tree has is a fact // about the tree, and a binary installed in one place and pointed at another // must answer from the one it was pointed at. package project import ( "fmt" "os" "path/filepath" "strings" ) // Marker is the directory an operator creates to state "this is a project". // It is never inferred. `.git` was tried and is in every clone, including this // repository's own, so a plugin resolved its store inside itself. const Marker = ".kettle" // Everything under the marker, each resolved by the same walk so that which // command wrote a file cannot change where it landed. var ( storeParts = []string{Marker, "issues"} payloadParts = []string{Marker, "payload"} configParts = []string{Marker, "config.yaml"} scaffoldParts = []string{Marker, "scaffold.yaml"} ) // Anchors are the directories a root search starts from, in order, first hit // wins: the project the agent harness was opened on, then the working // directory. A non-empty start overrides both and exists so resolution can be // exercised against a scratch tree. func Anchors(start string) []string { if start != "" { abs, err := filepath.Abs(start) if err != nil { return nil } return []string{abs} } var out []string for _, d := range []string{os.Getenv("CLAUDE_PROJECT_DIR"), cwd()} { if d == "" || !isDir(d) { continue } abs, err := filepath.Abs(d) if err != nil { continue } if !contains(out, abs) { out = append(out, abs) } } return out } // Parents yields start and every ancestor of it, up to the filesystem root. func Parents(start string) []string { d, err := filepath.Abs(start) if err != nil { return nil } var out []string for { out = append(out, d) parent := filepath.Dir(d) if parent == d { return out } d = parent } } // GitDirOf is the private git directory `d/.git` points at, or "". // // Only a `.git` FILE is a pointer; in an ordinary clone `.git` is a directory // and there is nothing to follow. func GitDirOf(d string) string { p := filepath.Join(d, ".git") fi, err := os.Stat(p) if err != nil || fi.IsDir() { return "" } head, err := os.ReadFile(p) if err != nil { return "" } for _, line := range strings.Split(string(head), "\n") { line = strings.TrimSpace(line) target, ok := strings.CutPrefix(line, "gitdir:") if !ok { continue } target = strings.TrimSpace(target) if target == "" { return "" } if !filepath.IsAbs(target) { target = filepath.Join(d, target) } abs, err := filepath.Abs(target) if err != nil { return "" } return abs } return "" } // MainWorktree is the main working tree of d's repository when d is a linked // worktree, or "". // // `/.git` -> `
/.git/worktrees/`, whose `commondir` file // holds a path to `
/.git`; the main working tree is its parent. The // `.git` basename check keeps this to worktrees: a submodule's `.git` is a // pointer too, but it points into `/.git/modules/…`, and the tree it // belongs to is already on the parent chain. func MainWorktree(d string) string { gitdir := GitDirOf(d) if gitdir == "" || !isDir(gitdir) { return "" } common := gitdir if raw, err := os.ReadFile(filepath.Join(gitdir, "commondir")); err == nil { if rel := strings.TrimSpace(string(raw)); rel != "" { if abs, err := filepath.Abs(filepath.Join(gitdir, rel)); err == nil { common = abs } } } if filepath.Base(common) != ".git" { return "" } root := filepath.Dir(common) abs, err := filepath.Abs(d) if err != nil { return "" } if root != "" && isDir(root) && root != abs { return root } return "" } // Root is the nearest ancestor of an anchor (inclusive) holding the marker, or // "" when there is no project. // // A marker, not a fixed number of `..` hops: how deep a caller sits below the // root is an implementation detail of the layout, and the layout is not a // promise. Walking up means every command sees one store from anywhere inside // the project — including from inside the store itself — while a cd into a // DIFFERENT project correctly answers with that project's store. // // A linked worktree is the same project on another branch, and the marker is // gitignored, so it is only ever in the main checkout: the chain is searched // first and always wins, then the main working tree of any worktree met on it. func Root(start string) string { for _, anchor := range Anchors(start) { var hops []string for _, d := range Parents(anchor) { if isDir(filepath.Join(d, Marker)) { return d } if main := MainWorktree(d); main != "" && !contains(hops, main) { hops = append(hops, main) } } // One level of indirection, never two: a main checkout is not itself a // linked worktree, so this cannot chain and cannot cycle. for _, hop := range hops { for _, d := range Parents(hop) { if isDir(filepath.Join(d, Marker)) { return d } } } } return "" } // StoreRoot is the absolute path of the issue store, or "" with no project. func StoreRoot(start string) string { return under(start, storeParts) } // PayloadRoot is the absolute path of the request-payload scratchpad, or "". // // A sibling of the store under the same marker, resolved by the same walk, so // the scratchpad and the store can never end up in two different projects — // and so a scratchpad can never sit INSIDE a store, where a call that touched // no issue would still materialize the issue directory. func PayloadRoot(start string) string { return under(start, payloadParts) } // ConfigPath is the absolute path of the project's tracker config, or "". func ConfigPath(start string) string { return under(start, configParts) } // ScaffoldPath is the absolute path of the record of what was written into the // project's `.claude/` tree, or "". // // A file of its own rather than a field in config.yaml, and the reason is a rule // stated in [config]: an unknown key in config.yaml is an error rather than a // silent drop, so a field added there is a one-way door for a file that may be // committed and read by whatever version each machine happens to have. This // record is written and read by one binary about one directory, so it can carry // that cost where the shared file cannot. func ScaffoldPath(start string) string { return under(start, scaffoldParts) } func under(start string, parts []string) string { root := Root(start) if root == "" { return "" } return filepath.Join(append([]string{root}, parts...)...) } // NotFoundError explains why no project could be resolved, naming every // directory the search began from. // // The anchors, not the whole chain above them: an operator who sees the two // places the search started knows immediately whether it started where they // meant it to. func NotFoundError(start string) error { dirs := strings.Join(Anchors(start), " and ") if dirs == "" { dirs = "nowhere" } return fmt.Errorf("no %s/ found — searched up from %s. Run `kettle init` in the project you mean to track issues in", Marker, dirs) } func isDir(p string) bool { fi, err := os.Stat(p) return err == nil && fi.IsDir() } func cwd() string { d, err := os.Getwd() if err != nil { return "" } return d } func contains(xs []string, x string) bool { for _, v := range xs { if v == x { return true } } return false }