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,206 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Initializing is a statement, and the only one that matters here: *this*
|
||||
// directory is the project whose issues live in it. It is answered once, by a
|
||||
// person, and everything downstream reads the answer instead of guessing.
|
||||
//
|
||||
// The marker is deliberately something an operator makes, not something
|
||||
// inferred from the tree: `.git` is in every clone including this repository's
|
||||
// own, so a plugin that inferred its root from one wrote issues into itself.
|
||||
|
||||
// Layouts this has been through, migrated in on init in the order listed —
|
||||
// oldest first, so a tree that skipped a generation still lands in one place.
|
||||
//
|
||||
// Each is a move, never a copy: two stores is the state the marker exists to
|
||||
// prevent, and a store left behind at an old path is a store somebody will edit
|
||||
// by accident months later.
|
||||
var legacy = map[string][]string{
|
||||
"issues": {
|
||||
filepath.Join("tmp", "issues"),
|
||||
filepath.Join(".tea", "issues"),
|
||||
},
|
||||
"payload": {
|
||||
filepath.Join("tmp", "payload"),
|
||||
filepath.Join(".tea", "payload"),
|
||||
},
|
||||
}
|
||||
|
||||
// ClashError reports that a migration found the same name on both sides.
|
||||
//
|
||||
// Two versions of one issue, and which one survives is not a decision a
|
||||
// migration gets to make quietly.
|
||||
type ClashError struct {
|
||||
Src, Dst string
|
||||
Names []string
|
||||
}
|
||||
|
||||
func (e *ClashError) Error() string {
|
||||
names := e.Names
|
||||
suffix := ""
|
||||
if len(names) > 5 {
|
||||
suffix = fmt.Sprintf(" (+%d more)", len(names)-5)
|
||||
names = names[:5]
|
||||
}
|
||||
return fmt.Sprintf("%s and %s both hold %s%s — move or delete one side first; nothing was changed",
|
||||
e.Src, e.Dst, strings.Join(names, ", "), suffix)
|
||||
}
|
||||
|
||||
// Init makes root a project. Everything it does is idempotent:
|
||||
//
|
||||
// - creates .tea/issues/ and .tea/payload/
|
||||
// - moves an existing tmp/issues/ and tmp/payload/ in, if it finds them
|
||||
// - adds .tea/ to .gitignore
|
||||
//
|
||||
// The move is the migration off the old layout and it is a move, not a copy:
|
||||
// two stores is the state the marker exists to prevent, and a store left behind
|
||||
// at the old path is a store somebody will edit by accident.
|
||||
//
|
||||
// .tea/ is gitignored because an `origin: local` issue is the only copy of that
|
||||
// work and the operator, not this command, decides what goes in a shared
|
||||
// history. Committing the store is a legitimate choice — drop the line if you
|
||||
// make it.
|
||||
//
|
||||
// Returns one line per thing done, for the receipt.
|
||||
func Init(root string, dryRun bool) ([]string, error) {
|
||||
var done []string
|
||||
marker := filepath.Join(root, Marker)
|
||||
fresh := !isDir(marker)
|
||||
|
||||
for _, name := range []string{"issues", "payload"} {
|
||||
d := filepath.Join(marker, name)
|
||||
if isDir(d) {
|
||||
continue
|
||||
}
|
||||
if !dryRun {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
return done, err
|
||||
}
|
||||
}
|
||||
done = append(done, "created "+filepath.Join(Marker, name))
|
||||
}
|
||||
|
||||
for _, name := range []string{"issues", "payload"} {
|
||||
for _, old := range legacy[name] {
|
||||
src := filepath.Join(root, old)
|
||||
moved, err := migrate(src, filepath.Join(marker, name), dryRun)
|
||||
if err != nil {
|
||||
return done, err
|
||||
}
|
||||
switch {
|
||||
case moved == nil:
|
||||
// nothing there to migrate
|
||||
case len(moved) == 0:
|
||||
done = append(done, old+" was empty — nothing to move")
|
||||
default:
|
||||
done = append(done, fmt.Sprintf("moved %d file(s) from %s to %s",
|
||||
len(moved), old, filepath.Join(Marker, name)))
|
||||
}
|
||||
}
|
||||
}
|
||||
// The old marker goes only when the migration emptied it — anything else
|
||||
// parked in there is somebody's, and this is not the command that decides
|
||||
// what.
|
||||
if !dryRun {
|
||||
os.Remove(filepath.Join(root, ".tea"))
|
||||
}
|
||||
|
||||
added, err := addToGitignore(filepath.Join(root, ".gitignore"), Marker+"/", dryRun)
|
||||
if err != nil {
|
||||
return done, err
|
||||
}
|
||||
if added {
|
||||
done = append(done, "added "+Marker+"/ to .gitignore")
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(done) == 0:
|
||||
done = append(done, "already initialized — nothing to do")
|
||||
case fresh:
|
||||
done = append(done, fmt.Sprintf("%s now tracks issues in %s/issues", root, Marker))
|
||||
}
|
||||
return done, nil
|
||||
}
|
||||
|
||||
// migrate moves the CONTENTS of src into dst — contents, not the directory, so
|
||||
// an already-created destination is not a reason to refuse. Returns the names
|
||||
// moved, or nil when there was nothing to migrate.
|
||||
func migrate(src, dst string, dryRun bool) ([]string, error) {
|
||||
if !isDir(src) {
|
||||
return nil, nil
|
||||
}
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
sort.Strings(names)
|
||||
if len(names) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
var clashes []string
|
||||
for _, n := range names {
|
||||
if _, err := os.Lstat(filepath.Join(dst, n)); err == nil {
|
||||
clashes = append(clashes, n)
|
||||
}
|
||||
}
|
||||
if len(clashes) > 0 {
|
||||
return nil, &ClashError{Src: src, Dst: dst, Names: clashes}
|
||||
}
|
||||
if dryRun {
|
||||
return names, nil
|
||||
}
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, n := range names {
|
||||
if err := os.Rename(filepath.Join(src, n), filepath.Join(dst, n)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
os.Remove(src) // only succeeds when we emptied it, which is the intent
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// addToGitignore appends entry unless some line already ignores it.
|
||||
func addToGitignore(path, entry string, dryRun bool) (bool, error) {
|
||||
var lines []string
|
||||
if raw, err := os.ReadFile(path); err == nil {
|
||||
lines = strings.Split(strings.TrimSuffix(string(raw), "\n"), "\n")
|
||||
} else if !os.IsNotExist(err) {
|
||||
return false, err
|
||||
}
|
||||
want := strings.TrimSuffix(entry, "/")
|
||||
for _, line := range lines {
|
||||
if strings.TrimSuffix(strings.TrimSpace(line), "/") == want {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
if dryRun {
|
||||
return true, nil
|
||||
}
|
||||
trailer := "\n"
|
||||
if len(lines) == 0 || lines[len(lines)-1] == "" {
|
||||
trailer = ""
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.WriteString(trailer + entry + "\n"); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// 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
|
||||
}
|
||||
@@ -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