From e6da12b60ac01a6773be630b9e7e99712d4c44a4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:39:57 +0900 Subject: [PATCH] chore: extract go devtool to devkit (#795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove all Go source code from scripts/ — the devtool CLI now lives in its own repo at https://github.com/rararulab/devkit. Install via `go install github.com/rararulab/devkit@latest`. - Delete scripts/cmd/, scripts/internal/, scripts/go.mod, scripts/go.sum - Add .devkit.toml with layer map and crates_dir config - Update justfile: wt/check-agent-md/check-deps call devkit directly - Update CI: remove devtool-checks job (covered by pre-commit hooks) - Update .pre-commit-config.yaml to call devkit - Update docs/src/harness-engineering.md Closes #795 Co-Authored-By: Claude Opus 4.6 --- .devkit.toml | 48 ++ .github/workflows/lint.yml | 24 +- .pre-commit-config.yaml | 2 +- docs/src/harness-engineering.md | 43 +- justfile | 18 +- scripts/AGENT.md | 37 -- scripts/cmd/devtool/main.go | 29 -- scripts/go.mod | 29 -- scripts/go.sum | 54 --- scripts/internal/agentmd/commands.go | 62 --- scripts/internal/deps/commands.go | 338 -------------- scripts/internal/worktree/commands.go | 148 ------ scripts/internal/worktree/git.go | 284 ------------ scripts/internal/worktree/tui.go | 637 -------------------------- 14 files changed, 83 insertions(+), 1670 deletions(-) create mode 100644 .devkit.toml delete mode 100644 scripts/AGENT.md delete mode 100644 scripts/cmd/devtool/main.go delete mode 100644 scripts/go.mod delete mode 100644 scripts/go.sum delete mode 100644 scripts/internal/agentmd/commands.go delete mode 100644 scripts/internal/deps/commands.go delete mode 100644 scripts/internal/worktree/commands.go delete mode 100644 scripts/internal/worktree/git.go delete mode 100644 scripts/internal/worktree/tui.go diff --git a/.devkit.toml b/.devkit.toml new file mode 100644 index 000000000..0aaa6e9b8 --- /dev/null +++ b/.devkit.toml @@ -0,0 +1,48 @@ +# devkit configuration — used by https://github.com/rararulab/devkit +# Install: go install github.com/rararulab/devkit@latest + +[agent-md] +crates_dir = "crates" + +[deps] +crates_dir = "crates" + +# Layer assignments for dependency direction checking. +# A crate at layer N must NOT depend on a crate at layer N+1 or higher. +# Key = layer number (0 = lowest), value = list of crate package names. +[deps.layers] +0 = [ + "base", + "rara-error", + "common-runtime", + "common-telemetry", + "common-worker", + "yunara-store", + "rara-tool-macro", + "crawl4ai", + "rara-paths", + "rara-model", + "rara-domain-shared", + "rara-api", +] +1 = [ + "rara-soul", + "rara-symphony", + "rara-skills", + "rara-vault", + "rara-composio", + "rara-keyring-store", + "rara-codex-oauth", + "rara-git", +] +2 = ["rara-kernel"] +3 = [ + "rara-dock", + "rara-sessions", + "rara-agents", + "rara-mcp", + "rara-pg-credential-store", +] +4 = ["rara-channels", "rara-backend-admin"] +5 = ["rara-app", "rara-server"] +6 = ["rara-cli"] diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6fb0f5461..6baed2a7b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -45,39 +45,19 @@ jobs: - name: Buf format check run: cd api && buf format --diff --exit-code - devtool-checks: - name: Devtool Checks - runs-on: arc-runner-set - steps: - - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - with: - persist-credentials: false - - - name: Build devtool - run: cd scripts && go build -o bin/devtool ./cmd/devtool/ - - - name: Check AGENT.md existence - run: scripts/bin/devtool check-agent-md - - - name: Check crate dependency direction - run: scripts/bin/devtool check-deps - lint-success: name: Lint Success runs-on: arc-runner-set - needs: [rust-format, proto-lint, devtool-checks] + needs: [rust-format, proto-lint] if: always() steps: - name: Check all lint jobs status env: RUST_FORMAT_RESULT: ${{ needs.rust-format.result }} PROTO_LINT_RESULT: ${{ needs.proto-lint.result }} - DEVTOOL_CHECKS_RESULT: ${{ needs.devtool-checks.result }} run: | if [[ "$RUST_FORMAT_RESULT" != "success" || \ - "$PROTO_LINT_RESULT" != "success" || \ - "$DEVTOOL_CHECKS_RESULT" != "success" ]]; then + "$PROTO_LINT_RESULT" != "success" ]]; then echo "One or more lint jobs failed" exit 1 fi diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 799ec82b9..5b81504eb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,7 +35,7 @@ repos: - id: check-agent-md name: check AGENT.md presence - entry: go run ./scripts/cmd/devtool/ check-agent-md + entry: devkit check-agent-md language: system pass_filenames: false files: ^crates/ diff --git a/docs/src/harness-engineering.md b/docs/src/harness-engineering.md index 709a4c6fa..35d937e41 100644 --- a/docs/src/harness-engineering.md +++ b/docs/src/harness-engineering.md @@ -14,24 +14,24 @@ Three principles guide our approach: 2. **Waiting is expensive; corrections are cheap.** We use minimal blocking gates at commit time and continuous quality tracking on `main`. 3. **Build the missing capability into the repo.** When an agent struggles, the fix is never "try harder" — it's making the codebase more legible and enforceable. -## Devtool Commands +## Devkit Commands -All enforcement tools live as subcommands of the Go-based `devtool` CLI under `scripts/`. +Enforcement tools are provided by [devkit](https://github.com/rararulab/devkit), a standalone Go CLI. Install with `go install github.com/rararulab/devkit@latest`. Configuration lives in `.devkit.toml` at the repo root. -### `devtool check-agent-md` +### `devkit check-agent-md` Verifies every crate under `crates/` has an `AGENT.md` file. AGENT.md files are the primary way agents understand a crate's purpose, invariants, and anti-patterns without reading every source file. ```bash just check-agent-md # Run standalone -scripts/bin/devtool check-agent-md +devkit check-agent-md ``` -**Runs in:** PR lint CI (blocking). +**Runs in:** pre-commit hook (blocking). -### `devtool check-deps` +### `devkit check-deps` -Validates crate dependency direction against a 7-layer architecture map. Lower-layer crates must not depend on higher-layer crates: +Validates crate dependency direction against a 7-layer architecture map defined in `.devkit.toml`. Lower-layer crates must not depend on higher-layer crates: ``` Layer 0 (foundation) → common/*, paths, rara-model, domain/*, rara-api @@ -45,17 +45,28 @@ Layer 6 (entry) → rara-cli ```bash just check-deps # Run standalone -scripts/bin/devtool check-deps +devkit check-deps ``` -**Runs in:** PR lint CI (blocking). +**Runs in:** pre-commit hook (blocking). + +### `devkit wt` + +Interactive worktree manager TUI. Provides selection, bulk cleanup of merged worktrees, pruning, and disk size reporting. + +```bash +just wt # Launch TUI +devkit wt list # Non-interactive list +devkit wt clean # Remove merged worktrees +devkit wt nuke # Force-remove all except main +``` ## CI Integration | Check | Trigger | Blocking? | Purpose | |-------|---------|-----------|---------| -| `check-agent-md` | PR lint | Yes | Prevent crates without agent guidelines | -| `check-deps` | PR lint | Yes | Prevent architecture layer violations | +| `check-agent-md` | Pre-commit hook | Yes | Prevent crates without agent guidelines | +| `check-deps` | Pre-commit hook | Yes | Prevent architecture layer violations | | `cargo clippy -D warnings` | PR lint | Yes | Code quality | | `cargo +nightly fmt --check` | PR lint | Yes | Formatting consistency | | `buf lint` | PR lint | Yes | Protobuf schema quality | @@ -65,10 +76,10 @@ scripts/bin/devtool check-deps When you identify a rule that agents frequently violate: -1. Create a `scripts/internal//commands.go` subcommand -2. Register it in `scripts/cmd/devtool/main.go` -3. Add a `just` recipe in `justfile` -4. Add a CI job in `.github/workflows/lint.yml` -5. Decide: **blocking** (PR gate) or **tracking** (main-only report)? +1. Add a new subcommand in [devkit](https://github.com/rararulab/devkit) under `internal//commands.go` +2. Register it in `main.go` +3. Add a `just` recipe in this repo's `justfile` +4. Optionally add a pre-commit hook in `.pre-commit-config.yaml` +5. Decide: **blocking** (pre-commit gate) or **tracking** (main-only report)? The bar for blocking checks is high — they must be deterministic, fast, and have zero false positives. Quality tracking checks can be softer. diff --git a/justfile b/justfile index 2d6fbfe14..0bbce42dc 100644 --- a/justfile +++ b/justfile @@ -83,8 +83,7 @@ alias t := test [doc("check that every crate has an AGENT.md")] [group("👆 Code Quality")] check-agent-md: - @cd scripts && go build -o bin/devtool ./cmd/devtool/ - @scripts/bin/devtool check-agent-md + @devkit check-agent-md [doc("run linting checks (clippy, docs, buf, zizmor, yamllint-rs, cargo-deny, agent-md, check-deps)")] [group("👆 Code Quality")] @@ -311,22 +310,15 @@ alias ma := migrate-add # Worktree Management # ======================================================================================== -DEVTOOL := "scripts/bin/devtool" - -[doc("build devtool binary")] -[group("🔧 Development")] -devtool-build: - @cd scripts && go build -o bin/devtool ./cmd/devtool/ - [doc("interactive worktree manager (TUI)")] [group("🌳 Worktree")] -wt: devtool-build - @{{DEVTOOL}} wt +wt: + @devkit wt [doc("check crate dependency direction rules")] [group("👆 Code Quality")] -check-deps: devtool-build - @{{DEVTOOL}} check-deps +check-deps: + @devkit check-deps # ======================================================================================== # Dependency Management diff --git a/scripts/AGENT.md b/scripts/AGENT.md deleted file mode 100644 index f0491ce0d..000000000 --- a/scripts/AGENT.md +++ /dev/null @@ -1,37 +0,0 @@ -# scripts (devtool) — Agent Guidelines - -## Purpose -Unified Go CLI developer toolkit for rara. Houses automation scripts that are too complex for inline bash in the justfile. - -## Architecture -Follows the [k9s](https://github.com/derailed/k9s) project structure pattern: - -- `cmd/devtool/main.go` — thin entry point, wires top-level command groups -- `internal//` — each command group gets its own package under `internal/` - - `internal/worktree/git.go` — low-level git worktree operations (Entry, List, Remove, etc.) - - `internal/worktree/tui.go` — bubbletea v2 interactive TUI (table view, key bindings) - - `internal/worktree/commands.go` — urfave/cli subcommand definitions + non-interactive fallbacks - -Key libraries: -- CLI framework: [urfave/cli v3](https://github.com/urfave/cli) -- TUI framework: [bubbletea v2](https://charm.land/bubbletea) + [bubbles v2](https://charm.land/bubbles) (table component) + [lipgloss v2](https://charm.land/lipgloss) - -Build: `cd scripts && go build -o bin/devtool ./cmd/devtool/` -The justfile wraps this via `just devtool-build`. - -## Critical Invariants -- Go module lives in `scripts/go.mod` (not the repo root) — all `go` commands must run from `scripts/`. -- Built binary goes to `scripts/bin/` which is gitignored. -- Each new command group should be a separate package under `internal/` with its own exported `Cmd()` function, registered in `cmd/devtool/main.go`. -- bubbletea v2 uses `charm.land/` import paths, NOT `github.com/charmbracelet/`. - -## What NOT To Do -- Do NOT put complex bash logic in the justfile — write Go instead. -- Do NOT use cobra — this project uses urfave/cli v3. -- Do NOT put business logic in `cmd/devtool/main.go` — keep it as a thin wiring layer. -- Do NOT create standalone Go binaries for each tool — add subcommands to devtool. -- Do NOT use bubbletea v1 (`github.com/charmbracelet/bubbletea`) — use v2 (`charm.land/bubbletea/v2`). - -## Dependencies -- Upstream: justfile invokes devtool via `just wt`. -- External: git CLI (called via `os/exec`). diff --git a/scripts/cmd/devtool/main.go b/scripts/cmd/devtool/main.go deleted file mode 100644 index a4d3dd7e6..000000000 --- a/scripts/cmd/devtool/main.go +++ /dev/null @@ -1,29 +0,0 @@ -// Entry point for the devtool CLI — a unified developer toolkit for rara. -package main - -import ( - "context" - "log" - "os" - - "github.com/rararulab/rara/scripts/internal/agentmd" - "github.com/rararulab/rara/scripts/internal/deps" - "github.com/rararulab/rara/scripts/internal/worktree" - "github.com/urfave/cli/v3" -) - -func main() { - cmd := &cli.Command{ - Name: "devtool", - Usage: "Unified developer toolkit for rara", - Commands: []*cli.Command{ - agentmd.Cmd(), - worktree.Cmd(), - deps.Cmd(), - }, - } - - if err := cmd.Run(context.Background(), os.Args); err != nil { - log.Fatal(err) - } -} diff --git a/scripts/go.mod b/scripts/go.mod deleted file mode 100644 index 3b8a004e0..000000000 --- a/scripts/go.mod +++ /dev/null @@ -1,29 +0,0 @@ -module github.com/rararulab/rara/scripts - -go 1.26.0 - -require ( - charm.land/bubbles/v2 v2.0.0 - charm.land/bubbletea/v2 v2.0.2 - charm.land/lipgloss/v2 v2.0.2 - github.com/pelletier/go-toml/v2 v2.2.4 - github.com/urfave/cli/v3 v3.7.0 -) - -require ( - github.com/charmbracelet/colorprofile v0.4.2 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/charmbracelet/x/termios v0.1.1 // indirect - github.com/charmbracelet/x/windows v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.11.0 // indirect - github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-runewidth v0.0.20 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.42.0 // indirect -) diff --git a/scripts/go.sum b/scripts/go.sum deleted file mode 100644 index eb8c1ee82..000000000 --- a/scripts/go.sum +++ /dev/null @@ -1,54 +0,0 @@ -charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= -charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= -charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= -charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= -charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= -charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= -github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= -github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= -github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= -github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= -github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= -github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= -github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= -github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= -github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= -github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= -github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= -github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= -github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= -github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= -github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= -github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= -github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= -github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= -github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/urfave/cli/v3 v3.7.0 h1:AGSnbUyjtLiM+WJUb4dzXKldl/gL+F8OwmRDtVr6g2U= -github.com/urfave/cli/v3 v3.7.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/scripts/internal/agentmd/commands.go b/scripts/internal/agentmd/commands.go deleted file mode 100644 index d00084b5f..000000000 --- a/scripts/internal/agentmd/commands.go +++ /dev/null @@ -1,62 +0,0 @@ -// commands.go defines the check-agent-md subcommand that verifies -// every crate under crates/ has an AGENT.md file. -package agentmd - -import ( - "context" - "fmt" - "os" - "path/filepath" - "sort" - - "github.com/urfave/cli/v3" -) - -// Cmd returns the "check-agent-md" command. -func Cmd() *cli.Command { - return &cli.Command{ - Name: "check-agent-md", - Usage: "Verify every crate has an AGENT.md file", - Action: func(_ context.Context, _ *cli.Command) error { - return runCheck() - }, - } -} - -// runCheck iterates crates/*/ and reports any directory missing AGENT.md. -func runCheck() error { - // Locate the repository root relative to the working directory. - // The devtool binary is typically invoked from the repo root. - cratesDir := "crates" - - entries, err := os.ReadDir(cratesDir) - if err != nil { - return fmt.Errorf("failed to read %s: %w", cratesDir, err) - } - - var missing []string - for _, e := range entries { - if !e.IsDir() { - continue - } - agentPath := filepath.Join(cratesDir, e.Name(), "AGENT.md") - if _, err := os.Stat(agentPath); os.IsNotExist(err) { - missing = append(missing, e.Name()) - } - } - - if len(missing) > 0 { - sort.Strings(missing) - fmt.Fprintln(os.Stderr, "ERROR: The following crates are missing AGENT.md:") - fmt.Fprintln(os.Stderr) - for _, name := range missing { - fmt.Fprintf(os.Stderr, " - crates/%s — see CLAUDE.md for template\n", name) - } - fmt.Fprintln(os.Stderr) - fmt.Fprintln(os.Stderr, "Every crate must have an AGENT.md. See the 'AGENT.md Requirements' section in CLAUDE.md.") - return cli.Exit("", 1) - } - - fmt.Println("All crates have AGENT.md.") - return nil -} diff --git a/scripts/internal/deps/commands.go b/scripts/internal/deps/commands.go deleted file mode 100644 index 2dfa536f9..000000000 --- a/scripts/internal/deps/commands.go +++ /dev/null @@ -1,338 +0,0 @@ -// Package deps enforces crate dependency direction rules. -// -// The workspace crates are organized into layers (0 = lowest, 6 = highest). -// A crate at layer N must NOT depend on a crate at layer N+1 or higher. -// Known violations are tracked in an allowlist so existing issues are -// documented while new violations are caught. -package deps - -import ( - "context" - "fmt" - "os" - "path/filepath" - "sort" - - toml "github.com/pelletier/go-toml/v2" - "github.com/urfave/cli/v3" -) - -// Cmd returns the top-level "check-deps" command. -func Cmd() *cli.Command { - return &cli.Command{ - Name: "check-deps", - Usage: "Check crate dependency direction rules", - Action: func(_ context.Context, _ *cli.Command) error { - return runCheckDeps() - }, - } -} - -// layerMap assigns each workspace crate (by Cargo package name) to a layer. -// -// Layer 0 — foundation: no workspace dependencies -// Layer 1 — core primitives: depend only on layer 0 -// Layer 2 — kernel: the central orchestration crate -// Layer 3 — kernel extensions: depend on kernel -// Layer 4 — integration: depend on layer 3 crates -// Layer 5 — application: wire everything together -// Layer 6 — entry: binary crates and API -var layerMap = map[string]int{ - // Layer 0 — foundation - "base": 0, - "rara-error": 0, - "common-runtime": 0, - "common-telemetry": 0, - "common-worker": 0, - "yunara-store": 0, - "rara-tool-macro": 0, - "crawl4ai": 0, - "rara-paths": 0, - "rara-model": 0, - "rara-domain-shared": 0, - "rara-api": 0, // protobuf-generated type definitions, no workspace deps - - // Layer 1 — core primitives (depend only on layer 0) - "rara-soul": 1, - "rara-symphony": 1, - "rara-skills": 1, - "rara-vault": 1, - "rara-composio": 1, - "rara-keyring-store": 1, - "rara-codex-oauth": 1, - "rara-git": 1, - - // Layer 2 — kernel - "rara-kernel": 2, - - // Layer 3 — kernel extensions (depend on kernel) - "rara-dock": 3, - "rara-sessions": 3, - "rara-agents": 3, - "rara-mcp": 3, - "rara-pg-credential-store": 3, - - // Layer 4 — integration (depend on layer 3 crates) - "rara-channels": 4, - "rara-backend-admin": 4, - - // Layer 5 — application - "rara-app": 5, - "rara-server": 5, - - // Layer 6 — entry - "rara-cli": 6, -} - -// allowedViolations lists known dependency direction violations that -// existed before this check was introduced. Each entry is "from -> to". -// Remove entries as they are fixed. -var allowedViolations = map[string]bool{ - // kernel (layer 2) depends on rara-soul (layer 1) — this is actually fine, - // higher layers can depend on lower layers. Only reverse is a violation. -} - -// violation records a single dependency direction breach. -type violation struct { - From string - FromLayer int - To string - ToLayer int -} - -func (v violation) String() string { - return fmt.Sprintf("%s (layer %d) -> %s (layer %d)", v.From, v.FromLayer, v.To, v.ToLayer) -} - -func (v violation) key() string { - return fmt.Sprintf("%s -> %s", v.From, v.To) -} - -func runCheckDeps() error { - // Find the workspace root by looking for the root Cargo.toml - root, err := findWorkspaceRoot() - if err != nil { - return fmt.Errorf("finding workspace root: %w", err) - } - - fmt.Printf("Workspace root: %s\n", root) - - // Parse workspace dependency aliases from root Cargo.toml - aliases, err := parseWorkspaceAliases(filepath.Join(root, "Cargo.toml")) - if err != nil { - return fmt.Errorf("parsing workspace aliases: %w", err) - } - - // Find all crate Cargo.toml files - crateTomlFiles, err := findCrateTomlFiles(root) - if err != nil { - return fmt.Errorf("finding crate Cargo.toml files: %w", err) - } - - var violations []violation - var unknownCrates []string - - for _, tomlPath := range crateTomlFiles { - pkgName, deps, err := parseCrateDeps(tomlPath, aliases) - if err != nil { - fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", tomlPath, err) - continue - } - - fromLayer, known := layerMap[pkgName] - if !known { - unknownCrates = append(unknownCrates, pkgName) - continue - } - - for _, dep := range deps { - toLayer, depKnown := layerMap[dep] - if !depKnown { - // External or unknown dependency — skip - continue - } - if toLayer > fromLayer { - v := violation{ - From: pkgName, - FromLayer: fromLayer, - To: dep, - ToLayer: toLayer, - } - if !allowedViolations[v.key()] { - violations = append(violations, v) - } - } - } - } - - // Report unknown crates - if len(unknownCrates) > 0 { - sort.Strings(unknownCrates) - fmt.Println("\nWarning: crates not in layer map (add them to scripts/internal/deps/commands.go):") - for _, c := range unknownCrates { - fmt.Printf(" - %s\n", c) - } - } - - // Report violations - if len(violations) > 0 { - sort.Slice(violations, func(i, j int) bool { - return violations[i].String() < violations[j].String() - }) - fmt.Println("\nDependency direction violations found:") - for _, v := range violations { - fmt.Printf(" ERROR: %s\n", v) - } - fmt.Printf("\n%d violation(s) found. A lower-layer crate must not depend on a higher-layer crate.\n", len(violations)) - fmt.Println("If this is intentional, add it to allowedViolations in scripts/internal/deps/commands.go") - return fmt.Errorf("dependency check failed with %d violation(s)", len(violations)) - } - - fmt.Println("\nAll dependency direction checks passed.") - return nil -} - -// workspaceProbe is a minimal struct to detect whether a Cargo.toml -// contains a [workspace] section. -type workspaceProbe struct { - Workspace *struct{} `toml:"workspace"` -} - -// findWorkspaceRoot walks up from cwd to find the directory containing -// a Cargo.toml with [workspace]. -func findWorkspaceRoot() (string, error) { - dir, err := os.Getwd() - if err != nil { - return "", err - } - for { - candidate := filepath.Join(dir, "Cargo.toml") - if data, err := os.ReadFile(candidate); err == nil { - var probe workspaceProbe - if err := toml.Unmarshal(data, &probe); err == nil && probe.Workspace != nil { - return dir, nil - } - } - parent := filepath.Dir(dir) - if parent == dir { - return "", fmt.Errorf("no workspace Cargo.toml found") - } - dir = parent - } -} - -// cargoWorkspace is used to decode the root Cargo.toml. -type cargoWorkspace struct { - Workspace struct { - Dependencies map[string]any `toml:"dependencies"` - } `toml:"workspace"` -} - -// parseWorkspaceAliases extracts workspace crate names from the root -// Cargo.toml by looking for [workspace.dependencies] entries that -// have a `path` field (i.e. local workspace crates, not external deps). -func parseWorkspaceAliases(rootToml string) (map[string]bool, error) { - data, err := os.ReadFile(rootToml) - if err != nil { - return nil, err - } - - var ws cargoWorkspace - if err := toml.Unmarshal(data, &ws); err != nil { - return nil, fmt.Errorf("parsing %s: %w", rootToml, err) - } - - aliases := make(map[string]bool) - for name, val := range ws.Workspace.Dependencies { - // Inline table entries with a path field are workspace crates. - // e.g. rara-kernel = { path = "crates/kernel" } - if tbl, ok := val.(map[string]any); ok { - if _, hasPath := tbl["path"]; hasPath { - aliases[name] = true - } - } - } - - return aliases, nil -} - -// findCrateTomlFiles finds all Cargo.toml files in the crates/ directory -// and the api/ directory. -func findCrateTomlFiles(root string) ([]string, error) { - var files []string - - // Walk crates/ directory - cratesDir := filepath.Join(root, "crates") - err := filepath.Walk(cratesDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.Name() == "Cargo.toml" && !info.IsDir() { - files = append(files, path) - } - return nil - }) - if err != nil { - return nil, err - } - - // Also check api/ - apiToml := filepath.Join(root, "api", "Cargo.toml") - if _, err := os.Stat(apiToml); err == nil { - files = append(files, apiToml) - } - - return files, nil -} - -// crateCargo is used to decode a crate-level Cargo.toml. -type crateCargo struct { - Package struct { - Name string `toml:"name"` - } `toml:"package"` - Dependencies map[string]any `toml:"dependencies"` - BuildDependencies map[string]any `toml:"build-dependencies"` - // dev-dependencies are intentionally excluded: they don't affect the - // runtime dependency graph, so a dev-only import of a higher-layer - // crate (e.g. a test helper) should not count as a layer violation. -} - -// parseCrateDeps extracts the package name and workspace crate dependencies -// from a crate's Cargo.toml file. Only [dependencies] and -// [build-dependencies] are considered; [dev-dependencies] are excluded -// because they do not affect the runtime dependency graph. -func parseCrateDeps(tomlPath string, workspaceCrates map[string]bool) (string, []string, error) { - data, err := os.ReadFile(tomlPath) - if err != nil { - return "", nil, err - } - - var crate crateCargo - if err := toml.Unmarshal(data, &crate); err != nil { - return "", nil, fmt.Errorf("parsing %s: %w", tomlPath, err) - } - - if crate.Package.Name == "" { - return "", nil, fmt.Errorf("no package name found in %s", tomlPath) - } - - var deps []string - // Collect workspace crate deps from both [dependencies] and [build-dependencies]. - for _, section := range []map[string]any{crate.Dependencies, crate.BuildDependencies} { - for name, val := range section { - if !workspaceCrates[name] { - continue - } - // Accept both `dep = { workspace = true }` and `dep.workspace = true`. - if tbl, ok := val.(map[string]any); ok { - if ws, exists := tbl["workspace"]; exists { - if b, ok := ws.(bool); ok && b { - deps = append(deps, name) - } - } - } - } - } - - return crate.Package.Name, deps, nil -} diff --git a/scripts/internal/worktree/commands.go b/scripts/internal/worktree/commands.go deleted file mode 100644 index cac2dac08..000000000 --- a/scripts/internal/worktree/commands.go +++ /dev/null @@ -1,148 +0,0 @@ -// commands.go defines CLI subcommands for worktree management. -package worktree - -import ( - "context" - "fmt" - "os" - "os/exec" - - "github.com/urfave/cli/v3" -) - -// Cmd returns the top-level "worktree" command group. -func Cmd() *cli.Command { - return &cli.Command{ - Name: "worktree", - Aliases: []string{"wt"}, - Usage: "Manage git worktree lifecycle", - // Default action: launch interactive TUI - Action: func(_ context.Context, _ *cli.Command) error { - return RunTUI() - }, - Commands: []*cli.Command{ - { - Name: "list", - Aliases: []string{"ls"}, - Usage: "List all worktrees (non-interactive)", - Action: func(_ context.Context, _ *cli.Command) error { - return runList() - }, - }, - { - Name: "clean", - Usage: "Remove worktrees whose branches are merged into main (non-interactive)", - Action: func(_ context.Context, _ *cli.Command) error { - return runClean() - }, - }, - { - Name: "nuke", - Usage: "Force-remove ALL worktrees except the main checkout (non-interactive)", - Action: func(_ context.Context, _ *cli.Command) error { - return runNuke() - }, - }, - }, - } -} - -func runList() error { - out, err := exec.Command("git", "worktree", "list").CombinedOutput() - if err != nil { - return fmt.Errorf("git worktree list: %w\n%s", err, out) - } - fmt.Print(string(out)) - return nil -} - -func runClean() error { - if err := Prune(); err != nil { - return err - } - - mainPath, err := MainPath() - if err != nil { - return err - } - - merged, err := MergedBranches() - if err != nil { - return err - } - if len(merged) == 0 { - fmt.Println("✅ No merged branches to clean up.") - return nil - } - - entries, err := List() - if err != nil { - return err - } - - branchHandled := make(map[string]bool) - removed := 0 - - for _, e := range entries { - if e.Path == mainPath || e.Branch == "" || !merged[e.Branch] { - continue - } - fmt.Printf("🗑️ Removing worktree: %s (branch: %s)\n", e.Path, e.Branch) - if err := Remove(e.Path, false); err != nil { - fmt.Fprintf(os.Stderr, " ⚠️ %s\n", err) - continue - } - if err := DeleteBranch(e.Branch, false); err != nil { - fmt.Fprintf(os.Stderr, " ⚠️ %s\n", err) - } - branchHandled[e.Branch] = true - removed++ - } - - for branch := range merged { - if branchHandled[branch] { - continue - } - fmt.Printf("🗑️ Deleting merged branch: %s (no worktree)\n", branch) - if err := DeleteBranch(branch, false); err != nil { - fmt.Fprintf(os.Stderr, " ⚠️ %s\n", err) - continue - } - removed++ - } - - fmt.Printf("✅ Cleaned up %d merged worktree(s)/branch(es).\n", removed) - return nil -} - -func runNuke() error { - mainPath, err := MainPath() - if err != nil { - return err - } - - entries, err := List() - if err != nil { - return err - } - - removed := 0 - for _, e := range entries { - if e.Path == mainPath { - continue - } - fmt.Printf("🗑️ Removing: %s\n", e.Path) - if err := Remove(e.Path, true); err != nil { - fmt.Fprintf(os.Stderr, " ⚠️ %s — cleaning up manually\n", err) - os.RemoveAll(e.Path) - } - if e.Branch != "" { - _ = DeleteBranch(e.Branch, true) - } - removed++ - } - - _ = Prune() - fmt.Printf("✅ Removed %d worktree(s).\n", removed) - return nil -} diff --git a/scripts/internal/worktree/git.go b/scripts/internal/worktree/git.go deleted file mode 100644 index 9bcda7d2e..000000000 --- a/scripts/internal/worktree/git.go +++ /dev/null @@ -1,284 +0,0 @@ -// git.go provides low-level git worktree operations. -package worktree - -import ( - "bufio" - "fmt" - "io/fs" - "os" - "os/exec" - "path/filepath" - "strings" - "time" -) - -// Status describes the state of a worktree entry. -type Status int - -const ( - StatusActive Status = iota // branch exists, not merged - StatusMerged // branch fully merged into main - StatusDetached // detached HEAD (no branch) - StatusPrunable // stale reference, can be pruned -) - -// String returns a human-readable label for the status. -func (s Status) String() string { - switch s { - case StatusMerged: - return "merged" - case StatusDetached: - return "detached" - case StatusPrunable: - return "prunable" - default: - return "active" - } -} - -// Entry holds parsed porcelain output for a single git worktree. -type Entry struct { - Path string - Branch string // empty for detached HEAD - IsMain bool - Prunable bool - Locked bool // worktree has a lock file - IsCurrent bool // worktree is the current working directory - Status Status - LastActive time.Time // last modification time of the worktree directory - DiskSize int64 // total disk usage in bytes -} - -// Protected returns true if the worktree cannot be deleted. -func (e Entry) Protected() bool { - return e.IsMain || e.Locked || e.IsCurrent -} - -// List parses `git worktree list --porcelain` and returns all entries, -// enriched with merge status information. -func List() ([]Entry, error) { - out, err := exec.Command("git", "worktree", "list", "--porcelain").Output() - if err != nil { - return nil, fmt.Errorf("git worktree list: %w", err) - } - - mainPath, err := MainPath() - if err != nil { - return nil, err - } - - merged, err := MergedBranches() - if err != nil { - return nil, err - } - - // Detect current working directory to mark the active worktree - cwd, _ := os.Getwd() - - var entries []Entry - var cur Entry - prunable := false - locked := false - - // finalizeEntry fills computed fields and returns the entry ready for collection. - finalizeEntry := func(e Entry) Entry { - e.IsMain = e.Path == mainPath - e.Prunable = prunable - e.Locked = locked - e.IsCurrent = isSameOrChild(cwd, e.Path) - e.Status = classifyEntry(e, merged) - // Populate LastActive for non-prunable entries with existing paths - if !e.Prunable { - if _, err := os.Stat(e.Path); err == nil { - e.LastActive = lastActiveTime(e.Path) - } - } - return e - } - - scanner := bufio.NewScanner(strings.NewReader(string(out))) - for scanner.Scan() { - line := scanner.Text() - switch { - case strings.HasPrefix(line, "worktree "): - cur = Entry{Path: strings.TrimPrefix(line, "worktree ")} - prunable = false - locked = false - case strings.HasPrefix(line, "branch refs/heads/"): - cur.Branch = strings.TrimPrefix(line, "branch refs/heads/") - case line == "prunable": - prunable = true - case line == "locked", strings.HasPrefix(line, "locked "): - locked = true - case line == "": - if cur.Path != "" { - entries = append(entries, finalizeEntry(cur)) - } - cur = Entry{} - } - } - if cur.Path != "" { - entries = append(entries, finalizeEntry(cur)) - } - return entries, nil -} - -// dirSize computes total disk usage of a directory tree in bytes. -// Returns 0 on any error. -func dirSize(path string) int64 { - var total int64 - _ = filepath.WalkDir(path, func(_ string, d fs.DirEntry, err error) error { - if err != nil { - return nil // skip unreadable entries - } - if !d.IsDir() { - if info, err := d.Info(); err == nil { - total += info.Size() - } - } - return nil - }) - return total -} - -// lastActiveTime returns the most recent modification time among key git files -// in the worktree, providing a meaningful "last active" signal. -// In linked worktrees, .git is a file containing "gitdir: " — this function -// resolves the actual git directory to find HEAD and index files. -// Falls back to the directory mtime if no git files are found. -func lastActiveTime(path string) time.Time { - var latest time.Time - - // Resolve the actual git directory (handles both main checkout and linked worktrees) - gitDir := resolveGitDir(path) - candidates := []string{ - filepath.Join(gitDir, "HEAD"), - filepath.Join(gitDir, "index"), - filepath.Join(path, ".git"), // mtime of .git itself (file or dir) - } - for _, c := range candidates { - if info, err := os.Stat(c); err == nil { - if info.ModTime().After(latest) { - latest = info.ModTime() - } - } - } - // Fall back to directory mtime - if latest.IsZero() { - if info, err := os.Stat(path); err == nil { - latest = info.ModTime() - } - } - return latest -} - -// resolveGitDir returns the path to the actual git directory for a worktree. -// For the main checkout, this is /.git. For linked worktrees, .git is a -// file containing "gitdir: " pointing to the real git metadata. -func resolveGitDir(worktreePath string) string { - dotGit := filepath.Join(worktreePath, ".git") - info, err := os.Stat(dotGit) - if err != nil { - return dotGit - } - // Main checkout: .git is a directory - if info.IsDir() { - return dotGit - } - // Linked worktree: .git is a file with "gitdir: " - data, err := os.ReadFile(dotGit) - if err != nil { - return dotGit - } - line := strings.TrimSpace(string(data)) - if !strings.HasPrefix(line, "gitdir: ") { - return dotGit - } - gitdir := strings.TrimPrefix(line, "gitdir: ") - if !filepath.IsAbs(gitdir) { - gitdir = filepath.Join(worktreePath, gitdir) - } - return gitdir -} - -func classifyEntry(e Entry, merged map[string]bool) Status { - if e.Prunable { - return StatusPrunable - } - if e.Branch == "" { - return StatusDetached - } - if merged[e.Branch] { - return StatusMerged - } - return StatusActive -} - -// isSameOrChild returns true if child is the same as or under parent directory. -func isSameOrChild(child, parent string) bool { - c, err1 := filepath.EvalSymlinks(child) - p, err2 := filepath.EvalSymlinks(parent) - if err1 != nil || err2 != nil { - return child == parent - } - return c == p || strings.HasPrefix(c, p+string(os.PathSeparator)) -} - -// MainPath returns the top-level path of the main checkout. -func MainPath() (string, error) { - out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() - if err != nil { - return "", fmt.Errorf("git rev-parse --show-toplevel: %w", err) - } - return strings.TrimSpace(string(out)), nil -} - -// MergedBranches returns branch names that are fully merged into main. -func MergedBranches() (map[string]bool, error) { - out, err := exec.Command("git", "branch", "--merged", "main", "--format=%(refname:short)").Output() - if err != nil { - return nil, fmt.Errorf("git branch --merged: %w", err) - } - m := make(map[string]bool) - scanner := bufio.NewScanner(strings.NewReader(string(out))) - for scanner.Scan() { - b := strings.TrimSpace(scanner.Text()) - if b != "" && b != "main" { - m[b] = true - } - } - return m, nil -} - -// Prune runs `git worktree prune` to clean stale references. -func Prune() error { - if out, err := exec.Command("git", "worktree", "prune").CombinedOutput(); err != nil { - return fmt.Errorf("git worktree prune: %w\n%s", err, out) - } - return nil -} - -// Remove removes a worktree at the given path. -func Remove(path string, force bool) error { - args := []string{"worktree", "remove"} - if force { - args = append(args, "--force") - } - args = append(args, path) - if out, err := exec.Command("git", args...).CombinedOutput(); err != nil { - return fmt.Errorf("git worktree remove %s: %w\n%s", path, err, out) - } - return nil -} - -// DeleteBranch deletes a local branch. If force is true, uses -D instead of -d. -func DeleteBranch(name string, force bool) error { - flag := "-d" - if force { - flag = "-D" - } - if out, err := exec.Command("git", "branch", flag, name).CombinedOutput(); err != nil { - return fmt.Errorf("git branch %s %s: %w\n%s", flag, name, err, out) - } - return nil -} diff --git a/scripts/internal/worktree/tui.go b/scripts/internal/worktree/tui.go deleted file mode 100644 index a97a56cf2..000000000 --- a/scripts/internal/worktree/tui.go +++ /dev/null @@ -1,637 +0,0 @@ -// tui.go implements an interactive terminal UI for worktree management. -package worktree - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "time" - - tea "charm.land/bubbletea/v2" - "charm.land/bubbles/v2/table" - "charm.land/lipgloss/v2" -) - -// Color palette — muted, modern terminal aesthetic. -var ( - colorPurple = lipgloss.Color("99") - colorGreen = lipgloss.Color("42") - colorYellow = lipgloss.Color("214") - colorRed = lipgloss.Color("196") - colorDim = lipgloss.Color("241") - colorFaint = lipgloss.Color("238") - colorCyan = lipgloss.Color("80") - colorWhite = lipgloss.Color("255") - colorSubtle = lipgloss.Color("245") - colorHotPink = lipgloss.Color("205") - - styleTitle = lipgloss.NewStyle(). - Bold(true). - Foreground(colorWhite). - Background(colorPurple). - Padding(0, 1). - MarginBottom(1) - - styleStatus = map[Status]lipgloss.Style{ - StatusActive: lipgloss.NewStyle().Foreground(colorGreen), - StatusMerged: lipgloss.NewStyle().Foreground(colorYellow), - StatusDetached: lipgloss.NewStyle().Foreground(colorSubtle), - StatusPrunable: lipgloss.NewStyle().Foreground(colorRed), - } - - // Indicator icons per status - statusIcon = map[Status]string{ - StatusActive: "●", - StatusMerged: "◆", - StatusDetached: "○", - StatusPrunable: "✖", - } - - styleCheck = lipgloss.NewStyle().Foreground(colorHotPink).Bold(true) - styleMain = lipgloss.NewStyle().Foreground(colorCyan).Bold(true) - stylePath = lipgloss.NewStyle().Foreground(colorWhite) - styleBranch = lipgloss.NewStyle().Foreground(colorCyan) - styleDimPath = lipgloss.NewStyle().Foreground(colorDim) - - styleMessage = lipgloss.NewStyle().Foreground(colorGreen).Bold(true) - styleError = lipgloss.NewStyle().Foreground(colorRed).Bold(true) - styleBusy = lipgloss.NewStyle().Foreground(colorYellow).Bold(true) - - styleHelpKey = lipgloss.NewStyle().Foreground(colorCyan).Bold(true) - styleHelpDesc = lipgloss.NewStyle().Foreground(colorDim) - styleHelpSep = lipgloss.NewStyle().Foreground(colorFaint) - - styleCount = lipgloss.NewStyle().Foreground(colorSubtle) - styleLock = lipgloss.NewStyle().Foreground(colorDim) - styleToastBox = lipgloss.NewStyle().Foreground(colorWhite).Background(lipgloss.Color("52")).Padding(0, 1) - styleToastText = lipgloss.NewStyle().Foreground(lipgloss.Color("217")) -) - -const toastDuration = 4 * time.Second - -// Messages returned by async commands. -type deleteResultMsg struct { - removed int - errors []string // per-worktree errors - freedBytes int64 // total bytes freed by successful removals -} - -// sizeResultMsg delivers asynchronously computed disk sizes. -// The generation field prevents stale results from overwriting a newer entry list. -type sizeResultMsg struct { - generation int // must match tuiModel.generation to apply - sizes map[int]int64 // index → bytes -} - -type pruneResultMsg struct{ err error } -type reloadResultMsg struct { - entries []Entry - err error -} - -// dismissToastMsg is sent by tea.Tick to auto-dismiss a toast. -type dismissToastMsg struct{ id int } - -// dismissMessageMsg clears the status bar message after a delay. -type dismissMessageMsg struct{ seq int } - -// toast represents a floating notification that auto-dismisses. -type toast struct { - id int - text string -} - -type tuiModel struct { - table table.Model - entries []Entry - selected map[int]bool - message string // status message after an action - messageSeq int // incremented on each new message, used for auto-dismiss - busy bool // true while an async operation is running - quitting bool - toasts []toast // active toast notifications (errors) - toastSeq int // auto-incrementing toast ID - sizesLoaded bool // true once async size computation has completed - generation int // incremented on each reload, guards against stale sizeResultMsg - windowHeight int // terminal height from tea.WindowSizeMsg -} - -// RunTUI launches the interactive worktree manager. -func RunTUI() error { - entries, err := List() - if err != nil { - return err - } - - m := newTUIModel(entries) - p := tea.NewProgram(m) - if _, err := p.Run(); err != nil { - return fmt.Errorf("TUI error: %w", err) - } - return nil -} - -// humanSize formats a byte count into a human-readable string. -func humanSize(bytes int64) string { - switch { - case bytes == 0: - return "-" - case bytes < 1024: - return fmt.Sprintf("%d B", bytes) - case bytes < 1024*1024: - return fmt.Sprintf("%.0f KB", float64(bytes)/1024) - case bytes < 1024*1024*1024: - return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024)) - default: - return fmt.Sprintf("%.1f GB", float64(bytes)/(1024*1024*1024)) - } -} - -// relativeTime formats a timestamp as a human-readable relative duration. -func relativeTime(t time.Time) string { - if t.IsZero() { - return "-" - } - d := time.Since(t) - switch { - case d < time.Minute: - return "just now" - case d < time.Hour: - return fmt.Sprintf("%dm ago", int(d.Minutes())) - case d < 24*time.Hour: - return fmt.Sprintf("%dh ago", int(d.Hours())) - case d < 30*24*time.Hour: - return fmt.Sprintf("%dd ago", int(d.Hours()/24)) - default: - months := int(d.Hours() / 24 / 30) - if months < 1 { - months = 1 - } - return fmt.Sprintf("%d mo ago", months) - } -} - -func newTUIModel(entries []Entry) tuiModel { - // Wider columns to accommodate ANSI color codes in cell values - columns := []table.Column{ - {Title: " ", Width: 4}, - {Title: "Path", Width: 36}, - {Title: "Branch", Width: 28}, - {Title: "Status", Width: 18}, - {Title: "Last Active", Width: 12}, - {Title: "Size", Width: 8}, - } - - rows := make([]table.Row, len(entries)) - for i, e := range entries { - rows[i] = entryToRow(e, false, false) - } - - totalWidth := 0 - for _, c := range columns { - totalWidth += c.Width + 2 - } - - t := table.New( - table.WithColumns(columns), - table.WithWidth(totalWidth), - table.WithFocused(true), - ) - - s := table.DefaultStyles() - s.Header = s.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderBottom(true). - Bold(true). - Foreground(colorSubtle) - s.Selected = s.Selected. - Foreground(colorWhite). - Background(lipgloss.Color("236")). - Bold(true) - t.SetStyles(s) - - t.SetRows(rows) - // Initial height before WindowSizeMsg; will be recalculated on resize - t.SetHeight(min(len(entries)+1, 25)) - - m := tuiModel{ - table: t, - entries: entries, - selected: make(map[int]bool), - } - return m -} - -func entryToRow(e Entry, selected bool, sizesLoaded bool) table.Row { - // Selection indicator column - check := " " - if selected { - check = styleCheck.Render("✓") - } - if e.IsMain { - check = styleMain.Render("★") - } else if e.Locked { - check = styleLock.Render("🔒") - } else if e.IsCurrent { - check = styleMain.Render("▸") - } - - // Path with dimmed prefix - path := shortenPath(e.Path) - if strings.HasPrefix(path, ".worktrees/") { - path = styleDimPath.Render(".worktrees/") + stylePath.Render(strings.TrimPrefix(path, ".worktrees/")) - } else { - path = stylePath.Render(path) - } - - // Branch with color - branch := e.Branch - if branch == "" { - branch = styleDimPath.Render("(detached)") - } else { - branch = styleBranch.Render(branch) - } - - // Status with icon and color, add lock/current tag - icon := statusIcon[e.Status] - stStyle := styleStatus[e.Status] - statusText := icon + " " + e.Status.String() - if e.Locked { - statusText += styleLock.Render(" 🔒") - } else if e.IsCurrent { - statusText += styleLock.Render(" cwd") - } - status := stStyle.Render(statusText) - - // Last active column - lastActive := styleDimPath.Render(relativeTime(e.LastActive)) - - // Size column — show placeholder until async computation finishes - var size string - if !sizesLoaded { - size = styleDimPath.Render("...") - } else if e.DiskSize == 0 { - size = styleDimPath.Render("-") - } else { - size = styleDimPath.Render(humanSize(e.DiskSize)) - } - - return table.Row{check, path, branch, status, lastActive, size} -} - -func shortenPath(p string) string { - home, err := os.UserHomeDir() - if err == nil { - p = strings.Replace(p, home, "~", 1) - } - // further shorten .worktrees/ prefix - if idx := strings.Index(p, ".worktrees/"); idx >= 0 { - p = ".worktrees/" + filepath.Base(p) - } - return p -} - -// setMessage sets the status bar message and returns a Cmd to auto-dismiss it. -func (m *tuiModel) setMessage(text string) tea.Cmd { - m.messageSeq++ - seq := m.messageSeq - m.message = text - return tea.Tick(toastDuration, func(time.Time) tea.Msg { - return dismissMessageMsg{seq: seq} - }) -} - -// pushToast adds an error toast and returns a Cmd to auto-dismiss it. -func (m *tuiModel) pushToast(text string) tea.Cmd { - m.toastSeq++ - id := m.toastSeq - m.toasts = append(m.toasts, toast{id: id, text: text}) - return tea.Tick(toastDuration, func(time.Time) tea.Msg { - return dismissToastMsg{id: id} - }) -} - -// chromeLines is the number of vertical lines consumed by non-table UI elements: -// title (1) + margin (1) + blank (2) + post-table blank (2) + help (1) + trailing newline (1) = 8. -const chromeLines = 8 - -// tableHeight computes the ideal table height based on terminal size and entry count. -// Falls back to a sensible default when the terminal size is not yet known. -func (m *tuiModel) tableHeight() int { - rows := len(m.entries) + 1 // +1 for header - if m.windowHeight > 0 { - available := m.windowHeight - chromeLines - if available < 5 { - available = 5 - } - return min(rows, available) - } - // Before first WindowSizeMsg, use a conservative default - return min(rows, 25) -} - -func (m tuiModel) Init() tea.Cmd { - return m.computeSizesCmd() -} - -// computeSizesCmd returns a tea.Cmd that computes disk sizes for all entries in the background. -// Captures the current generation to discard stale results after a reload. -func (m *tuiModel) computeSizesCmd() tea.Cmd { - entries := m.entries - gen := m.generation - return func() tea.Msg { - sizes := make(map[int]int64, len(entries)) - for i, e := range entries { - if e.Prunable { - continue - } - if _, err := os.Stat(e.Path); err != nil { - continue - } - sizes[i] = dirSize(e.Path) - } - return sizeResultMsg{generation: gen, sizes: sizes} - } -} - -func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - // Auto-dismiss handlers - case dismissToastMsg: - for i, t := range m.toasts { - if t.id == msg.id { - m.toasts = append(m.toasts[:i], m.toasts[i+1:]...) - break - } - } - return m, nil - - case dismissMessageMsg: - if msg.seq == m.messageSeq { - m.message = "" - } - return m, nil - - case sizeResultMsg: - // Discard stale results from a previous generation - if msg.generation != m.generation { - return m, nil - } - for i, sz := range msg.sizes { - if i < len(m.entries) { - m.entries[i].DiskSize = sz - } - } - m.sizesLoaded = true - m.refreshRows() - return m, nil - - // Async result handlers - case deleteResultMsg: - m.busy = false - var cmds []tea.Cmd - cmds = append(cmds, m.setMessage(fmt.Sprintf("Removed %d worktree(s), freed %s", msg.removed, humanSize(msg.freedBytes)))) - for _, errText := range msg.errors { - cmds = append(cmds, m.pushToast(errText)) - } - cmds = append(cmds, m.reloadCmd()) - return m, tea.Batch(cmds...) - - case pruneResultMsg: - m.busy = false - if msg.err != nil { - return m, m.pushToast(msg.err.Error()) - } - var cmds []tea.Cmd - cmds = append(cmds, m.setMessage("Pruned stale worktree references")) - cmds = append(cmds, m.reloadCmd()) - return m, tea.Batch(cmds...) - - case reloadResultMsg: - m.busy = false - if msg.err != nil { - return m, m.pushToast(msg.err.Error()) - } - m.entries = msg.entries - m.selected = make(map[int]bool) - m.sizesLoaded = false - m.generation++ - m.refreshRows() - m.table.SetHeight(m.tableHeight()) - // Re-trigger async size computation for the new entries - sizeCmd := m.computeSizesCmd() - // If no message was set by a prior handler (e.g. deleteResultMsg), - // show a brief "Refreshed" note - if m.message == "Refreshing..." { - return m, tea.Batch(m.setMessage("Refreshed"), sizeCmd) - } - return m, sizeCmd - - case tea.WindowSizeMsg: - m.windowHeight = msg.Height - m.table.SetHeight(m.tableHeight()) - return m, nil - - case tea.KeyPressMsg: - // Ignore keys while busy - if m.busy { - return m, nil - } - switch msg.String() { - case "q", "ctrl+c": - m.quitting = true - return m, tea.Quit - - case "space": - // Toggle selection (skip protected worktrees) - idx := m.table.Cursor() - if idx < len(m.entries) && !m.entries[idx].Protected() { - wasSelected := m.selected[idx] - m.selected[idx] = !wasSelected - if wasSelected { - delete(m.selected, idx) - } else { - // Auto-advance cursor when selecting - m.table.MoveDown(1) - } - m.refreshRows() - } - return m, nil - - case "a": - // Select all merged (skip protected) - for i, e := range m.entries { - if e.Status == StatusMerged && !e.Protected() { - m.selected[i] = true - } - } - m.refreshRows() - return m, nil - - case "d": - // Delete selected worktrees (force) - return m, m.deleteSelectedCmd(true) - - case "c": - // Clean selected merged worktrees - return m, m.deleteSelectedCmd(false) - - case "C": - // Clean ALL merged worktrees (skip protected) - for i, e := range m.entries { - if e.Status == StatusMerged && !e.Protected() { - m.selected[i] = true - } - } - return m, m.deleteSelectedCmd(false) - - case "p": - // Prune stale references - m.busy = true - m.message = "Pruning..." - return m, func() tea.Msg { - err := Prune() - return pruneResultMsg{err: err} - } - - case "r": - // Refresh list - m.busy = true - m.message = "Refreshing..." - return m, m.reloadCmd() - } - } - - var cmd tea.Cmd - m.table, cmd = m.table.Update(msg) - return m, cmd -} - -// deleteSelectedCmd returns a tea.Cmd that removes selected worktrees in the background. -func (m *tuiModel) deleteSelectedCmd(force bool) tea.Cmd { - type target struct { - path string - branch string - diskSize int64 // cached size from entry, or computed fresh if not loaded - } - var targets []target - for idx, sel := range m.selected { - if !sel || idx >= len(m.entries) { - continue - } - e := m.entries[idx] - if e.Protected() { - continue - } - targets = append(targets, target{path: e.Path, branch: e.Branch, diskSize: e.DiskSize}) - } - if len(targets) == 0 { - return nil - } - - m.busy = true - m.message = fmt.Sprintf("Removing %d worktree(s)...", len(targets)) - m.selected = make(map[int]bool) - m.refreshRows() - - return func() tea.Msg { - removed := 0 - var freedBytes int64 - var errors []string - for _, t := range targets { - // Use cached size if available, otherwise compute fresh - sz := t.diskSize - if sz == 0 { - sz = dirSize(t.path) - } - if err := Remove(t.path, force); err != nil { - errors = append(errors, fmt.Sprintf("%s: %s", shortenPath(t.path), err)) - continue - } - freedBytes += sz - if t.branch != "" { - _ = DeleteBranch(t.branch, force) - } - removed++ - } - _ = Prune() - return deleteResultMsg{removed: removed, errors: errors, freedBytes: freedBytes} - } -} - -// reloadCmd returns a tea.Cmd that refreshes the worktree list in the background. -func (m *tuiModel) reloadCmd() tea.Cmd { - m.busy = true - return func() tea.Msg { - entries, err := List() - return reloadResultMsg{entries: entries, err: err} - } -} - -func (m *tuiModel) refreshRows() { - rows := make([]table.Row, len(m.entries)) - for i, e := range m.entries { - rows[i] = entryToRow(e, m.selected[i], m.sizesLoaded) - } - m.table.SetRows(rows) -} - -// helpItem renders a single "key desc" help entry with styled key. -func helpItem(key, desc string) string { - return styleHelpKey.Render(key) + " " + styleHelpDesc.Render(desc) -} - -func (m tuiModel) View() tea.View { - if m.quitting { - return tea.NewView("") - } - - var b strings.Builder - - // Title bar with worktree count - selected := len(m.selected) - title := "Worktree Manager" - counter := styleCount.Render(fmt.Sprintf(" %d worktrees", len(m.entries))) - if selected > 0 { - counter = styleCheck.Render(fmt.Sprintf(" %d selected", selected)) - } - b.WriteString(styleTitle.Render(title) + counter) - b.WriteString("\n\n") - - // Table - b.WriteString(m.table.View()) - b.WriteString("\n\n") - - // Status message - if m.busy { - b.WriteString(styleBusy.Render(" " + m.message)) - b.WriteString("\n\n") - } else if m.message != "" { - b.WriteString(styleMessage.Render(" " + m.message)) - b.WriteString("\n\n") - } - - // Floating toast notifications (errors) - for _, t := range m.toasts { - b.WriteString(styleToastBox.Render(styleToastText.Render(" " + t.text))) - b.WriteString("\n") - } - if len(m.toasts) > 0 { - b.WriteString("\n") - } - - // Help bar — grouped by function - sep := styleHelpSep.Render(" · ") - helpLine := strings.Join([]string{ - helpItem("space", "select") + sep + helpItem("a", "all merged"), - helpItem("c", "clean") + sep + helpItem("C", "clean all") + sep + helpItem("d", "force del"), - helpItem("p", "prune") + sep + helpItem("r", "refresh") + sep + helpItem("q", "quit"), - }, styleHelpSep.Render(" │ ")) - b.WriteString(" " + helpLine) - b.WriteString("\n") - - v := tea.NewView(b.String()) - v.AltScreen = true - return v -}