feat: add the kettle CLI, replacing the plugin's Python scripts
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>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Every test here strips CLAUDE_PROJECT_DIR: it is the first anchor of the
|
||||
// walk, so the harness's own value would point every fixture at whatever
|
||||
// repository the suite happens to run in.
|
||||
func fixture(t *testing.T) string {
|
||||
t.Helper()
|
||||
t.Setenv("CLAUDE_PROJECT_DIR", "")
|
||||
dir := t.TempDir()
|
||||
// macOS hands out /var/… , a symlink to /private/var, and the walk works
|
||||
// in resolved paths.
|
||||
real, err := filepath.EvalSymlinks(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return real
|
||||
}
|
||||
|
||||
func chdir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
t.Chdir(dir)
|
||||
}
|
||||
|
||||
func TestRootFindsTheNearestMarkerUpFromTheWorkingDirectory(t *testing.T) {
|
||||
root := fixture(t)
|
||||
deep := filepath.Join(root, "a", "b", "c")
|
||||
if err := os.MkdirAll(deep, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, Marker), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chdir(t, deep)
|
||||
|
||||
if got := Root(""); got != root {
|
||||
t.Errorf("Root() = %q, want %q", got, root)
|
||||
}
|
||||
if got := StoreRoot(""); got != filepath.Join(root, Marker, "issues") {
|
||||
t.Errorf("StoreRoot() = %q", got)
|
||||
}
|
||||
// The scratchpad is a sibling of the store, never inside it: a call that
|
||||
// touches no issue must not materialize the issue directory.
|
||||
if got := PayloadRoot(""); got != filepath.Join(root, Marker, "payload") {
|
||||
t.Errorf("PayloadRoot() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoMarkerIsAnAnswerNotAFallback(t *testing.T) {
|
||||
dir := fixture(t)
|
||||
chdir(t, dir)
|
||||
|
||||
if got := Root(""); got != "" {
|
||||
t.Errorf("Root() = %q, want empty — a plausible-looking directory is the failure this replaces", got)
|
||||
}
|
||||
if got := StoreRoot(""); got != "" {
|
||||
t.Errorf("StoreRoot() = %q, want empty", got)
|
||||
}
|
||||
if err := NotFoundError(""); err == nil {
|
||||
t.Fatal("NotFoundError must explain itself")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheNearestMarkerWinsOverAnAncestorOne(t *testing.T) {
|
||||
outer := fixture(t)
|
||||
inner := filepath.Join(outer, "vendored")
|
||||
for _, d := range []string{filepath.Join(outer, Marker), filepath.Join(inner, Marker)} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
chdir(t, inner)
|
||||
|
||||
if got := Root(""); got != inner {
|
||||
t.Errorf("Root() = %q, want the nearest marker %q", got, inner)
|
||||
}
|
||||
}
|
||||
|
||||
// A linked worktree is a SIBLING of the main checkout, so the gitignored
|
||||
// marker is never on its parent chain. The whole sync layer once died there
|
||||
// while the tracker CLI in the same directory worked.
|
||||
func TestALinkedWorktreeResolvesToTheMainCheckout(t *testing.T) {
|
||||
base := fixture(t)
|
||||
main := filepath.Join(base, "repo")
|
||||
wt := filepath.Join(base, "repo-feat")
|
||||
gitdir := filepath.Join(main, ".git", "worktrees", "feat")
|
||||
|
||||
for _, d := range []string{filepath.Join(main, Marker), gitdir, wt} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// <worktree>/.git points at the private dir; commondir points back at
|
||||
// <main>/.git, whose parent is the main working tree.
|
||||
write(t, filepath.Join(wt, ".git"), "gitdir: "+gitdir+"\n")
|
||||
write(t, filepath.Join(gitdir, "commondir"), "../..\n")
|
||||
|
||||
chdir(t, wt)
|
||||
if got := Root(""); got != main {
|
||||
t.Errorf("Root() = %q, want the main checkout %q", got, main)
|
||||
}
|
||||
}
|
||||
|
||||
// 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. Following it would be a hop to nowhere.
|
||||
func TestASubmoduleIsNotAWorktree(t *testing.T) {
|
||||
base := fixture(t)
|
||||
sub := filepath.Join(base, "super", "sub")
|
||||
gitdir := filepath.Join(base, "super", ".git", "modules", "sub")
|
||||
for _, d := range []string{sub, gitdir} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write(t, filepath.Join(sub, ".git"), "gitdir: "+gitdir+"\n")
|
||||
|
||||
if got := MainWorktree(sub); got != "" {
|
||||
t.Errorf("MainWorktree() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOrdinaryCloneHasNothingToFollow(t *testing.T) {
|
||||
dir := fixture(t)
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := GitDirOf(dir); got != "" {
|
||||
t.Errorf("GitDirOf() = %q — only a .git FILE is a pointer", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeProjectDirIsTheFirstAnchor(t *testing.T) {
|
||||
base := fixture(t)
|
||||
opened := filepath.Join(base, "opened")
|
||||
elsewhere := filepath.Join(base, "elsewhere")
|
||||
for _, d := range []string{filepath.Join(opened, Marker), filepath.Join(elsewhere, Marker)} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("CLAUDE_PROJECT_DIR", opened)
|
||||
chdir(t, elsewhere)
|
||||
|
||||
if got := Root(""); got != opened {
|
||||
t.Errorf("Root() = %q, want the opened project %q", got, opened)
|
||||
}
|
||||
}
|
||||
|
||||
func write(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user