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>
207 lines
5.9 KiB
Go
207 lines
5.9 KiB
Go
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
|
|
}
|