9480e48312
The plugin resolved its issue store from `__file__`, which put it inside a versioned plugin cache: issues written from one project were invisible from the next, and `origin: local` files — the only copy of that work by definition — were stranded a version bump at a time. The walk that answers "which directory is the project" was written three times over, and in a linked worktree the three disagreed. Both are runtime failures rather than logic ones, so the fix is a compiled binary: one walk, imported rather than re-derived, and a layering rule the build graph enforces instead of a grep. Seven packages, knowledge flowing one way. `project` answers which directory is the project and depends on nothing. `issue` is the domain — format, taxonomy, validation, checkboxes, dependency graph, the store, eviction — offline, with no tracker in it. `wire` holds the protocol shapes. `gitea` is the transport, `mapping` the bridge, `config` the credentials, `cmd` the command tree. Four tests hold the boundaries, each failing on a real mistake rather than a naming convention. The marker moves to `.kettle/` and the login pin moves out of the harness's settings file into `.kettle/config.yaml`, which pins a login by NAME; the tokens live in one file per machine, mode 0600, outside every working tree. That retires the PreToolUse guard hook entirely — the binary holds its own credentials, so a command running under a login nobody chose is not expressible rather than caught. `kettle init` migrates an older `tmp/issues` or `.tea/issues` store in, as a move: a store left behind at an old path is one somebody edits by accident months later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
246 lines
7.2 KiB
Go
246 lines
7.2 KiB
Go
// 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"}
|
|
)
|
|
|
|
// 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 "".
|
|
//
|
|
// `<worktree>/.git` -> `<main>/.git/worktrees/<name>`, whose `commondir` file
|
|
// holds a path to `<main>/.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 `<super>/.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) }
|
|
|
|
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
|
|
}
|