From 97f4daa3ace3b752cd7292299d50d783115885f4 Mon Sep 17 00:00:00 2001 From: Nick Song Date: Fri, 31 Jul 2026 18:25:53 +0200 Subject: [PATCH 01/21] Remove the built-in MCP server from Grove core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grove's MCP server was a second interface to a small set of features for users (local devs and coding agents) who already have shell access. It cost a JSON-RPC protocol implementation, `.mcp.json` lifecycle management, an announcements SQLite database, and its own tests and failure modes — without providing a trust or authentication boundary the CLI doesn't already have. The `gw` CLI is now the only first-party agent interface. Removed: - `internal/mcp/` (server + announcements store) and `cmd/mcp.go` - the hidden `gw mcp-serve` command - `.mcp.json` generation on create and cleanup on delete - `models.MCPConfig` / `models.MCPServer` - the `modernc.org/sqlite` dependency tree Added a migration path instead of silent breakage: workspaces created by older versions still carry a `grove` entry pointing at the removed command, so `gw doctor` reports it and `gw doctor --fix` removes just that entry. `internal/workspace/mcpmigrate.go` only claims entries Grove actually wrote (command `gw`, args containing `mcp-serve`) so an external adapter named `grove` and any other server in the file are left untouched. `announce` / `get_announcements` are intentionally not reimplemented; there is no demonstrated usage to preserve. Release binary (darwin/arm64, -s -w): 13,030,674 → 8,951,234 bytes (-31%). --- AGENTS.md | 1 - CHANGELOG.md | 15 ++ CLAUDE.md | 1 - README.md | 5 +- cmd/mcp.go | 27 --- cmd/root.go | 1 - docs/ai-tools.md | 29 ++- e2e/run.sh | 100 +++------ go.mod | 9 - go.sum | 49 ----- internal/mcp/server.go | 309 --------------------------- internal/mcp/server_test.go | 102 --------- internal/mcp/store.go | 141 ------------ internal/mcp/store_test.go | 147 ------------- internal/models/models.go | 10 - internal/workspace/mcpmigrate.go | 103 +++++++++ internal/workspace/workspace.go | 102 ++------- internal/workspace/workspace_test.go | 145 ++++++++----- openwiki/architecture.md | 8 - openwiki/integrations.md | 147 ++----------- openwiki/quickstart.md | 3 +- 21 files changed, 309 insertions(+), 1145 deletions(-) delete mode 100644 cmd/mcp.go delete mode 100644 internal/mcp/server.go delete mode 100644 internal/mcp/server_test.go delete mode 100644 internal/mcp/store.go delete mode 100644 internal/mcp/store_test.go create mode 100644 internal/workspace/mcpmigrate.go diff --git a/AGENTS.md b/AGENTS.md index 1660d7d..8f85d50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,6 @@ Tool-specific integrations (Codex memory sync, Zellij, archive, dashboard) live - **internal/gitops/** — Thin wrappers around `git` subprocess calls. Includes `ReadGroveConfig()`. - **internal/lifecycle/** — Runs global lifecycle hooks (`post_create`, `pre_delete`, `on_close`) defined in `[hooks]`. Hooks may be bare command strings or tables with metadata (`stream`, `timeout`, `on_failure`); the global `--no-hooks`/`-n` flag skips them all. Plugins register here. - **internal/logging/** — Structured logging. -- **internal/mcp/** — MCP JSON-RPC server exposing workspace state to Codex. - **internal/models/** — Data structs with JSON serialization. - **internal/picker/** — Interactive terminal menus. - **internal/plugin/** — Plugin install/upgrade/remove from GitHub releases. diff --git a/CHANGELOG.md b/CHANGELOG.md index 476384b..4716129 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## Unreleased + +### Breaking + +- Removed Grove's built-in MCP server. `gw mcp-serve`, the generated `.mcp.json` + `grove` entry, the announcements SQLite database, and the `announce` / + `get_announcements` tools are gone. The `gw` CLI is now the only first-party + agent interface. Run `gw doctor --fix` to strip the stale `grove` entry from + `.mcp.json` files in existing workspaces (other MCP servers are preserved). + +### Maintenance + +- Dropped the `modernc.org/sqlite` dependency tree; the release binary shrank + from 13.0 MB to 9.0 MB (-31%). + ## v1.1.11 ### Features diff --git a/CLAUDE.md b/CLAUDE.md index 9c6c003..5170f3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,6 @@ Tool-specific integrations (Claude Code memory sync, Zellij, archive, dashboard) - **internal/gitops/** — Thin wrappers around `git` subprocess calls. Includes `ReadGroveConfig()`. - **internal/lifecycle/** — Runs global lifecycle hooks (`post_create`, `pre_delete`, `on_close`) defined in `[hooks]`. Hooks may be bare command strings or tables with metadata (`stream`, `timeout`, `on_failure`); the global `--no-hooks`/`-n` flag skips them all. Plugins register here. - **internal/logging/** — Structured logging. -- **internal/mcp/** — MCP JSON-RPC server exposing workspace state to Claude Code. - **internal/models/** — Data structs with JSON serialization. - **internal/picker/** — Interactive terminal menus. - **internal/plugin/** — Plugin install/upgrade/remove from GitHub releases. diff --git a/README.md b/README.md index dd38677..76b3c9f 100644 --- a/README.md +++ b/README.md @@ -98,13 +98,14 @@ Full documentation lives in the [OpenWiki](openwiki/quickstart.md) — start wit - [Architecture](openwiki/architecture.md) — layered design, data model, concurrency, and key decisions - [Workflows](openwiki/workflows.md) — how each command maps to code (create, sync, run, delete, presets…) - [Operations](openwiki/operations.md) — configuration, hooks, state, troubleshooting, and release process -- [Integrations](openwiki/integrations.md) — plugins, the MCP server, and workspace source provenance +- [Integrations](openwiki/integrations.md) — plugins, agent usage, and workspace source provenance ### Focused topic guides - [Hooks](docs/hooks.md) — global hooks (terminal integration) & per-repo hooks (`.grove.toml`, `gw run`) - [Plugins](docs/plugins.md) — extend gw with external commands -- [AI coding tools](docs/ai-tools.md) — Claude Code workflows, MCP server +- [AI coding tools](docs/ai-tools.md) — Claude Code workflows, agent usage +- [Agent CLI contract](docs/agent-cli.md) — machine-readable output, error codes, exit codes ## Requirements diff --git a/cmd/mcp.go b/cmd/mcp.go deleted file mode 100644 index 15b3a5e..0000000 --- a/cmd/mcp.go +++ /dev/null @@ -1,27 +0,0 @@ -package cmd - -import ( - "github.com/nicksenap/grove/internal/mcp" - "github.com/spf13/cobra" -) - -var mcpWorkspace string - -var mcpServeCmd = &cobra.Command{ - Use: "mcp-serve", - Short: "Start MCP stdio server for cross-workspace communication", - Hidden: true, - Run: func(cmd *cobra.Command, args []string) { - if mcpWorkspace == "" { - exitError("--workspace is required") - } - - if err := mcp.RunServer(mcpWorkspace); err != nil { - exitError(err.Error()) - } - }, -} - -func init() { - mcpServeCmd.Flags().StringVarP(&mcpWorkspace, "workspace", "w", "", "Workspace name") -} diff --git a/cmd/root.go b/cmd/root.go index bfe3e62..aa51e39 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -66,7 +66,6 @@ func init() { removeDirCmd, runCmd, exploreCmd, - mcpServeCmd, pluginCmd, wizardCmd, bugReportCmd, diff --git a/docs/ai-tools.md b/docs/ai-tools.md index 1a6d40e..a85ca5e 100644 --- a/docs/ai-tools.md +++ b/docs/ai-tools.md @@ -70,8 +70,31 @@ gw dash # launch the dashboard See the [gw-dash README](https://github.com/nicksenap/gw-dash) for keybindings, Zellij integration, and architecture. -## MCP server +## No MCP server — use the CLI -Grove exposes a cross-workspace communication server via MCP (Model Context Protocol). This lets Claude Code agents in different workspaces announce changes and discover what other agents are working on. +Grove has no built-in MCP server. The `gw` CLI *is* the agent interface: any agent +with shell access can drive Grove through ordinary commands with stable +machine-readable output. -The server is started automatically via `.mcp.json` — no manual setup needed. +```bash +gw context --format json # where am I, what state is everything in +gw list --format json +gw status --format json +gw create feat-x -r svc-auth,api-gateway -b feat/x --format json +``` + +See [Agent CLI contract](agent-cli.md) for the response envelope, error codes, and +exit codes. + +### Migrating from the old MCP server + +Workspaces created by Grove ≤ 1.1.11 have a `grove` entry in their `.mcp.json` +pointing at the removed `gw mcp-serve`. Clean it up with: + +```bash +gw doctor # reports stale entries +gw doctor --fix # removes only the grove entry, keeping other servers +``` + +The `announce` / `get_announcements` cross-workspace coordination tools were +removed with the server and have no CLI replacement. diff --git a/e2e/run.sh b/e2e/run.sh index 65ddc49..998f14a 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -124,21 +124,11 @@ else fail "expected branch feat/e2e, got ${auth_branch}" fi -# Verify .mcp.json was written in workspace root AND worktree dirs -if [ -f "${WS_DIR}/.mcp.json" ]; then - if jq -e '.mcpServers.grove' "${WS_DIR}/.mcp.json" > /dev/null 2>&1; then - pass ".mcp.json has grove server entry (workspace root)" - else - fail ".mcp.json missing grove entry" - fi -else - fail ".mcp.json not created in workspace root" -fi - -if [ ! -f "${WS_DIR}/svc-auth/.mcp.json" ]; then - pass ".mcp.json not written into repo worktrees (workspace root only)" +# Verify no .mcp.json is generated (Grove no longer ships an MCP server) +if [ ! -f "${WS_DIR}/.mcp.json" ] && [ ! -f "${WS_DIR}/svc-auth/.mcp.json" ]; then + pass "create writes no .mcp.json" else - fail ".mcp.json should not be written inside a repo worktree" + fail ".mcp.json should not be created by gw create" fi # Verify .grove.toml setup hook ran @@ -1001,78 +991,48 @@ fi gw delete run-ws --force 2>&1 # --------------------------------------------------------------------------- -# Test: MCP server (stdio JSON-RPC) +# Test: legacy .mcp.json migration (gw doctor --fix) # --------------------------------------------------------------------------- -section "MCP server" +section "Legacy .mcp.json migration" gw create mcp-ws --branch feat/mcp --repos svc-auth 2>&1 - -# Inline MCP smoke test (no Python dependency needed) -MCP_ERRORS=0 - -# Helper to send JSON-RPC and read response -mcp_test() { - local input="$1" - local expected_id="$2" - - # Send all messages and capture output - echo "$input" | timeout_cmd 10 gw mcp-serve --workspace mcp-ws 2>/dev/null || true +MCP_WS_DIR="${GROVE_HOME}/.grove/workspaces/mcp-ws" + +# Simulate a workspace created by an older Grove: a grove entry pointing at the +# removed `gw mcp-serve`, alongside an unrelated server that must survive. +cat > "${MCP_WS_DIR}/.mcp.json" <<'LEGACY' +{ + "mcpServers": { + "grove": {"command": "gw", "args": ["mcp-serve", "--workspace", "mcp-ws"]}, + "keeper": {"command": "keep-me", "args": []} + } } +LEGACY -# Test initialize + tools/list + announce + get_announcements + list_workspaces -MCP_INPUT=$(cat <<'JSONRPC' -{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"e2e","version":"0.1"}}} -{"jsonrpc":"2.0","method":"notifications/initialized"} -{"jsonrpc":"2.0","method":"ping","id":99} -{"jsonrpc":"2.0","method":"tools/list","id":2} -{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"announce","arguments":{"repo_url":"git@github.com:org/repo.git","category":"info","message":"e2e test"}}} -{"jsonrpc":"2.0","method":"tools/call","id":4,"params":{"name":"get_announcements","arguments":{"repo_url":"git@github.com:org/repo.git"}}} -{"jsonrpc":"2.0","method":"tools/call","id":5,"params":{"name":"list_workspaces","arguments":{}}} -JSONRPC -) - -MCP_OUT=$(echo "${MCP_INPUT}" | timeout_cmd 10 "${GW_BIN}" mcp-serve --workspace mcp-ws 2>/dev/null || true) - -# Check initialize response -if echo "${MCP_OUT}" | grep -q '"protocolVersion"'; then - pass "MCP initialize" +if gw doctor 2>&1 | grep -q "mcp.json"; then + pass "doctor reports stale .mcp.json grove entry" else - fail "MCP initialize failed" + fail "doctor did not report stale .mcp.json" fi -# Check ping response (id: 99) -if echo "${MCP_OUT}" | grep -q '"id":99'; then - pass "MCP ping" -else - fail "MCP ping failed" -fi - -# Check tools/list has all 3 tools -if echo "${MCP_OUT}" | grep -q '"announce"' && echo "${MCP_OUT}" | grep -q '"get_announcements"' && echo "${MCP_OUT}" | grep -q '"list_workspaces"'; then - pass "MCP tools/list returns all 3 tools" -else - fail "MCP tools/list missing tools" -fi +gw doctor --fix > /dev/null 2>&1 || true -# Check announce returned "published" -if echo "${MCP_OUT}" | grep -q 'published'; then - pass "MCP announce tool works" +if jq -e '.mcpServers.grove' "${MCP_WS_DIR}/.mcp.json" > /dev/null 2>&1; then + fail "doctor --fix should remove the grove entry" else - fail "MCP announce failed" + pass "doctor --fix removes the grove entry" fi -# Check get_announcements returns empty (same workspace excluded) -if echo "${MCP_OUT}" | grep -q '\[\]'; then - pass "MCP get_announcements excludes own workspace" +if jq -e '.mcpServers.keeper' "${MCP_WS_DIR}/.mcp.json" > /dev/null 2>&1; then + pass "doctor --fix preserves other MCP servers" else - fail "MCP get_announcements should return empty" + fail "doctor --fix should preserve unrelated MCP servers" fi -# Check list_workspaces returns workspace name -if echo "${MCP_OUT}" | grep -q 'mcp-ws'; then - pass "MCP list_workspaces returns current workspace" +if gw mcp-serve --workspace mcp-ws > /dev/null 2>&1; then + fail "gw mcp-serve should no longer exist" else - fail "MCP list_workspaces missing workspace" + pass "gw mcp-serve is gone" fi gw delete mcp-ws --force 2>&1 diff --git a/go.mod b/go.mod index 838109e..8aab54a 100644 --- a/go.mod +++ b/go.mod @@ -6,19 +6,10 @@ require ( github.com/BurntSushi/toml v1.6.0 github.com/spf13/cobra v1.10.2 golang.org/x/term v0.45.0 - modernc.org/sqlite v1.54.0 ) require ( - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/spf13/pflag v1.0.9 // indirect golang.org/x/sys v0.47.0 // indirect - modernc.org/libc v1.74.1 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index b94a14a..3a095a9 100644 --- a/go.sum +++ b/go.sum @@ -1,65 +1,16 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= -github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= -modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= -modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= -modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= -modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= -modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= -modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= -modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= -modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= -modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= -modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= -modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/mcp/server.go b/internal/mcp/server.go deleted file mode 100644 index 438ab82..0000000 --- a/internal/mcp/server.go +++ /dev/null @@ -1,309 +0,0 @@ -package mcp - -import ( - "bufio" - "database/sql" - "encoding/json" - "fmt" - "os" - "strings" - - "github.com/nicksenap/grove/internal/state" -) - -// JSONRPCRequest is an incoming JSON-RPC 2.0 message. -type JSONRPCRequest struct { - JSONRPC string `json:"jsonrpc"` - Method string `json:"method"` - ID json.RawMessage `json:"id,omitempty"` // absent for notifications, present (even if null) for requests - Params json.RawMessage `json:"params,omitempty"` - hasID bool -} - -// unmarshalRequest parses a JSON-RPC message in a single pass, distinguishing -// notifications (no "id" key) from requests ("id" key present, even if value is null). -func unmarshalRequest(data []byte) (JSONRPCRequest, bool) { - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return JSONRPCRequest{}, false - } - - var req JSONRPCRequest - if v, ok := raw["jsonrpc"]; ok { - if err := json.Unmarshal(v, &req.JSONRPC); err != nil { - return JSONRPCRequest{}, false - } - } - if v, ok := raw["method"]; ok { - if err := json.Unmarshal(v, &req.Method); err != nil { - return JSONRPCRequest{}, false - } - } - if v, ok := raw["params"]; ok { - req.Params = v - } - if v, ok := raw["id"]; ok { - req.ID = v - req.hasID = true - } - - return req, true -} - -func (r *JSONRPCRequest) isNotification() bool { - return !r.hasID -} - -// JSONRPCResponse is an outgoing JSON-RPC 2.0 message. -type JSONRPCResponse struct { - JSONRPC string `json:"jsonrpc"` - ID json.RawMessage `json:"id"` - Result any `json:"result,omitempty"` - Error *RPCError `json:"error,omitempty"` -} - -type RPCError struct { - Code int `json:"code"` - Message string `json:"message"` -} - -// ToolDef describes an MCP tool. -type ToolDef struct { - Name string `json:"name"` - Description string `json:"description"` - InputSchema map[string]any `json:"inputSchema"` -} - -// ContentItem is a text content block returned by tools. -type ContentItem struct { - Type string `json:"type"` - Text string `json:"text"` -} - -var tools = []ToolDef{ - { - Name: "announce", - Description: "Publish an announcement visible to other workspaces working on the same repo", - InputSchema: map[string]any{ - "type": "object", - "properties": map[string]any{ - "repo_url": map[string]any{"type": "string", "description": "Git remote URL (SSH or HTTPS)"}, - "category": map[string]any{"type": "string", "enum": []string{"breaking_change", "status", "warning", "info"}}, - "message": map[string]any{"type": "string", "description": "Announcement text"}, - }, - "required": []string{"repo_url", "category", "message"}, - }, - }, - { - Name: "get_announcements", - Description: "Get recent announcements from other workspaces for a repo", - InputSchema: map[string]any{ - "type": "object", - "properties": map[string]any{ - "repo_url": map[string]any{"type": "string", "description": "Git remote URL"}, - "since": map[string]any{"type": "string", "description": "ISO 8601 datetime filter (optional)"}, - }, - "required": []string{"repo_url"}, - }, - }, - { - Name: "list_workspaces", - Description: "List all Grove workspaces", - InputSchema: map[string]any{ - "type": "object", - "properties": map[string]any{}, - }, - }, -} - -// RunServer starts the MCP stdio server for the given workspace. -func RunServer(workspaceID string) error { - if workspaceID == "" { - return fmt.Errorf("workspace ID is required") - } - - db, err := OpenDB() - if err != nil { - return fmt.Errorf("opening database: %w", err) - } - defer db.Close() - - scanner := bufio.NewScanner(os.Stdin) - scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 1MB buffer - - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - - req, ok := unmarshalRequest([]byte(line)) - if !ok { - continue // skip malformed JSON - } - - // Notifications (no "id" key) — no response expected - if req.isNotification() { - continue - } - - resp := handleRequest(req, workspaceID, db) - if resp != nil { - data, _ := json.Marshal(resp) - fmt.Fprintf(os.Stdout, "%s\n", data) - os.Stdout.Sync() - } - } - - return nil -} - -func handleRequest(req JSONRPCRequest, workspaceID string, db *sql.DB) *JSONRPCResponse { - switch req.Method { - case "initialize": - return &JSONRPCResponse{ - JSONRPC: "2.0", - ID: req.ID, - Result: map[string]any{ - "protocolVersion": "2024-11-05", - "capabilities": map[string]any{ - "tools": map[string]any{}, - }, - "serverInfo": map[string]any{ - "name": "grove", - "version": "0.13.0-go", - }, - }, - } - - case "ping": - return &JSONRPCResponse{ - JSONRPC: "2.0", - ID: req.ID, - Result: map[string]any{}, - } - - case "tools/list": - return &JSONRPCResponse{ - JSONRPC: "2.0", - ID: req.ID, - Result: map[string]any{"tools": tools}, - } - - case "tools/call": - return handleToolCall(req, workspaceID, db) - - default: - return &JSONRPCResponse{ - JSONRPC: "2.0", - ID: req.ID, - Error: &RPCError{Code: -32601, Message: "Method not found: " + req.Method}, - } - } -} - -func handleToolCall(req JSONRPCRequest, workspaceID string, db *sql.DB) *JSONRPCResponse { - var params struct { - Name string `json:"name"` - Arguments json.RawMessage `json:"arguments"` - } - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - return &JSONRPCResponse{ - JSONRPC: "2.0", - ID: req.ID, - Error: &RPCError{Code: -32602, Message: "Invalid params"}, - } - } - - var content []ContentItem - - switch params.Name { - case "announce": - var args struct { - RepoURL string `json:"repo_url"` - Category string `json:"category"` - Message string `json:"message"` - } - if err := json.Unmarshal(params.Arguments, &args); err != nil { - content = []ContentItem{{Type: "text", Text: "Error: invalid arguments"}} - break - } - - id, err := InsertAnnouncement(db, workspaceID, args.RepoURL, args.Category, args.Message) - if err != nil { - content = []ContentItem{{Type: "text", Text: "Error: " + err.Error()}} - } else { - content = []ContentItem{{Type: "text", Text: fmt.Sprintf("Announcement #%d published (%s)", id, args.Category)}} - } - - case "get_announcements": - var args struct { - RepoURL string `json:"repo_url"` - Since string `json:"since"` - } - if err := json.Unmarshal(params.Arguments, &args); err != nil { - content = []ContentItem{{Type: "text", Text: "Error: invalid arguments"}} - break - } - - announcements, err := QueryAnnouncements(db, args.RepoURL, workspaceID, args.Since) - if err != nil { - content = []ContentItem{{Type: "text", Text: "Error: " + err.Error()}} - } else { - data, _ := json.Marshal(announcements) - content = []ContentItem{{Type: "text", Text: string(data)}} - } - - case "list_workspaces": - workspaces, err := state.Load() - if err != nil { - content = []ContentItem{{Type: "text", Text: "Error: " + err.Error()}} - } else { - type wsInfo struct { - Name string `json:"name"` - Branch string `json:"branch"` - Path string `json:"path"` - Repos []struct { - RepoName string `json:"repo_name"` - Branch string `json:"branch"` - SourceRepo string `json:"source_repo"` - } `json:"repos"` - } - var result []wsInfo - for _, ws := range workspaces { - info := wsInfo{ - Name: ws.Name, - Branch: ws.Branch, - Path: ws.Path, - } - for _, r := range ws.Repos { - info.Repos = append(info.Repos, struct { - RepoName string `json:"repo_name"` - Branch string `json:"branch"` - SourceRepo string `json:"source_repo"` - }{ - RepoName: r.RepoName, - Branch: r.Branch, - SourceRepo: r.SourceRepo, - }) - } - result = append(result, info) - } - data, _ := json.Marshal(result) - content = []ContentItem{{Type: "text", Text: string(data)}} - } - - default: - return &JSONRPCResponse{ - JSONRPC: "2.0", - ID: req.ID, - Error: &RPCError{Code: -32602, Message: "Unknown tool: " + params.Name}, - } - } - - return &JSONRPCResponse{ - JSONRPC: "2.0", - ID: req.ID, - Result: map[string]any{"content": content}, - } -} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go deleted file mode 100644 index 5e64755..0000000 --- a/internal/mcp/server_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package mcp - -import ( - "encoding/json" - "testing" -) - -func TestUnmarshalRequest_Request(t *testing.T) { - data := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize"}`) - req, ok := unmarshalRequest(data) - if !ok { - t.Fatal("unmarshalRequest returned !ok for valid input") - } - if req.Method != "initialize" { - t.Errorf("Method = %q, want %q", req.Method, "initialize") - } - if req.isNotification() { - t.Error("expected request, got notification") - } - if string(req.ID) != "1" { - t.Errorf("ID raw = %q, want %q", string(req.ID), "1") - } -} - -func TestUnmarshalRequest_NullID(t *testing.T) { - // id is present but null — spec says this is still a request. - data := []byte(`{"jsonrpc":"2.0","id":null,"method":"ping"}`) - req, ok := unmarshalRequest(data) - if !ok { - t.Fatal("unmarshalRequest returned !ok") - } - if req.isNotification() { - t.Error("id:null should be a request, not a notification") - } -} - -func TestUnmarshalRequest_Notification(t *testing.T) { - // Absent id — notification. - data := []byte(`{"jsonrpc":"2.0","method":"notifications/initialized"}`) - req, ok := unmarshalRequest(data) - if !ok { - t.Fatal("unmarshalRequest returned !ok") - } - if !req.isNotification() { - t.Error("expected notification, got request") - } -} - -func TestUnmarshalRequest_MalformedJSON(t *testing.T) { - req, ok := unmarshalRequest([]byte(`{not json`)) - if ok { - t.Errorf("expected !ok for malformed JSON, got %+v", req) - } -} - -func TestUnmarshalRequest_WrongFieldType(t *testing.T) { - // "method" as a number is malformed — we should reject rather than coerce to "". - data := []byte(`{"jsonrpc":"2.0","id":1,"method":123}`) - _, ok := unmarshalRequest(data) - if ok { - t.Error("expected !ok when method is not a string") - } -} - -func TestHandleRequest_Initialize(t *testing.T) { - req := JSONRPCRequest{Method: "initialize", ID: json.RawMessage(`1`), hasID: true} - resp := handleRequest(req, "ws-1", nil) - if resp == nil || resp.Error != nil { - t.Fatalf("initialize returned error: %+v", resp) - } - result, ok := resp.Result.(map[string]any) - if !ok { - t.Fatalf("result type = %T, want map", resp.Result) - } - if result["protocolVersion"] != "2024-11-05" { - t.Errorf("protocolVersion = %v, want 2024-11-05", result["protocolVersion"]) - } -} - -func TestHandleRequest_UnknownMethod(t *testing.T) { - req := JSONRPCRequest{Method: "frobnicate", ID: json.RawMessage(`1`), hasID: true} - resp := handleRequest(req, "ws-1", nil) - if resp.Error == nil { - t.Fatal("expected error response for unknown method") - } - if resp.Error.Code != -32601 { - t.Errorf("error code = %d, want -32601", resp.Error.Code) - } -} - -func TestHandleRequest_ToolsList(t *testing.T) { - req := JSONRPCRequest{Method: "tools/list", ID: json.RawMessage(`1`), hasID: true} - resp := handleRequest(req, "ws-1", nil) - if resp.Error != nil { - t.Fatalf("tools/list returned error: %+v", resp.Error) - } - result := resp.Result.(map[string]any) - got := result["tools"].([]ToolDef) - if len(got) != len(tools) { - t.Errorf("got %d tools, want %d", len(got), len(tools)) - } -} diff --git a/internal/mcp/store.go b/internal/mcp/store.go deleted file mode 100644 index 4916ff2..0000000 --- a/internal/mcp/store.go +++ /dev/null @@ -1,141 +0,0 @@ -package mcp - -import ( - "database/sql" - "os" - "path/filepath" - "regexp" - "strings" - - "github.com/nicksenap/grove/internal/config" - _ "modernc.org/sqlite" -) - -func dbPath() string { - return filepath.Join(config.GroveDir, "messages.db") -} - -// OpenDB opens the SQLite database, creates tables, and prunes old entries. -func OpenDB() (*sql.DB, error) { - os.MkdirAll(config.GroveDir, 0o755) - db, err := sql.Open("sqlite", dbPath()) - if err != nil { - return nil, err - } - - // WAL mode for concurrent access - db.Exec("PRAGMA journal_mode=WAL") - - // Create table - _, err = db.Exec(` - CREATE TABLE IF NOT EXISTS announcements ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - workspace_id TEXT NOT NULL, - repo_url TEXT NOT NULL, - category TEXT NOT NULL, - message TEXT NOT NULL, - created_at TEXT DEFAULT (datetime('now')) - ); - CREATE INDEX IF NOT EXISTS idx_repo_created ON announcements(repo_url, created_at); - `) - if err != nil { - db.Close() - return nil, err - } - - // Prune entries older than 30 days - db.Exec("DELETE FROM announcements WHERE created_at < datetime('now', '-30 days')") - - return db, nil -} - -var validCategories = map[string]bool{ - "breaking_change": true, - "status": true, - "warning": true, - "info": true, -} - -// InsertAnnouncement inserts a new announcement. -func InsertAnnouncement(db *sql.DB, workspaceID, repoURL, category, message string) (int64, error) { - if !validCategories[category] { - return 0, &InvalidCategoryError{category} - } - repoURL = NormalizeRepoURL(repoURL) - - result, err := db.Exec( - "INSERT INTO announcements (workspace_id, repo_url, category, message) VALUES (?, ?, ?, ?)", - workspaceID, repoURL, category, message, - ) - if err != nil { - return 0, err - } - return result.LastInsertId() -} - -// Announcement is a stored announcement record. -type Announcement struct { - ID int64 `json:"id"` - WorkspaceID string `json:"workspace_id"` - RepoURL string `json:"repo_url"` - Category string `json:"category"` - Message string `json:"message"` - CreatedAt string `json:"created_at"` -} - -// QueryAnnouncements returns announcements for a repo, excluding the given workspace. -func QueryAnnouncements(db *sql.DB, repoURL, excludeWorkspace, since string) ([]Announcement, error) { - repoURL = NormalizeRepoURL(repoURL) - - query := "SELECT id, workspace_id, repo_url, category, message, created_at FROM announcements WHERE repo_url = ? AND workspace_id != ?" - args := []any{repoURL, excludeWorkspace} - - if since != "" { - query += " AND created_at >= ?" - args = append(args, since) - } - - query += " ORDER BY created_at DESC LIMIT 50" - - rows, err := db.Query(query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - var results []Announcement - for rows.Next() { - var a Announcement - if err := rows.Scan(&a.ID, &a.WorkspaceID, &a.RepoURL, &a.Category, &a.Message, &a.CreatedAt); err != nil { - continue - } - results = append(results, a) - } - if results == nil { - results = []Announcement{} - } - return results, nil -} - -// NormalizeRepoURL converts SSH/HTTPS git URLs to "owner/repo" form. -var sshPattern = regexp.MustCompile(`^git@[^:]+:(.+?)(?:\.git)?$`) -var httpsPattern = regexp.MustCompile(`^https?://[^/]+/(.+?)(?:\.git)?$`) - -func NormalizeRepoURL(url string) string { - if m := sshPattern.FindStringSubmatch(url); len(m) == 2 { - return m[1] - } - if m := httpsPattern.FindStringSubmatch(url); len(m) == 2 { - return m[1] - } - return strings.TrimSuffix(url, ".git") -} - -// InvalidCategoryError is returned when an invalid category is used. -type InvalidCategoryError struct { - Category string -} - -func (e *InvalidCategoryError) Error() string { - return "invalid category: " + e.Category + ". Must be one of: breaking_change, status, warning, info" -} diff --git a/internal/mcp/store_test.go b/internal/mcp/store_test.go deleted file mode 100644 index 8e794e1..0000000 --- a/internal/mcp/store_test.go +++ /dev/null @@ -1,147 +0,0 @@ -package mcp - -import ( - "errors" - "testing" - - "github.com/nicksenap/grove/internal/config" -) - -func TestNormalizeRepoURL(t *testing.T) { - tests := []struct { - name, in, want string - }{ - {"ssh with .git", "git@github.com:nicksenap/grove.git", "nicksenap/grove"}, - {"ssh without .git", "git@github.com:nicksenap/grove", "nicksenap/grove"}, - {"https with .git", "https://github.com/nicksenap/grove.git", "nicksenap/grove"}, - {"https without .git", "https://github.com/nicksenap/grove", "nicksenap/grove"}, - {"http with .git", "http://example.com/a/b.git", "a/b"}, - {"already normalized", "nicksenap/grove", "nicksenap/grove"}, - {"trailing .git plain", "nicksenap/grove.git", "nicksenap/grove"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := NormalizeRepoURL(tt.in); got != tt.want { - t.Errorf("NormalizeRepoURL(%q) = %q, want %q", tt.in, got, tt.want) - } - }) - } -} - -func setupTestDB(t *testing.T) func() { - t.Helper() - orig := config.GroveDir - config.GroveDir = t.TempDir() - return func() { config.GroveDir = orig } -} - -func TestInsertAnnouncement_ValidCategories(t *testing.T) { - defer setupTestDB(t)() - db, err := OpenDB() - if err != nil { - t.Fatalf("OpenDB: %v", err) - } - defer db.Close() - - for _, cat := range []string{"breaking_change", "status", "warning", "info"} { - id, err := InsertAnnouncement(db, "ws-1", "git@github.com:owner/repo.git", cat, "hi") - if err != nil { - t.Errorf("InsertAnnouncement(%q): %v", cat, err) - } - if id == 0 { - t.Errorf("InsertAnnouncement(%q) returned id 0", cat) - } - } -} - -func TestInsertAnnouncement_InvalidCategory(t *testing.T) { - defer setupTestDB(t)() - db, err := OpenDB() - if err != nil { - t.Fatalf("OpenDB: %v", err) - } - defer db.Close() - - _, err = InsertAnnouncement(db, "ws-1", "owner/repo", "urgent", "boom") - var icerr *InvalidCategoryError - if !errors.As(err, &icerr) { - t.Fatalf("expected *InvalidCategoryError, got %v", err) - } - if icerr.Category != "urgent" { - t.Errorf("Category = %q, want %q", icerr.Category, "urgent") - } -} - -func TestQueryAnnouncements_ExcludesSelf(t *testing.T) { - defer setupTestDB(t)() - db, err := OpenDB() - if err != nil { - t.Fatalf("OpenDB: %v", err) - } - defer db.Close() - - // Both workspaces publish to the same repo. - if _, err := InsertAnnouncement(db, "ws-me", "owner/repo", "info", "mine"); err != nil { - t.Fatal(err) - } - if _, err := InsertAnnouncement(db, "ws-other", "owner/repo", "info", "theirs"); err != nil { - t.Fatal(err) - } - - got, err := QueryAnnouncements(db, "owner/repo", "ws-me", "") - if err != nil { - t.Fatalf("QueryAnnouncements: %v", err) - } - if len(got) != 1 { - t.Fatalf("got %d results, want 1 (self excluded)", len(got)) - } - if got[0].WorkspaceID != "ws-other" { - t.Errorf("WorkspaceID = %q, want %q", got[0].WorkspaceID, "ws-other") - } - if got[0].Message != "theirs" { - t.Errorf("Message = %q, want %q", got[0].Message, "theirs") - } -} - -func TestQueryAnnouncements_NormalizesRepoURL(t *testing.T) { - // Insert with SSH form, query with HTTPS form — both normalize to owner/repo. - defer setupTestDB(t)() - db, err := OpenDB() - if err != nil { - t.Fatalf("OpenDB: %v", err) - } - defer db.Close() - - if _, err := InsertAnnouncement(db, "ws-a", "git@github.com:owner/repo.git", "info", "hi"); err != nil { - t.Fatal(err) - } - - got, err := QueryAnnouncements(db, "https://github.com/owner/repo.git", "ws-b", "") - if err != nil { - t.Fatalf("QueryAnnouncements: %v", err) - } - if len(got) != 1 { - t.Errorf("got %d results, want 1 (URL normalization)", len(got)) - } -} - -func TestQueryAnnouncements_EmptyReturnsNonNilSlice(t *testing.T) { - // Callers JSON-marshal the result; nil would become "null", [] is better. - defer setupTestDB(t)() - db, err := OpenDB() - if err != nil { - t.Fatalf("OpenDB: %v", err) - } - defer db.Close() - - got, err := QueryAnnouncements(db, "owner/nonexistent", "ws-a", "") - if err != nil { - t.Fatalf("QueryAnnouncements: %v", err) - } - if got == nil { - t.Error("expected non-nil empty slice, got nil") - } - if len(got) != 0 { - t.Errorf("len = %d, want 0", len(got)) - } -} diff --git a/internal/models/models.go b/internal/models/models.go index bbe7924..d1c922a 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -233,16 +233,6 @@ type DoctorIssue struct { SuggestedAction string `json:"suggested_action"` } -// MCPConfig is the .mcp.json structure written to workspaces. -type MCPConfig struct { - MCPServers map[string]MCPServer `json:"mcpServers"` -} - -type MCPServer struct { - Command string `json:"command"` - Args []string `json:"args"` -} - // ToJSON marshals to indented JSON. func ToJSON(v interface{}) ([]byte, error) { return json.MarshalIndent(v, "", " ") diff --git a/internal/workspace/mcpmigrate.go b/internal/workspace/mcpmigrate.go new file mode 100644 index 0000000..5d85def --- /dev/null +++ b/internal/workspace/mcpmigrate.go @@ -0,0 +1,103 @@ +package workspace + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/nicksenap/grove/internal/logging" +) + +// Grove used to run a built-in MCP server (`gw mcp-serve`) and wrote a `grove` +// entry into each workspace's `.mcp.json` at creation time. The server is gone, +// so those entries now point at a command that no longer exists. +// +// This file is a one-way migration shim: it detects and removes the stale entry +// from workspaces created by older Grove versions. It never writes `.mcp.json` +// and never touches entries belonging to other tools. Once existing workspaces +// have been recycled it can be deleted. + +// mcpConfigFile is the per-workspace MCP client config Grove used to write into. +const mcpConfigFile = ".mcp.json" + +// StaleMCPEntry reports whether the workspace at wsPath still carries Grove's +// legacy `grove` MCP server entry. Anything unreadable, unparseable, or owned by +// another tool reports false — the migration only claims what Grove wrote. +func StaleMCPEntry(wsPath string) bool { + servers, _, err := readMCPServers(filepath.Join(wsPath, mcpConfigFile)) + if err != nil { + return false + } + return isGroveEntry(servers["grove"]) +} + +// CleanStaleMCPEntry removes Grove's legacy `grove` entry from the workspace's +// `.mcp.json`, preserving every other server. The file is deleted when Grove's +// entry was the only one left. It reports whether anything changed. +func CleanStaleMCPEntry(wsPath string) bool { + path := filepath.Join(wsPath, mcpConfigFile) + servers, root, err := readMCPServers(path) + if err != nil || !isGroveEntry(servers["grove"]) { + return false + } + + delete(servers, "grove") + + if len(servers) == 0 && len(root) == 1 { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + logging.Warn("could not remove %s: %s", path, err) + return false + } + return true + } + + root["mcpServers"] = servers + out, err := json.MarshalIndent(root, "", " ") + if err != nil { + logging.Warn("could not marshal %s: %s", path, err) + return false + } + if err := os.WriteFile(path, out, 0o644); err != nil { + logging.Warn("could not update %s: %s", path, err) + return false + } + return true +} + +// readMCPServers parses a `.mcp.json` and returns its mcpServers map plus the +// full decoded document, so callers can rewrite it without losing sibling keys. +func readMCPServers(path string) (servers map[string]any, root map[string]any, err error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, nil, err + } + if err := json.Unmarshal(data, &root); err != nil { + return nil, nil, err + } + servers, ok := root["mcpServers"].(map[string]any) + if !ok { + return map[string]any{}, root, nil + } + return servers, root, nil +} + +// isGroveEntry reports whether an mcpServers entry is the one Grove used to +// write: `gw mcp-serve ...`. A user-authored `grove` entry pointing somewhere +// else (e.g. an external adapter) is left alone. +func isGroveEntry(entry any) bool { + m, ok := entry.(map[string]any) + if !ok { + return false + } + if cmd, _ := m["command"].(string); cmd != "gw" && !strings.HasSuffix(cmd, "/gw") { + return false + } + args, _ := m["args"].([]any) + for _, a := range args { + if s, _ := a.(string); s == "mcp-serve" { + return true + } + } + return false +} diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 2dabc43..c1ec64e 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -155,9 +155,6 @@ func (s *Service) CreateWithOpts(name string, opts CreateOpts) error { // Record stats s.Stats.RecordCreated(ws) - // Write .mcp.json - writeMCPConfig(ws) - logging.Info("workspace %q created at %s", name, wsPath) console.Successf("Workspace %s created at %s", name, wsPath) @@ -271,81 +268,6 @@ func (s *Service) runSetupHooks(ws models.Workspace) { wg.Wait() } -// mcpServerEntry returns the grove MCP server config. -func mcpServerEntry(wsName string) models.MCPServer { - return models.MCPServer{ - Command: "gw", - Args: []string{"mcp-serve", "--workspace", wsName}, - } -} - -// mergeMCPConfig reads existing .mcp.json, adds/updates the grove entry, writes back. -func mergeMCPConfig(path string, wsName string) { - var existing map[string]any - - data, err := os.ReadFile(path) - if err == nil { - json.Unmarshal(data, &existing) - } - if existing == nil { - existing = make(map[string]any) - } - - servers, ok := existing["mcpServers"].(map[string]any) - if !ok { - servers = make(map[string]any) - } - servers["grove"] = mcpServerEntry(wsName) - existing["mcpServers"] = servers - - out, err := json.MarshalIndent(existing, "", " ") - if err != nil { - return - } - tmp := path + ".tmp" - if err := os.WriteFile(tmp, out, 0o644); err != nil { - return - } - os.Rename(tmp, path) -} - -func writeMCPConfig(ws models.Workspace) { - mergeMCPConfig(filepath.Join(ws.Path, ".mcp.json"), ws.Name) -} - -// removeMCPConfig removes the grove entry from the workspace's .mcp.json. -func removeMCPConfig(ws models.Workspace) { - path := filepath.Join(ws.Path, ".mcp.json") - data, err := os.ReadFile(path) - if err != nil { - return - } - var existing map[string]any - if err := json.Unmarshal(data, &existing); err != nil { - return - } - servers, ok := existing["mcpServers"].(map[string]any) - if !ok { - return - } - delete(servers, "grove") - if len(servers) == 0 { - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - logging.Warn("could not remove %s: %s", path, err) - } - return - } - existing["mcpServers"] = servers - out, err := json.MarshalIndent(existing, "", " ") - if err != nil { - logging.Warn("could not marshal %s: %s", path, err) - return - } - if err := os.WriteFile(path, out, 0o644); err != nil { - logging.Warn("could not update %s: %s", path, err) - } -} - // Delete removes a workspace and its worktrees. func (s *Service) Delete(name string) error { ws, err := s.State.GetWorkspace(name) @@ -357,7 +279,6 @@ func (s *Service) Delete(name string) error { } logging.Info("deleting workspace %q", name) - removeMCPConfig(*ws) // Parallel teardown+remove for all repos succeeded := make([]bool, len(ws.Repos)) @@ -971,6 +892,10 @@ func (s *Service) Doctor(fix bool) ([]models.DoctorIssue, int, error) { continue } + f, iss = s.checkStaleMCPConfig(ws, fix) + fixed += f + issues = append(issues, iss...) + f, iss = s.checkWorkspaceRepos(&ws, fix) fixed += f issues = append(issues, iss...) @@ -979,6 +904,25 @@ func (s *Service) Doctor(fix bool) ([]models.DoctorIssue, int, error) { return issues, fixed, nil } +// checkStaleMCPConfig flags the legacy `grove` entry in a workspace's +// `.mcp.json`, left behind by Grove versions that shipped a built-in MCP +// server. `--fix` removes just that entry. +func (s *Service) checkStaleMCPConfig(ws models.Workspace, fix bool) (int, []models.DoctorIssue) { + if !StaleMCPEntry(ws.Path) { + return 0, nil + } + issue := models.DoctorIssue{ + Workspace: ws.Name, + Repo: nil, + Issue: "stale grove entry in .mcp.json (built-in MCP server was removed)", + SuggestedAction: "remove the grove entry from .mcp.json", + } + if fix && CleanStaleMCPEntry(ws.Path) { + return 1, []models.DoctorIssue{issue} + } + return 0, []models.DoctorIssue{issue} +} + func (s *Service) checkWorkspaceExists(ws models.Workspace, fix bool) (int, []models.DoctorIssue) { if _, err := os.Stat(ws.Path); err == nil { return 0, nil diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index 2855ce4..c5394b7 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -353,32 +353,22 @@ func TestCreateWithOptsPersistsSource(t *testing.T) { } } -func TestCreateWritesMCPConfig(t *testing.T) { +func TestCreateWritesNoMCPConfig(t *testing.T) { env := setupTestEnv(t) env.createRepo("api") env.svc.Create("mcp-ws", "feat/mcp", []string{"api"}, env.repoMap, env.cfg) - // .mcp.json in workspace root - mcpPath := filepath.Join(env.wsDir, "mcp-ws", ".mcp.json") - data, err := os.ReadFile(mcpPath) - if err != nil { - t.Fatalf("reading .mcp.json: %v", err) - } - var mcpCfg models.MCPConfig - if err := json.Unmarshal(data, &mcpCfg); err != nil { - t.Fatalf("parsing .mcp.json: %v", err) - } - if _, ok := mcpCfg.MCPServers["grove"]; !ok { - t.Error(".mcp.json missing grove server entry") + // Grove no longer ships an MCP server, so workspace creation must not + // generate a .mcp.json anywhere. + wsRoot := filepath.Join(env.wsDir, "mcp-ws") + if _, err := os.Stat(filepath.Join(wsRoot, ".mcp.json")); err == nil { + t.Error("create should not write .mcp.json in the workspace root") } - // .mcp.json should NOT be written inside the repo worktree — that would - // dirty the tree and break sync. Claude Code is run from the workspace - // root, which is where the shell integration cd's the user. - wt := filepath.Join(env.wsDir, "mcp-ws", "api") + wt := filepath.Join(wsRoot, "api") if _, err := os.Stat(filepath.Join(wt, ".mcp.json")); err == nil { - t.Error(".mcp.json should not be written inside a repo worktree") + t.Error("create should not write .mcp.json inside a repo worktree") } status := env.run(wt, "git", "status", "--porcelain") if status != "" { @@ -1138,65 +1128,112 @@ func TestAllWorkspacesSummaryMultiple(t *testing.T) { } // --------------------------------------------------------------------------- -// MCP config tests +// Legacy .mcp.json migration // --------------------------------------------------------------------------- -func TestMCPConfigMergesWithExisting(t *testing.T) { +func TestCleanStaleMCPEntryPreservesOtherServers(t *testing.T) { env := setupTestEnv(t) env.createRepo("api") - env.svc.Create("merge-ws", "feat/merge", []string{"api"}, env.repoMap, env.cfg) - // Add another server to .mcp.json - mcpPath := filepath.Join(env.wsDir, "merge-ws", "api", ".mcp.json") - existing := `{"mcpServers":{"grove":{"command":"gw","args":["mcp-serve","--workspace","merge-ws"]},"other":{"command":"other-tool","args":[]}}}` - os.WriteFile(mcpPath, []byte(existing), 0o644) - - // Create another workspace that writes .mcp.json — simulate by calling writeMCPConfig directly - ws, _ := env.svc.State.GetWorkspace("merge-ws") - writeMCPConfig(*ws) + wsPath := filepath.Join(env.wsDir, "merge-ws") + mcpPath := filepath.Join(wsPath, ".mcp.json") + legacy := `{"mcpServers":{"grove":{"command":"gw","args":["mcp-serve","--workspace","merge-ws"]},"other":{"command":"other-tool","args":[]}}}` + os.WriteFile(mcpPath, []byte(legacy), 0o644) - // "other" server should still be there - data, _ := os.ReadFile(mcpPath) - var mcpCfg map[string]interface{} - json.Unmarshal(data, &mcpCfg) + if !StaleMCPEntry(wsPath) { + t.Fatal("expected the legacy grove entry to be detected") + } + if !CleanStaleMCPEntry(wsPath) { + t.Fatal("expected cleanup to report a change") + } - servers := mcpCfg["mcpServers"].(map[string]interface{}) + data, err := os.ReadFile(mcpPath) + if err != nil { + t.Fatalf("file should still exist: %v", err) + } + var cfg map[string]any + json.Unmarshal(data, &cfg) + servers := cfg["mcpServers"].(map[string]any) + if _, ok := servers["grove"]; ok { + t.Error("'grove' should be removed") + } if _, ok := servers["other"]; !ok { t.Error("existing 'other' server should be preserved") } - if _, ok := servers["grove"]; !ok { - t.Error("'grove' server should exist") + if StaleMCPEntry(wsPath) { + t.Error("cleanup should be idempotent") } } -func TestMCPConfigRemoveOnlyGrove(t *testing.T) { +func TestCleanStaleMCPEntryRemovesEmptyFile(t *testing.T) { env := setupTestEnv(t) env.createRepo("api") - env.svc.Create("rmcp-ws", "feat/rmcp", []string{"api"}, env.repoMap, env.cfg) - // Add another server at the workspace-root .mcp.json - mcpPath := filepath.Join(env.wsDir, "rmcp-ws", ".mcp.json") - existing := `{"mcpServers":{"grove":{"command":"gw","args":["mcp-serve","--workspace","rmcp-ws"]},"keeper":{"command":"keep-me","args":[]}}}` - os.WriteFile(mcpPath, []byte(existing), 0o644) + wsPath := filepath.Join(env.wsDir, "rmcp-ws") + mcpPath := filepath.Join(wsPath, ".mcp.json") + legacy := `{"mcpServers":{"grove":{"command":"gw","args":["mcp-serve","--workspace","rmcp-ws"]}}}` + os.WriteFile(mcpPath, []byte(legacy), 0o644) - ws, _ := env.svc.State.GetWorkspace("rmcp-ws") - removeMCPConfig(*ws) + if !CleanStaleMCPEntry(wsPath) { + t.Fatal("expected cleanup to report a change") + } + if _, err := os.Stat(mcpPath); !os.IsNotExist(err) { + t.Error(".mcp.json should be deleted once grove was its only server") + } +} - // "keeper" should remain, "grove" should be gone - data, err := os.ReadFile(mcpPath) +func TestCleanStaleMCPEntryIgnoresForeignGroveEntry(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.svc.Create("ext-ws", "feat/ext", []string{"api"}, env.repoMap, env.cfg) + + wsPath := filepath.Join(env.wsDir, "ext-ws") + mcpPath := filepath.Join(wsPath, ".mcp.json") + // A user-installed external adapter that happens to be named "grove" is not + // ours to delete. + external := `{"mcpServers":{"grove":{"command":"grove-mcp-adapter","args":["serve"]}}}` + os.WriteFile(mcpPath, []byte(external), 0o644) + + if StaleMCPEntry(wsPath) { + t.Error("an external adapter should not be flagged as a stale grove entry") + } + if CleanStaleMCPEntry(wsPath) { + t.Error("an external adapter should not be modified") + } + data, _ := os.ReadFile(mcpPath) + if string(data) != external { + t.Errorf("file should be untouched, got %s", data) + } +} + +func TestDoctorReportsAndFixesStaleMCPConfig(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.svc.Create("doc-ws", "feat/doc", []string{"api"}, env.repoMap, env.cfg) + + wsPath := filepath.Join(env.wsDir, "doc-ws") + os.WriteFile(filepath.Join(wsPath, ".mcp.json"), + []byte(`{"mcpServers":{"grove":{"command":"gw","args":["mcp-serve","--workspace","doc-ws"]}}}`), 0o644) + + issues, fixed, err := env.svc.Doctor(false) if err != nil { - t.Fatalf("file should still exist: %v", err) + t.Fatalf("doctor: %v", err) } - var mcpCfg map[string]interface{} - json.Unmarshal(data, &mcpCfg) - servers := mcpCfg["mcpServers"].(map[string]interface{}) - if _, ok := servers["grove"]; ok { - t.Error("'grove' should be removed") + if len(issues) != 1 || fixed != 0 { + t.Fatalf("expected 1 unfixed issue, got %d issues / %d fixed", len(issues), fixed) + } + + issues, fixed, err = env.svc.Doctor(true) + if err != nil { + t.Fatalf("doctor --fix: %v", err) + } + if len(issues) != 1 || fixed != 1 { + t.Fatalf("expected 1 fixed issue, got %d issues / %d fixed", len(issues), fixed) } - if _, ok := servers["keeper"]; !ok { - t.Error("'keeper' should be preserved") + if _, err := os.Stat(filepath.Join(wsPath, ".mcp.json")); !os.IsNotExist(err) { + t.Error("doctor --fix should remove the stale .mcp.json") } } diff --git a/openwiki/architecture.md b/openwiki/architecture.md index d508b09..9907ff6 100644 --- a/openwiki/architecture.md +++ b/openwiki/architecture.md @@ -163,13 +163,6 @@ Manages external commands: - Stores plugin metadata in `~/.grove/plugins/` - Plugins are exec'd from PATH or `~/.grove/plugins/` -#### MCP Server (`internal/mcp/`) -Exposes workspace state via JSON-RPC for Claude Code integration: -- Listens on stdin/stdout -- Serves workspace list, details, and status -- Allows Claude Code to query and create workspaces -- Started via `gw mcp-serve` (usually auto-launched by Claude) - ### 6. **UI Layers** #### Interactive Picker (`internal/picker/`) @@ -310,7 +303,6 @@ Test fixtures often create temporary directories with real git repos, allowing t | `internal/gitops/gitops.go` | Git wrappers | ~600 lines, subprocess management | | `internal/lifecycle/lifecycle.go` | Hook system | ~300 lines, placeholder expansion | | `internal/plugin/` | Plugin management | Install, upgrade, remove | -| `internal/mcp/` | MCP server | Claude Code integration | | `docs/hooks.md` | Hook documentation | Comprehensive, with examples | | `docs/plugins.md` | Plugin documentation | Installation, environment vars | | `AGENTS.md`, `CLAUDE.md` | Agent guidance | Architecture and dev setup | diff --git a/openwiki/integrations.md b/openwiki/integrations.md index eebbf18..c9b4bd4 100644 --- a/openwiki/integrations.md +++ b/openwiki/integrations.md @@ -197,123 +197,30 @@ gw archive list # List archived workspaces --- -## Claude Code Integration (`gw mcp-serve`) +## Agent Integration (the CLI itself) -Grove exposes a **Model Context Protocol (MCP)** server on stdin/stdout that allows Claude Code to query and create workspaces directly. - -### What is MCP? - -MCP is a protocol for AI agents to interact with external tools via JSON-RPC. Claude Code uses MCP servers to access Grove state. - -### Start the MCP Server +Grove has no built-in MCP server. Coding agents with shell access drive Grove +through `gw` directly, using machine-readable output: ```bash -gw mcp-serve -``` - -Listens on stdin/stdout (usually started by Claude Code automatically via `.mcp.json`). - -### Available Methods - -#### `list_workspaces` - -List all workspaces. - -```json -{ - "jsonrpc": "2.0", - "method": "list_workspaces", - "id": 1 -} -``` - -Response: - -```json -{ - "jsonrpc": "2.0", - "result": [ - { - "name": "feat-login", - "branch": "feat/login", - "path": "~/.grove/workspaces/feat-login", - "created_at": "2024-01-15T10:30:45.123456", - "repos": ["svc-api", "svc-auth"] - } - ], - "id": 1 -} -``` - -#### `get_workspace` - -Get details of a specific workspace. - -```json -{ - "jsonrpc": "2.0", - "method": "get_workspace", - "params": { "name": "feat-login" }, - "id": 2 -} +gw context --format json # workspace, repos, git state, safe next actions +gw list --format json +gw status --format json +gw create feat-x -r svc-a,svc-b -b feat/x --format json +gw delete feat-x --force --format json ``` -#### `create_workspace` - -Create a new workspace from Claude Code. - -```json -{ - "jsonrpc": "2.0", - "method": "create_workspace", - "params": { - "name": "claude-task", - "branch": "feat/claude-task", - "repos": ["svc-api", "svc-auth"], - "source": { - "provider": "claude", - "url": "claude:///task-id", - "title": "Fix auth bug" - } - }, - "id": 3 -} -``` - -#### `get_workspace_status` - -Get git status across all repos in workspace. - -```json -{ - "jsonrpc": "2.0", - "method": "get_workspace_status", - "params": { "name": "feat-login" }, - "id": 4 -} -``` - -### Implementation Details - -- **Location**: `internal/mcp/mcp.go` -- **Protocol**: JSON-RPC 2.0 over stdin/stdout -- **Thread-safe**: Synchronizes access to state.json -- **Error handling**: Returns JSON-RPC error codes for invalid operations +Every machine-mode response uses one versioned envelope with stable error codes +and semantic exit codes — see [Agent CLI contract](../docs/agent-cli.md). -### Custom Claude Code Setup +### Migrating off the removed MCP server -If Claude Code is not auto-configured with Grove's MCP server, add to `.mcp.json` in your project root: +Grove ≤ 1.1.11 ran `gw mcp-serve` and wrote a `grove` entry into each +workspace's `.mcp.json`. Both are gone. `gw doctor` reports leftover entries and +`gw doctor --fix` removes only Grove's entry, preserving other servers. -```json -{ - "mcpServers": { - "grove": { - "command": "gw", - "args": ["mcp-serve"] - } - } -} -``` +The `announce` / `get_announcements` cross-workspace coordination tools and their +SQLite database were removed with no replacement. --- @@ -332,7 +239,7 @@ gw create my-feature -b feat/login -r svc-a,svc-b \ This metadata is: - Stored in workspace state (`.source` field) - Passed to hooks via placeholders: `{source_url}`, `{source_ref}`, `{source_title}` -- Available to plugins (MCP, claude memory, dashboard) +- Available to plugins (claude memory, dashboard) **Use cases**: - Claude Code agents → Trace back to original PR or task @@ -519,26 +426,6 @@ Verify hook is in `~/.grove/config.toml`: grep "post_create" ~/.grove/config.toml ``` -### MCP Server Not Starting - -Ensure gw is on PATH: -```bash -which gw -gw mcp-serve # Test manually -``` - -Check Claude Code config (`.mcp.json`): -```json -{ - "mcpServers": { - "grove": { - "command": "gw", - "args": ["mcp-serve"] - } - } -} -``` - ### Memory Sync Issues Ensure `gw-claude` plugin is installed: diff --git a/openwiki/quickstart.md b/openwiki/quickstart.md index 7e6bd94..447fb5f 100644 --- a/openwiki/quickstart.md +++ b/openwiki/quickstart.md @@ -138,7 +138,7 @@ Grove is organized into clear layers: - **Core logic**: `internal/workspace/` orchestrates git worktrees and manages workspace state - **Configuration**: `internal/config/` loads global config; `internal/gitops/` reads per-repo `.grove.toml` - **Data**: `internal/state/` persists workspace list to `~/.grove/state.json` -- **Integrations**: `internal/lifecycle/` runs hooks; `internal/plugin/` manages plugins; `internal/mcp/` serves workspace state to Claude Code +- **Integrations**: `internal/lifecycle/` runs hooks; `internal/plugin/` manages plugins; agents drive Grove through the CLI itself All repos are discovered once at command start via `internal/discover/`, then matched to the requested repos by name. Multi-repo operations use goroutines for concurrent execution. @@ -168,7 +168,6 @@ All repos are discovered once at command start via `internal/discover/`, then ma - `internal/gitops/gitops.go` — Git subprocess wrappers - `internal/lifecycle/lifecycle.go` — Lifecycle hooks - `internal/plugin/` — Plugin management -- `internal/mcp/` — MCP server for Claude Code integration ## Testing From 6c090de4ed83d6925d61b9cb75a9796f096509a3 Mon Sep 17 00:00:00 2001 From: Nick Song Date: Fri, 31 Jul 2026 18:29:12 +0200 Subject: [PATCH 02/21] Add the machine-readable CLI contract (internal/machine) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines the versioned envelope agents parse, the stable error codes they branch on, and the exit-code classes they react to. No command uses it yet — this commit is the contract plus its documentation so the following commits have one shape to converge on. Design notes: - Error classification lives with the envelope, not in string matching at the CLI boundary. Services return `*machine.Error` carrying a code, a suggested fix, and safe next commands; unclassified errors surface as INTERNAL rather than being guessed onto a neighbouring code. - Exit codes are classes (usage / not-found / conflict / precondition / permission / transient / cancelled) so a shell caller can decide whether to retry without parsing JSON. Only TRANSIENT is retry-safe, and a test enforces that every declared code has a class. - `Emit` is a no-op in text mode, so call sites can be unconditional and human output never gets JSON interleaved into it. - Warnings are collected and attached to the envelope once, then drained, so a degraded-but-successful run is machine-visible instead of stderr-only. - `DetectEarly` reads --format before Cobra parses, so pre-command output like the update notice can be suppressed in machine mode. Cobra stays the source of truth and still rejects invalid values. - Even a marshal failure writes a valid envelope, keeping the "stdout is always parseable JSON" guarantee true on the error path. docs/agent-cli.md documents the envelope, the code/exit table, the compatibility policy, and the create → inspect → sync → delete example. --- docs/agent-cli.md | 190 ++++++++++++++++++++++ internal/machine/errors.go | 184 +++++++++++++++++++++ internal/machine/machine.go | 251 ++++++++++++++++++++++++++++ internal/machine/machine_test.go | 271 +++++++++++++++++++++++++++++++ 4 files changed, 896 insertions(+) create mode 100644 docs/agent-cli.md create mode 100644 internal/machine/errors.go create mode 100644 internal/machine/machine.go create mode 100644 internal/machine/machine_test.go diff --git a/docs/agent-cli.md b/docs/agent-cli.md new file mode 100644 index 0000000..3e55f93 --- /dev/null +++ b/docs/agent-cli.md @@ -0,0 +1,190 @@ +# Agent CLI contract + +Grove's CLI is its only first-party agent interface. There is no MCP server and +no daemon: a coding agent or CI script with shell access drives Grove through +`gw` and parses one JSON envelope per command. + +This page is the contract. It is versioned, and Grove treats it as public API. + +## Machine mode + +Add `--format json` (short: `-o json`) to any command: + +```bash +gw context --format json +gw list --format json +gw status feature-x --format json +gw create feat-x -r svc-auth,api-gateway -b feat/x --format json +gw doctor --format json +``` + +In machine mode Grove guarantees: + +| Guarantee | Detail | +| --- | --- | +| One response | stdout carries exactly one JSON envelope, nothing else | +| Clean channels | progress, warnings, hook output, and debug logs go to stderr | +| No decoration | no colors, spinners, prompts, or version-update notices | +| No TTY needed | commands never require a terminal and never block on input | +| Structured failure | errors use the same envelope, with a stable code and a meaningful exit code | + +Anything Grove would have asked interactively becomes a `USAGE` error instead of +a prompt, so a command either does the requested work or explains what argument +was missing. + +`--format text` (the default) keeps the existing human output — tables, colors, +and interactive pickers — unchanged. + +## Response envelope + +Success: + +```json +{ + "ok": true, + "schemaVersion": 1, + "result": { "name": "feat-x", "path": "/Users/me/.grove/workspaces/feat-x" }, + "next_actions": [ + { "description": "Inspect repo state", "command": "gw status feat-x --format json" } + ] +} +``` + +Failure: + +```json +{ + "ok": false, + "schemaVersion": 1, + "error": { + "code": "WORKTREE_DIRTY", + "message": "api has uncommitted changes" + }, + "fix": "Commit, stash, or explicitly force deletion", + "next_actions": [ + { "description": "Inspect changes", "command": "gw status api --format json" } + ] +} +``` + +Fields: + +| Field | Type | Notes | +| --- | --- | --- | +| `ok` | bool | Always present. `false` means the command did not complete its request. | +| `schemaVersion` | int | Envelope version. Currently `1`. | +| `result` | object | Present when `ok` is `true`. Command-specific; never `null`. | +| `error` | object | Present when `ok` is `false`. Has `code`, `message`, and optional `details`. | +| `fix` | string | Optional human-readable remedy for the error. | +| `warnings` | string[] | Optional non-fatal problems (e.g. a repo whose fetch failed). Also printed to stderr. | +| `next_actions` | array | Always present, possibly empty. Each entry has `description` and a runnable `command`. | + +`ok: true` with a non-empty `warnings` array is normal and means "the request +succeeded, some non-essential part degraded". Commands that touch several repos +report per-repo outcomes inside `result`, so a partial success is always +inspectable rather than collapsed into one boolean. + +## Error codes + +Codes are stable identifiers. Branch on `error.code`, never on `error.message`. + +| Code | Exit | Meaning | +| --- | --- | --- | +| `USAGE` | 2 | Malformed invocation: bad flag, missing argument, or input Grove cannot obtain non-interactively. | +| `WORKSPACE_NOT_FOUND` | 3 | No workspace by that name, and none inferable from the cwd. | +| `REPO_NOT_FOUND` | 3 | Named repo is not in the workspace or not discoverable. | +| `NO_WORKSPACES` | 3 | The operation needs at least one workspace to exist. | +| `WORKSPACE_EXISTS` | 4 | A workspace with that name already exists. | +| `WORKTREE_EXISTS` | 4 | The branch already has a worktree in that repo. | +| `WORKTREE_DIRTY` | 4 | Uncommitted changes block the operation. | +| `BRANCH_CONFLICT` | 4 | The requested branch state conflicts with the repo's. | +| `STATE_CHANGED` | 4 | Relevant state changed since a plan was produced (see `gw apply`). | +| `NOT_INITIALIZED` | 5 | Grove has no config yet — run `gw init `. | +| `GIT_FAILED` | 5 | A git subprocess failed. | +| `HOOK_FAILED` | 5 | A lifecycle or per-repo hook failed with `on_failure = "abort"`. | +| `PERMISSION_DENIED` | 6 | Filesystem or credential permission failure. | +| `TRANSIENT` | 7 | May succeed on retry (network, lock contention). | +| `CANCELLED` | 8 | The user aborted an interactive flow. | +| `INTERNAL` | 1 | Unclassified failure. Grove does not model this case yet; treat the message as opaque. | + +### Exit code classes + +Exit codes group failures so a shell caller can react without parsing JSON: + +| Exit | Class | Agent response | +| --- | --- | --- | +| 0 | success | continue | +| 1 | internal | report; do not retry blindly | +| 2 | usage | fix the invocation | +| 3 | not found | re-discover state (`gw context --format json`) | +| 4 | conflict | resolve state, or re-plan | +| 5 | precondition | fix the environment | +| 6 | permission | escalate to a human | +| 7 | transient | retry with backoff | +| 8 | cancelled | stop | + +Only exit 7 is safe to retry unconditionally. + +## Compatibility policy + +`schemaVersion` describes the envelope, not the per-command `result` payloads. + +Compatible changes (no version bump): + +- adding a field to the envelope, a `result`, or an `error.details` +- adding a new error code, exit-code class, or `next_actions` entry +- changing any `message`, `fix`, or `description` text +- adding a command or flag + +Breaking changes (bump `schemaVersion`, announce in `CHANGELOG.md`): + +- removing or renaming an envelope field +- removing or renaming an error code +- changing an existing code's exit class +- changing the type or meaning of an existing `result` field + +Clients should: + +1. reject `schemaVersion` greater than the version they were written against; +2. ignore unknown fields and unknown error codes (fall back to the exit class); +3. read `ok` first, then `error.code`, then `result`. + +## Lifecycle example + +A full create → inspect → sync → delete loop with no human-formatted output: + +```bash +# 1. Discover where we are and what exists. +gw context --format json + +# 2. Create a workspace across two repos. +gw create feat-x -r svc-auth,api-gateway -b feat/x --format json + +# 3. Inspect per-repo git state. +gw status feat-x --format json + +# 4. Rebase onto base branches; per-repo outcomes come back in result.repos. +gw sync feat-x --format json + +# 5. Preview a destructive operation before running it. +gw plan delete feat-x --format json > plan.json +gw apply plan.json --format json +``` + +Every step returns one envelope; a non-zero exit tells the caller which class of +recovery to attempt. + +## Migrating from the MCP server + +Grove ≤ 1.1.11 shipped a built-in MCP server (`gw mcp-serve`) and wrote a `grove` +entry into each workspace's `.mcp.json`. Both were removed — the CLI covers the +same ground for any client with shell access. + +```bash +gw doctor # reports leftover .mcp.json grove entries +gw doctor --fix # removes only Grove's entry, preserving other MCP servers +``` + +The `announce` / `get_announcements` cross-workspace coordination tools were +removed with no replacement. If you need an MCP surface, an external adapter can +wrap this CLI contract without changes to Grove core. diff --git a/internal/machine/errors.go b/internal/machine/errors.go new file mode 100644 index 0000000..e57becd --- /dev/null +++ b/internal/machine/errors.go @@ -0,0 +1,184 @@ +package machine + +import ( + "errors" + "fmt" +) + +// Code is a stable, machine-readable error identifier. Agents branch on these, +// so a code's name and meaning are part of Grove's public API: adding a code is +// a compatible change, renaming or repurposing one is not. +type Code string + +const ( + // CodeInternal is an unclassified failure. Its presence means Grove does not + // model this failure yet — treat the message as opaque. + CodeInternal Code = "INTERNAL" + + // CodeUsage is a malformed invocation: bad flag, missing argument, or a + // command that needs input Grove cannot obtain non-interactively. + CodeUsage Code = "USAGE" + + // Not found. + CodeWorkspaceNotFound Code = "WORKSPACE_NOT_FOUND" + CodeRepoNotFound Code = "REPO_NOT_FOUND" + CodeNoWorkspaces Code = "NO_WORKSPACES" + + // Conflicts: the request collides with existing state. + CodeWorkspaceExists Code = "WORKSPACE_EXISTS" + CodeWorktreeExists Code = "WORKTREE_EXISTS" + CodeWorktreeDirty Code = "WORKTREE_DIRTY" + CodeBranchConflict Code = "BRANCH_CONFLICT" + CodeStateChanged Code = "STATE_CHANGED" + + // Preconditions: the environment is not ready for the request. + CodeNotInitialized Code = "NOT_INITIALIZED" + CodeGitFailed Code = "GIT_FAILED" + CodeHookFailed Code = "HOOK_FAILED" + + // CodePermission is a filesystem or credential permission failure. + CodePermission Code = "PERMISSION_DENIED" + + // CodeTransient is a failure that may succeed on retry (network, lock + // contention). Agents may retry these; they must not retry other codes. + CodeTransient Code = "TRANSIENT" + + // CodeCancelled means the user aborted an interactive flow. + CodeCancelled Code = "CANCELLED" +) + +// Exit codes. Grouping failures by class lets a caller react without parsing +// JSON at all: retry on 7, fix its own invocation on 2, stop on 4. +const ( + ExitOK = 0 + ExitFailure = 1 // unclassified / internal + ExitUsage = 2 // bad invocation + ExitNotFound = 3 // named thing does not exist + ExitConflict = 4 // state collides with the request + ExitPrecondition = 5 // environment not ready + ExitPermission = 6 + ExitTransient = 7 // retry may help + ExitCancelled = 8 +) + +// exitCodes maps each error code to its exit class. Every Code must appear here; +// TestEveryCodeHasExitCode enforces it. +var exitCodes = map[Code]int{ + CodeInternal: ExitFailure, + CodeUsage: ExitUsage, + CodeWorkspaceNotFound: ExitNotFound, + CodeRepoNotFound: ExitNotFound, + CodeNoWorkspaces: ExitNotFound, + CodeWorkspaceExists: ExitConflict, + CodeWorktreeExists: ExitConflict, + CodeWorktreeDirty: ExitConflict, + CodeBranchConflict: ExitConflict, + CodeStateChanged: ExitConflict, + CodeNotInitialized: ExitPrecondition, + CodeGitFailed: ExitPrecondition, + CodeHookFailed: ExitPrecondition, + CodePermission: ExitPermission, + CodeTransient: ExitTransient, + CodeCancelled: ExitCancelled, +} + +// AllCodes returns every declared error code, so docs and tests can enumerate +// the contract instead of hand-maintaining a second list. +func AllCodes() []Code { + return []Code{ + CodeInternal, + CodeUsage, + CodeWorkspaceNotFound, + CodeRepoNotFound, + CodeNoWorkspaces, + CodeWorkspaceExists, + CodeWorktreeExists, + CodeWorktreeDirty, + CodeBranchConflict, + CodeStateChanged, + CodeNotInitialized, + CodeGitFailed, + CodeHookFailed, + CodePermission, + CodeTransient, + CodeCancelled, + } +} + +// Error is a classified failure. Grove's service layer returns these so the CLI +// can emit a stable code, a suggested fix, and safe follow-up commands without +// pattern-matching on message text. +type Error struct { + Code Code + Message string + Fix string + Details any + NextActions []Action + Wrapped error +} + +func (e *Error) Error() string { return e.Message } +func (e *Error) Unwrap() error { return e.Wrapped } + +// WithFix returns a copy carrying a suggested remedy. +func (e *Error) WithFix(fix string) *Error { + c := *e + c.Fix = fix + return &c +} + +// WithActions returns a copy carrying safe next commands. +func (e *Error) WithActions(actions ...Action) *Error { + c := *e + c.NextActions = actions + return &c +} + +// WithDetails returns a copy carrying structured context. +func (e *Error) WithDetails(details any) *Error { + c := *e + c.Details = details + return &c +} + +// Errorf builds a classified error. +func Errorf(code Code, format string, args ...any) *Error { + return &Error{Code: code, Message: fmt.Sprintf(format, args...)} +} + +// Wrap classifies an existing error, preserving it for errors.Is/As. +func Wrap(code Code, err error, format string, args ...any) *Error { + return &Error{Code: code, Message: fmt.Sprintf(format, args...), Wrapped: err} +} + +// AsError returns the classified error in err's chain, or an CodeInternal error +// wrapping it. It never returns nil for a non-nil err. +func AsError(err error) *Error { + if err == nil { + return nil + } + var e *Error + if errors.As(err, &e) { + return e + } + return &Error{Code: CodeInternal, Message: err.Error(), Wrapped: err} +} + +// CodeFor returns the stable code for any error. +func CodeFor(err error) Code { + if err == nil { + return "" + } + return AsError(err).Code +} + +// ExitCodeFor returns the process exit code for any error. +func ExitCodeFor(err error) int { + if err == nil { + return ExitOK + } + if code, ok := exitCodes[AsError(err).Code]; ok { + return code + } + return ExitFailure +} diff --git a/internal/machine/machine.go b/internal/machine/machine.go new file mode 100644 index 0000000..bb71401 --- /dev/null +++ b/internal/machine/machine.go @@ -0,0 +1,251 @@ +// Package machine defines Grove's machine-readable CLI contract: the response +// envelope agents parse, the stable error codes they branch on, and the exit +// codes they check. +// +// The contract exists so a coding agent (or a CI script) never has to parse +// human-formatted tables. It has three rules: +// +// 1. In machine mode, stdout carries exactly one JSON envelope — nothing else. +// Progress, warnings, hook output, and debug logs go to stderr. +// 2. Every response, success or failure, uses the same envelope shape and +// carries schemaVersion so a client can detect an incompatible Grove. +// 3. Error codes and exit codes are part of the public API. Renaming a code is +// a breaking change; adding one is not. +// +// See docs/agent-cli.md for the full policy. +package machine + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + "sync" +) + +// SchemaVersion is the envelope version. It is bumped only when an existing +// field changes meaning or disappears — adding fields does not bump it. +const SchemaVersion = 1 + +// Envelope is the single response shape for machine mode. Exactly one of Result +// or Error is set. NextActions is always present (possibly empty) so clients can +// index it without a nil check. +type Envelope struct { + OK bool `json:"ok"` + SchemaVersion int `json:"schemaVersion"` + Result any `json:"result,omitempty"` + Error *Failure `json:"error,omitempty"` + Fix string `json:"fix,omitempty"` + Warnings []string `json:"warnings,omitempty"` + NextActions []Action `json:"next_actions"` +} + +// Failure is the structured error body. Code is stable; Message is human text +// and may change freely. Details carries command-specific context (e.g. which +// repos failed) and is never required by the contract. +type Failure struct { + Code Code `json:"code"` + Message string `json:"message"` + Details any `json:"details,omitempty"` +} + +// Action is a safe, relevant command an agent can run next. Command is a +// literal shell command so an agent can execute it without reconstruction. +type Action struct { + Description string `json:"description"` + Command string `json:"command"` +} + +// NextAction builds an Action. +func NextAction(description, command string) Action { + return Action{Description: description, Command: command} +} + +// --------------------------------------------------------------------------- +// Output mode +// --------------------------------------------------------------------------- + +// Format selects human or machine output. +type Format string + +const ( + // FormatText is the default human-oriented output (tables, colors, prompts). + FormatText Format = "text" + // FormatJSON is machine mode: one envelope on stdout, everything else on stderr. + FormatJSON Format = "json" +) + +var ( + mu sync.RWMutex + format = FormatText + warnings []string + emitted bool +) + +// SetFormat sets the output mode from a user-supplied string. +func SetFormat(s string) error { + switch Format(strings.ToLower(strings.TrimSpace(s))) { + case FormatText: + setFormat(FormatText) + case FormatJSON: + setFormat(FormatJSON) + default: + return fmt.Errorf("unknown --format %q (want \"text\" or \"json\")", s) + } + return nil +} + +func setFormat(f Format) { + mu.Lock() + defer mu.Unlock() + format = f +} + +// Current returns the active output format. +func Current() Format { + mu.RLock() + defer mu.RUnlock() + return format +} + +// Enabled reports whether machine mode is active. Call it before writing +// anything to stdout. +func Enabled() bool { return Current() == FormatJSON } + +// DetectEarly scans raw args for the format flag before Cobra parses them, so +// pre-command output (like the update notice) can honor machine mode. Cobra +// remains the source of truth and will reject invalid values later; anything +// unparseable here is ignored. +func DetectEarly(args []string) { + for i, a := range args { + switch { + case a == "--format" || a == "-o": + if i+1 < len(args) { + SetFormat(args[i+1]) + } + case strings.HasPrefix(a, "--format="): + SetFormat(strings.TrimPrefix(a, "--format=")) + case strings.HasPrefix(a, "-o="): + SetFormat(strings.TrimPrefix(a, "-o=")) + } + } +} + +// Warn records a warning for inclusion in the envelope. Callers still print it +// to stderr; this only makes it machine-visible. +func Warn(msg string) { + if !Enabled() { + return + } + mu.Lock() + defer mu.Unlock() + warnings = append(warnings, msg) +} + +// Reset clears accumulated state. Tests only. +func Reset() { + mu.Lock() + defer mu.Unlock() + format = FormatText + warnings = nil + emitted = false +} + +// --------------------------------------------------------------------------- +// Emitting +// --------------------------------------------------------------------------- + +// Emit writes a success envelope to stdout. It is a no-op outside machine mode, +// so call sites can be unconditional and let the human path print its own view. +func Emit(result any, actions ...Action) { + if !Enabled() { + return + } + write(os.Stdout, successEnvelope(result, actions)) +} + +// EmitTo is Emit against an explicit writer, ignoring the active format. Used by +// tests and by commands that build an envelope for a file (e.g. gw plan). +func EmitTo(w io.Writer, result any, actions ...Action) { + write(w, successEnvelope(result, actions)) +} + +func successEnvelope(result any, actions []Action) Envelope { + if result == nil { + result = struct{}{} + } + return Envelope{ + OK: true, + SchemaVersion: SchemaVersion, + Result: result, + Warnings: takeWarnings(), + NextActions: normalizeActions(actions), + } +} + +// EmitError writes a failure envelope to stdout and returns the exit code the +// process should use. Outside machine mode it writes nothing and just returns +// the code, leaving the human error message to the caller. +func EmitError(err error) int { + if err == nil { + return ExitOK + } + if Enabled() { + write(os.Stdout, ErrorEnvelope(err)) + } + return ExitCodeFor(err) +} + +// ErrorEnvelope converts any error into the failure envelope. Unclassified +// errors become CodeInternal, which is the contract's explicit "Grove does not +// model this failure yet" signal rather than a silent mismatch. +func ErrorEnvelope(err error) Envelope { + e := AsError(err) + return Envelope{ + OK: false, + SchemaVersion: SchemaVersion, + Error: &Failure{Code: e.Code, Message: e.Message, Details: e.Details}, + Fix: e.Fix, + Warnings: takeWarnings(), + NextActions: normalizeActions(e.NextActions), + } +} + +// write marshals one envelope followed by a newline. A marshal failure must +// still leave valid JSON on stdout, so it degrades to a hand-built envelope. +func write(w io.Writer, env Envelope) { + data, err := json.MarshalIndent(env, "", " ") + if err != nil { + fmt.Fprintf(w, `{"ok":false,"schemaVersion":%d,"error":{"code":%q,"message":%q},"next_actions":[]}`+"\n", + SchemaVersion, CodeInternal, "could not serialize response: "+err.Error()) + return + } + fmt.Fprintln(w, string(data)) + mu.Lock() + emitted = true + mu.Unlock() +} + +// Emitted reports whether an envelope has already been written, so a command +// can avoid producing a second one on a later failure. +func Emitted() bool { + mu.RLock() + defer mu.RUnlock() + return emitted +} + +func takeWarnings() []string { + mu.Lock() + defer mu.Unlock() + w := warnings + warnings = nil + return w +} + +func normalizeActions(actions []Action) []Action { + if actions == nil { + return []Action{} + } + return actions +} diff --git a/internal/machine/machine_test.go b/internal/machine/machine_test.go new file mode 100644 index 0000000..4ddaf14 --- /dev/null +++ b/internal/machine/machine_test.go @@ -0,0 +1,271 @@ +package machine + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "reflect" + "strings" + "testing" +) + +func decode(t *testing.T, data []byte) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("output is not valid JSON: %v\n%s", err, data) + } + return m +} + +func TestSuccessEnvelopeShape(t *testing.T) { + t.Cleanup(Reset) + var buf bytes.Buffer + EmitTo(&buf, map[string]string{"name": "feat-x"}, NextAction("inspect it", "gw status feat-x --format json")) + + got := decode(t, buf.Bytes()) + if got["ok"] != true { + t.Errorf("ok = %v, want true", got["ok"]) + } + if got["schemaVersion"] != float64(SchemaVersion) { + t.Errorf("schemaVersion = %v, want %d", got["schemaVersion"], SchemaVersion) + } + result, ok := got["result"].(map[string]any) + if !ok || result["name"] != "feat-x" { + t.Errorf("result = %v", got["result"]) + } + if _, ok := got["error"]; ok { + t.Error("success envelope must not carry an error key") + } + actions, ok := got["next_actions"].([]any) + if !ok || len(actions) != 1 { + t.Fatalf("next_actions = %v", got["next_actions"]) + } + first := actions[0].(map[string]any) + if first["command"] != "gw status feat-x --format json" { + t.Errorf("action command = %v", first["command"]) + } +} + +// next_actions is always present so clients can iterate it unconditionally. +func TestNextActionsAlwaysPresent(t *testing.T) { + t.Cleanup(Reset) + var buf bytes.Buffer + EmitTo(&buf, nil) + + got := decode(t, buf.Bytes()) + actions, ok := got["next_actions"].([]any) + if !ok { + t.Fatalf("next_actions missing or not an array: %v", got["next_actions"]) + } + if len(actions) != 0 { + t.Errorf("next_actions = %v, want []", actions) + } + // A nil result still serializes as an object, never as null. + if _, ok := got["result"].(map[string]any); !ok { + t.Errorf("result = %v, want {}", got["result"]) + } +} + +func TestErrorEnvelopeShape(t *testing.T) { + t.Cleanup(Reset) + err := Errorf(CodeWorktreeDirty, "api has uncommitted changes"). + WithFix("Commit, stash, or explicitly force deletion"). + WithActions(NextAction("inspect changes", "gw status api --format json")) + + env := ErrorEnvelope(err) + data, _ := json.Marshal(env) + got := decode(t, data) + + if got["ok"] != false { + t.Errorf("ok = %v, want false", got["ok"]) + } + body, ok := got["error"].(map[string]any) + if !ok { + t.Fatalf("error body missing: %v", got) + } + if body["code"] != string(CodeWorktreeDirty) { + t.Errorf("code = %v, want %s", body["code"], CodeWorktreeDirty) + } + if body["message"] != "api has uncommitted changes" { + t.Errorf("message = %v", body["message"]) + } + if got["fix"] != "Commit, stash, or explicitly force deletion" { + t.Errorf("fix = %v", got["fix"]) + } + if _, ok := got["result"]; ok { + t.Error("error envelope must not carry a result key") + } +} + +// An unclassified error must still produce a valid envelope, flagged INTERNAL +// rather than silently mapped onto some unrelated code. +func TestUnclassifiedErrorBecomesInternal(t *testing.T) { + t.Cleanup(Reset) + env := ErrorEnvelope(errors.New("boom")) + if env.Error.Code != CodeInternal { + t.Errorf("code = %s, want %s", env.Error.Code, CodeInternal) + } + if ExitCodeFor(errors.New("boom")) != ExitFailure { + t.Errorf("exit code = %d, want %d", ExitCodeFor(errors.New("boom")), ExitFailure) + } +} + +func TestAsErrorFindsWrappedClassification(t *testing.T) { + sentinel := errors.New("git exploded") + classified := Wrap(CodeGitFailed, sentinel, "cloning failed") + wrapped := errors.Join(errors.New("context"), classified) + + if got := CodeFor(wrapped); got != CodeGitFailed { + t.Errorf("code = %s, want %s", got, CodeGitFailed) + } + if !errors.Is(wrapped, sentinel) { + t.Error("wrapping must preserve errors.Is on the original error") + } +} + +func TestExitCodeClasses(t *testing.T) { + cases := map[Code]int{ + CodeUsage: ExitUsage, + CodeWorkspaceNotFound: ExitNotFound, + CodeWorkspaceExists: ExitConflict, + CodeWorktreeDirty: ExitConflict, + CodeStateChanged: ExitConflict, + CodeNotInitialized: ExitPrecondition, + CodePermission: ExitPermission, + CodeTransient: ExitTransient, + CodeCancelled: ExitCancelled, + } + for code, want := range cases { + if got := ExitCodeFor(Errorf(code, "x")); got != want { + t.Errorf("%s → exit %d, want %d", code, got, want) + } + } + if ExitCodeFor(nil) != ExitOK { + t.Error("nil error must exit 0") + } +} + +// Every declared code needs an exit class, or an agent gets a generic 1 for a +// failure Grove actually models. +func TestEveryCodeHasExitCode(t *testing.T) { + for _, code := range AllCodes() { + if _, ok := exitCodes[code]; !ok { + t.Errorf("code %s has no exit code mapping", code) + } + } +} + +func TestSetFormat(t *testing.T) { + t.Cleanup(Reset) + if Enabled() { + t.Error("text mode must be the default") + } + if err := SetFormat("json"); err != nil { + t.Fatalf("SetFormat(json): %v", err) + } + if !Enabled() { + t.Error("json format should enable machine mode") + } + if err := SetFormat("yaml"); err == nil { + t.Error("unknown format should be rejected") + } + if !Enabled() { + t.Error("a rejected format must not change the active mode") + } +} + +func TestDetectEarly(t *testing.T) { + for _, args := range [][]string{ + {"list", "--format", "json"}, + {"list", "--format=json"}, + {"list", "-o", "json"}, + {"create", "x", "-o=json"}, + } { + Reset() + DetectEarly(args) + if !Enabled() { + t.Errorf("DetectEarly(%v) did not enable machine mode", args) + } + } + + Reset() + DetectEarly([]string{"create", "--branch", "feature/format"}) + if Enabled() { + t.Error("DetectEarly must not be fooled by unrelated values") + } + t.Cleanup(Reset) +} + +// Emit is a no-op in text mode: human commands print their own tables and must +// not have JSON interleaved into them. +func TestEmitSilentInTextMode(t *testing.T) { + t.Cleanup(Reset) + Reset() + + r, w, _ := os.Pipe() + stdout := os.Stdout + os.Stdout = w + Emit(map[string]string{"a": "b"}) + w.Close() + os.Stdout = stdout + + var buf bytes.Buffer + buf.ReadFrom(r) + if buf.Len() != 0 { + t.Errorf("text mode wrote to stdout: %q", buf.String()) + } + if Emitted() { + t.Error("nothing was emitted, Emitted() should be false") + } +} + +func TestWarningsAttachToEnvelopeOnce(t *testing.T) { + t.Cleanup(Reset) + Reset() + SetFormat("json") + + Warn("api: fetch failed, using local state") + var buf bytes.Buffer + EmitTo(&buf, nil) + + got := decode(t, buf.Bytes()) + warns, ok := got["warnings"].([]any) + if !ok || len(warns) != 1 || !strings.Contains(warns[0].(string), "fetch failed") { + t.Fatalf("warnings = %v", got["warnings"]) + } + + // Drained, so a second envelope does not repeat them. + buf.Reset() + EmitTo(&buf, nil) + if _, ok := decode(t, buf.Bytes())["warnings"]; ok { + t.Error("warnings should be drained after being emitted") + } +} + +func TestWarnIgnoredInTextMode(t *testing.T) { + t.Cleanup(Reset) + Reset() + Warn("noise") + if len(takeWarnings()) != 0 { + t.Error("text mode should not accumulate warnings") + } +} + +// Envelope field order is part of the contract's readability, not its +// correctness — but the key set is contractual. +func TestEnvelopeKeySet(t *testing.T) { + t.Cleanup(Reset) + fields := map[string]bool{} + typ := reflect.TypeOf(Envelope{}) + for i := 0; i < typ.NumField(); i++ { + tag := typ.Field(i).Tag.Get("json") + fields[strings.Split(tag, ",")[0]] = true + } + for _, want := range []string{"ok", "schemaVersion", "result", "error", "fix", "warnings", "next_actions"} { + if !fields[want] { + t.Errorf("envelope is missing the %q field", want) + } + } +} From aa0746793c8a8ae924af65d38e34c106e184852d Mon Sep 17 00:00:00 2001 From: Nick Song Date: Fri, 31 Jul 2026 18:35:33 +0200 Subject: [PATCH 03/21] Add global --format json machine mode and wire read commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--format json` (`-o json`) is now available on every command and turns on machine mode: one envelope on stdout, everything else on stderr, no colors, no prompts, no version notice, and an exit code matching the failure class. Plumbing: - Root registers --format as a persistent flag and validates it in PersistentPreRunE, so a bad value is a structured USAGE error rather than a pflag message. A test asserts every subcommand inherits the flag — an agent can't know which commands opted in. - Execute() reads --format before Cobra parses so the update notice is suppressed in machine mode; otherwise the first line of stdout... would be fine, but the first line of *stderr* would be release news an agent has to filter, and the notice is irrelevant to a non-human caller anyway. - Cobra's own parse failures are mapped to USAGE (exit 2) by marker matching, which is confined to that one boundary. Already-classified errors pass through untouched. - fail() replaces ad-hoc "print red text, exit 1": one envelope or one stderr line plus the fix hint, and the error's exit class. Non-interactivity is enforced where blocking could happen, not just promised: pickers refuse to run in machine mode with a USAGE error, and Confirm/Prompt return their defaults without touching stdin. console.NoColor strips ANSI from stderr diagnostics, and console.Warning now also records into the envelope's warnings array so a degraded-but-successful run is machine-visible. Read commands emit the envelope: list, list --status, ws show, status, doctor, repos, preset list/show, plugin list. Two shape decisions worth noting: - `gw doctor` reporting problems is ok:true with issues in the result. A failed *diagnosis* would be ok:false; findings are the successful output. - `gw repos` with no configured repo dirs is now NOT_INITIALIZED instead of an empty list, so an agent cannot misread it as "this machine has no repos". Status output was refactored so the human table and the envelope both render one workspace.StatusReport — they cannot drift. Its Dirty()/Behind() helpers drive state-dependent next_actions (offer sync only when actually behind). The legacy `--json` flag keeps its exact pre-envelope output for existing scripts and plugins; its help text now marks it deprecated in favour of --format json, and a test pins that it still exists. Service-layer errors are now classified at the point of detection (internal/workspace/errors.go): WORKSPACE_NOT_FOUND, WORKSPACE_EXISTS, REPO_NOT_FOUND, WORKTREE_EXISTS, WORKTREE_DIRTY, GIT_FAILED — each carrying a fix hint and safe next commands. Note: text-mode exit codes are now semantic too (e.g. `gw status missing-ws` exits 3, not 1). That is the point of the contract, and 0 still means success. --- cmd/doctor.go | 43 +++++++++-- cmd/list.go | 80 +++++++++++++++---- cmd/machine_test.go | 94 ++++++++++++++++++++++ cmd/plugin.go | 13 +++- cmd/preset.go | 23 +++++- cmd/repos.go | 22 ++++-- cmd/root.go | 104 +++++++++++++++++++++---- cmd/status.go | 39 ++++++++-- internal/console/console.go | 42 ++++++++-- internal/picker/picker.go | 25 +++++- internal/workspace/errors.go | 74 ++++++++++++++++++ internal/workspace/resolve.go | 4 +- internal/workspace/workspace.go | 133 +++++++++++++++++++++----------- 13 files changed, 588 insertions(+), 108 deletions(-) create mode 100644 cmd/machine_test.go create mode 100644 internal/workspace/errors.go diff --git a/cmd/doctor.go b/cmd/doctor.go index b296721..564af98 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -1,11 +1,11 @@ package cmd import ( - "encoding/json" - "fmt" "os" "github.com/nicksenap/grove/internal/console" + "github.com/nicksenap/grove/internal/machine" + "github.com/nicksenap/grove/internal/models" "github.com/nicksenap/grove/internal/workspace" "github.com/spf13/cobra" ) @@ -15,18 +15,40 @@ var ( doctorJSON bool ) +// doctorResult is the machine payload for `gw doctor`. Healthy is explicit so a +// client does not have to infer it from an empty array, and Fixed reports what +// --fix actually changed. +type doctorResult struct { + Healthy bool `json:"healthy"` + Issues []models.DoctorIssue `json:"issues"` + Fixed int `json:"fixed"` +} + var doctorCmd = &cobra.Command{ Use: "doctor", Short: "Diagnose workspace health issues", Run: func(cmd *cobra.Command, args []string) { issues, fixed, err := workspace.NewService().Doctor(doctorFix) if err != nil { - exitError(err.Error()) + fail(err) + } + if issues == nil { + issues = []models.DoctorIssue{} + } + + if machine.Enabled() { + // Reporting problems is a successful diagnosis, not a failed command: + // ok stays true and the issues live in the result. + machine.Emit(doctorResult{ + Healthy: len(issues) == 0, + Issues: issues, + Fixed: fixed, + }, doctorNextActions(issues, doctorFix)...) + return } if doctorJSON { - data, _ := json.MarshalIndent(issues, "", " ") - fmt.Println(string(data)) + emitLegacyJSON(issues) return } @@ -51,7 +73,16 @@ var doctorCmd = &cobra.Command{ }, } +func doctorNextActions(issues []models.DoctorIssue, fixed bool) []machine.Action { + if len(issues) == 0 || fixed { + return nil + } + return []machine.Action{ + machine.NextAction("Repair the reported issues", "gw doctor --fix --format json"), + } +} + func init() { doctorCmd.Flags().BoolVar(&doctorFix, "fix", false, "Auto-fix issues") - doctorCmd.Flags().BoolVarP(&doctorJSON, "json", "j", false, "Output as JSON") + doctorCmd.Flags().BoolVarP(&doctorJSON, "json", "j", false, legacyJSONUsage) } diff --git a/cmd/list.go b/cmd/list.go index defbaee..ebe3b9f 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -7,6 +7,8 @@ import ( "strings" "github.com/nicksenap/grove/internal/console" + "github.com/nicksenap/grove/internal/machine" + "github.com/nicksenap/grove/internal/models" "github.com/nicksenap/grove/internal/state" "github.com/nicksenap/grove/internal/workspace" "github.com/spf13/cobra" @@ -56,15 +58,28 @@ var listCmd = &cobra.Command{ } func init() { - wsListCmd.Flags().BoolVarP(&listJSON, "json", "j", false, "Output as JSON") + wsListCmd.Flags().BoolVarP(&listJSON, "json", "j", false, legacyJSONUsage) wsListCmd.Flags().BoolVarP(&listStatus, "status", "s", false, "Include git status") - wsShowCmd.Flags().BoolVarP(&wsShowJSON, "json", "j", false, "Output as JSON") + wsShowCmd.Flags().BoolVarP(&wsShowJSON, "json", "j", false, legacyJSONUsage) wsCmd.AddCommand(wsListCmd, wsShowCmd, wsDeleteCmd) - listCmd.Flags().BoolVarP(&listJSON, "json", "j", false, "Output as JSON") + listCmd.Flags().BoolVarP(&listJSON, "json", "j", false, legacyJSONUsage) listCmd.Flags().BoolVarP(&listStatus, "status", "s", false, "Include git status") } +// listResult is the machine payload for `gw list`. Count is included so a client +// can assert on it without walking the array. +type listResult struct { + Workspaces []models.Workspace `json:"workspaces"` + Count int `json:"count"` +} + +// listStatusResult is the machine payload for `gw list --status`. +type listStatusResult struct { + Workspaces []workspace.WorkspaceSummary `json:"workspaces"` + Count int `json:"count"` +} + func doListAll() { if listStatus { listWithStatus() @@ -73,12 +88,16 @@ func doListAll() { workspaces, err := state.Load() if err != nil { - exitError(err.Error()) + fail(err) + } + + if machine.Enabled() { + machine.Emit(listResult{Workspaces: workspaces, Count: len(workspaces)}, listNextActions(workspaces)...) + return } if listJSON { - data, _ := json.MarshalIndent(workspaces, "", " ") - fmt.Println(string(data)) + emitLegacyJSON(workspaces) return } @@ -99,15 +118,34 @@ func doListAll() { table.Render() } +// listNextActions points at the cheapest useful follow-up: inspecting a real +// workspace, or creating the first one when there are none. +func listNextActions(workspaces []models.Workspace) []machine.Action { + if len(workspaces) == 0 { + return []machine.Action{ + machine.NextAction("Create the first workspace", + "gw create -r -b --format json"), + } + } + return []machine.Action{ + machine.NextAction("Inspect repo state for a workspace", + "gw status "+workspaces[0].Name+" --format json"), + } +} + func listWithStatus() { summaries, err := workspace.NewService().AllWorkspacesSummary() if err != nil { - exitError(err.Error()) + fail(err) + } + + if machine.Enabled() { + machine.Emit(listStatusResult{Workspaces: summaries, Count: len(summaries)}) + return } if listJSON { - data, _ := json.MarshalIndent(summaries, "", " ") - fmt.Println(string(data)) + emitLegacyJSON(summaries) return } @@ -131,15 +169,20 @@ func listWithStatus() { func doShowOne(name string) { ws, err := state.GetWorkspace(name) if err != nil { - exitError(err.Error()) + fail(err) } if ws == nil { - exitError("Workspace not found: " + name) + fail(workspace.ErrWorkspaceNotFound(name)) + } + + if machine.Enabled() { + machine.Emit(map[string]any{"workspace": ws}, + machine.NextAction("Inspect repo state", "gw status "+ws.Name+" --format json")) + return } if wsShowJSON { - data, _ := json.MarshalIndent(ws, "", " ") - fmt.Println(string(data)) + emitLegacyJSON(ws) return } @@ -177,3 +220,14 @@ func doShowOne(name string) { } table.Render() } + +// emitLegacyJSON prints the pre-envelope bare JSON shape behind the deprecated +// `--json` flag. Kept byte-compatible for existing scripts and plugins; new +// consumers should use `--format json`. +func emitLegacyJSON(v any) { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + fail(machine.Wrap(machine.CodeInternal, err, "could not serialize output: %s", err)) + } + fmt.Println(string(data)) +} diff --git a/cmd/machine_test.go b/cmd/machine_test.go new file mode 100644 index 0000000..5fe52c9 --- /dev/null +++ b/cmd/machine_test.go @@ -0,0 +1,94 @@ +package cmd + +import ( + "errors" + "testing" + + "github.com/nicksenap/grove/internal/machine" + "github.com/spf13/cobra" +) + +func TestClassifyCommandErrMapsCobraFailuresToUsage(t *testing.T) { + tests := []string{ + `unknown command "frobnicate" for "gw"`, + "unknown flag: --frobnicate", + "unknown shorthand flag: 'z' in -z", + `invalid argument "maybe" for "-f, --force" flag`, + "accepts 1 arg(s), received 3", + "flag needs an argument: --branch", + } + for _, msg := range tests { + if got := machine.CodeFor(classifyCommandErr(errors.New(msg))); got != machine.CodeUsage { + t.Errorf("%q → %s, want %s", msg, got, machine.CodeUsage) + } + } +} + +// A real operational failure must not be relabelled as the caller's mistake. +func TestClassifyCommandErrLeavesOtherErrorsAlone(t *testing.T) { + err := errors.New("could not write state.json: disk full") + if got := machine.CodeFor(classifyCommandErr(err)); got != machine.CodeInternal { + t.Errorf("code = %s, want %s", got, machine.CodeInternal) + } +} + +// An already-classified error keeps its code — classification happens closest to +// the cause, and the CLI boundary must not overwrite it. +func TestClassifyCommandErrPreservesClassification(t *testing.T) { + err := machine.Errorf(machine.CodeWorktreeDirty, "api has uncommitted changes") + if got := machine.CodeFor(classifyCommandErr(err)); got != machine.CodeWorktreeDirty { + t.Errorf("code = %s, want %s", got, machine.CodeWorktreeDirty) + } +} + +// Machine mode must be reachable on every command, since an agent has no way to +// know which subcommands opted in. +func TestFormatFlagIsGlobal(t *testing.T) { + if rootCmd.PersistentFlags().Lookup("format") == nil { + t.Fatal("--format must be a persistent (global) flag") + } + if sh := rootCmd.PersistentFlags().ShorthandLookup("o"); sh == nil || sh.Name != "format" { + t.Error("-o should be the shorthand for --format") + } + + // InheritedFlags resolves the persistent flags a subcommand receives from its + // parents, which is how --format reaches every command. + var missing []string + walk(rootCmd, func(c *cobra.Command) { + if c.InheritedFlags().Lookup("format") == nil && c.Flags().Lookup("format") == nil { + missing = append(missing, c.CommandPath()) + } + }) + if len(missing) > 0 { + t.Errorf("commands without --format: %v", missing) + } +} + +// --json predates the envelope. It stays available so existing scripts and +// plugins keep working, and must never be silently repurposed. +func TestLegacyJSONFlagStillExists(t *testing.T) { + for _, path := range []string{"list", "status", "doctor", "repos"} { + c, _, err := rootCmd.Find([]string{path}) + if err != nil { + t.Fatalf("finding %q: %v", path, err) + } + flag := c.Flags().Lookup("json") + if flag == nil { + t.Errorf("gw %s lost its --json flag", path) + continue + } + if flag.Usage != legacyJSONUsage { + t.Errorf("gw %s --json usage = %q, want it marked deprecated", path, flag.Usage) + } + } +} + +func walk(c *cobra.Command, fn func(*cobra.Command)) { + for _, sub := range c.Commands() { + if sub.Name() == "help" || sub.Name() == "completion" { + continue + } + fn(sub) + walk(sub, fn) + } +} diff --git a/cmd/plugin.go b/cmd/plugin.go index e87ed84..17674a4 100644 --- a/cmd/plugin.go +++ b/cmd/plugin.go @@ -6,6 +6,7 @@ import ( "os" "github.com/nicksenap/grove/internal/console" + "github.com/nicksenap/grove/internal/machine" "github.com/nicksenap/grove/internal/plugin" "github.com/spf13/cobra" ) @@ -52,7 +53,15 @@ var pluginListCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { plugins, err := plugin.List() if err != nil { - exitError(err.Error()) + fail(err) + } + + if machine.Enabled() { + if plugins == nil { + plugins = []plugin.InstalledPlugin{} + } + machine.Emit(map[string]any{"plugins": plugins, "count": len(plugins)}) + return } if len(plugins) == 0 { @@ -120,6 +129,6 @@ plugins that were installed via "gw plugin install".`, } func init() { - pluginListCmd.Flags().BoolVarP(&pluginListJSON, "json", "j", false, "Output as JSON") + pluginListCmd.Flags().BoolVarP(&pluginListJSON, "json", "j", false, legacyJSONUsage) pluginCmd.AddCommand(pluginInstallCmd, pluginListCmd, pluginRemoveCmd, pluginUpgradeCmd) } diff --git a/cmd/preset.go b/cmd/preset.go index 4befddd..09d225b 100644 --- a/cmd/preset.go +++ b/cmd/preset.go @@ -9,6 +9,7 @@ import ( "github.com/nicksenap/grove/internal/config" "github.com/nicksenap/grove/internal/console" "github.com/nicksenap/grove/internal/discover" + "github.com/nicksenap/grove/internal/machine" "github.com/nicksenap/grove/internal/models" "github.com/nicksenap/grove/internal/picker" "github.com/spf13/cobra" @@ -83,6 +84,16 @@ var presetListCmd = &cobra.Command{ Short: "List all presets", Run: func(cmd *cobra.Command, args []string) { cfg := config.RequireConfig() + + if machine.Enabled() { + presets := cfg.Presets + if presets == nil { + presets = map[string]models.Preset{} + } + machine.Emit(map[string]any{"presets": presets, "count": len(presets)}) + return + } + if len(cfg.Presets) == 0 { if !presetListJSON { console.Info("No presets configured") @@ -117,7 +128,13 @@ var presetShowCmd = &cobra.Command{ cfg := config.RequireConfig() preset, ok := cfg.Presets[args[0]] if !ok { - exitError(fmt.Sprintf("Preset %s not found", args[0])) + fail(machine.Errorf(machine.CodeRepoNotFound, "preset %s not found", args[0]). + WithActions(machine.NextAction("List presets", "gw preset list --format json"))) + } + + if machine.Enabled() { + machine.Emit(map[string]any{"name": args[0], "repos": preset.Repos}) + return } if presetShowJSON { @@ -184,7 +201,7 @@ var presetRemoveCmd = &cobra.Command{ func init() { presetAddCmd.Flags().StringVarP(&presetAddRepos, "repos", "r", "", "Comma-separated repo names") - presetListCmd.Flags().BoolVarP(&presetListJSON, "json", "j", false, "Output as JSON") - presetShowCmd.Flags().BoolVarP(&presetShowJSON, "json", "j", false, "Output as JSON") + presetListCmd.Flags().BoolVarP(&presetListJSON, "json", "j", false, legacyJSONUsage) + presetShowCmd.Flags().BoolVarP(&presetShowJSON, "json", "j", false, legacyJSONUsage) presetCmd.AddCommand(presetAddCmd, presetListCmd, presetShowCmd, presetRemoveCmd) } diff --git a/cmd/repos.go b/cmd/repos.go index a063d55..3d87008 100644 --- a/cmd/repos.go +++ b/cmd/repos.go @@ -1,14 +1,13 @@ package cmd import ( - "encoding/json" - "fmt" "os" "strings" "github.com/nicksenap/grove/internal/config" "github.com/nicksenap/grove/internal/console" "github.com/nicksenap/grove/internal/discover" + "github.com/nicksenap/grove/internal/machine" "github.com/spf13/cobra" ) @@ -30,13 +29,16 @@ var reposCmd = &cobra.Command{ Short: "List discovered repos with their remotes", Long: "Lists git repositories found in the configured repo directories, " + "including each repo's origin remote and derived owner/repo name. " + - "Use --json for machine-readable output.", + "Use --format json for machine-readable output.", Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { cfg := config.RequireConfig() if len(cfg.RepoDirs) == 0 { - console.Error("No repo directories configured. Run: gw add-dir ") - return + // A missing repo dir is a precondition failure, not an empty result: + // an agent must not read this as "the machine has no repos". + fail(machine.Errorf(machine.CodeNotInitialized, "no repo directories configured"). + WithFix("Register at least one directory containing git repos"). + WithActions(machine.NextAction("Add a repo directory", "gw add-dir "))) } infos := discover.DiscoverReposWithCache(cfg.RepoDirs) @@ -50,9 +52,13 @@ var reposCmd = &cobra.Command{ } } + if machine.Enabled() { + machine.Emit(map[string]any{"repos": entries, "count": len(entries)}) + return + } + if reposJSON { - data, _ := json.MarshalIndent(entries, "", " ") - fmt.Println(string(data)) + emitLegacyJSON(entries) return } @@ -75,5 +81,5 @@ var reposCmd = &cobra.Command{ } func init() { - reposCmd.Flags().BoolVarP(&reposJSON, "json", "j", false, "Output as JSON") + reposCmd.Flags().BoolVarP(&reposJSON, "json", "j", false, legacyJSONUsage) } diff --git a/cmd/root.go b/cmd/root.go index aa51e39..e3e499d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,8 +8,10 @@ import ( "strings" "github.com/nicksenap/grove/internal/config" + "github.com/nicksenap/grove/internal/console" "github.com/nicksenap/grove/internal/lifecycle" "github.com/nicksenap/grove/internal/logging" + "github.com/nicksenap/grove/internal/machine" "github.com/nicksenap/grove/internal/picker" "github.com/nicksenap/grove/internal/plugin" "github.com/nicksenap/grove/internal/update" @@ -19,15 +21,28 @@ import ( // Version is set by goreleaser via -ldflags at build time. var Version = "dev" -var verbose bool +var ( + verbose bool + outputFormat string +) var rootCmd = &cobra.Command{ Use: "gw", Short: "Grove — Git Worktree Workspace Orchestrator", Long: "Manages multi-repo worktree-based workspaces", - PersistentPreRun: func(cmd *cobra.Command, args []string) { + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // --format is validated here rather than at the flag layer so an unknown + // value is a structured USAGE error like any other bad invocation. + if err := machine.SetFormat(outputFormat); err != nil { + return machine.Wrap(machine.CodeUsage, err, "%s", err.Error()) + } + if machine.Enabled() { + // Machine mode owns stdout, so nothing decorative may reach it. + console.NoColor = true + } logging.Setup(verbose) logging.Info("gw %s", cmd.Name()) + return nil }, Run: func(cmd *cobra.Command, args []string) { cmd.Help() @@ -36,6 +51,8 @@ var rootCmd = &cobra.Command{ func init() { rootCmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "Enable debug logging") + rootCmd.PersistentFlags().StringVarP(&outputFormat, "format", "o", string(machine.FormatText), + `Output format: "text" (human) or "json" (machine-readable envelope)`) rootCmd.PersistentFlags().BoolVarP(&lifecycle.Disabled, "no-hooks", "n", false, "Skip lifecycle hooks") rootCmd.Version = Version rootCmd.SetVersionTemplate("gw {{.Version}}\n") @@ -73,9 +90,16 @@ func init() { } func Execute() { - // Non-blocking version check - if notice := update.NewChecker(config.GroveDir).FormatNotice(Version); notice != "" { - fmt.Fprintf(os.Stderr, "\033[2m%s\033[0m\n", notice) + // Read --format before Cobra parses, so pre-command output can honor machine + // mode. Cobra still validates the value in PersistentPreRunE. + machine.DetectEarly(os.Args[1:]) + + // Non-blocking version check. Suppressed in machine mode: an agent asked for + // one envelope, not release news. + if !machine.Enabled() { + if notice := update.NewChecker(config.GroveDir).FormatNotice(Version); notice != "" { + fmt.Fprintf(os.Stderr, "\033[2m%s\033[0m\n", notice) + } } if err := rootCmd.Execute(); err != nil { @@ -90,20 +114,37 @@ func Execute() { if errors.As(execErr, &exitErr) { os.Exit(exitErr.ExitCode()) } - fmt.Fprintf(os.Stderr, "\033[1;31merror:\033[0m plugin %s: %s\n", name, execErr) - os.Exit(1) + fail(machine.Wrap(machine.CodeInternal, execErr, "plugin %s: %s", name, execErr)) } // If Exec used syscall.Exec (Unix), we never reach here. os.Exit(0) } } } - // Print the error ourselves since we silenced cobra - fmt.Fprintf(os.Stderr, "\033[1;31merror:\033[0m %s\n", err) - os.Exit(1) + fail(classifyCommandErr(err)) } } +// classifyCommandErr maps Cobra's own parse failures onto contract codes. Cobra +// reports them as plain errors, and an agent that mistypes a flag should get +// USAGE (exit 2) rather than an opaque internal failure. +func classifyCommandErr(err error) error { + var classified *machine.Error + if errors.As(err, &classified) { + return err + } + msg := err.Error() + for _, marker := range []string{ + "unknown command", "unknown flag", "unknown shorthand flag", + "invalid argument", "accepts", "requires at least", "flag needs an argument", + } { + if strings.Contains(msg, marker) { + return machine.Wrap(machine.CodeUsage, err, "%s", msg) + } + } + return err +} + // isUnknownCommandErr checks if the error is cobra's "unknown command" error. func isUnknownCommandErr(err error) bool { return strings.Contains(err.Error(), "unknown command") @@ -135,16 +176,49 @@ func pluginArgs(name string) []string { return nil } -// exitError prints error to stderr and exits. +// legacyJSONUsage documents the pre-envelope `--json` flag. It still emits the +// old bare shapes so existing scripts and plugins keep working; `--format json` +// is the versioned contract described in docs/agent-cli.md. +const legacyJSONUsage = "Legacy bare JSON output (deprecated: use --format json)" + +// fail terminates the command with a single structured failure: one envelope on +// stdout in machine mode, a colored line on stderr otherwise, and the exit code +// matching the error's class. +func fail(err error) { + code := machine.EmitError(err) + if !machine.Enabled() { + e := machine.AsError(err) + console.Error(e.Message) + if e.Fix != "" { + console.Info("fix: " + e.Fix) + } + } + os.Exit(code) +} + +// exitError is the unclassified escape hatch, kept for call sites that only have +// a message. Prefer fail() with a coded machine.Error. func exitError(msg string) { - fmt.Fprintf(os.Stderr, "\033[1;31merror:\033[0m %s\n", msg) - os.Exit(1) + fail(machine.Errorf(machine.CodeInternal, "%s", msg)) } -// exitOnPickerErr exits silently on user cancellation, or calls exitError for real errors. +// exitOnPickerErr exits silently on user cancellation, or fails for real errors. +// Cancellation stays exit 0 so shell integration (gw go, wrappers) treats an +// escaped picker as "never mind", not as a failure. func exitOnPickerErr(err error) { if errors.Is(err, picker.ErrCancelled) { os.Exit(0) } - exitError(err.Error()) + fail(err) +} + +// requireArgs rejects interactive fallbacks in machine mode. Machine mode +// promises never to block on input, so a missing argument that a human would be +// prompted for is a USAGE error instead. +func requireArgs(what, example string) { + if !machine.Enabled() { + return + } + fail(machine.Errorf(machine.CodeUsage, "%s is required in --format json (machine mode never prompts)", what). + WithFix("Pass it explicitly, e.g. " + example)) } diff --git a/cmd/status.go b/cmd/status.go index 35da373..c2abed5 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -2,6 +2,7 @@ package cmd import ( "github.com/nicksenap/grove/internal/console" + "github.com/nicksenap/grove/internal/machine" "github.com/nicksenap/grove/internal/workspace" "github.com/spf13/cobra" ) @@ -30,21 +31,49 @@ var statusCmd = &cobra.Command{ ws, err := workspace.ResolveWorkspace(name) if err != nil { - exitError(err.Error()) + fail(err) } - if err := workspace.NewService().Status(ws.Name, workspace.StatusOptions{ + opts := workspace.StatusOptions{ JSON: statusJSON, Verbose: statusVerbose, PR: statusPR, - }); err != nil { - exitError(err.Error()) + } + svc := workspace.NewService() + + if machine.Enabled() { + report, err := svc.StatusReport(ws.Name, opts) + if err != nil { + fail(err) + } + machine.Emit(report, statusNextActions(report)...) + return + } + + if err := svc.Status(ws.Name, opts); err != nil { + fail(err) } }, } +// statusNextActions suggests the follow-up that matches the observed state: +// rebase when a repo is behind, commit guidance when a repo is dirty. Both are +// omitted when neither applies, rather than padding the envelope with noise. +func statusNextActions(report *workspace.StatusReport) []machine.Action { + var actions []machine.Action + if report.Behind() { + actions = append(actions, machine.NextAction("Rebase repos onto their base branches", + "gw sync "+report.Workspace+" --format json")) + } + if dirty := report.Dirty(); len(dirty) > 0 { + actions = append(actions, machine.NextAction("Review uncommitted changes in "+dirty[0], + "git -C "+report.Path+"/"+dirty[0]+" diff")) + } + return actions +} + func init() { - statusCmd.Flags().BoolVarP(&statusJSON, "json", "j", false, "Output as JSON") + statusCmd.Flags().BoolVarP(&statusJSON, "json", "j", false, legacyJSONUsage) statusCmd.Flags().BoolVarP(&statusVerbose, "verbose", "V", false, "Show full git status") statusCmd.Flags().BoolVarP(&statusPR, "pr", "P", false, "Show PR/MR status (requires gh or glab)") statusCmd.Flags().BoolVarP(&statusAll, "all", "a", false, "Show all workspaces (deprecated, use: gw list -s)") diff --git a/internal/console/console.go b/internal/console/console.go index 288b6a1..b5a1c50 100644 --- a/internal/console/console.go +++ b/internal/console/console.go @@ -5,6 +5,8 @@ import ( "fmt" "os" "strings" + + "github.com/nicksenap/grove/internal/machine" ) // ANSI color codes @@ -16,9 +18,22 @@ const ( boldYellow = "\033[1;33m" ) +// NoColor strips ANSI escapes from every helper in this package. Machine mode +// sets it so stderr diagnostics stay plain text for log scrapers, and it is also +// the hook a future NO_COLOR/non-TTY check would use. +var NoColor bool + +// paint returns code unless colors are disabled. +func paint(code string) string { + if NoColor { + return "" + } + return code +} + // Error prints an error message to stderr. func Error(msg string) { - fmt.Fprintf(os.Stderr, "%serror:%s %s\n", boldRed, reset, msg) + fmt.Fprintf(os.Stderr, "%serror:%s %s\n", paint(boldRed), paint(reset), msg) } // Errorf prints a formatted error message to stderr. @@ -28,7 +43,7 @@ func Errorf(format string, args ...any) { // Success prints a success message to stderr. func Success(msg string) { - fmt.Fprintf(os.Stderr, "%sok:%s %s\n", boldGreen, reset, msg) + fmt.Fprintf(os.Stderr, "%sok:%s %s\n", paint(boldGreen), paint(reset), msg) } // Successf prints a formatted success message to stderr. @@ -38,7 +53,7 @@ func Successf(format string, args ...any) { // Info prints an info message to stderr. func Info(msg string) { - fmt.Fprintf(os.Stderr, "%s%s%s\n", dim, msg, reset) + fmt.Fprintf(os.Stderr, "%s%s%s\n", paint(dim), msg, paint(reset)) } // Infof prints a formatted info message to stderr. @@ -46,9 +61,11 @@ func Infof(format string, args ...any) { Info(fmt.Sprintf(format, args...)) } -// Warning prints a warning message to stderr. +// Warning prints a warning message to stderr and records it for the machine +// envelope, so a degraded-but-successful run is visible to agents too. func Warning(msg string) { - fmt.Fprintf(os.Stderr, "%swarn:%s %s\n", boldYellow, reset, msg) + machine.Warn(msg) + fmt.Fprintf(os.Stderr, "%swarn:%s %s\n", paint(boldYellow), paint(reset), msg) } // Warningf prints a formatted warning message to stderr. @@ -58,7 +75,18 @@ func Warningf(format string, args ...any) { // Confirm asks the user a yes/no question. Returns true for yes. // Defaults to defaultYes if the user just presses enter. +// +// In machine mode it never reads stdin — a command promised to be +// non-interactive must not hang waiting for a human. It answers with the +// default, which is the conservative choice for destructive prompts. Commands +// that need a real decision should require an explicit flag instead of relying +// on this fallback. func Confirm(prompt string, defaultYes bool) bool { + if machine.Enabled() { + machine.Warn(fmt.Sprintf("skipped prompt %q in machine mode, assuming %v", prompt, defaultYes)) + return defaultYes + } + hint := "[y/N]" if defaultYes { hint = "[Y/n]" @@ -83,6 +111,10 @@ func Prompt(label string) string { // PromptDefault asks the user for text input, showing defaultValue in brackets // when non-empty. Empty input (just Enter) returns defaultValue. func PromptDefault(label, defaultValue string) string { + if machine.Enabled() { + machine.Warn(fmt.Sprintf("skipped prompt %q in machine mode, using %q", label, defaultValue)) + return defaultValue + } if defaultValue != "" { fmt.Fprintf(os.Stderr, "%s [%s]: ", label, defaultValue) } else { diff --git a/internal/picker/picker.go b/internal/picker/picker.go index 05042dc..1184a23 100644 --- a/internal/picker/picker.go +++ b/internal/picker/picker.go @@ -8,12 +8,29 @@ import ( "strings" "github.com/nicksenap/grove/internal/console" + "github.com/nicksenap/grove/internal/machine" "golang.org/x/term" ) // ErrCancelled is returned when the user cancels a picker with Escape or Ctrl+C. var ErrCancelled = errors.New("selection cancelled") +// unavailable reports why an interactive picker cannot run: machine mode +// promises never to prompt, and a non-TTY session cannot render one. Both are +// USAGE failures — the caller has to name what it wants explicitly. +func unavailable(prompt string) error { + if machine.Enabled() { + return machine.Errorf(machine.CodeUsage, + "%s requires an explicit value in --format json (machine mode never prompts)", strings.TrimSuffix(prompt, ":")). + WithFix("Pass the value as an argument or flag instead of relying on interactive selection") + } + if !console.IsTerminal(os.Stdin) || !console.IsTerminal(os.Stderr) { + return machine.Errorf(machine.CodeUsage, + "interactive selection requires a terminal. Provide explicit flags instead") + } + return nil +} + // PickOne shows a single-select picker with type-to-search. // Returns the selected item or error if cancelled. func PickOne(prompt string, choices []string) (string, error) { @@ -23,8 +40,8 @@ func PickOne(prompt string, choices []string) (string, error) { if len(choices) == 1 { return choices[0], nil } - if !console.IsTerminal(os.Stdin) || !console.IsTerminal(os.Stderr) { - return "", fmt.Errorf("interactive selection requires a terminal. Provide explicit flags instead") + if err := unavailable(prompt); err != nil { + return "", err } m := newSelectModel(prompt, choices, false) @@ -43,8 +60,8 @@ func PickOne(prompt string, choices []string) (string, error) { // Prepends an "(all)" option for quick select-all. // Returns the selected items or error if cancelled. func PickMany(prompt string, choices []string) ([]string, error) { - if !console.IsTerminal(os.Stdin) || !console.IsTerminal(os.Stderr) { - return nil, fmt.Errorf("interactive selection requires a terminal. Provide explicit flags instead") + if err := unavailable(prompt); err != nil { + return nil, err } if len(choices) == 0 { return nil, fmt.Errorf("no choices available") diff --git a/internal/workspace/errors.go b/internal/workspace/errors.go new file mode 100644 index 0000000..373a04f --- /dev/null +++ b/internal/workspace/errors.go @@ -0,0 +1,74 @@ +package workspace + +import ( + "fmt" + + "github.com/nicksenap/grove/internal/machine" +) + +// Classified errors for the machine CLI contract. Constructing them here — next +// to the logic that detects each condition — keeps the CLI boundary free of +// message pattern-matching and guarantees the human and machine paths report the +// same cause. + +// ErrWorkspaceNotFound reports a workspace name that is not in state. +func ErrWorkspaceNotFound(name string) *machine.Error { + return machine.Errorf(machine.CodeWorkspaceNotFound, "workspace %s not found", name). + WithFix("List existing workspaces, or create this one"). + WithActions( + machine.NextAction("List workspaces", "gw list --format json"), + ) +} + +// ErrNotInWorkspace reports that no workspace could be inferred from the cwd. +func ErrNotInWorkspace() *machine.Error { + return machine.Errorf(machine.CodeWorkspaceNotFound, + "not inside a workspace. Provide a workspace name or cd into one"). + WithFix("Pass a workspace name explicitly, or run from inside a workspace directory"). + WithActions( + machine.NextAction("Discover current context", "gw context --format json"), + machine.NextAction("List workspaces", "gw list --format json"), + ) +} + +// ErrWorkspaceExists reports a name collision on create/rename. +func ErrWorkspaceExists(name string) *machine.Error { + return machine.Errorf(machine.CodeWorkspaceExists, "workspace %s already exists", name). + WithFix("Pick a different name, or delete the existing workspace first"). + WithActions( + machine.NextAction("Inspect the existing workspace", "gw status "+name+" --format json"), + ) +} + +// ErrRepoNotFound reports a repo that is not discoverable or not in a workspace. +func ErrRepoNotFound(name string) *machine.Error { + return machine.Errorf(machine.CodeRepoNotFound, "repo %s not found", name). + WithFix("Check the repo name against discovered repos"). + WithActions( + machine.NextAction("List discovered repos", "gw repos --format json"), + ) +} + +// ErrWorktreeExists reports that a branch already has a worktree in a repo, so +// git cannot check it out again. +func ErrWorktreeExists(branch, repo string) *machine.Error { + return machine.Errorf(machine.CodeWorktreeExists, + "branch %s already has a worktree in %s", branch, repo). + WithFix("Use a different branch name, or remove the existing worktree"). + WithActions( + machine.NextAction("Find the workspace holding that branch", "gw list --status --format json"), + ) +} + +// ErrWorktreeDirty reports uncommitted changes blocking an operation. +func ErrWorktreeDirty(repos []string) *machine.Error { + return machine.Errorf(machine.CodeWorktreeDirty, + "uncommitted changes in: %v", repos). + WithDetails(map[string]any{"dirty_repos": repos}). + WithFix("Commit or stash the changes, or re-run with --force") +} + +// ErrGit wraps a failed git subprocess. +func ErrGit(err error, format string, args ...any) *machine.Error { + return machine.Wrap(machine.CodeGitFailed, err, "%s: %s", fmt.Sprintf(format, args...), err) +} diff --git a/internal/workspace/resolve.go b/internal/workspace/resolve.go index 9cb5d1a..79b71ff 100644 --- a/internal/workspace/resolve.go +++ b/internal/workspace/resolve.go @@ -16,7 +16,7 @@ func ResolveWorkspace(name string) (*models.Workspace, error) { return nil, err } if ws == nil { - return nil, fmt.Errorf("workspace %s not found", name) + return nil, ErrWorkspaceNotFound(name) } return ws, nil } @@ -35,5 +35,5 @@ func ResolveWorkspace(name string) (*models.Workspace, error) { return ws, nil } - return nil, fmt.Errorf("not inside a workspace. Provide a workspace name or cd into one") + return nil, ErrNotInWorkspace() } diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index c1ec64e..d559092 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -79,7 +79,7 @@ func (s *Service) CreateWithOpts(name string, opts CreateOpts) error { return err } if existing != nil { - return fmt.Errorf("workspace %s already exists", name) + return ErrWorkspaceExists(name) } logging.Info("creating workspace %q (branch=%s, repos=%v)", name, branch, repoNames) @@ -98,7 +98,7 @@ func (s *Service) CreateWithOpts(name string, opts CreateOpts) error { sourcePath, ok := repoMap[repoName] if !ok { os.RemoveAll(wsPath) - return fmt.Errorf("repo %s not found", repoName) + return ErrRepoNotFound(repoName) } sourcePaths[i] = sourcePath } @@ -177,7 +177,7 @@ func provisionWorktreeNoFetch(sourcePath, repoName, wsPath, branch string, mode // Check if branch already has a worktree hasWT, _ := gitops.WorktreeHasBranch(sourcePath, branch) if hasWT { - return nil, fmt.Errorf("branch %s already has a worktree in %s", branch, repoName) + return nil, ErrWorktreeExists(branch, repoName) } // Track mode: check out an existing remote branch (e.g. a PR head) rather @@ -187,7 +187,7 @@ func provisionWorktreeNoFetch(sourcePath, repoName, wsPath, branch string, mode if gitops.RemoteBranchExists(sourcePath, branch) { logging.Info("tracking existing remote branch %q in %s", branch, repoName) if err := gitops.WorktreeAddTracking(sourcePath, wtPath, branch); err != nil { - return nil, fmt.Errorf("adding tracking worktree: %w", err) + return nil, ErrGit(err, "adding tracking worktree for %s", repoName) } return &models.RepoWorktree{ RepoName: repoName, @@ -213,7 +213,7 @@ func provisionWorktreeNoFetch(sourcePath, repoName, wsPath, branch string, mode plainBase := strings.TrimPrefix(base, "origin/") if err2 := gitops.CreateBranch(sourcePath, branch, plainBase); err2 != nil { if err3 := gitops.CreateBranch(sourcePath, branch, "HEAD"); err3 != nil { - return nil, fmt.Errorf("creating branch: %w", err) + return nil, ErrGit(err, "creating branch %s in %s", branch, repoName) } } } @@ -221,7 +221,7 @@ func provisionWorktreeNoFetch(sourcePath, repoName, wsPath, branch string, mode // Add worktree if err := gitops.WorktreeAdd(sourcePath, wtPath, branch); err != nil { - return nil, fmt.Errorf("adding worktree: %w", err) + return nil, ErrGit(err, "adding worktree for %s", repoName) } return &models.RepoWorktree{ @@ -275,7 +275,7 @@ func (s *Service) Delete(name string) error { return err } if ws == nil { - return fmt.Errorf("workspace %s not found", name) + return ErrWorkspaceNotFound(name) } logging.Info("deleting workspace %q", name) @@ -345,7 +345,7 @@ func (s *Service) Rename(oldName, newName string) error { return err } if ws == nil { - return fmt.Errorf("workspace %s not found", oldName) + return ErrWorkspaceNotFound(oldName) } existing, err := s.State.GetWorkspace(newName) @@ -353,7 +353,7 @@ func (s *Service) Rename(oldName, newName string) error { return err } if existing != nil { - return fmt.Errorf("workspace %s already exists", newName) + return ErrWorkspaceExists(newName) } oldPath := ws.Path @@ -406,7 +406,7 @@ func (s *Service) AddRepos(wsName string, repoNames []string, repoMap map[string return err } if ws == nil { - return fmt.Errorf("workspace %s not found", wsName) + return ErrWorkspaceNotFound(wsName) } existing := make(map[string]bool) @@ -430,7 +430,7 @@ func (s *Service) AddRepos(wsName string, repoNames []string, repoMap map[string for _, repoName := range toAdd { sourcePath, ok := repoMap[repoName] if !ok { - return fmt.Errorf("repo %s not found", repoName) + return ErrRepoNotFound(repoName) } rw, err := provisionWorktree(sourcePath, repoName, ws.Path, ws.Branch) @@ -460,7 +460,7 @@ func (s *Service) RemoveRepos(wsName string, repoNames []string) error { return err } if ws == nil { - return fmt.Errorf("workspace %s not found", wsName) + return ErrWorkspaceNotFound(wsName) } type removeItem struct { @@ -570,7 +570,7 @@ func (s *Service) Sync(wsName string) error { return err } if ws == nil { - return fmt.Errorf("workspace %s not found", wsName) + return ErrWorkspaceNotFound(wsName) } logging.Info("syncing workspace %q", wsName) @@ -588,7 +588,10 @@ func (s *Service) Sync(wsName string) error { return nil } -type repoStatusResult struct { +// RepoStatus is one repo's git state within a workspace. Ahead/Behind are +// strings because "-" means "could not be determined" — a distinct outcome from +// zero that agents need to see rather than have flattened into 0. +type RepoStatus struct { Repo string `json:"repo"` Branch string `json:"branch"` Status string `json:"status"` @@ -597,8 +600,42 @@ type repoStatusResult struct { PR *gitops.PRInfo `json:"pr,omitempty"` } -func collectRepoStatus(r models.RepoWorktree) repoStatusResult { - rs := repoStatusResult{ +// Clean reports whether the worktree has no uncommitted changes. +func (r RepoStatus) Clean() bool { return r.Status == "clean" || r.Status == "" } + +// StatusReport is the full status of a workspace: the data behind both the human +// table and the machine envelope, so the two can never disagree. +type StatusReport struct { + Workspace string `json:"workspace"` + Path string `json:"path"` + Branch string `json:"branch"` + Source *models.WorkspaceSource `json:"source,omitempty"` + Repos []RepoStatus `json:"repos"` +} + +// Dirty returns the names of repos with uncommitted changes. +func (r *StatusReport) Dirty() []string { + var names []string + for _, repo := range r.Repos { + if !repo.Clean() { + names = append(names, repo.Repo) + } + } + return names +} + +// Behind reports whether any repo has commits to pull in from its base branch. +func (r *StatusReport) Behind() bool { + for _, repo := range r.Repos { + if repo.Behind != "" && repo.Behind != "-" && repo.Behind != "0" { + return true + } + } + return false +} + +func collectRepoStatus(r models.RepoWorktree) RepoStatus { + rs := RepoStatus{ Repo: r.RepoName, Branch: r.Branch, } @@ -691,29 +728,43 @@ type StatusOptions struct { PR bool } -// Status displays git status for a workspace. -func (s *Service) Status(wsName string, opts StatusOptions) error { +// StatusReport collects git status for a workspace without printing anything. +func (s *Service) StatusReport(wsName string, opts StatusOptions) (*StatusReport, error) { ws, err := s.State.GetWorkspace(wsName) if err != nil { - return err + return nil, err } if ws == nil { - return fmt.Errorf("workspace %s not found", wsName) + return nil, ErrWorkspaceNotFound(wsName) } - results := s.fetchStatusResults(ws.Repos, opts.PR) + return &StatusReport{ + Workspace: ws.Name, + Path: ws.Path, + Branch: ws.Branch, + Source: ws.Source, + Repos: s.fetchStatusResults(ws.Repos, opts.PR), + }, nil +} + +// Status displays git status for a workspace as a human-oriented table. +func (s *Service) Status(wsName string, opts StatusOptions) error { + report, err := s.StatusReport(wsName, opts) + if err != nil { + return err + } if opts.JSON { - return s.printStatusJSON(ws, results) + return s.printStatusJSON(report) } - s.printStatusTable(ws, results, opts) - s.printVerboseStatus(results, opts) + s.printStatusTable(report, opts) + s.printVerboseStatus(report.Repos, opts) return nil } -func (s *Service) fetchStatusResults(repos []models.RepoWorktree, withPR bool) []repoStatusResult { - results := make([]repoStatusResult, len(repos)) +func (s *Service) fetchStatusResults(repos []models.RepoWorktree, withPR bool) []RepoStatus { + results := make([]RepoStatus, len(repos)) var wg sync.WaitGroup for i, r := range repos { wg.Add(1) @@ -729,26 +780,18 @@ func (s *Service) fetchStatusResults(repos []models.RepoWorktree, withPR bool) [ return results } -func (s *Service) printStatusJSON(ws *models.Workspace, results []repoStatusResult) error { - type wsStatus struct { - Workspace string `json:"workspace"` - Path string `json:"path"` - Source *models.WorkspaceSource `json:"source,omitempty"` - Repos []repoStatusResult `json:"repos"` - } - data, _ := json.MarshalIndent(wsStatus{ - Workspace: ws.Name, - Path: ws.Path, - Source: ws.Source, - Repos: results, - }, "", " ") +// printStatusJSON emits the legacy bare-object shape behind the deprecated +// `--json` flag. New consumers should use `--format json`, which wraps the same +// StatusReport in the versioned envelope. +func (s *Service) printStatusJSON(report *StatusReport) error { + data, _ := json.MarshalIndent(report, "", " ") fmt.Println(string(data)) return nil } -func (s *Service) printStatusTable(ws *models.Workspace, results []repoStatusResult, opts StatusOptions) { - fmt.Fprintf(os.Stdout, "Workspace: %s (%s)\n", ws.Name, ws.Path) - if line := formatSourceLine(ws.Source); line != "" { +func (s *Service) printStatusTable(report *StatusReport, opts StatusOptions) { + fmt.Fprintf(os.Stdout, "Workspace: %s (%s)\n", report.Workspace, report.Path) + if line := formatSourceLine(report.Source); line != "" { fmt.Fprintf(os.Stdout, "%s\n", line) } fmt.Fprintln(os.Stdout) @@ -759,13 +802,13 @@ func (s *Service) printStatusTable(ws *models.Workspace, results []repoStatusRes } table := console.NewTable(os.Stdout, headers) - for _, rs := range results { + for _, rs := range report.Repos { table.AddRow(statusRow(rs, opts.PR)) } table.Render() } -func statusRow(rs repoStatusResult, withPR bool) []string { +func statusRow(rs RepoStatus, withPR bool) []string { upDown := formatUpDown(rs.Ahead, rs.Behind) statusStr := formatStatus(rs.Status) if withPR { @@ -793,7 +836,7 @@ func formatStatus(status string) string { return fmt.Sprintf("%d changed", lines) } -func (s *Service) printVerboseStatus(results []repoStatusResult, opts StatusOptions) { +func (s *Service) printVerboseStatus(results []RepoStatus, opts StatusOptions) { if !opts.Verbose { return } From ad597d8ce0aba85a02d386190955cce580ae9a85 Mon Sep 17 00:00:00 2001 From: Nick Song Date: Fri, 31 Jul 2026 18:42:12 +0200 Subject: [PATCH 04/21] Return structured per-repo results from mutating operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create/delete/sync/add-repo/remove-repo/run now report what happened to each repo instead of printing prose and returning a bare error. Multi-repo work is partially failable by nature, so "did it work?" is not answerable with one boolean — the envelope carries a result per repo with a stable outcome from a fixed vocabulary (created, added, already_present, removed, not_found, rebased, up_to_date, skipped, exited, failed) plus a Detail explaining any non-obvious outcome. Service API: CreateWithOpts/Delete/Sync/AddRepos/RemoveRepos/Run return typed results. The positional Create() wrapper keeps its error-only signature since it exists for historical call sites. Failure-vs-data decisions, which are the substance of this commit: - Sync returns ok:true with per-repo outcomes even when a repo could not be rebased. Sibling repos may have advanced, and reporting a command failure would hide that. Only an unreadable workspace is an error. - Create stays all-or-nothing and returns no result on failure, so a caller can never mistake a rolled-back attempt for a half-built workspace. - Delete reports state_removed, distinguishing "gone" from "partially gone" when a worktree could not be removed and the state entry was kept on purpose so `gw doctor` can still find the leftover. - add-repo/remove-repo treat already-present and not-found repos as outcomes rather than errors, so an agent retrying after a partial failure converges. - AddRepos now persists repos that succeeded before a later repo failed; abandoning them left worktrees on disk that state did not know about. - Sync's dirty/undeterminable-upstream cases became explicit skipped outcomes with reasons instead of stderr-only warnings. - A post_create hook failure reports HOOK_FAILED with the workspace in details, so the caller knows the workspace exists and must not retry create. Machine-mode stdout stays a single envelope even while running foreign code: per-repo setup/run hook stdout is redirected to stderr (`gw run` children keep their [repo] prefixes there), since hook output is arbitrary text. Lifecycle hooks already wrote to stderr only. Destructive operations require explicit intent in machine mode: delete, remove-repo, and create --replace demand --force rather than treating a prompt they are forbidden to show as consent. `gw delete` with no NAME is a USAGE error instead of an interactive picker. Behavior preserved deliberately: remove-repo still deletes branches without --force while whole-workspace delete still forces, so unmerged work survives removing a single repo. A test pins that difference now that both paths share deleteRepo(). --- cmd/addrepo.go | 16 +- cmd/create.go | 64 +++++-- cmd/delete.go | 23 ++- cmd/removerepo.go | 20 +- cmd/run.go | 10 +- cmd/sync_cmd.go | 31 ++- internal/workspace/results.go | 112 +++++++++++ internal/workspace/results_test.go | 237 +++++++++++++++++++++++ internal/workspace/run.go | 64 ++++++- internal/workspace/service.go | 7 + internal/workspace/workspace.go | 276 +++++++++++++++++---------- internal/workspace/workspace_test.go | 52 ++--- 12 files changed, 746 insertions(+), 166 deletions(-) create mode 100644 internal/workspace/results.go create mode 100644 internal/workspace/results_test.go diff --git a/cmd/addrepo.go b/cmd/addrepo.go index 7605832..0dc192e 100644 --- a/cmd/addrepo.go +++ b/cmd/addrepo.go @@ -8,6 +8,7 @@ import ( "github.com/nicksenap/grove/internal/console" "github.com/nicksenap/grove/internal/discover" "github.com/nicksenap/grove/internal/gitops" + "github.com/nicksenap/grove/internal/machine" "github.com/nicksenap/grove/internal/picker" "github.com/nicksenap/grove/internal/state" "github.com/nicksenap/grove/internal/workspace" @@ -39,7 +40,9 @@ var addRepoCmd = &cobra.Command{ exitError(err.Error()) } if len(workspaces) == 0 { - exitError("No workspaces") + fail(machine.Errorf(machine.CodeNoWorkspaces, "no workspaces exist"). + WithActions(machine.NextAction("Create one", + "gw create -r -b --format json"))) } choices := make([]string, len(workspaces)) for i, ws := range workspaces { @@ -102,7 +105,9 @@ var addRepoCmd = &cobra.Command{ } } if len(choices) == 0 { - exitError("All discovered repos are already in the workspace") + fail(machine.Errorf(machine.CodeUsage, + "all discovered repos are already in the workspace — nothing to add"). + WithActions(machine.NextAction("Discover more repo directories", "gw add-dir "))) } selected, err := picker.PickMany("Select repos to add:", choices) if err != nil { @@ -111,9 +116,12 @@ var addRepoCmd = &cobra.Command{ repoNames = selected } - if err := workspace.NewService().AddRepos(wsName, repoNames, repoMap); err != nil { - exitError(err.Error()) + result, err := workspace.NewService().AddRepos(wsName, repoNames, repoMap) + if err != nil { + fail(err) } + machine.Emit(result, + machine.NextAction("Inspect repo state", "gw status "+wsName+" --format json")) }, } diff --git a/cmd/create.go b/cmd/create.go index 9e161c5..78a6407 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -11,6 +11,7 @@ import ( "github.com/nicksenap/grove/internal/discover" "github.com/nicksenap/grove/internal/gitops" "github.com/nicksenap/grove/internal/lifecycle" + "github.com/nicksenap/grove/internal/machine" "github.com/nicksenap/grove/internal/models" "github.com/nicksenap/grove/internal/picker" "github.com/nicksenap/grove/internal/state" @@ -47,7 +48,8 @@ var createCmd = &cobra.Command{ if createPreset != "" { preset, ok := cfg.Presets[createPreset] if !ok { - exitError("Preset not found: " + createPreset) + fail(machine.Errorf(machine.CodeUsage, "preset %s not found", createPreset). + WithActions(machine.NextAction("List presets", "gw preset list --format json"))) } repoNames = preset.Repos } else if createAll { @@ -66,15 +68,18 @@ var createCmd = &cobra.Command{ continue } if len(cfg.RepoDirs) == 0 { - exitError("No repo_dirs configured — cannot clone remote repo") + fail(machine.Errorf(machine.CodeNotInitialized, "no repo_dirs configured — cannot clone %s", name). + WithActions(machine.NextAction("Add a repo directory", "gw add-dir "))) } console.Infof("Cloning %s ...", name) clonedPath, repoName, err := gitops.Clone(name, cfg.RepoDirs[0]) if err != nil { - exitError(err.Error()) + fail(machine.Wrap(machine.CodeTransient, err, "cloning %s: %s", name, err). + WithFix("Check network access and repository permissions, then retry")) } if existing, ok := repoMap[repoName]; ok && existing != clonedPath { - exitError("repo name conflict: " + repoName + " already exists locally at " + existing) + fail(machine.Errorf(machine.CodeBranchConflict, + "repo name conflict: %s already exists locally at %s", repoName, existing)) } repoMap[repoName] = clonedPath repoNames[i] = repoName @@ -147,7 +152,8 @@ var createCmd = &cobra.Command{ // Validate repos exist for _, name := range repoNames { if _, ok := repoMap[name]; !ok { - exitError("Unknown repo: " + name + ". Available: " + strings.Join(repoNamesList(repos), ", ")) + fail(workspace.ErrRepoNotFound(name). + WithDetails(map[string]any{"available": repoNamesList(repos)})) } } @@ -160,11 +166,13 @@ var createCmd = &cobra.Command{ // Branch — prompt if omitted and in a terminal. branch := createBranch if branch == "" { + requireArgs("--branch", "gw create "+name+" -b feat/x --format json") if console.IsTerminal(os.Stdin) { branch = console.PromptDefault("Branch name", name) } if branch == "" { - exitError("Branch is required: --branch / -b") + fail(machine.Errorf(machine.CodeUsage, "branch is required"). + WithFix("Pass --branch / -b")) } } if name == "" { @@ -176,16 +184,24 @@ var createCmd = &cobra.Command{ if createReplace { cwd, err := os.Getwd() if err != nil { - exitError("cannot determine working directory: " + err.Error()) + fail(machine.Wrap(machine.CodeInternal, err, "cannot determine working directory: %s", err)) } currentWs, _ := state.FindWorkspaceByPath(cwd) if currentWs == nil { - exitError("--replace requires running from inside an existing workspace") + fail(machine.Errorf(machine.CodeUsage, + "--replace requires running from inside an existing workspace"). + WithActions(machine.NextAction("Discover current context", "gw context --format json"))) } if currentWs.Name == name { - exitError("--replace would collide: new workspace name matches the current one (" + name + "). Pass a different NAME.") + fail(machine.Errorf(machine.CodeWorkspaceExists, + "--replace would collide: new workspace name matches the current one (%s)", name). + WithFix("Pass a different NAME")) } + // --replace deletes an existing workspace, so machine mode demands the + // destructive intent be explicit rather than inferred from a skipped + // prompt. if !createForce { + requireArgs("--force (with --replace)", "gw create "+name+" -b --replace --force --format json") if !console.Confirm("Delete workspace "+currentWs.Name+" and replace with "+name+"?", false) { return } @@ -194,12 +210,12 @@ var createCmd = &cobra.Command{ vars := lifecycle.Vars{Name: currentWs.Name, Path: currentWs.Path, Branch: currentWs.Branch} if err := lifecycle.Run("pre_delete", vars); err != nil && !errors.Is(err, lifecycle.ErrNoHook) { if lifecycle.ShouldAbort(err) { - exitError(err.Error()) + fail(machine.Wrap(machine.CodeHookFailed, err, "%s", err)) } console.Warning(err.Error()) } - if err := workspace.NewService().Delete(currentWs.Name); err != nil { - exitError("failed to delete current workspace: " + err.Error()) + if _, err := workspace.NewService().Delete(currentWs.Name); err != nil { + fail(machine.Wrap(machine.CodeFor(err), err, "failed to delete current workspace: %s", err)) } replacedName = currentWs.Name } @@ -227,12 +243,19 @@ var createCmd = &cobra.Command{ opts.BranchMode = workspace.BranchModeTrack } - if err := workspace.NewService().CreateWithOpts(name, opts); err != nil { + result, err := workspace.NewService().CreateWithOpts(name, opts) + if err != nil { if replacedName != "" { - exitError("failed to create new workspace (old workspace " + replacedName + " was already deleted): " + err.Error()) + // The replaced workspace is already gone, so this is not a no-op + // failure — say so explicitly instead of leaving the caller to + // assume nothing changed. + fail(machine.Wrap(machine.CodeFor(err), err, + "failed to create new workspace (old workspace %s was already deleted): %s", replacedName, err). + WithDetails(map[string]any{"deleted_workspace": replacedName})) } - exitError(err.Error()) + fail(err) } + result.Replaced = replacedName // Fire post_create hook if configured wsPath := filepath.Join(cfg.WorkspaceDir, name) @@ -244,10 +267,19 @@ var createCmd = &cobra.Command{ } if err := lifecycle.Run("post_create", vars); err != nil && !errors.Is(err, lifecycle.ErrNoHook) { if lifecycle.ShouldAbort(err) { - exitError(err.Error()) + // The workspace exists; the hook is what failed. Report the + // workspace in details so the caller does not retry create. + fail(machine.Wrap(machine.CodeHookFailed, err, "%s", err). + WithDetails(map[string]any{"workspace": name, "path": wsPath}). + WithFix("Fix the post_create hook, or re-run with --no-hooks")) } console.Warning(err.Error()) } + + machine.Emit(result, + machine.NextAction("Inspect repo state", "gw status "+name+" --format json"), + machine.NextAction("Run configured processes", "gw run "+name+" --format json"), + ) }, } diff --git a/cmd/delete.go b/cmd/delete.go index d5fe144..94e8319 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -7,6 +7,7 @@ import ( "github.com/nicksenap/grove/internal/console" "github.com/nicksenap/grove/internal/lifecycle" + "github.com/nicksenap/grove/internal/machine" "github.com/nicksenap/grove/internal/picker" "github.com/nicksenap/grove/internal/state" "github.com/nicksenap/grove/internal/workspace" @@ -44,13 +45,14 @@ func doDelete(args []string, force bool) { if len(args) > 0 { names = []string{args[0]} } else { + requireArgs("NAME", "gw delete --force --format json") // Interactive multi-select workspaces, err := state.Load() if err != nil { - exitError(err.Error()) + fail(err) } if len(workspaces) == 0 { - exitError("No workspaces to delete") + fail(machine.Errorf(machine.CodeNoWorkspaces, "no workspaces to delete")) } choices := make([]string, len(workspaces)) for i, ws := range workspaces { @@ -64,11 +66,15 @@ func doDelete(args []string, force bool) { } if !force { + // Deletion destroys worktrees and branches. In machine mode a skipped + // prompt must never be read as consent, so --force is mandatory. + requireArgs("--force", "gw delete "+names[0]+" --force --format json") if !console.Confirm(fmt.Sprintf("Delete %s?", strings.Join(names, ", ")), false) { return } } + results := make([]*workspace.DeleteResult, 0, len(names)) for _, name := range names { // Fire pre_delete hook before teardown (e.g. harvest Claude memory) ws, _ := state.GetWorkspace(name) @@ -76,16 +82,23 @@ func doDelete(args []string, force bool) { vars := lifecycle.Vars{Name: name, Path: ws.Path, Branch: ws.Branch} if err := lifecycle.Run("pre_delete", vars); err != nil && !errors.Is(err, lifecycle.ErrNoHook) { if lifecycle.ShouldAbort(err) { - exitError(err.Error()) + fail(machine.Wrap(machine.CodeHookFailed, err, "%s", err). + WithDetails(map[string]any{"workspace": name}). + WithFix("Fix the pre_delete hook, or re-run with --no-hooks")) } console.Warning(err.Error()) } } - if err := workspace.NewService().Delete(name); err != nil { - exitError(err.Error()) + result, err := workspace.NewService().Delete(name) + if err != nil { + fail(err) } + results = append(results, result) } + + machine.Emit(map[string]any{"deleted": results, "count": len(results)}, + machine.NextAction("List remaining workspaces", "gw list --format json")) } func init() { diff --git a/cmd/removerepo.go b/cmd/removerepo.go index 4bdffc2..d38e941 100644 --- a/cmd/removerepo.go +++ b/cmd/removerepo.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/nicksenap/grove/internal/console" + "github.com/nicksenap/grove/internal/machine" "github.com/nicksenap/grove/internal/picker" "github.com/nicksenap/grove/internal/state" "github.com/nicksenap/grove/internal/workspace" @@ -30,7 +31,7 @@ var removeRepoCmd = &cobra.Command{ exitError(err.Error()) } if len(workspaces) == 0 { - exitError("No workspaces") + fail(machine.Errorf(machine.CodeNoWorkspaces, "no workspaces exist")) } choices := make([]string, len(workspaces)) for i, ws := range workspaces { @@ -53,13 +54,13 @@ var removeRepoCmd = &cobra.Command{ // Interactive: pick from repos in workspace ws, err := state.GetWorkspace(wsName) if err != nil { - exitError(err.Error()) + fail(err) } if ws == nil { - exitError("Workspace not found: " + wsName) + fail(workspace.ErrWorkspaceNotFound(wsName)) } if len(ws.Repos) == 0 { - exitError("No repos in workspace") + fail(machine.Errorf(machine.CodeRepoNotFound, "workspace %s has no repos", wsName)) } choices := ws.RepoNames() selected, err := picker.PickMany("Select repos to remove:", choices) @@ -70,14 +71,21 @@ var removeRepoCmd = &cobra.Command{ } if !removeRepoForce { + // Removing a repo deletes its worktree, so machine mode requires the + // destructive intent to be explicit instead of assumed from a prompt + // it is not allowed to show. + requireArgs("--force", "gw remove-repo "+wsName+" -r --force --format json") if !console.Confirm(fmt.Sprintf("Remove %s from %s?", strings.Join(repoNames, ", "), wsName), false) { return } } - if err := workspace.NewService().RemoveRepos(wsName, repoNames); err != nil { - exitError(err.Error()) + result, err := workspace.NewService().RemoveRepos(wsName, repoNames) + if err != nil { + fail(err) } + machine.Emit(result, + machine.NextAction("Inspect repo state", "gw status "+wsName+" --format json")) }, } diff --git a/cmd/run.go b/cmd/run.go index fef369d..dddc613 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -1,6 +1,7 @@ package cmd import ( + "github.com/nicksenap/grove/internal/machine" "github.com/nicksenap/grove/internal/workspace" "github.com/spf13/cobra" ) @@ -16,8 +17,13 @@ var runCmd = &cobra.Command{ name = args[0] } - if err := workspace.Run(name); err != nil { - exitError(err.Error()) + result, err := workspace.Run(name) + if err != nil { + fail(err) } + + // Child output already went to stderr in machine mode, so stdout still + // holds exactly one envelope describing how each process ended. + machine.Emit(result) }, } diff --git a/cmd/sync_cmd.go b/cmd/sync_cmd.go index 0f87e38..a680b4f 100644 --- a/cmd/sync_cmd.go +++ b/cmd/sync_cmd.go @@ -1,6 +1,7 @@ package cmd import ( + "github.com/nicksenap/grove/internal/machine" "github.com/nicksenap/grove/internal/workspace" "github.com/spf13/cobra" ) @@ -18,15 +19,39 @@ var syncCmd = &cobra.Command{ ws, err := workspace.ResolveWorkspace(name) if err != nil { - exitError(err.Error()) + fail(err) } - if err := workspace.NewService().Sync(ws.Name); err != nil { - exitError(err.Error()) + result, err := workspace.NewService().Sync(ws.Name) + if err != nil { + fail(err) } + + // A repo that could not be rebased is reported in the result, not as a + // command failure: sibling repos may have advanced, and the caller needs + // to see which did. + machine.Emit(result, syncNextActions(result)...) }, } +func syncNextActions(result *workspace.SyncResult) []machine.Action { + var actions []machine.Action + if failed := workspace.FailedRepos(result.Repos); len(failed) > 0 { + actions = append(actions, machine.NextAction( + "Inspect the repos that could not be rebased", + "gw status "+result.Workspace+" --format json")) + } + for _, r := range result.Repos { + if r.Outcome == workspace.OutcomeSkipped && r.Detail == "dirty working tree" { + actions = append(actions, machine.NextAction( + "Commit or stash changes in "+r.Repo+", then re-run sync", + "git -C "+r.Path+" status")) + break + } + } + return actions +} + func init() { syncCmd.ValidArgsFunction = completeWorkspaceNames } diff --git a/internal/workspace/results.go b/internal/workspace/results.go new file mode 100644 index 0000000..dd457a3 --- /dev/null +++ b/internal/workspace/results.go @@ -0,0 +1,112 @@ +package workspace + +import "github.com/nicksenap/grove/internal/models" + +// Per-repo outcome vocabulary. These strings are part of the machine CLI +// contract (docs/agent-cli.md): an agent branches on them to decide what to do +// next, so they are stable identifiers rather than prose. +const ( + // Create / add. + OutcomeCreated = "created" + OutcomeAdded = "added" + OutcomeAlreadyExists = "already_present" + + // Delete / remove. + OutcomeRemoved = "removed" + OutcomeNotFound = "not_found" + + // Sync. + OutcomeRebased = "rebased" + OutcomeUpToDate = "up_to_date" + OutcomeSkipped = "skipped" + + // Run. + OutcomeExited = "exited" + + // Any operation. + OutcomeFailed = "failed" +) + +// RepoResult is one repo's outcome within a multi-repo operation. Multi-repo +// work is partially failable by nature, so every operation reports per-repo +// results instead of collapsing them into a single boolean. +type RepoResult struct { + Repo string `json:"repo"` + Outcome string `json:"outcome"` + Branch string `json:"branch,omitempty"` + Path string `json:"path,omitempty"` + // Detail explains a non-obvious outcome: why a repo was skipped, what + // failed, or how many commits a rebase moved. + Detail string `json:"detail,omitempty"` +} + +// Failed reports whether this repo's operation did not achieve its goal. +func (r RepoResult) Failed() bool { return r.Outcome == OutcomeFailed } + +// CreateResult describes a created workspace. +type CreateResult struct { + Name string `json:"name"` + Path string `json:"path"` + Branch string `json:"branch"` + Source *models.WorkspaceSource `json:"source,omitempty"` + Repos []RepoResult `json:"repos"` + Replaced string `json:"replaced,omitempty"` +} + +// DeleteResult describes a deleted workspace. StateRemoved is false when some +// worktree could not be removed and the state entry was deliberately kept, so an +// agent can tell "gone" from "partially gone". +type DeleteResult struct { + Name string `json:"name"` + Path string `json:"path"` + Repos []RepoResult `json:"repos"` + StateRemoved bool `json:"state_removed"` +} + +// SyncResult describes a sync across a workspace's repos. +type SyncResult struct { + Workspace string `json:"workspace"` + Repos []RepoResult `json:"repos"` +} + +// ReposChangeResult describes an add-repo / remove-repo operation. +type ReposChangeResult struct { + Workspace string `json:"workspace"` + Repos []RepoResult `json:"repos"` +} + +// RunResult describes one `gw run` invocation. ExitCode is the child process's +// status; -1 means it never started. +type RunResult struct { + Workspace string `json:"workspace"` + Repos []RunRepoResult `json:"repos"` +} + +// RunRepoResult is one repo's run-hook outcome. +type RunRepoResult struct { + Repo string `json:"repo"` + Outcome string `json:"outcome"` + ExitCode int `json:"exit_code"` + Detail string `json:"detail,omitempty"` +} + +// anyFailed reports whether any repo failed. +func anyFailed(results []RepoResult) bool { + for _, r := range results { + if r.Failed() { + return true + } + } + return false +} + +// FailedRepos returns the names of repos whose operation failed. +func FailedRepos(results []RepoResult) []string { + var names []string + for _, r := range results { + if r.Failed() { + names = append(names, r.Repo) + } + } + return names +} diff --git a/internal/workspace/results_test.go b/internal/workspace/results_test.go new file mode 100644 index 0000000..77c45da --- /dev/null +++ b/internal/workspace/results_test.go @@ -0,0 +1,237 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" +) + +// The machine CLI contract promises per-repo results for every mutating +// operation, so these tests pin the outcomes rather than just "no error". + +func outcomeOf(t *testing.T, results []RepoResult, repo string) RepoResult { + t.Helper() + for _, r := range results { + if r.Repo == repo { + return r + } + } + t.Fatalf("no result for repo %q in %+v", repo, results) + return RepoResult{} +} + +func TestCreateResultReportsEveryRepo(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.createRepo("web") + + result, err := env.svc.CreateWithOpts("res-ws", CreateOpts{ + Branch: "feat/res", + Repos: []string{"api", "web"}, + RepoMap: env.repoMap, + Cfg: env.cfg, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + if result.Name != "res-ws" || result.Branch != "feat/res" { + t.Errorf("result identity = %s/%s", result.Name, result.Branch) + } + if len(result.Repos) != 2 { + t.Fatalf("expected 2 repo results, got %d", len(result.Repos)) + } + for _, repo := range []string{"api", "web"} { + r := outcomeOf(t, result.Repos, repo) + if r.Outcome != OutcomeCreated { + t.Errorf("%s outcome = %s, want %s", repo, r.Outcome, OutcomeCreated) + } + if r.Path == "" || r.Branch != "feat/res" { + t.Errorf("%s result missing path/branch: %+v", repo, r) + } + if _, err := os.Stat(r.Path); err != nil { + t.Errorf("%s reported path %s does not exist: %v", repo, r.Path, err) + } + } +} + +// Create is all-or-nothing, so a failure must not report a half-built workspace. +func TestCreateFailureReturnsNoResult(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + + result, err := env.svc.CreateWithOpts("bad-ws", CreateOpts{ + Branch: "feat/bad", + Repos: []string{"api", "ghost"}, + RepoMap: env.repoMap, + Cfg: env.cfg, + }) + if err == nil { + t.Fatal("expected an error for an unknown repo") + } + if result != nil { + t.Errorf("expected no result on failure, got %+v", result) + } +} + +func TestDeleteResultReportsRemovals(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.createRepo("web") + env.svc.Create("del-res", "feat/del", []string{"api", "web"}, env.repoMap, env.cfg) + + result, err := env.svc.Delete("del-res") + if err != nil { + t.Fatalf("delete: %v", err) + } + if !result.StateRemoved { + t.Error("a clean delete should remove the state entry") + } + if len(result.Repos) != 2 { + t.Fatalf("expected 2 repo results, got %d", len(result.Repos)) + } + for _, r := range result.Repos { + if r.Outcome != OutcomeRemoved { + t.Errorf("%s outcome = %s (%s), want %s", r.Repo, r.Outcome, r.Detail, OutcomeRemoved) + } + } +} + +func TestSyncResultDistinguishesOutcomes(t *testing.T) { + env := setupTestEnv(t) + env.createRepoWithRemote("clean") + env.createRepoWithRemote("dirty") + env.svc.Create("sync-res", "feat/sync", []string{"clean", "dirty"}, env.repoMap, env.cfg) + + // Leave one worktree dirty; sync must skip it rather than fail the command. + dirtyPath := filepath.Join(env.wsDir, "sync-res", "dirty") + os.WriteFile(filepath.Join(dirtyPath, "scratch.txt"), []byte("wip"), 0o644) + + result, err := env.svc.Sync("sync-res") + if err != nil { + t.Fatalf("sync: %v", err) + } + + clean := outcomeOf(t, result.Repos, "clean") + if clean.Outcome != OutcomeUpToDate { + t.Errorf("clean repo outcome = %s (%s), want %s", clean.Outcome, clean.Detail, OutcomeUpToDate) + } + + dirty := outcomeOf(t, result.Repos, "dirty") + if dirty.Outcome != OutcomeSkipped { + t.Errorf("dirty repo outcome = %s, want %s", dirty.Outcome, OutcomeSkipped) + } + if dirty.Detail == "" { + t.Error("a skipped repo must explain why") + } +} + +func TestSyncReportsRebase(t *testing.T) { + env := setupTestEnv(t) + clone := env.createRepoWithRemote("api") + env.svc.Create("rebase-res", "feat/rebase", []string{"api"}, env.repoMap, env.cfg) + + // Advance the base branch so the workspace branch is behind. + base := env.run(clone, "git", "branch", "--show-current") + os.WriteFile(filepath.Join(clone, "upstream.txt"), []byte("new"), 0o644) + env.run(clone, "git", "add", ".") + env.run(clone, "git", "commit", "-q", "-m", "upstream work") + env.run(clone, "git", "push", "-q", "origin", base) + + result, err := env.svc.Sync("rebase-res") + if err != nil { + t.Fatalf("sync: %v", err) + } + api := outcomeOf(t, result.Repos, "api") + if api.Outcome != OutcomeRebased { + t.Fatalf("outcome = %s (%s), want %s", api.Outcome, api.Detail, OutcomeRebased) + } + if api.Detail == "" { + t.Error("a rebase should report what it moved onto") + } +} + +// Adding a repo that is already there is a no-op, not a failure: an agent +// retrying after a partial failure must be able to converge. +func TestAddReposReportsAlreadyPresent(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.createRepo("web") + env.svc.Create("add-res", "feat/add", []string{"api"}, env.repoMap, env.cfg) + + result, err := env.svc.AddRepos("add-res", []string{"api", "web"}, env.repoMap) + if err != nil { + t.Fatalf("add-repo: %v", err) + } + if got := outcomeOf(t, result.Repos, "api"); got.Outcome != OutcomeAlreadyExists { + t.Errorf("api outcome = %s, want %s", got.Outcome, OutcomeAlreadyExists) + } + if got := outcomeOf(t, result.Repos, "web"); got.Outcome != OutcomeAdded { + t.Errorf("web outcome = %s, want %s", got.Outcome, OutcomeAdded) + } +} + +// Likewise, removing a repo that is not in the workspace converges instead of +// failing. +func TestRemoveReposReportsNotFound(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.createRepo("web") + env.svc.Create("rm-res", "feat/rm", []string{"api", "web"}, env.repoMap, env.cfg) + + result, err := env.svc.RemoveRepos("rm-res", []string{"web", "ghost"}) + if err != nil { + t.Fatalf("remove-repo: %v", err) + } + if got := outcomeOf(t, result.Repos, "web"); got.Outcome != OutcomeRemoved { + t.Errorf("web outcome = %s (%s), want %s", got.Outcome, got.Detail, OutcomeRemoved) + } + if got := outcomeOf(t, result.Repos, "ghost"); got.Outcome != OutcomeNotFound { + t.Errorf("ghost outcome = %s, want %s", got.Outcome, OutcomeNotFound) + } + + ws, _ := env.svc.State.GetWorkspace("rm-res") + if ws.FindRepo("web") != nil { + t.Error("web should be gone from state") + } +} + +// Removing a single repo must not force-delete its branch — unmerged work is +// preserved, unlike deleting the whole workspace. +func TestRemoveReposKeepsUnmergedBranch(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.svc.Create("keep-branch", "feat/keep", []string{"api"}, env.repoMap, env.cfg) + + wt := filepath.Join(env.wsDir, "keep-branch", "api") + os.WriteFile(filepath.Join(wt, "work.txt"), []byte("unmerged"), 0o644) + env.run(wt, "git", "add", ".") + env.run(wt, "git", "commit", "-q", "-m", "unmerged work") + + if _, err := env.svc.RemoveRepos("keep-branch", []string{"api"}); err != nil { + t.Fatalf("remove-repo: %v", err) + } + + branches := env.run(env.repoMap["api"], "git", "branch", "--list", "feat/keep") + if branches == "" { + t.Error("an unmerged branch should survive remove-repo") + } +} + +func TestFailedReposFiltersResults(t *testing.T) { + results := []RepoResult{ + {Repo: "a", Outcome: OutcomeRemoved}, + {Repo: "b", Outcome: OutcomeFailed}, + {Repo: "c", Outcome: OutcomeFailed}, + } + failed := FailedRepos(results) + if len(failed) != 2 || failed[0] != "b" || failed[1] != "c" { + t.Errorf("FailedRepos = %v, want [b c]", failed) + } + if !anyFailed(results) { + t.Error("anyFailed should be true") + } + if anyFailed(results[:1]) { + t.Error("anyFailed should be false when nothing failed") + } +} diff --git a/internal/workspace/run.go b/internal/workspace/run.go index 9e79c5c..c82d114 100644 --- a/internal/workspace/run.go +++ b/internal/workspace/run.go @@ -1,7 +1,9 @@ package workspace import ( + "errors" "fmt" + "io" "os" "os/exec" "os/signal" @@ -13,6 +15,7 @@ import ( "github.com/nicksenap/grove/internal/console" "github.com/nicksenap/grove/internal/gitops" + "github.com/nicksenap/grove/internal/machine" "github.com/nicksenap/grove/internal/models" "github.com/nicksenap/grove/internal/streamio" ) @@ -47,17 +50,36 @@ func GetRunnable(ws *models.Workspace) []RunnableRepo { return result } -// Run executes run hooks for a workspace, printing output directly. -func Run(wsName string) error { +// Run executes run hooks for a workspace, streaming each repo's output with a +// [repo] prefix, and reports how every process ended. +// +// In machine mode the children's stdout is redirected to stderr: their output is +// arbitrary text and would otherwise corrupt the single JSON envelope stdout is +// reserved for. +func Run(wsName string) (*RunResult, error) { ws, err := ResolveWorkspace(wsName) if err != nil { - return err + return nil, err } runnable := GetRunnable(ws) + result := &RunResult{Workspace: ws.Name} if len(runnable) == 0 { console.Info("No repos have a run hook configured in .grove.toml") - return nil + return result, nil + } + + // Children inherit this writer for stdout; machine mode keeps stdout clean. + childOut := io.Writer(os.Stdout) + if machine.Enabled() { + childOut = os.Stderr + } + + var resultMu sync.Mutex + record := func(r RunRepoResult) { + resultMu.Lock() + defer resultMu.Unlock() + result.Repos = append(result.Repos, r) } // Pre-run hooks (parallel) @@ -83,13 +105,19 @@ func Run(wsName string) error { cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // Prefix output with repo name - outW := &streamio.PrefixWriter{Prefix: fmt.Sprintf("[%s] ", r.RepoName), W: os.Stdout} + outW := &streamio.PrefixWriter{Prefix: fmt.Sprintf("[%s] ", r.RepoName), W: childOut} errW := &streamio.PrefixWriter{Prefix: fmt.Sprintf("[%s] ", r.RepoName), W: os.Stderr} cmd.Stdout = outW cmd.Stderr = errW if err := cmd.Start(); err != nil { console.Warningf("%s: failed to start: %s", r.RepoName, err) + record(RunRepoResult{ + Repo: r.RepoName, + Outcome: OutcomeFailed, + ExitCode: -1, + Detail: "failed to start: " + err.Error(), + }) continue } @@ -105,13 +133,22 @@ func Run(wsName string) error { // Emit any trailing line the process printed without a newline. outW.Flush() errW.Flush() + res := RunRepoResult{Repo: name, Outcome: OutcomeExited} if err != nil { - if !shuttingDown.Load() { + res.ExitCode = exitCodeOf(c, err) + res.Detail = err.Error() + // A process we shut down ourselves did not fail on its own terms. + if shuttingDown.Load() { + res.Outcome = OutcomeExited + res.Detail = "terminated during shutdown" + } else { + res.Outcome = OutcomeFailed console.Warningf("%s: exited with error: %s", name, err) } } else { console.Infof("%s: exited (0)", name) } + record(res) }(r.RepoName, cmd, outW, errW) } @@ -165,7 +202,20 @@ func Run(wsName string) error { // Post-run hooks (parallel) runHooks(runnable, "post_run", func(r RunnableRepo) string { return r.PostRun }) - return nil + return result, nil +} + +// exitCodeOf extracts a child's exit status, falling back to -1 when the process +// state is unavailable (killed before reporting, or a non-exit error). +func exitCodeOf(c *exec.Cmd, err error) int { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode() + } + if c.ProcessState != nil { + return c.ProcessState.ExitCode() + } + return -1 } func runHooks(runnable []RunnableRepo, hookName string, getCmd func(RunnableRepo) string) { diff --git a/internal/workspace/service.go b/internal/workspace/service.go index 7e64fd1..32786b4 100644 --- a/internal/workspace/service.go +++ b/internal/workspace/service.go @@ -5,6 +5,7 @@ import ( "os/exec" "github.com/nicksenap/grove/internal/config" + "github.com/nicksenap/grove/internal/machine" "github.com/nicksenap/grove/internal/state" "github.com/nicksenap/grove/internal/stats" ) @@ -27,10 +28,16 @@ func NewService() *Service { } } +// prodRunCmd runs a per-repo hook with its output visible. In machine mode its +// stdout goes to stderr: hook output is arbitrary text, and stdout is reserved +// for the single response envelope. func prodRunCmd(dir, cmdStr string) error { cmd := exec.Command("sh", "-c", cmdStr) cmd.Dir = dir cmd.Stdout = os.Stdout + if machine.Enabled() { + cmd.Stdout = os.Stderr + } cmd.Stderr = os.Stderr return cmd.Run() } diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index d559092..fcb221f 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -11,6 +11,7 @@ import ( "github.com/nicksenap/grove/internal/console" "github.com/nicksenap/grove/internal/gitops" "github.com/nicksenap/grove/internal/logging" + "github.com/nicksenap/grove/internal/machine" "github.com/nicksenap/grove/internal/models" ) @@ -56,18 +57,24 @@ type CreateOpts struct { // repoMap is name→source_path. It preserves the historical positional signature // by delegating to CreateWithOpts. func (s *Service) Create(name, branch string, repoNames []string, repoMap map[string]string, cfg *models.Config) error { - return s.CreateWithOpts(name, CreateOpts{ + _, err := s.CreateWithOpts(name, CreateOpts{ Branch: branch, Repos: repoNames, RepoMap: repoMap, Cfg: cfg, }) + return err } // CreateWithOpts creates a new workspace from the given options. It is the full // implementation behind Create, additionally supporting per-repo branch tracking // (BranchMode/TrackBranchRepo) and a persisted Source link. -func (s *Service) CreateWithOpts(name string, opts CreateOpts) error { +// +// Creation is all-or-nothing: any repo that fails to provision rolls the whole +// workspace back, so the returned result only ever describes a complete +// workspace. Per-repo entries are still reported so a caller can see exactly +// which worktrees and branches now exist. +func (s *Service) CreateWithOpts(name string, opts CreateOpts) (*CreateResult, error) { branch := opts.Branch repoNames := opts.Repos repoMap := opts.RepoMap @@ -76,17 +83,17 @@ func (s *Service) CreateWithOpts(name string, opts CreateOpts) error { // Check duplicate existing, err := s.State.GetWorkspace(name) if err != nil { - return err + return nil, err } if existing != nil { - return ErrWorkspaceExists(name) + return nil, ErrWorkspaceExists(name) } logging.Info("creating workspace %q (branch=%s, repos=%v)", name, branch, repoNames) wsPath := filepath.Join(cfg.WorkspaceDir, name) if err := os.MkdirAll(wsPath, 0o755); err != nil { - return fmt.Errorf("creating workspace dir: %w", err) + return nil, machine.Wrap(machine.CodePermission, err, "creating workspace dir %s: %s", wsPath, err) } ws := models.NewWorkspace(name, wsPath, branch) @@ -98,7 +105,7 @@ func (s *Service) CreateWithOpts(name string, opts CreateOpts) error { sourcePath, ok := repoMap[repoName] if !ok { os.RemoveAll(wsPath) - return ErrRepoNotFound(repoName) + return nil, ErrRepoNotFound(repoName) } sourcePaths[i] = sourcePath } @@ -132,7 +139,7 @@ func (s *Service) CreateWithOpts(name string, opts CreateOpts) error { logging.Error("workspace creation failed for %q — rolled back", name) rollback(created) os.RemoveAll(wsPath) - return fmt.Errorf("provisioning %s: %w", repoName, err) + return nil, machine.Wrap(machine.CodeFor(err), err, "provisioning %s: %s", repoName, err) } created = append(created, *rw) } @@ -149,7 +156,7 @@ func (s *Service) CreateWithOpts(name string, opts CreateOpts) error { if err := s.State.AddWorkspace(ws); err != nil { rollback(created) os.RemoveAll(wsPath) - return err + return nil, err } // Record stats @@ -163,7 +170,23 @@ func (s *Service) CreateWithOpts(name string, opts CreateOpts) error { os.WriteFile(cdFile, []byte(wsPath), 0o644) } - return nil + repos := make([]RepoResult, len(created)) + for i, r := range created { + repos[i] = RepoResult{ + Repo: r.RepoName, + Outcome: OutcomeCreated, + Branch: r.Branch, + Path: r.WorktreePath, + } + } + + return &CreateResult{ + Name: name, + Path: wsPath, + Branch: branch, + Source: ws.Source, + Repos: repos, + }, nil } func provisionWorktree(sourcePath, repoName, wsPath, branch string) (*models.RepoWorktree, error) { @@ -268,74 +291,96 @@ func (s *Service) runSetupHooks(ws models.Workspace) { wg.Wait() } -// Delete removes a workspace and its worktrees. -func (s *Service) Delete(name string) error { +// Delete removes a workspace, its worktrees, and its branches. It reports one +// result per repo: removal is parallel and independently failable, and a repo +// whose worktree could not be removed keeps the workspace's state entry alive so +// the leftover is discoverable via `gw doctor` instead of vanishing from state. +func (s *Service) Delete(name string) (*DeleteResult, error) { ws, err := s.State.GetWorkspace(name) if err != nil { - return err + return nil, err } if ws == nil { - return ErrWorkspaceNotFound(name) + return nil, ErrWorkspaceNotFound(name) } logging.Info("deleting workspace %q", name) // Parallel teardown+remove for all repos - succeeded := make([]bool, len(ws.Repos)) + results := make([]RepoResult, len(ws.Repos)) var wg sync.WaitGroup for i, r := range ws.Repos { wg.Add(1) go func(idx int, repo models.RepoWorktree) { defer wg.Done() - groveCfg, _ := gitops.ReadGroveConfig(repo.SourceRepo) - if groveCfg != nil && groveCfg.Teardown != "" { - s.RunCmdSilent(repo.WorktreePath, groveCfg.Teardown) - } - - if err := gitops.WorktreeRemove(repo.SourceRepo, repo.WorktreePath); err != nil { - if err := os.RemoveAll(repo.WorktreePath); err != nil { - logging.Warn("failed to remove worktree for %s: %s", repo.RepoName, err) - console.Warningf("%s: failed to remove worktree: %s", repo.RepoName, err) - return - } - } - - if err := gitops.DeleteBranch(repo.SourceRepo, repo.Branch, true); err != nil { - logging.Warn("failed to delete branch %q in %s: %s", repo.Branch, repo.RepoName, err) - console.Warningf("%s: failed to delete branch %s: %s", repo.RepoName, repo.Branch, err) - } else { - logging.Info("deleted branch %q in %s", repo.Branch, repo.RepoName) - } - - succeeded[idx] = true + results[idx] = s.deleteRepo(repo, true) }(i, r) } wg.Wait() - failCount := 0 - for _, ok := range succeeded { - if !ok { - failCount++ - } - } - if failCount > 0 { - logging.Warn("workspace %q: %d worktree(s) failed to remove", name, failCount) + failed := FailedRepos(results) + if len(failed) > 0 { + logging.Warn("workspace %q: %d worktree(s) failed to remove", name, len(failed)) } os.RemoveAll(ws.Path) s.Stats.RecordDeleted(*ws) + stateRemoved := false _, dirErr := os.Stat(ws.Path) - if failCount == 0 || os.IsNotExist(dirErr) { + if len(failed) == 0 || os.IsNotExist(dirErr) { if err := s.State.RemoveWorkspace(name); err != nil { - return err + return nil, err } + stateRemoved = true } logging.Info("workspace %q deleted", name) console.Successf("Workspace %s deleted", name) - return nil + + return &DeleteResult{ + Name: ws.Name, + Path: ws.Path, + Repos: results, + StateRemoved: stateRemoved, + }, nil +} + +// deleteRepo tears down one repo's worktree and branch. A failed branch deletion +// is not fatal — the worktree is what makes the workspace exist — but it is +// reported in Detail so the leftover branch is not silently lost. +// +// forceBranch mirrors the historical difference between the two callers: deleting +// a whole workspace force-deletes its branches, while removing a single repo from +// a workspace does not, so unmerged work is preserved. +func (s *Service) deleteRepo(repo models.RepoWorktree, forceBranch bool) RepoResult { + res := RepoResult{Repo: repo.RepoName, Branch: repo.Branch, Path: repo.WorktreePath} + + groveCfg, _ := gitops.ReadGroveConfig(repo.SourceRepo) + if groveCfg != nil && groveCfg.Teardown != "" { + s.RunCmdSilent(repo.WorktreePath, groveCfg.Teardown) + } + + if err := gitops.WorktreeRemove(repo.SourceRepo, repo.WorktreePath); err != nil { + if err := os.RemoveAll(repo.WorktreePath); err != nil { + logging.Warn("failed to remove worktree for %s: %s", repo.RepoName, err) + console.Warningf("%s: failed to remove worktree: %s", repo.RepoName, err) + res.Outcome = OutcomeFailed + res.Detail = "could not remove worktree: " + err.Error() + return res + } + } + + res.Outcome = OutcomeRemoved + if err := gitops.DeleteBranch(repo.SourceRepo, repo.Branch, forceBranch); err != nil { + logging.Warn("failed to delete branch %q in %s: %s", repo.Branch, repo.RepoName, err) + console.Warningf("%s: failed to delete branch %s: %s", repo.RepoName, repo.Branch, err) + res.Detail = "worktree removed, but branch " + repo.Branch + " remains: " + err.Error() + return res + } + logging.Info("deleted branch %q in %s", repo.Branch, repo.RepoName) + return res } // Rename renames a workspace using a state-first pattern with rollback. @@ -399,14 +444,16 @@ func (s *Service) Rename(oldName, newName string) error { return nil } -// AddRepos adds repos to an existing workspace. -func (s *Service) AddRepos(wsName string, repoNames []string, repoMap map[string]string) error { +// AddRepos adds repos to an existing workspace. Repos already present are +// reported as already_present rather than treated as an error, so the operation +// is idempotent for a caller retrying after a partial failure. +func (s *Service) AddRepos(wsName string, repoNames []string, repoMap map[string]string) (*ReposChangeResult, error) { ws, err := s.State.GetWorkspace(wsName) if err != nil { - return err + return nil, err } if ws == nil { - return ErrWorkspaceNotFound(wsName) + return nil, ErrWorkspaceNotFound(wsName) } existing := make(map[string]bool) @@ -414,55 +461,74 @@ func (s *Service) AddRepos(wsName string, repoNames []string, repoMap map[string existing[r.RepoName] = true } + result := &ReposChangeResult{Workspace: wsName} var toAdd []string for _, name := range repoNames { - if !existing[name] { - toAdd = append(toAdd, name) + if existing[name] { + result.Repos = append(result.Repos, RepoResult{Repo: name, Outcome: OutcomeAlreadyExists}) + continue } + toAdd = append(toAdd, name) } if len(toAdd) == 0 { console.Info("All repos already in workspace") - return nil + return result, nil } beforeLen := len(ws.Repos) for _, repoName := range toAdd { sourcePath, ok := repoMap[repoName] if !ok { - return ErrRepoNotFound(repoName) + return nil, ErrRepoNotFound(repoName) } rw, err := provisionWorktree(sourcePath, repoName, ws.Path, ws.Branch) if err != nil { - return fmt.Errorf("adding %s: %w", repoName, err) + // Persist what already succeeded before surfacing the failure: + // abandoning it would leave worktrees on disk that state does not know + // about, which is worse than a partially populated workspace. + if len(ws.Repos) > beforeLen { + s.State.UpdateWorkspace(*ws) + } + return nil, machine.Wrap(machine.CodeFor(err), err, "adding %s: %s", repoName, err) } ws.Repos = append(ws.Repos, *rw) + result.Repos = append(result.Repos, RepoResult{ + Repo: repoName, + Outcome: OutcomeAdded, + Branch: rw.Branch, + Path: rw.WorktreePath, + }) } newWS := models.Workspace{Repos: ws.Repos[beforeLen:]} s.runSetupHooks(newWS) if err := s.State.UpdateWorkspace(*ws); err != nil { - return err + return nil, err } logging.Info("added %d repo(s) to workspace %q", len(toAdd), wsName) console.Successf("Added %d repo(s) to %s", len(toAdd), wsName) - return nil + return result, nil } -// RemoveRepos removes repos from a workspace. -func (s *Service) RemoveRepos(wsName string, repoNames []string) error { +// RemoveRepos removes repos from a workspace. A name that is not in the +// workspace is reported as not_found instead of failing the whole call, so +// removing an already-removed repo is idempotent. +func (s *Service) RemoveRepos(wsName string, repoNames []string) (*ReposChangeResult, error) { ws, err := s.State.GetWorkspace(wsName) if err != nil { - return err + return nil, err } if ws == nil { - return ErrWorkspaceNotFound(wsName) + return nil, ErrWorkspaceNotFound(wsName) } + result := &ReposChangeResult{Workspace: wsName} + type removeItem struct { name string repo *models.RepoWorktree @@ -470,49 +536,47 @@ func (s *Service) RemoveRepos(wsName string, repoNames []string) error { var items []removeItem for _, repoName := range repoNames { r := ws.FindRepo(repoName) - if r != nil { - items = append(items, removeItem{name: repoName, repo: r}) + if r == nil { + result.Repos = append(result.Repos, RepoResult{Repo: repoName, Outcome: OutcomeNotFound}) + continue } + items = append(items, removeItem{name: repoName, repo: r}) } - succeeded := make([]bool, len(items)) + removed := make([]RepoResult, len(items)) var wg sync.WaitGroup for i, item := range items { wg.Add(1) go func(idx int, r models.RepoWorktree) { defer wg.Done() - groveCfg, _ := gitops.ReadGroveConfig(r.SourceRepo) - if groveCfg != nil && groveCfg.Teardown != "" { - s.RunCmdSilent(r.WorktreePath, groveCfg.Teardown) - } - - if err := gitops.WorktreeRemove(r.SourceRepo, r.WorktreePath); err != nil { - os.RemoveAll(r.WorktreePath) - } - - gitops.DeleteBranch(r.SourceRepo, r.Branch, false) - succeeded[idx] = true + removed[idx] = s.deleteRepo(r, false) }(i, *item.repo) } wg.Wait() for i, item := range items { - if succeeded[i] { + if !removed[i].Failed() { ws.RemoveRepo(item.name) } + result.Repos = append(result.Repos, removed[i]) } if err := s.State.UpdateWorkspace(*ws); err != nil { - return err + return nil, err } - logging.Info("removed %d repo(s) from workspace %q", len(repoNames), wsName) - console.Successf("Removed %d repo(s) from %s", len(repoNames), wsName) - return nil + logging.Info("removed %d repo(s) from workspace %q", len(items), wsName) + console.Successf("Removed %d repo(s) from %s", len(items), wsName) + return result, nil } -// syncOneRepo syncs a single repo. -func (s *Service) syncOneRepo(r models.RepoWorktree) { +// syncOneRepo rebases a single repo onto its base branch and reports the +// outcome. Non-fatal problems (fetch failure, undeterminable upstream) become a +// skipped result with a reason rather than aborting the whole sync — one +// unreachable remote should not stop the other repos from advancing. +func (s *Service) syncOneRepo(r models.RepoWorktree) RepoResult { + res := RepoResult{Repo: r.RepoName, Branch: r.Branch, Path: r.WorktreePath} + if err := gitops.Fetch(r.SourceRepo); err != nil { console.Warningf("%s: fetch failed, using local state: %s", r.RepoName, err) } @@ -522,28 +586,37 @@ func (s *Service) syncOneRepo(r models.RepoWorktree) { status, err := gitops.RepoStatus(r.WorktreePath) if err != nil { console.Warningf("%s: status check failed: %s", r.RepoName, err) - return + res.Outcome = OutcomeFailed + res.Detail = "status check failed: " + err.Error() + return res } if status != "" { console.Warningf("%s: skipping (dirty working tree)", r.RepoName) - return + res.Outcome = OutcomeSkipped + res.Detail = "dirty working tree" + return res } upstream, err := gitops.ResolveBaseBranch(r.SourceRepo) if err != nil { console.Warningf("%s: could not determine base branch: %s", r.RepoName, err) - return + res.Outcome = OutcomeSkipped + res.Detail = "could not determine base branch: " + err.Error() + return res } _, behind, err := gitops.CommitsAheadBehind(r.WorktreePath, upstream) if err != nil { console.Warningf("%s: cannot determine ahead/behind: %s", r.RepoName, err) - return + res.Outcome = OutcomeSkipped + res.Detail = "cannot determine ahead/behind: " + err.Error() + return res } if behind == 0 { console.Infof("%s: ✓ up to date", r.RepoName) - return + res.Outcome = OutcomeUpToDate + return res } if groveCfg != nil && groveCfg.PreSync != "" { @@ -553,39 +626,48 @@ func (s *Service) syncOneRepo(r models.RepoWorktree) { if err := gitops.RebaseOnto(r.WorktreePath, upstream); err != nil { console.Errorf("%s: rebase failed: %s", r.RepoName, err) gitops.RebaseAbort(r.WorktreePath) - return + res.Outcome = OutcomeFailed + res.Detail = "rebase onto " + upstream + " failed and was aborted: " + err.Error() + return res } console.Successf("%s: rebased (%d commits)", r.RepoName, behind) + res.Outcome = OutcomeRebased + res.Detail = fmt.Sprintf("rebased onto %s (%d commits)", upstream, behind) if groveCfg != nil && groveCfg.PostSync != "" { s.RunCmdSilent(r.WorktreePath, groveCfg.PostSync) } + return res } -// Sync rebases workspace repos onto their base branches. -func (s *Service) Sync(wsName string) error { +// Sync rebases workspace repos onto their base branches. It returns per-repo +// outcomes and only errors when the workspace itself cannot be read — an +// individual repo's failure is data, not a command failure, because the other +// repos may well have advanced. +func (s *Service) Sync(wsName string) (*SyncResult, error) { ws, err := s.State.GetWorkspace(wsName) if err != nil { - return err + return nil, err } if ws == nil { - return ErrWorkspaceNotFound(wsName) + return nil, ErrWorkspaceNotFound(wsName) } logging.Info("syncing workspace %q", wsName) + results := make([]RepoResult, len(ws.Repos)) var wg sync.WaitGroup - for _, r := range ws.Repos { + for i, r := range ws.Repos { wg.Add(1) - go func(repo models.RepoWorktree) { + go func(idx int, repo models.RepoWorktree) { defer wg.Done() - s.syncOneRepo(repo) - }(r) + results[idx] = s.syncOneRepo(repo) + }(i, r) } wg.Wait() - return nil + return &SyncResult{Workspace: wsName, Repos: results}, nil } // RepoStatus is one repo's git state within a workspace. Ahead/Behind are diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index c5394b7..52ad3e6 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -244,7 +244,7 @@ func TestCreateTrackModeChecksOutExistingBranch(t *testing.T) { env.createRepoWithRemote("api") env.pushRemoteBranch(env.repoMap["api"], "feat/pr-head", "pr-marker.txt") - err := env.svc.CreateWithOpts("pr-ws", CreateOpts{ + _, err := env.svc.CreateWithOpts("pr-ws", CreateOpts{ Branch: "feat/pr-head", Repos: []string{"api"}, RepoMap: env.repoMap, @@ -274,7 +274,7 @@ func TestCreateTrackModeFallsBackWhenRemoteMissing(t *testing.T) { // Track mode requested but no such remote branch exists → fall back to // creating a new branch from base (no error). - err := env.svc.CreateWithOpts("fallback-ws", CreateOpts{ + _, err := env.svc.CreateWithOpts("fallback-ws", CreateOpts{ Branch: "feat/ghost-pr", Repos: []string{"api"}, RepoMap: env.repoMap, @@ -301,7 +301,7 @@ func TestCreateTrackModeOnlyAppliesToDesignatedRepo(t *testing.T) { // Both repos share the branch name, but only "api" is the track repo; // "web" should create a fresh branch from base (no api-pr.txt leakage). - err := env.svc.CreateWithOpts("mixed-ws", CreateOpts{ + _, err := env.svc.CreateWithOpts("mixed-ws", CreateOpts{ Branch: "feat/shared", Repos: []string{"api", "web"}, RepoMap: env.repoMap, @@ -333,7 +333,7 @@ func TestCreateWithOptsPersistsSource(t *testing.T) { Ref: "1172", Title: "Surface data source status", } - err := env.svc.CreateWithOpts("src-ws", CreateOpts{ + _, err := env.svc.CreateWithOpts("src-ws", CreateOpts{ Branch: "feat/src", Repos: []string{"api"}, RepoMap: env.repoMap, @@ -457,7 +457,7 @@ func TestDeleteSuccess(t *testing.T) { env.createRepo("api") env.svc.Create("del-ws", "feat/del", []string{"api"}, env.repoMap, env.cfg) - err := env.svc.Delete("del-ws") + _, err := env.svc.Delete("del-ws") if err != nil { t.Fatalf("delete: %v", err) } @@ -478,7 +478,7 @@ func TestDeleteNotFound(t *testing.T) { env := setupTestEnv(t) _ = env // setup env for state path - err := env.svc.Delete("nonexistent") + _, err := env.svc.Delete("nonexistent") if err == nil { t.Error("expected error for nonexistent workspace") } @@ -599,7 +599,7 @@ func TestReplaceSequence(t *testing.T) { } // Delete old (simulates --replace first half). - if err := env.svc.Delete("old-ws"); err != nil { + if _, err := env.svc.Delete("old-ws"); err != nil { t.Fatalf("delete old: %v", err) } @@ -640,7 +640,7 @@ func TestSyncUpToDate(t *testing.T) { env.svc.Create("sync-ws", "feat/sync", []string{"api"}, env.repoMap, env.cfg) // No upstream changes — should be up to date - err := env.svc.Sync("sync-ws") + _, err := env.svc.Sync("sync-ws") if err != nil { t.Fatalf("sync: %v", err) } @@ -650,7 +650,7 @@ func TestSyncNotFound(t *testing.T) { env := setupTestEnv(t) _ = env - err := env.svc.Sync("nonexistent") + _, err := env.svc.Sync("nonexistent") if err == nil { t.Error("expected error") } @@ -667,7 +667,7 @@ func TestAddReposSuccess(t *testing.T) { env.svc.Create("add-ws", "feat/add", []string{"api"}, env.repoMap, env.cfg) - err := env.svc.AddRepos("add-ws", []string{"web"}, env.repoMap) + _, err := env.svc.AddRepos("add-ws", []string{"web"}, env.repoMap) if err != nil { t.Fatalf("add: %v", err) } @@ -689,7 +689,7 @@ func TestAddReposAlreadyPresent(t *testing.T) { env.svc.Create("dup-ws", "feat/dup", []string{"api"}, env.repoMap, env.cfg) // Adding same repo again should be a no-op - err := env.svc.AddRepos("dup-ws", []string{"api"}, env.repoMap) + _, err := env.svc.AddRepos("dup-ws", []string{"api"}, env.repoMap) if err != nil { t.Fatalf("add duplicate: %v", err) } @@ -704,7 +704,7 @@ func TestAddReposNotFound(t *testing.T) { env := setupTestEnv(t) _ = env - err := env.svc.AddRepos("nonexistent", []string{"api"}, env.repoMap) + _, err := env.svc.AddRepos("nonexistent", []string{"api"}, env.repoMap) if err == nil { t.Error("expected error") } @@ -720,7 +720,7 @@ func TestRemoveReposSuccess(t *testing.T) { env.createRepo("web") env.svc.Create("rm-ws", "feat/rm", []string{"api", "web"}, env.repoMap, env.cfg) - err := env.svc.RemoveRepos("rm-ws", []string{"web"}) + _, err := env.svc.RemoveRepos("rm-ws", []string{"web"}) if err != nil { t.Fatalf("remove: %v", err) } @@ -746,7 +746,7 @@ func TestRemoveReposMultiple(t *testing.T) { env.createRepo("worker") env.svc.Create("rm-multi", "feat/rm-multi", []string{"api", "web", "worker"}, env.repoMap, env.cfg) - err := env.svc.RemoveRepos("rm-multi", []string{"web", "worker"}) + _, err := env.svc.RemoveRepos("rm-multi", []string{"web", "worker"}) if err != nil { t.Fatalf("remove: %v", err) } @@ -774,7 +774,7 @@ func TestRemoveReposNonexistent(t *testing.T) { env.svc.Create("rm-ne", "feat/rm-ne", []string{"api"}, env.repoMap, env.cfg) // Removing a repo not in workspace should be a no-op - err := env.svc.RemoveRepos("rm-ne", []string{"nonexistent"}) + _, err := env.svc.RemoveRepos("rm-ne", []string{"nonexistent"}) if err != nil { t.Fatalf("remove nonexistent: %v", err) } @@ -1027,7 +1027,7 @@ func TestDeleteMultiRepoAllCleaned(t *testing.T) { env.createRepo("web") env.svc.Create("multi-del", "feat/md", []string{"api", "web"}, env.repoMap, env.cfg) - err := env.svc.Delete("multi-del") + _, err := env.svc.Delete("multi-del") if err != nil { t.Fatalf("delete: %v", err) } @@ -1053,7 +1053,7 @@ func TestSyncMultiRepo(t *testing.T) { env.createRepoWithRemote("web") env.svc.Create("sync-multi", "feat/sm", []string{"api", "web"}, env.repoMap, env.cfg) - err := env.svc.Sync("sync-multi") + _, err := env.svc.Sync("sync-multi") if err != nil { t.Fatalf("sync: %v", err) } @@ -1076,7 +1076,7 @@ func TestSyncRebases(t *testing.T) { env.run(repo, "git", "push", "-q", "origin", "HEAD") // Sync should rebase - err := env.svc.Sync("rebase-ws") + _, err := env.svc.Sync("rebase-ws") if err != nil { t.Fatalf("sync: %v", err) } @@ -1245,7 +1245,7 @@ func TestSyncSkipsDirty(t *testing.T) { wt := filepath.Join(env.wsDir, "dirty-ws", "api") os.WriteFile(filepath.Join(wt, "dirt.txt"), []byte("uncommitted"), 0o644) - err := env.svc.Sync("dirty-ws") + _, err := env.svc.Sync("dirty-ws") if err != nil { t.Fatalf("sync: %v", err) } @@ -1455,7 +1455,7 @@ func TestAddReposBranchConflict(t *testing.T) { // feat/conflict. This should work because it's a different branch. // But adding web with a branch that already has a worktree should fail. // The branch "feat/conflict" already has a worktree, so adding it again should error. - err := env.svc.AddRepos("ws2", []string{"web"}, env.repoMap) + _, err := env.svc.AddRepos("ws2", []string{"web"}, env.repoMap) // This will try to create branch "feat/other" on "web" — should work (different branch) if err != nil { t.Fatalf("adding web with different branch should succeed: %v", err) @@ -1503,7 +1503,7 @@ func TestRemoveReposWorkspaceNotFound(t *testing.T) { env := setupTestEnv(t) _ = env - err := env.svc.RemoveRepos("nonexistent", []string{"api"}) + _, err := env.svc.RemoveRepos("nonexistent", []string{"api"}) if err == nil { t.Error("expected error for nonexistent workspace") } @@ -1567,7 +1567,7 @@ func TestSyncConflictAbortsRebase(t *testing.T) { env.run(repo, "git", "push", "-q", "origin", "HEAD") // Sync should handle the conflict gracefully (abort rebase, no error) - err := env.svc.Sync("conflict-ws") + _, err := env.svc.Sync("conflict-ws") if err != nil { t.Fatalf("sync should not return error on conflict: %v", err) } @@ -1641,7 +1641,7 @@ func TestLoggingCreateAndDelete(t *testing.T) { } // Delete workspace - err = env.svc.Delete("log-ws") + _, err = env.svc.Delete("log-ws") if err != nil { t.Fatalf("delete: %v", err) } @@ -1670,7 +1670,7 @@ func TestLoggingSync(t *testing.T) { t.Fatalf("create: %v", err) } - err = env.svc.Sync("sync-log-ws") + _, err = env.svc.Sync("sync-log-ws") if err != nil { t.Fatalf("sync: %v", err) } @@ -1689,7 +1689,7 @@ func TestLoggingAddAndRemoveRepos(t *testing.T) { env.svc.Create("addrem-ws", "feat/addrem", []string{"api"}, env.repoMap, env.cfg) - err := env.svc.AddRepos("addrem-ws", []string{"web"}, env.repoMap) + _, err := env.svc.AddRepos("addrem-ws", []string{"web"}, env.repoMap) if err != nil { t.Fatalf("add-repo: %v", err) } @@ -1699,7 +1699,7 @@ func TestLoggingAddAndRemoveRepos(t *testing.T) { t.Errorf("log should contain add-repo, got:\n%s", log) } - err = env.svc.RemoveRepos("addrem-ws", []string{"web"}) + _, err = env.svc.RemoveRepos("addrem-ws", []string{"web"}) if err != nil { t.Fatalf("remove-repo: %v", err) } From 24e82a56a461168d144085eca9f429a80ba62652 Mon Sep 17 00:00:00 2001 From: Nick Song Date: Fri, 31 Jul 2026 18:45:21 +0200 Subject: [PATCH 05/21] Add gw context for one-call agent discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent's first question is "where am I and what can I do?". Answering it previously took several commands (list, status, repos, preset list) plus knowledge of which one to trust. `gw context --format json` answers it once. It reports the workspace containing the cwd (or null), each repo's live branch, base branch, remote, and dirty/ahead/behind state, the configured repo dirs, presets, workspace inventory, and safe next actions. Design decisions: - It is a projection, not a new state model: every field is derived from config.toml, state.json, or a local git query, and nothing is cached. - RepoContext embeds RepoStatus, so `gw context` and `gw status` cannot report different git state for the same repo. A test asserts they agree. - Only local git operations run (no fetch, no PR lookups) so the command is cheap enough to call before every decision, with per-repo collection in parallel like the rest of the service. - A missing config reports initialized:false instead of erroring — an agent's first call is exactly how it should discover Grove needs `gw init`, and the next_actions then point at it. - workspace is null outside a workspace rather than a best guess; that null is the signal that later commands need an explicit name. - Path containment resolves symlinks, prefers the deepest match, and rejects ".." results so a sibling like `feat-other` is never treated as inside `feat`. Running from a repo subdirectory resolves the parent workspace, which is where agents actually work from. - Fields for features that do not exist in core (blueprint identity, preparation/Oven status) are omitted rather than stubbed as null; adding them later is a compatible change under the schema policy. - List-valued fields are always arrays, never null, so clients can iterate without nil checks. --- cmd/context.go | 140 ++++++++++++++++++++++ cmd/root.go | 1 + internal/workspace/context.go | 185 +++++++++++++++++++++++++++++ internal/workspace/context_test.go | 182 ++++++++++++++++++++++++++++ 4 files changed, 508 insertions(+) create mode 100644 cmd/context.go create mode 100644 internal/workspace/context.go create mode 100644 internal/workspace/context_test.go diff --git a/cmd/context.go b/cmd/context.go new file mode 100644 index 0000000..7695a46 --- /dev/null +++ b/cmd/context.go @@ -0,0 +1,140 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/nicksenap/grove/internal/config" + "github.com/nicksenap/grove/internal/console" + "github.com/nicksenap/grove/internal/machine" + "github.com/nicksenap/grove/internal/workspace" + "github.com/spf13/cobra" +) + +var contextCmd = &cobra.Command{ + Use: "context", + Short: "Show the current Grove context (workspace, repos, git state)", + Long: `One read-only call that answers "where am I and what can I do?". + +Reports the workspace containing the current directory (if any), each repo's live +branch and dirty/ahead/behind state, configured repo dirs and presets, and the +safe next commands. Intended as an agent's first call before deciding anything.`, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cwd, err := os.Getwd() + if err != nil { + fail(machine.Wrap(machine.CodeInternal, err, "cannot determine working directory: %s", err)) + } + + // A missing config is reported as initialized:false, not an error: an + // agent's first call is exactly how it should learn Grove needs setup. + cfg, _ := config.Load() + + ctx, err := workspace.NewService().Context(cwd, Version, cfg) + if err != nil { + fail(err) + } + + if machine.Enabled() { + machine.Emit(ctx, contextNextActions(ctx)...) + return + } + printContext(ctx) + }, +} + +// contextNextActions offers only commands that are safe and relevant right now: +// setup when uninitialized, workspace-scoped actions when inside one, discovery +// otherwise. +func contextNextActions(ctx *workspace.Context) []machine.Action { + if !ctx.Initialized { + return []machine.Action{ + machine.NextAction("Initialize Grove with a directory containing git repos", + "gw init "), + } + } + + if ctx.Workspace == nil { + actions := []machine.Action{ + machine.NextAction("List workspaces", "gw list --format json"), + machine.NextAction("Create a workspace", + "gw create -r -b --format json"), + } + if len(ctx.RepoDirs) == 0 { + return append([]machine.Action{ + machine.NextAction("Register a directory containing git repos", "gw add-dir "), + }, actions...) + } + return actions + } + + ws := ctx.Workspace + actions := []machine.Action{ + machine.NextAction("Inspect repo state", "gw status "+ws.Name+" --format json"), + } + for _, r := range ws.Repos { + if r.Behind != "" && r.Behind != "-" && r.Behind != "0" { + actions = append(actions, machine.NextAction("Rebase repos onto their base branches", + "gw sync "+ws.Name+" --format json")) + break + } + } + actions = append(actions, machine.NextAction("Preview deleting this workspace", + "gw plan delete "+ws.Name+" --format json")) + return actions +} + +func printContext(ctx *workspace.Context) { + if !ctx.Initialized { + console.Warning("Grove is not initialized. Run: gw init ") + return + } + + home, _ := os.UserHomeDir() + short := func(p string) string { + if home != "" && p != "" { + return strings.Replace(p, home, "~", 1) + } + return p + } + + fmt.Fprintf(os.Stdout, "Grove: %s\n", ctx.GroveVersion) + fmt.Fprintf(os.Stdout, "Config: %s\n", short(ctx.ConfigPath)) + fmt.Fprintf(os.Stdout, "Repo dirs: %s\n", strings.Join(shortAll(ctx.RepoDirs, short), ", ")) + if len(ctx.Presets) > 0 { + fmt.Fprintf(os.Stdout, "Presets: %s\n", strings.Join(ctx.Presets, ", ")) + } + fmt.Fprintf(os.Stdout, "Workspaces: %d\n", ctx.WorkspaceCount) + + if ctx.Workspace == nil { + fmt.Fprintf(os.Stdout, "\nNot inside a workspace (%s)\n", short(ctx.Cwd)) + return + } + + ws := ctx.Workspace + fmt.Fprintf(os.Stdout, "\nWorkspace: %s (%s)\n", ws.Name, short(ws.Path)) + fmt.Fprintf(os.Stdout, "Branch: %s\n", ws.Branch) + if ws.Source != nil && ws.Source.URL != "" { + fmt.Fprintf(os.Stdout, "Source: %s\n", ws.Source.URL) + } + fmt.Fprintln(os.Stdout) + + table := console.NewTable(os.Stdout, []string{"Repo", "Branch", "Base", "↑↓", "State"}) + for _, r := range ws.Repos { + state := "clean" + if r.Dirty { + state = "modified" + } + table.AddRow([]string{r.Repo, r.Branch, r.BaseBranch, r.Ahead + "↑ " + r.Behind + "↓", state}) + } + table.Render() +} + +func shortAll(paths []string, short func(string) string) []string { + out := make([]string, len(paths)) + for i, p := range paths { + out[i] = short(p) + } + return out +} diff --git a/cmd/root.go b/cmd/root.go index e3e499d..726020a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -64,6 +64,7 @@ func init() { // Register all subcommands rootCmd.AddCommand( initCmd, + contextCmd, createCmd, listCmd, wsCmd, diff --git a/internal/workspace/context.go b/internal/workspace/context.go new file mode 100644 index 0000000..cbd2f5a --- /dev/null +++ b/internal/workspace/context.go @@ -0,0 +1,185 @@ +package workspace + +import ( + "path/filepath" + "sort" + "strings" + "sync" + + "github.com/nicksenap/grove/internal/config" + "github.com/nicksenap/grove/internal/gitops" + "github.com/nicksenap/grove/internal/models" +) + +// Context is the answer to "where am I and what can I do?" — the single +// read-only snapshot an agent fetches before deciding anything. +// +// It is a projection over existing state and git, not a new state model: every +// field is derived from ~/.grove/config.toml, ~/.grove/state.json, or a git +// query. Nothing here is cached or persisted. +// +// Fields for features that do not exist in core yet (blueprint identity, +// preparation/Oven status) are deliberately absent rather than stubbed as null; +// they are additive when those features land, which the schema policy allows. +type Context struct { + // Environment. + GroveVersion string `json:"grove_version"` + Initialized bool `json:"initialized"` + ConfigPath string `json:"config_path"` + Cwd string `json:"cwd"` + + // Configuration. + RepoDirs []string `json:"repo_dirs"` + WorkspaceDir string `json:"workspace_dir"` + Presets []string `json:"presets"` + + // Current position. Workspace is null when the cwd is not inside one, which + // is the signal that a name must be passed explicitly to other commands. + Workspace *WorkspaceContext `json:"workspace"` + + // Everything else that exists. + WorkspaceCount int `json:"workspace_count"` + Workspaces []string `json:"workspaces"` +} + +// WorkspaceContext describes the workspace the caller is currently inside. +type WorkspaceContext struct { + Name string `json:"name"` + Path string `json:"path"` + Branch string `json:"branch"` + Source *models.WorkspaceSource `json:"source,omitempty"` + Repos []RepoContext `json:"repos"` +} + +// RepoContext is one repo's live state. It embeds RepoStatus so `gw context` and +// `gw status` report identical git state for the same repo — two commands +// describing the same thing differently is how agents get confused. +type RepoContext struct { + RepoStatus + SourceRepo string `json:"source_repo"` + Path string `json:"path"` + Remote string `json:"remote,omitempty"` + BaseBranch string `json:"base_branch,omitempty"` + Dirty bool `json:"dirty"` +} + +// Context assembles a snapshot for the given working directory. cfg may be nil, +// which reports Initialized: false instead of failing — an agent's first call is +// exactly how it should discover that Grove needs `gw init`. +// +// Only local git queries are used (no fetch, no PR lookups), so the command stays +// cheap enough to call before every decision. +func (s *Service) Context(cwd, version string, cfg *models.Config) (*Context, error) { + ctx := &Context{ + GroveVersion: version, + Initialized: cfg != nil, + ConfigPath: config.ConfigPath, + Cwd: cwd, + RepoDirs: []string{}, + Presets: []string{}, + Workspaces: []string{}, + } + + if cfg != nil { + if cfg.RepoDirs != nil { + ctx.RepoDirs = cfg.RepoDirs + } + ctx.WorkspaceDir = cfg.WorkspaceDir + for name := range cfg.Presets { + ctx.Presets = append(ctx.Presets, name) + } + sort.Strings(ctx.Presets) + } + + all, err := s.State.Load() + if err != nil { + return nil, err + } + ctx.WorkspaceCount = len(all) + for _, ws := range all { + ctx.Workspaces = append(ctx.Workspaces, ws.Name) + } + + current := findWorkspaceByPath(all, cwd) + if current == nil { + return ctx, nil + } + + ctx.Workspace = &WorkspaceContext{ + Name: current.Name, + Path: current.Path, + Branch: current.Branch, + Source: current.Source, + Repos: s.repoContexts(current.Repos), + } + return ctx, nil +} + +// repoContexts collects live git state for each repo in parallel — the same +// concurrency the rest of the service uses, so context cost is one git round +// regardless of repo count. +func (s *Service) repoContexts(repos []models.RepoWorktree) []RepoContext { + out := make([]RepoContext, len(repos)) + var wg sync.WaitGroup + for i, r := range repos { + wg.Add(1) + go func(idx int, repo models.RepoWorktree) { + defer wg.Done() + status := collectRepoStatus(repo) + // The same resolution sync uses, so the reported base branch is the + // one a rebase would actually target. + base, _ := gitops.ResolveBaseBranch(repo.SourceRepo) + out[idx] = RepoContext{ + RepoStatus: status, + SourceRepo: repo.SourceRepo, + Path: repo.WorktreePath, + Remote: gitops.RemoteURL(repo.WorktreePath, "origin"), + BaseBranch: base, + Dirty: !status.Clean(), + } + }(i, r) + } + wg.Wait() + return out +} + +// findWorkspaceByPath resolves the innermost workspace containing path. It works +// on an already-loaded slice so Context does a single state read. +func findWorkspaceByPath(all []models.Workspace, path string) *models.Workspace { + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + abs = resolved + } + + var best *models.Workspace + for i := range all { + wsPath := all[i].Path + if resolved, err := filepath.EvalSymlinks(wsPath); err == nil { + wsPath = resolved + } + if abs == wsPath || isSubPath(wsPath, abs) { + // Prefer the deepest match, so a workspace nested inside another + // workspace's directory still resolves to itself. + if best == nil || len(wsPath) > len(best.Path) { + best = &all[i] + } + } + } + return best +} + +// isSubPath reports whether child is inside parent. It rejects ".." results so a +// sibling directory sharing a name prefix is never mistaken for a child. +func isSubPath(parent, child string) bool { + rel, err := filepath.Rel(parent, child) + if err != nil { + return false + } + if filepath.IsAbs(rel) || rel == ".." { + return false + } + return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} diff --git a/internal/workspace/context_test.go b/internal/workspace/context_test.go new file mode 100644 index 0000000..569b2eb --- /dev/null +++ b/internal/workspace/context_test.go @@ -0,0 +1,182 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" + + "github.com/nicksenap/grove/internal/models" +) + +func TestContextInsideWorkspace(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.createRepo("web") + env.svc.Create("ctx-ws", "feat/ctx", []string{"api", "web"}, env.repoMap, env.cfg) + + wsPath := filepath.Join(env.wsDir, "ctx-ws") + ctx, err := env.svc.Context(wsPath, "test", env.cfg) + if err != nil { + t.Fatalf("context: %v", err) + } + + if !ctx.Initialized { + t.Error("initialized should be true with a config") + } + if ctx.Workspace == nil { + t.Fatal("expected to resolve the containing workspace") + } + if ctx.Workspace.Name != "ctx-ws" || ctx.Workspace.Branch != "feat/ctx" { + t.Errorf("workspace = %s/%s", ctx.Workspace.Name, ctx.Workspace.Branch) + } + if len(ctx.Workspace.Repos) != 2 { + t.Fatalf("expected 2 repos, got %d", len(ctx.Workspace.Repos)) + } + if ctx.WorkspaceCount != 1 || len(ctx.Workspaces) != 1 { + t.Errorf("workspace inventory = %d/%v", ctx.WorkspaceCount, ctx.Workspaces) + } + for _, r := range ctx.Workspace.Repos { + if r.Dirty { + t.Errorf("%s should be clean", r.Repo) + } + if r.Path == "" || r.SourceRepo == "" { + t.Errorf("%s missing paths: %+v", r.Repo, r) + } + if r.Status == "" { + t.Errorf("%s missing git status", r.Repo) + } + } +} + +// A repo's worktree is inside the workspace, so running from there must resolve +// the same workspace — agents run commands from repo directories. +func TestContextFromRepoSubdirectory(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.svc.Create("sub-ws", "feat/sub", []string{"api"}, env.repoMap, env.cfg) + + deep := filepath.Join(env.wsDir, "sub-ws", "api") + ctx, err := env.svc.Context(deep, "test", env.cfg) + if err != nil { + t.Fatalf("context: %v", err) + } + if ctx.Workspace == nil || ctx.Workspace.Name != "sub-ws" { + t.Fatalf("expected sub-ws, got %+v", ctx.Workspace) + } +} + +func TestContextReportsDirtyRepo(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.svc.Create("dirty-ctx", "feat/dirty", []string{"api"}, env.repoMap, env.cfg) + + wt := filepath.Join(env.wsDir, "dirty-ctx", "api") + os.WriteFile(filepath.Join(wt, "scratch.txt"), []byte("wip"), 0o644) + + ctx, err := env.svc.Context(wt, "test", env.cfg) + if err != nil { + t.Fatalf("context: %v", err) + } + if !ctx.Workspace.Repos[0].Dirty { + t.Error("expected the repo to be reported dirty") + } +} + +// Outside any workspace, workspace must be null rather than a guess — that null +// is what tells an agent it has to name a workspace explicitly. +func TestContextOutsideWorkspaceIsNull(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.svc.Create("some-ws", "feat/some", []string{"api"}, env.repoMap, env.cfg) + + ctx, err := env.svc.Context(env.reposDir, "test", env.cfg) + if err != nil { + t.Fatalf("context: %v", err) + } + if ctx.Workspace != nil { + t.Errorf("expected no workspace, got %+v", ctx.Workspace) + } + if ctx.WorkspaceCount != 1 { + t.Errorf("workspace_count = %d, want 1", ctx.WorkspaceCount) + } +} + +// A sibling directory sharing a name prefix must not be mistaken for being +// inside the workspace. +func TestContextRejectsSiblingPathPrefix(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.svc.Create("feat", "feat/one", []string{"api"}, env.repoMap, env.cfg) + + sibling := filepath.Join(env.wsDir, "feat-other") + os.MkdirAll(sibling, 0o755) + + ctx, err := env.svc.Context(sibling, "test", env.cfg) + if err != nil { + t.Fatalf("context: %v", err) + } + if ctx.Workspace != nil { + t.Errorf("feat-other is not inside feat, got %+v", ctx.Workspace) + } +} + +// No config is a reportable state, not a failure: it is how an agent discovers +// that Grove needs `gw init`. +func TestContextWithoutConfig(t *testing.T) { + env := setupTestEnv(t) + + ctx, err := env.svc.Context(env.dir, "test", nil) + if err != nil { + t.Fatalf("context should not fail without config: %v", err) + } + if ctx.Initialized { + t.Error("initialized should be false without a config") + } + if ctx.RepoDirs == nil || ctx.Presets == nil || ctx.Workspaces == nil { + t.Error("list fields must be empty arrays, never null") + } +} + +func TestContextListsPresetsSorted(t *testing.T) { + env := setupTestEnv(t) + env.cfg.Presets = map[string]models.Preset{ + "zeta": {Repos: []string{"api"}}, + "alpha": {Repos: []string{"web"}}, + "backend": {Repos: []string{"api", "web"}}, + } + + ctx, err := env.svc.Context(env.dir, "test", env.cfg) + if err != nil { + t.Fatalf("context: %v", err) + } + want := []string{"alpha", "backend", "zeta"} + for i, name := range want { + if ctx.Presets[i] != name { + t.Fatalf("presets = %v, want %v", ctx.Presets, want) + } + } +} + +// Context and status must agree; they are two views of one collection path. +func TestContextGitStateMatchesStatus(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.svc.Create("agree-ws", "feat/agree", []string{"api"}, env.repoMap, env.cfg) + + wt := filepath.Join(env.wsDir, "agree-ws", "api") + os.WriteFile(filepath.Join(wt, "change.txt"), []byte("x"), 0o644) + + ctx, err := env.svc.Context(wt, "test", env.cfg) + if err != nil { + t.Fatalf("context: %v", err) + } + report, err := env.svc.StatusReport("agree-ws", StatusOptions{}) + if err != nil { + t.Fatalf("status: %v", err) + } + + if ctx.Workspace.Repos[0].Status != report.Repos[0].Status { + t.Errorf("context status %q != status command %q", + ctx.Workspace.Repos[0].Status, report.Repos[0].Status) + } +} From 5740c0d7a006b769241594cf76ca509ba280a831 Mon Sep 17 00:00:00 2001 From: Nick Song Date: Fri, 31 Jul 2026 19:14:57 +0200 Subject: [PATCH 06/21] Restore cross-agent coordination as gw announce / gw announcements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The removed MCP server's real feature was letting agents in parallel workspaces leave each other notes about shared repos. That capability came from a shared store on disk, not from JSON-RPC — so it belongs in the CLI, where every agent with a shell can reach it. This restores announce/get_announcements as `gw announce` and `gw announcements`, and fixes the discovery weakness that made the MCP version go unused (0 rows in messages.db after months of real use): notes now arrive in `gw context` under result.announcements, so an agent receives coordination while orienting instead of having to notice a tool in a list and choose to call it. Storage is a directory of one-file-per-note JSON under ~/.grove/announcements/, not SQLite: - publishing is a single O_EXCL file creation, so concurrent agents cannot clobber each other and no locking is needed — a test runs 24 parallel publishers and asserts none are lost; - reading is a directory scan, unaffected by concurrent writers; - pruning unlinks expired files, which is safe during reads and writes, and runs opportunistically on publish so no background job is required. SQLite serialized writes that file creation already serializes, and cost the ~4 MB dependency tree this epic just removed. Binary stays at 9.1 MB. Contract decisions: - Notes are keyed by normalized repo remote (ssh/https/nested-group forms all collapse to owner/repo), so different worktrees of the same upstream match. Both publish and read derive keys through one function — deriving them differently would mean two agents silently never see each other. - Repos with no remote fall back to their local name, so the feature still works for local-only repos. - A workspace never sees its own notes; that is noise, not coordination. - Coordination is advisory, so an unreadable or corrupt store degrades to zero announcements rather than failing the command the agent was actually running, and unparseable files are left on disk rather than deleted as if they were ours. - Retention is 30 days in the store but 7 days / 20 entries in `gw context`: an old note is history, not something to act on while orienting. AGENTS.md and CLAUDE.md now document the agent interface and these commands, so the discovery path is explicit rather than depending on tool-list osmosis. --- AGENTS.md | 25 +++ CHANGELOG.md | 14 +- CLAUDE.md | 25 +++ cmd/announce.go | 240 +++++++++++++++++++++ cmd/root.go | 2 + docs/agent-cli.md | 36 +++- docs/ai-tools.md | 11 +- e2e/run.sh | 51 +++++ internal/announce/announce.go | 322 +++++++++++++++++++++++++++++ internal/announce/announce_test.go | 248 ++++++++++++++++++++++ internal/workspace/context.go | 63 +++++- internal/workspace/context_test.go | 116 +++++++++++ internal/workspace/service.go | 3 + openwiki/integrations.md | 12 +- 14 files changed, 1151 insertions(+), 17 deletions(-) create mode 100644 cmd/announce.go create mode 100644 internal/announce/announce.go create mode 100644 internal/announce/announce_test.go diff --git a/AGENTS.md b/AGENTS.md index 8f85d50..5ab854d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,29 @@ When working in this repository, read the OpenWiki quickstart first, then follow Git Worktree Workspace Orchestrator — CLI tool invoked as `gw`. Manages multi-repo worktree-based workspaces so developers can spin up isolated branches across several repos at once. +## Agent interface + +The `gw` CLI is Grove's only agent interface — there is no MCP server. Add +`--format json` to any command for a versioned response envelope with stable +error codes; see [docs/agent-cli.md](docs/agent-cli.md). + +```bash +gw context --format json # where am I, repo git state, announcements, next actions +gw status --format json +gw create feat-x -r repo1,repo2 -b feat/x --format json +``` + +When several agents work in parallel workspaces on the same repos, coordinate +through announcements: + +```bash +gw announce -c breaking_change -m "auth tokens are now opaque strings" +gw announcements --format json +``` + +Notes from other workspaces about your repos also appear in `gw context` under +`result.announcements`. + ## Development - Go 1.25+ @@ -56,6 +79,8 @@ Tool-specific integrations (Codex memory sync, Zellij, archive, dashboard) live - **internal/gitops/** — Thin wrappers around `git` subprocess calls. Includes `ReadGroveConfig()`. - **internal/lifecycle/** — Runs global lifecycle hooks (`post_create`, `pre_delete`, `on_close`) defined in `[hooks]`. Hooks may be bare command strings or tables with metadata (`stream`, `timeout`, `on_failure`); the global `--no-hooks`/`-n` flag skips them all. Plugins register here. - **internal/logging/** — Structured logging. +- **internal/machine/** — Machine-readable CLI contract: response envelope, stable error codes, exit-code classes. +- **internal/announce/** — Cross-workspace agent coordination. Directory of JSON files under `~/.grove/announcements/`; one file per note, so concurrent agents need no locking. - **internal/models/** — Data structs with JSON serialization. - **internal/picker/** — Interactive terminal menus. - **internal/plugin/** — Plugin install/upgrade/remove from GitHub releases. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4716129..ed5f8f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,20 @@ - Removed Grove's built-in MCP server. `gw mcp-serve`, the generated `.mcp.json` `grove` entry, the announcements SQLite database, and the `announce` / - `get_announcements` tools are gone. The `gw` CLI is now the only first-party - agent interface. Run `gw doctor --fix` to strip the stale `grove` entry from + `get_announcements` MCP tools are gone — the latter two return as the + `gw announce` / `gw announcements` commands below. The `gw` CLI is now the only + first-party agent interface. Run `gw doctor --fix` to strip the stale `grove` entry from `.mcp.json` files in existing workspaces (other MCP servers are preserved). +### Features + +- `gw announce` / `gw announcements`: cross-workspace coordination for agents + working in parallel, replacing the MCP server's `announce` / + `get_announcements` tools. Notes are keyed by normalized repo remote, expire + after 30 days, and recent ones surface in `gw context` so an agent receives + them while orienting. Backed by a lock-free directory of JSON files under + `~/.grove/announcements/` — no SQLite. + ### Maintenance - Dropped the `modernc.org/sqlite` dependency tree; the release binary shrank diff --git a/CLAUDE.md b/CLAUDE.md index 5170f3e..898331d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,29 @@ When working in this repository, read the OpenWiki quickstart first, then follow Git Worktree Workspace Orchestrator — CLI tool invoked as `gw`. Manages multi-repo worktree-based workspaces so developers can spin up isolated branches across several repos at once. +## Agent interface + +The `gw` CLI is Grove's only agent interface — there is no MCP server. Add +`--format json` to any command for a versioned response envelope with stable +error codes; see [docs/agent-cli.md](docs/agent-cli.md). + +```bash +gw context --format json # where am I, repo git state, announcements, next actions +gw status --format json +gw create feat-x -r repo1,repo2 -b feat/x --format json +``` + +When several agents work in parallel workspaces on the same repos, coordinate +through announcements: + +```bash +gw announce -c breaking_change -m "auth tokens are now opaque strings" +gw announcements --format json +``` + +Notes from other workspaces about your repos also appear in `gw context` under +`result.announcements`. + ## Development - Go 1.25+ @@ -56,6 +79,8 @@ Tool-specific integrations (Claude Code memory sync, Zellij, archive, dashboard) - **internal/gitops/** — Thin wrappers around `git` subprocess calls. Includes `ReadGroveConfig()`. - **internal/lifecycle/** — Runs global lifecycle hooks (`post_create`, `pre_delete`, `on_close`) defined in `[hooks]`. Hooks may be bare command strings or tables with metadata (`stream`, `timeout`, `on_failure`); the global `--no-hooks`/`-n` flag skips them all. Plugins register here. - **internal/logging/** — Structured logging. +- **internal/machine/** — Machine-readable CLI contract: response envelope, stable error codes, exit-code classes. +- **internal/announce/** — Cross-workspace agent coordination. Directory of JSON files under `~/.grove/announcements/`; one file per note, so concurrent agents need no locking. - **internal/models/** — Data structs with JSON serialization. - **internal/picker/** — Interactive terminal menus. - **internal/plugin/** — Plugin install/upgrade/remove from GitHub releases. diff --git a/cmd/announce.go b/cmd/announce.go new file mode 100644 index 0000000..0b3d3ef --- /dev/null +++ b/cmd/announce.go @@ -0,0 +1,240 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/nicksenap/grove/internal/announce" + "github.com/nicksenap/grove/internal/console" + "github.com/nicksenap/grove/internal/gitops" + "github.com/nicksenap/grove/internal/machine" + "github.com/nicksenap/grove/internal/models" + "github.com/nicksenap/grove/internal/workspace" + "github.com/spf13/cobra" +) + +// Cross-workspace coordination for concurrent agents: `gw announce` publishes a +// note about a repo, `gw announcements` reads what other workspaces published +// about the repos you are touching. Recent notes also appear in `gw context`, so +// an agent receives them while orienting instead of having to remember to ask. + +var ( + announceRepos string + announceCategory string + announceMessage string + + announcementsRepos string + announcementsSince string + announcementsAll bool + announcementsLimit int + announcementsGlobal bool +) + +var announceCmd = &cobra.Command{ + Use: "announce", + Short: "Publish a note about a repo for agents in other workspaces", + Long: `Publish a coordination note that agents working on the same repos in other +workspaces will see. + +Repos default to every repo in the current workspace. Notes are keyed by each +repo's normalized remote, so a different worktree of the same upstream matches. +Notes expire after 30 days. + +Categories: ` + strings.Join(announce.Categories(), ", "), + Example: ` gw announce -c breaking_change -m "auth tokens are now opaque strings" + gw announce -r api-gateway -c warning -m "staging deploy is broken" --format json`, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + if strings.TrimSpace(announceMessage) == "" { + fail(machine.Errorf(machine.CodeUsage, "message is required"). + WithFix(`Pass --message / -m "what other agents need to know"`)) + } + if announceCategory == "" { + announceCategory = announce.CategoryInfo + } + + ws, repos := resolveAnnounceTargets(announceRepos) + + svc := workspace.NewService() + published := make([]*announce.Announcement, 0, len(repos)) + for _, key := range repos { + a, err := svc.Announce.Publish(ws, key, announceCategory, announceMessage) + if err != nil { + fail(classifyAnnounceErr(err)) + } + published = append(published, a) + } + + if !machine.Enabled() { + console.Successf("Announced to %d repo(s): %s", len(published), strings.Join(repos, ", ")) + return + } + machine.Emit(map[string]any{"published": published, "count": len(published)}, + machine.NextAction("Read what other workspaces published", "gw announcements --format json")) + }, +} + +var announcementsCmd = &cobra.Command{ + Use: "announcements", + Aliases: []string{"news"}, + Short: "Read notes other workspaces published about your repos", + Long: `Read coordination notes published by agents in other workspaces. + +By default this shows notes about the repos in the current workspace, excluding +your own. Recent notes also appear in "gw context".`, + Example: ` gw announcements + gw announcements --since 24h --format json + gw announcements --global --format json`, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + opts := announce.ListOptions{Limit: announcementsLimit} + + if !announcementsGlobal { + ws, repos := resolveAnnounceTargets(announcementsRepos) + opts.Repos = repos + if !announcementsAll { + opts.ExcludeWorkspace = ws + } + } + + if announcementsSince != "" { + d, err := time.ParseDuration(announcementsSince) + if err != nil { + fail(machine.Wrap(machine.CodeUsage, err, "invalid --since %q", announcementsSince). + WithFix(`Use a Go duration, e.g. "2h" or "72h"`)) + } + opts.Since = time.Now().UTC().Add(-d) + } + + found, err := workspace.NewService().Announce.List(opts) + if err != nil { + fail(machine.Wrap(machine.CodeInternal, err, "reading announcements: %s", err)) + } + + if machine.Enabled() { + machine.Emit(map[string]any{"announcements": found, "count": len(found)}) + return + } + + if len(found) == 0 { + console.Info("No announcements.") + return + } + table := console.NewTable(os.Stdout, []string{"When", "Workspace", "Repo", "Category", "Message"}) + for _, a := range found { + table.AddRow([]string{ + humanizeAge(time.Since(a.CreatedAt)), + a.Workspace, + a.Repo, + a.Category, + a.Message, + }) + } + table.Render() + }, +} + +// resolveAnnounceTargets returns the publishing workspace name and the repo +// coordination keys to use. +// +// Keys come from each repo's remote via announce.RepoKey, and both publishing and +// reading go through this one function — if the two sides derived keys +// differently they would silently never see each other's notes. +func resolveAnnounceTargets(reposFlag string) (wsName string, keys []string) { + var ws *models.Workspace + if resolved, err := workspace.ResolveWorkspace(""); err == nil { + ws = resolved + wsName = resolved.Name + } + + // Explicit repos: accept names (resolved against the workspace for their + // remote) or full remote URLs, so a caller outside any workspace still works. + if reposFlag != "" { + for _, raw := range strings.Split(reposFlag, ",") { + name := strings.TrimSpace(raw) + if name == "" { + continue + } + keys = append(keys, keyForRepo(ws, name)) + } + if len(keys) == 0 { + fail(machine.Errorf(machine.CodeUsage, "--repos listed no usable repo")) + } + return wsName, keys + } + + if ws == nil { + fail(machine.Errorf(machine.CodeWorkspaceNotFound, + "not inside a workspace, so there are no repos to use"). + WithFix("Pass --repos explicitly, or run from inside a workspace"). + WithActions(machine.NextAction("Discover current context", "gw context --format json"))) + } + for _, r := range ws.Repos { + keys = append(keys, announce.RepoKey(gitops.RemoteURL(r.WorktreePath, "origin"), r.RepoName)) + } + if len(keys) == 0 { + fail(machine.Errorf(machine.CodeRepoNotFound, "workspace %s has no repos", ws.Name)) + } + return wsName, keys +} + +// keyForRepo resolves one repo reference to its coordination key, preferring the +// remote of a matching repo in the workspace. +func keyForRepo(ws *models.Workspace, ref string) string { + if ws != nil { + if r := ws.FindRepo(ref); r != nil { + return announce.RepoKey(gitops.RemoteURL(r.WorktreePath, "origin"), r.RepoName) + } + } + return announce.NormalizeRepo(ref) +} + +// classifyAnnounceErr maps store validation failures onto contract codes. +func classifyAnnounceErr(err error) error { + var invalid *announce.InvalidCategoryError + if errors.As(err, &invalid) { + return machine.Wrap(machine.CodeUsage, err, "%s", err). + WithFix("Use one of: " + strings.Join(announce.Categories(), ", ")) + } + return machine.Wrap(machine.CodeInternal, err, "publishing announcement: %s", err) +} + +// humanizeAge renders a coarse relative age for the human table. +func humanizeAge(d time.Duration) string { + 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())) + default: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + } +} + +func init() { + announceCmd.Flags().StringVarP(&announceRepos, "repos", "r", "", + "Comma-separated repo names or remote URLs (default: every repo in the current workspace)") + announceCmd.Flags().StringVarP(&announceCategory, "category", "c", announce.CategoryInfo, + "Category: "+strings.Join(announce.Categories(), ", ")) + announceCmd.Flags().StringVarP(&announceMessage, "message", "m", "", "What other agents need to know") + announceCmd.RegisterFlagCompletionFunc("repos", completeRepoNames) + announceCmd.RegisterFlagCompletionFunc("category", + func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return announce.Categories(), cobra.ShellCompDirectiveNoFileComp + }) + + announcementsCmd.Flags().StringVarP(&announcementsRepos, "repos", "r", "", + "Comma-separated repo names or remote URLs (default: every repo in the current workspace)") + announcementsCmd.Flags().StringVar(&announcementsSince, "since", "", + `Only notes newer than this duration, e.g. "24h"`) + announcementsCmd.Flags().BoolVar(&announcementsAll, "include-own", false, + "Include notes published by the current workspace") + announcementsCmd.Flags().BoolVar(&announcementsGlobal, "global", false, + "Every repo, not just the current workspace's") + announcementsCmd.Flags().IntVar(&announcementsLimit, "limit", 50, "Maximum notes to return") +} diff --git a/cmd/root.go b/cmd/root.go index 726020a..619f83b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -65,6 +65,8 @@ func init() { rootCmd.AddCommand( initCmd, contextCmd, + announceCmd, + announcementsCmd, createCmd, listCmd, wsCmd, diff --git a/docs/agent-cli.md b/docs/agent-cli.md index 3e55f93..afe809c 100644 --- a/docs/agent-cli.md +++ b/docs/agent-cli.md @@ -174,6 +174,30 @@ gw apply plan.json --format json Every step returns one envelope; a non-zero exit tells the caller which class of recovery to attempt. +## Cross-agent coordination + +When several agents work in parallel workspaces on the same repos, they can leave +each other notes: + +```bash +# In workspace "alpha": tell everyone touching these repos what changed. +gw announce -c breaking_change -m "auth tokens are now opaque strings" --format json + +# In workspace "beta": notes about your repos arrive with your normal orientation. +gw context --format json # result.announcements +gw announcements --format json # dedicated read, 30-day horizon +``` + +Notes are keyed by each repo's normalized remote (`git@github.com:org/api.git` +and `https://github.com/org/api` both key on `org/api`), so different worktrees +of the same upstream match. A workspace never sees its own notes. Notes expire +after 30 days; `gw context` shows the last 7 days, capped at 20. + +Categories: `breaking_change`, `warning`, `status`, `info`. + +Coordination is advisory. An unreadable store degrades to zero announcements +rather than failing the command an agent was actually running. + ## Migrating from the MCP server Grove ≤ 1.1.11 shipped a built-in MCP server (`gw mcp-serve`) and wrote a `grove` @@ -185,6 +209,12 @@ gw doctor # reports leftover .mcp.json grove entries gw doctor --fix # removes only Grove's entry, preserving other MCP servers ``` -The `announce` / `get_announcements` cross-workspace coordination tools were -removed with no replacement. If you need an MCP surface, an external adapter can -wrap this CLI contract without changes to Grove core. +The MCP server's `announce` / `get_announcements` tools live on as `gw announce` +and `gw announcements` (see above). Coordination came from a shared store on +disk, not from the protocol, so the CLI provides it to any agent with a shell — +and `gw context` now delivers notes during orientation instead of hoping an agent +notices a tool in a list. The SQLite database is gone; the store is a directory of +small JSON files under `~/.grove/announcements/`. + +If you need an MCP surface, an external adapter can wrap this CLI contract +without changes to Grove core. diff --git a/docs/ai-tools.md b/docs/ai-tools.md index a85ca5e..3dfea8c 100644 --- a/docs/ai-tools.md +++ b/docs/ai-tools.md @@ -96,5 +96,12 @@ gw doctor # reports stale entries gw doctor --fix # removes only the grove entry, keeping other servers ``` -The `announce` / `get_announcements` cross-workspace coordination tools were -removed with the server and have no CLI replacement. +Cross-workspace coordination survived the removal as first-class commands: + +```bash +gw announce -c breaking_change -m "auth tokens are now opaque strings" +gw announcements --format json +``` + +Recent notes about your repos also appear in `gw context`, so a parallel agent +receives them while orienting rather than having to remember to ask. diff --git a/e2e/run.sh b/e2e/run.sh index 998f14a..6bb67c2 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -1037,6 +1037,57 @@ fi gw delete mcp-ws --force 2>&1 +# --------------------------------------------------------------------------- +# Test: cross-workspace agent coordination +# --------------------------------------------------------------------------- +section "Announcements" + +# Two workspaces over the same repo stand in for two concurrent agents. +gw create ann-alpha --branch feat/ann-alpha --repos svc-auth 2>&1 +gw create ann-beta --branch feat/ann-beta --repos svc-auth 2>&1 + +(cd "${GROVE_HOME}/.grove/workspaces/ann-alpha" && gw announce -c breaking_change -m "auth tokens are opaque now" --format json > /tmp/ann-publish.json 2>/dev/null) + +if jq -e '.ok == true and .result.count == 1' /tmp/ann-publish.json > /dev/null 2>&1; then + pass "gw announce publishes an envelope" +else + fail "gw announce failed: $(cat /tmp/ann-publish.json)" +fi + +# The other agent receives the note while simply orienting. +(cd "${GROVE_HOME}/.grove/workspaces/ann-beta" && gw context --format json > /tmp/ann-context.json 2>/dev/null) +if jq -e '[.result.announcements[] | select(.workspace == "ann-alpha")] | length == 1' /tmp/ann-context.json > /dev/null 2>&1; then + pass "gw context surfaces another workspace's announcement" +else + fail "context missing announcement: $(jq -c .result.announcements /tmp/ann-context.json)" +fi + +(cd "${GROVE_HOME}/.grove/workspaces/ann-beta" && gw announcements --format json > /tmp/ann-read.json 2>/dev/null) +if jq -e '.result.count == 1 and .result.announcements[0].category == "breaking_change"' /tmp/ann-read.json > /dev/null 2>&1; then + pass "gw announcements reads the note" +else + fail "gw announcements failed: $(cat /tmp/ann-read.json)" +fi + +# A workspace never sees its own notes — that is noise, not coordination. +(cd "${GROVE_HOME}/.grove/workspaces/ann-alpha" && gw announcements --format json > /tmp/ann-own.json 2>/dev/null) +if jq -e '.result.count == 0' /tmp/ann-own.json > /dev/null 2>&1; then + pass "announcements exclude the publishing workspace" +else + fail "publisher should not see its own note: $(cat /tmp/ann-own.json)" +fi + +# Invalid category is a structured USAGE failure, not a stored note. +(cd "${GROVE_HOME}/.grove/workspaces/ann-alpha" && gw announce -c gossip -m "nope" --format json > /tmp/ann-bad.json 2>/dev/null) || true +if jq -e '.ok == false and .error.code == "USAGE"' /tmp/ann-bad.json > /dev/null 2>&1; then + pass "invalid announcement category returns USAGE" +else + fail "expected USAGE for a bad category: $(cat /tmp/ann-bad.json)" +fi + +gw delete ann-alpha --force 2>&1 +gw delete ann-beta --force 2>&1 + # --------------------------------------------------------------------------- # Test: plugin system # --------------------------------------------------------------------------- diff --git a/internal/announce/announce.go b/internal/announce/announce.go new file mode 100644 index 0000000..89eae3f --- /dev/null +++ b/internal/announce/announce.go @@ -0,0 +1,322 @@ +// Package announce provides cross-workspace coordination between concurrent +// coding agents: one agent publishes a note about a repo ("I changed the auth +// token format"), and agents working on that same repo in other workspaces see +// it. +// +// # Why a directory of files +// +// The point of this feature is several agent processes running at once, so the +// store has to tolerate concurrent writers. Each announcement is therefore its +// own file created with O_EXCL in one directory: +// +// - publishing is a single atomic file creation — no locking, no read-modify- +// write window, and no way for two agents to clobber each other; +// - reading is a directory scan, unaffected by concurrent publishes; +// - pruning unlinks expired files, which is safe while others read or write. +// +// The predecessor of this package used SQLite for the same job. That pulled in a +// ~4 MB dependency tree to serialize writes that file creation already +// serializes, and required a JSON-RPC server to reach it. Grove's volume here is +// a handful of short messages per day. +package announce + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +// DefaultMaxAge is how long an announcement stays visible. Coordination notes +// are about work in flight; a month-old note is noise. +const DefaultMaxAge = 30 * 24 * time.Hour + +// Categories an announcement may carry. This vocabulary is part of the machine +// CLI contract, so agents can filter on it. +const ( + CategoryBreakingChange = "breaking_change" + CategoryStatus = "status" + CategoryWarning = "warning" + CategoryInfo = "info" +) + +// Categories lists every valid category, in rough order of urgency. +func Categories() []string { + return []string{CategoryBreakingChange, CategoryWarning, CategoryStatus, CategoryInfo} +} + +func validCategory(c string) bool { + for _, valid := range Categories() { + if c == valid { + return true + } + } + return false +} + +// Announcement is one note published by one workspace about one repo. +type Announcement struct { + ID string `json:"id"` + // Workspace is the publisher, so readers can tell who is doing what and + // exclude their own notes. + Workspace string `json:"workspace"` + // Repo is the coordination key: a normalized "owner/repo" derived from the + // repo's remote, so two workspaces holding different worktrees of the same + // upstream agree on it. See RepoKey. + Repo string `json:"repo"` + Category string `json:"category"` + Message string `json:"message"` + CreatedAt time.Time `json:"created_at"` +} + +// Store is a directory of announcement files. +type Store struct { + Dir string + // NowFn is injectable for tests. + NowFn func() time.Time + // MaxAge overrides DefaultMaxAge when non-zero. + MaxAge time.Duration +} + +// NewStore returns the production store rooted in groveDir. +func NewStore(groveDir string) *Store { + return &Store{Dir: filepath.Join(groveDir, "announcements"), NowFn: time.Now} +} + +func (s *Store) now() time.Time { + if s.NowFn != nil { + return s.NowFn() + } + return time.Now() +} + +func (s *Store) maxAge() time.Duration { + if s.MaxAge > 0 { + return s.MaxAge + } + return DefaultMaxAge +} + +// InvalidCategoryError reports an unsupported category. +type InvalidCategoryError struct{ Category string } + +func (e *InvalidCategoryError) Error() string { + return fmt.Sprintf("invalid category %q (want one of: %s)", + e.Category, strings.Join(Categories(), ", ")) +} + +// Publish stores an announcement and returns the stored record. It also prunes +// expired entries opportunistically, so the store cannot grow without bound and +// no background process is needed. +func (s *Store) Publish(workspace, repo, category, message string) (*Announcement, error) { + if !validCategory(category) { + return nil, &InvalidCategoryError{category} + } + if strings.TrimSpace(message) == "" { + return nil, fmt.Errorf("message is empty") + } + if strings.TrimSpace(repo) == "" { + return nil, fmt.Errorf("repo is empty") + } + + if err := os.MkdirAll(s.Dir, 0o755); err != nil { + return nil, fmt.Errorf("creating %s: %w", s.Dir, err) + } + + created := s.now().UTC() + a := Announcement{ + ID: newID(created), + Workspace: workspace, + Repo: NormalizeRepo(repo), + Category: category, + Message: strings.TrimSpace(message), + CreatedAt: created, + } + + data, err := json.Marshal(a) + if err != nil { + return nil, err + } + + // O_EXCL makes the publish atomic and collision-proof: if two agents somehow + // generate the same ID, the loser retries with a fresh one instead of + // overwriting the winner's note. + for attempt := 0; ; attempt++ { + path := filepath.Join(s.Dir, a.ID+".json") + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err == nil { + _, werr := f.Write(data) + cerr := f.Close() + if werr != nil { + return nil, werr + } + if cerr != nil { + return nil, cerr + } + break + } + if !os.IsExist(err) || attempt >= 5 { + return nil, fmt.Errorf("writing announcement: %w", err) + } + a.ID = newID(created) + if data, err = json.Marshal(a); err != nil { + return nil, err + } + } + + s.Prune() + return &a, nil +} + +// ListOptions filters a read. A zero value returns everything unexpired. +type ListOptions struct { + // Repos limits results to these coordination keys. Empty means every repo. + Repos []string + // ExcludeWorkspace drops one publisher's own notes — an agent coordinating + // with others does not need to be told what it just did. + ExcludeWorkspace string + // Since drops anything older. Zero means "everything unexpired". + Since time.Time + // Limit caps the result count. Zero means unlimited. + Limit int +} + +// List returns matching announcements, newest first. A malformed or vanished +// file is skipped rather than failing the read: coordination data is advisory, +// and one bad file must not blind an agent to the rest. +func (s *Store) List(opts ListOptions) ([]Announcement, error) { + entries, err := os.ReadDir(s.Dir) + if err != nil { + if os.IsNotExist(err) { + return []Announcement{}, nil + } + return nil, err + } + + wanted := make(map[string]bool, len(opts.Repos)) + for _, r := range opts.Repos { + wanted[NormalizeRepo(r)] = true + } + + cutoff := s.now().UTC().Add(-s.maxAge()) + + results := []Announcement{} + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + data, err := os.ReadFile(filepath.Join(s.Dir, entry.Name())) + if err != nil { + continue + } + var a Announcement + if err := json.Unmarshal(data, &a); err != nil { + continue + } + if a.CreatedAt.Before(cutoff) { + continue + } + if len(wanted) > 0 && !wanted[a.Repo] { + continue + } + if opts.ExcludeWorkspace != "" && a.Workspace == opts.ExcludeWorkspace { + continue + } + if !opts.Since.IsZero() && a.CreatedAt.Before(opts.Since) { + continue + } + results = append(results, a) + } + + sort.Slice(results, func(i, j int) bool { + if results[i].CreatedAt.Equal(results[j].CreatedAt) { + return results[i].ID > results[j].ID + } + return results[i].CreatedAt.After(results[j].CreatedAt) + }) + + if opts.Limit > 0 && len(results) > opts.Limit { + results = results[:opts.Limit] + } + return results, nil +} + +// Prune deletes expired announcements and reports how many were removed. +// Unlinking is safe while other processes read or publish. +func (s *Store) Prune() int { + entries, err := os.ReadDir(s.Dir) + if err != nil { + return 0 + } + cutoff := s.now().UTC().Add(-s.maxAge()) + + removed := 0 + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + path := filepath.Join(s.Dir, entry.Name()) + data, err := os.ReadFile(path) + if err != nil { + continue + } + var a Announcement + if err := json.Unmarshal(data, &a); err != nil { + // Unparseable files are left alone: they might be another tool's, and + // deleting data we cannot read is not ours to decide. + continue + } + if a.CreatedAt.Before(cutoff) && os.Remove(path) == nil { + removed++ + } + } + return removed +} + +// newID builds a lexically sortable, collision-resistant id. The timestamp uses +// a filename-safe layout (no colons) so it works on Windows too. +func newID(t time.Time) string { + var buf [4]byte + rand.Read(buf[:]) + return t.Format("20060102T150405.000000000") + "-" + hex.EncodeToString(buf[:]) +} + +var ( + sshPattern = regexp.MustCompile(`^(?:ssh://)?git@[^:/]+[:/](.+?)(?:\.git)?$`) + httpsPattern = regexp.MustCompile(`^https?://(?:[^@/]+@)?[^/]+/(.+?)(?:\.git)?$`) +) + +// NormalizeRepo reduces a git remote URL to "owner/repo" so that SSH and HTTPS +// remotes for the same upstream produce the same coordination key. A value that +// is not a URL (e.g. a bare repo name) is returned lowercased and trimmed, which +// keeps the store usable for repos with no remote. +func NormalizeRepo(repo string) string { + repo = strings.TrimSpace(repo) + if repo == "" { + return "" + } + if m := sshPattern.FindStringSubmatch(repo); len(m) == 2 { + return strings.ToLower(strings.Trim(m[1], "/")) + } + if m := httpsPattern.FindStringSubmatch(repo); len(m) == 2 { + return strings.ToLower(strings.Trim(m[1], "/")) + } + return strings.ToLower(strings.TrimSuffix(repo, ".git")) +} + +// RepoKey picks the coordination key for a repo: its normalized remote when it +// has one, otherwise its local name. Both sides of a coordination — publisher and +// reader — must call this, or they will key on different strings and never see +// each other's notes. +func RepoKey(remoteURL, localName string) string { + if key := NormalizeRepo(remoteURL); key != "" { + return key + } + return NormalizeRepo(localName) +} diff --git a/internal/announce/announce_test.go b/internal/announce/announce_test.go new file mode 100644 index 0000000..2eddee5 --- /dev/null +++ b/internal/announce/announce_test.go @@ -0,0 +1,248 @@ +package announce + +import ( + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +func testStore(t *testing.T) *Store { + t.Helper() + return &Store{Dir: filepath.Join(t.TempDir(), "announcements"), NowFn: time.Now} +} + +func TestPublishAndList(t *testing.T) { + s := testStore(t) + + a, err := s.Publish("ws-a", "git@github.com:org/api.git", CategoryBreakingChange, "auth token format changed") + if err != nil { + t.Fatalf("publish: %v", err) + } + if a.Repo != "org/api" { + t.Errorf("repo key = %q, want org/api", a.Repo) + } + if a.ID == "" || a.CreatedAt.IsZero() { + t.Errorf("stored record incomplete: %+v", a) + } + + got, err := s.List(ListOptions{}) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 1 || got[0].Message != "auth token format changed" { + t.Fatalf("list = %+v", got) + } +} + +// An agent coordinating with others must not be told what it just did itself. +func TestListExcludesOwnWorkspace(t *testing.T) { + s := testStore(t) + s.Publish("ws-a", "org/api", CategoryInfo, "from a") + s.Publish("ws-b", "org/api", CategoryInfo, "from b") + + got, err := s.List(ListOptions{ExcludeWorkspace: "ws-a"}) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 1 || got[0].Workspace != "ws-b" { + t.Fatalf("list = %+v, want only ws-b", got) + } +} + +func TestListFiltersByRepo(t *testing.T) { + s := testStore(t) + s.Publish("ws-a", "org/api", CategoryInfo, "about api") + s.Publish("ws-a", "org/web", CategoryInfo, "about web") + + got, err := s.List(ListOptions{Repos: []string{"https://github.com/org/web"}}) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 1 || got[0].Message != "about web" { + t.Fatalf("list = %+v", got) + } +} + +// SSH and HTTPS remotes for one upstream must produce one key, or two agents on +// the same repo never see each other. +func TestNormalizeRepoUnifiesRemoteForms(t *testing.T) { + want := "org/api" + for _, form := range []string{ + "git@github.com:org/api.git", + "git@github.com:org/api", + "ssh://git@github.com/org/api.git", + "https://github.com/org/api.git", + "https://github.com/org/api", + "https://user@gitlab.com/org/api.git", + "org/api", + "ORG/API", + } { + if got := NormalizeRepo(form); got != want { + t.Errorf("NormalizeRepo(%q) = %q, want %q", form, got, want) + } + } +} + +func TestNormalizeRepoKeepsNestedGroups(t *testing.T) { + if got := NormalizeRepo("git@gitlab.com:group/sub/api.git"); got != "group/sub/api" { + t.Errorf("got %q, want group/sub/api", got) + } +} + +func TestRepoKeyFallsBackToLocalName(t *testing.T) { + if got := RepoKey("", "my-repo"); got != "my-repo" { + t.Errorf("RepoKey with no remote = %q, want my-repo", got) + } + if got := RepoKey("git@github.com:org/api.git", "api"); got != "org/api" { + t.Errorf("RepoKey should prefer the remote, got %q", got) + } +} + +func TestPublishRejectsInvalidCategory(t *testing.T) { + s := testStore(t) + if _, err := s.Publish("ws", "org/api", "gossip", "hello"); err == nil { + t.Fatal("expected an invalid-category error") + } + for _, c := range Categories() { + if _, err := s.Publish("ws", "org/api", c, "msg"); err != nil { + t.Errorf("category %q should be valid: %v", c, err) + } + } +} + +func TestPublishRejectsEmptyMessageAndRepo(t *testing.T) { + s := testStore(t) + if _, err := s.Publish("ws", "org/api", CategoryInfo, " "); err == nil { + t.Error("expected an error for an empty message") + } + if _, err := s.Publish("ws", " ", CategoryInfo, "msg"); err == nil { + t.Error("expected an error for an empty repo") + } +} + +func TestListNewestFirst(t *testing.T) { + s := testStore(t) + base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + for i, msg := range []string{"first", "second", "third"} { + s.NowFn = func() time.Time { return base.Add(time.Duration(i) * time.Minute) } + s.Publish("ws-a", "org/api", CategoryInfo, msg) + } + s.NowFn = func() time.Time { return base.Add(time.Hour) } + + got, err := s.List(ListOptions{}) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 3 || got[0].Message != "third" || got[2].Message != "first" { + t.Fatalf("order = %+v", got) + } +} + +func TestListSinceAndLimit(t *testing.T) { + s := testStore(t) + base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + for i, msg := range []string{"old", "newer", "newest"} { + s.NowFn = func() time.Time { return base.Add(time.Duration(i) * time.Hour) } + s.Publish("ws-a", "org/api", CategoryInfo, msg) + } + s.NowFn = func() time.Time { return base.Add(5 * time.Hour) } + + got, _ := s.List(ListOptions{Since: base.Add(30 * time.Minute)}) + if len(got) != 2 { + t.Errorf("since filter returned %d, want 2", len(got)) + } + + got, _ = s.List(ListOptions{Limit: 1}) + if len(got) != 1 || got[0].Message != "newest" { + t.Errorf("limit returned %+v", got) + } +} + +func TestExpiredAnnouncementsAreHiddenAndPruned(t *testing.T) { + s := testStore(t) + s.MaxAge = time.Hour + base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + + s.NowFn = func() time.Time { return base } + s.Publish("ws-a", "org/api", CategoryInfo, "stale") + s.NowFn = func() time.Time { return base.Add(2 * time.Hour) } + + got, _ := s.List(ListOptions{}) + if len(got) != 0 { + t.Errorf("expired announcement still listed: %+v", got) + } + + // Publishing prunes, so the store cannot grow without bound and needs no + // background job. + s.Publish("ws-a", "org/api", CategoryInfo, "fresh") + entries, _ := os.ReadDir(s.Dir) + if len(entries) != 1 { + t.Errorf("expected the stale file to be pruned, found %d files", len(entries)) + } +} + +// The whole point of the feature is several agents running at once, so +// concurrent publishes must not lose or overwrite each other. +func TestConcurrentPublishesAllLand(t *testing.T) { + s := testStore(t) + const writers = 24 + + var wg sync.WaitGroup + errs := make(chan error, writers) + for i := 0; i < writers; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + if _, err := s.Publish("ws-a", "org/api", CategoryStatus, "note"); err != nil { + errs <- err + } + }(i) + } + wg.Wait() + close(errs) + for err := range errs { + t.Fatalf("concurrent publish failed: %v", err) + } + + got, err := s.List(ListOptions{}) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != writers { + t.Errorf("stored %d announcements, want %d", len(got), writers) + } +} + +// A corrupt file must not blind an agent to the rest — coordination data is +// advisory, and deleting data we cannot parse is not ours to decide. +func TestListSkipsUnparseableFiles(t *testing.T) { + s := testStore(t) + s.Publish("ws-a", "org/api", CategoryInfo, "good") + os.WriteFile(filepath.Join(s.Dir, "broken.json"), []byte("{not json"), 0o644) + + got, err := s.List(ListOptions{}) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 1 || got[0].Message != "good" { + t.Fatalf("list = %+v", got) + } + + s.Prune() + if _, err := os.Stat(filepath.Join(s.Dir, "broken.json")); err != nil { + t.Error("prune should leave unparseable files alone") + } +} + +func TestListOnMissingStoreIsEmpty(t *testing.T) { + s := testStore(t) + got, err := s.List(ListOptions{}) + if err != nil { + t.Fatalf("list on a fresh machine should not fail: %v", err) + } + if got == nil || len(got) != 0 { + t.Errorf("want an empty slice, got %+v", got) + } +} diff --git a/internal/workspace/context.go b/internal/workspace/context.go index cbd2f5a..e5ab6e3 100644 --- a/internal/workspace/context.go +++ b/internal/workspace/context.go @@ -5,9 +5,12 @@ import ( "sort" "strings" "sync" + "time" + "github.com/nicksenap/grove/internal/announce" "github.com/nicksenap/grove/internal/config" "github.com/nicksenap/grove/internal/gitops" + "github.com/nicksenap/grove/internal/logging" "github.com/nicksenap/grove/internal/models" ) @@ -37,6 +40,12 @@ type Context struct { // is the signal that a name must be passed explicitly to other commands. Workspace *WorkspaceContext `json:"workspace"` + // Announcements published by *other* workspaces about repos in this one. + // Surfacing them here is deliberate: an agent calls context to orient itself, + // so coordination notes arrive exactly when it is deciding what to do, rather + // than waiting for it to remember a separate command exists. + Announcements []announce.Announcement `json:"announcements"` + // Everything else that exists. WorkspaceCount int `json:"workspace_count"` Workspaces []string `json:"workspaces"` @@ -71,13 +80,14 @@ type RepoContext struct { // cheap enough to call before every decision. func (s *Service) Context(cwd, version string, cfg *models.Config) (*Context, error) { ctx := &Context{ - GroveVersion: version, - Initialized: cfg != nil, - ConfigPath: config.ConfigPath, - Cwd: cwd, - RepoDirs: []string{}, - Presets: []string{}, - Workspaces: []string{}, + GroveVersion: version, + Initialized: cfg != nil, + ConfigPath: config.ConfigPath, + Cwd: cwd, + RepoDirs: []string{}, + Presets: []string{}, + Workspaces: []string{}, + Announcements: []announce.Announcement{}, } if cfg != nil { @@ -112,9 +122,48 @@ func (s *Service) Context(cwd, version string, cfg *models.Config) (*Context, er Source: current.Source, Repos: s.repoContexts(current.Repos), } + ctx.Announcements = s.announcementsFor(ctx.Workspace) return ctx, nil } +// ContextAnnouncementWindow bounds how far back context looks for coordination +// notes. The store keeps a month, but a note older than a week is history rather +// than something an agent should act on while orienting. +const ContextAnnouncementWindow = 7 * 24 * time.Hour + +// ContextAnnouncementLimit caps how many notes context carries, so one chatty +// workspace cannot crowd out the rest of the snapshot. +const ContextAnnouncementLimit = 20 + +// announcementsFor collects recent notes from other workspaces about the repos in +// this one. Coordination is advisory, so a store failure degrades to no +// announcements rather than failing the whole context call. +func (s *Service) announcementsFor(ws *WorkspaceContext) []announce.Announcement { + if s.Announce == nil || ws == nil { + return []announce.Announcement{} + } + + keys := make([]string, 0, len(ws.Repos)) + for _, r := range ws.Repos { + keys = append(keys, announce.RepoKey(r.Remote, r.Repo)) + } + if len(keys) == 0 { + return []announce.Announcement{} + } + + found, err := s.Announce.List(announce.ListOptions{ + Repos: keys, + ExcludeWorkspace: ws.Name, + Since: time.Now().UTC().Add(-ContextAnnouncementWindow), + Limit: ContextAnnouncementLimit, + }) + if err != nil { + logging.Warn("could not read announcements: %s", err) + return []announce.Announcement{} + } + return found +} + // repoContexts collects live git state for each repo in parallel — the same // concurrency the rest of the service uses, so context cost is one git round // regardless of repo count. diff --git a/internal/workspace/context_test.go b/internal/workspace/context_test.go index 569b2eb..0bd8ea4 100644 --- a/internal/workspace/context_test.go +++ b/internal/workspace/context_test.go @@ -4,7 +4,9 @@ import ( "os" "path/filepath" "testing" + "time" + "github.com/nicksenap/grove/internal/announce" "github.com/nicksenap/grove/internal/models" ) @@ -180,3 +182,117 @@ func TestContextGitStateMatchesStatus(t *testing.T) { ctx.Workspace.Repos[0].Status, report.Repos[0].Status) } } + +// --------------------------------------------------------------------------- +// Announcements surfaced in context +// --------------------------------------------------------------------------- + +// Coordination only works if an agent actually receives it. Notes published by +// another workspace about a shared repo must appear in context without the agent +// having to know a separate command exists. +func TestContextSurfacesOtherWorkspaceAnnouncements(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.svc.Announce = &announce.Store{Dir: filepath.Join(env.groveDir, "announcements"), NowFn: time.Now} + env.svc.Create("mine", "feat/mine", []string{"api"}, env.repoMap, env.cfg) + + if _, err := env.svc.Announce.Publish("theirs", "api", announce.CategoryBreakingChange, + "token format changed"); err != nil { + t.Fatalf("publish: %v", err) + } + + ctx, err := env.svc.Context(filepath.Join(env.wsDir, "mine"), "test", env.cfg) + if err != nil { + t.Fatalf("context: %v", err) + } + if len(ctx.Announcements) != 1 { + t.Fatalf("announcements = %+v, want 1", ctx.Announcements) + } + if ctx.Announcements[0].Message != "token format changed" { + t.Errorf("message = %q", ctx.Announcements[0].Message) + } +} + +// An agent must not be shown its own notes: that is noise, not coordination. +func TestContextExcludesOwnAnnouncements(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.svc.Announce = &announce.Store{Dir: filepath.Join(env.groveDir, "announcements"), NowFn: time.Now} + env.svc.Create("mine", "feat/mine", []string{"api"}, env.repoMap, env.cfg) + + env.svc.Announce.Publish("mine", "api", announce.CategoryInfo, "my own note") + + ctx, err := env.svc.Context(filepath.Join(env.wsDir, "mine"), "test", env.cfg) + if err != nil { + t.Fatalf("context: %v", err) + } + if len(ctx.Announcements) != 0 { + t.Errorf("expected no announcements, got %+v", ctx.Announcements) + } +} + +// Notes about repos this workspace does not hold are somebody else's business. +func TestContextIgnoresUnrelatedRepoAnnouncements(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.svc.Announce = &announce.Store{Dir: filepath.Join(env.groveDir, "announcements"), NowFn: time.Now} + env.svc.Create("mine", "feat/mine", []string{"api"}, env.repoMap, env.cfg) + + env.svc.Announce.Publish("theirs", "some-other-repo", announce.CategoryWarning, "unrelated") + + ctx, err := env.svc.Context(filepath.Join(env.wsDir, "mine"), "test", env.cfg) + if err != nil { + t.Fatalf("context: %v", err) + } + if len(ctx.Announcements) != 0 { + t.Errorf("expected no announcements, got %+v", ctx.Announcements) + } +} + +// Context looks back a week, not the store's full retention: an old note is +// history, not something to act on while orienting. +func TestContextAnnouncementWindow(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + store := &announce.Store{Dir: filepath.Join(env.groveDir, "announcements")} + env.svc.Announce = store + env.svc.Create("mine", "feat/mine", []string{"api"}, env.repoMap, env.cfg) + + // Published well inside the store's retention but outside context's window. + store.NowFn = func() time.Time { return time.Now().Add(-10 * 24 * time.Hour) } + store.Publish("theirs", "api", announce.CategoryInfo, "ancient news") + store.NowFn = time.Now + + ctx, err := env.svc.Context(filepath.Join(env.wsDir, "mine"), "test", env.cfg) + if err != nil { + t.Fatalf("context: %v", err) + } + if len(ctx.Announcements) != 0 { + t.Errorf("context should not carry notes older than its window, got %+v", ctx.Announcements) + } + + // Still readable through the dedicated command's longer horizon. + all, err := store.List(announce.ListOptions{}) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(all) != 1 { + t.Errorf("the store should still hold the note, got %+v", all) + } +} + +// A missing store is normal on a fresh machine and must not fail context. +func TestContextWithoutAnnounceStore(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.svc.Announce = nil + env.svc.Create("mine", "feat/mine", []string{"api"}, env.repoMap, env.cfg) + + ctx, err := env.svc.Context(filepath.Join(env.wsDir, "mine"), "test", env.cfg) + if err != nil { + t.Fatalf("context: %v", err) + } + if ctx.Announcements == nil { + t.Error("announcements must be an empty array, never null") + } +} diff --git a/internal/workspace/service.go b/internal/workspace/service.go index 32786b4..bfada47 100644 --- a/internal/workspace/service.go +++ b/internal/workspace/service.go @@ -4,6 +4,7 @@ import ( "os" "os/exec" + "github.com/nicksenap/grove/internal/announce" "github.com/nicksenap/grove/internal/config" "github.com/nicksenap/grove/internal/machine" "github.com/nicksenap/grove/internal/state" @@ -14,6 +15,7 @@ import ( type Service struct { State *state.Store Stats *stats.Tracker + Announce *announce.Store RunCmd func(dir, cmd string) error RunCmdSilent func(dir, cmd string) error } @@ -23,6 +25,7 @@ func NewService() *Service { return &Service{ State: state.NewStore(config.GroveDir), Stats: stats.NewTracker(config.GroveDir), + Announce: announce.NewStore(config.GroveDir), RunCmd: prodRunCmd, RunCmdSilent: prodRunCmdSilent, } diff --git a/openwiki/integrations.md b/openwiki/integrations.md index c9b4bd4..9d15187 100644 --- a/openwiki/integrations.md +++ b/openwiki/integrations.md @@ -203,13 +203,18 @@ Grove has no built-in MCP server. Coding agents with shell access drive Grove through `gw` directly, using machine-readable output: ```bash -gw context --format json # workspace, repos, git state, safe next actions +gw context --format json # workspace, repos, git state, announcements, next actions gw list --format json gw status --format json gw create feat-x -r svc-a,svc-b -b feat/x --format json gw delete feat-x --force --format json ``` +Agents running in parallel workspaces coordinate through `gw announce` / +`gw announcements`, backed by a directory of JSON files under +`~/.grove/announcements/`. Notes are keyed by normalized repo remote, expire after +30 days, and recent ones surface in `gw context`. + Every machine-mode response uses one versioned envelope with stable error codes and semantic exit codes — see [Agent CLI contract](../docs/agent-cli.md). @@ -219,8 +224,9 @@ Grove ≤ 1.1.11 ran `gw mcp-serve` and wrote a `grove` entry into each workspace's `.mcp.json`. Both are gone. `gw doctor` reports leftover entries and `gw doctor --fix` removes only Grove's entry, preserving other servers. -The `announce` / `get_announcements` cross-workspace coordination tools and their -SQLite database were removed with no replacement. +The `announce` / `get_announcements` tools became `gw announce` / +`gw announcements`; their SQLite database was replaced by a lock-free directory of +JSON files. --- From c4446c36ad535e1bf08f969eeacc9bdaa5c0b3af Mon Sep 17 00:00:00 2001 From: Nick Song Date: Fri, 31 Jul 2026 19:22:11 +0200 Subject: [PATCH 07/21] Add gw plan / gw apply for reviewable mutations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Destructive agent operations now have a review step: `gw plan create` and `gw plan delete` describe what would change, and `gw apply` executes a plan that was reviewed. Two properties make a plan worth more than a printed warning: - It is produced by the same validation path as execution. validateCreate is now shared by PlanCreate and CreateWithOpts, so a plan cannot succeed where execution would fail validation — a table test asserts both paths return the same error code for duplicate names, unknown repos, missing branch/repos, and an already-worktreed branch. Create also now fails validation before touching the filesystem rather than mid-provision. - It pins the state it depends on with a fingerprint, and apply recomputes it. A delete plan's fingerprint deliberately includes each repo's dirty flag, so if an agent starts editing between review and apply, the delete is refused with STATE_CHANGED (exit 4) and the work survives. That is the property worth having; a plan that stayed valid while the world moved would be dangerous. Plans enumerate every repository, path, and branch, each marked destructive or not, so "delete workspace x" is never the whole story. Delete plans additionally warn about uncommitted changes and unpushed commits — the things a reviewer most needs to know. Apply rebuilds the create request from the plan document (repo set, source paths, workspace dir from the plan's own path), so a config change or shifted repo discovery between plan and apply cannot silently redirect the work. Ergonomics: apply accepts a bare plan, a saved `--format json` envelope (which is what `> plan.json` actually produces), or `-` for stdin. A saved *failure* envelope is refused rather than parsed into an empty plan, and an unrecognized plan schema_version is refused rather than misread. The plan document is versioned separately from the response envelope since they evolve for different reasons. `gw plan create` requires --repos/--preset/--all instead of falling back to an interactive picker: a plan must be reproducible from its inputs. The transactional execution and rollback semantics underneath belong with the transactional-operations epic (#59); this owns the public reviewable contract. --- CHANGELOG.md | 12 + cmd/plan.go | 260 ++++++++++++++++ cmd/root.go | 2 + docs/agent-cli.md | 50 +++ internal/workspace/plan.go | 529 ++++++++++++++++++++++++++++++++ internal/workspace/plan_test.go | 425 +++++++++++++++++++++++++ internal/workspace/workspace.go | 10 +- 7 files changed, 1282 insertions(+), 6 deletions(-) create mode 100644 cmd/plan.go create mode 100644 internal/workspace/plan.go create mode 100644 internal/workspace/plan_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index ed5f8f8..b16c078 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,18 @@ ### Features +- `gw plan create` / `gw plan delete` and `gw apply`: preview a mutation, review + every repository, path, and branch it would touch, then execute exactly what + was reviewed. Plans carry a fingerprint of the state they assume (including + per-repo dirtiness), and `gw apply` refuses with `STATE_CHANGED` if anything + relevant moved — so work created after a plan was reviewed is never destroyed + by it. +- `gw context`: one read-only call reporting the current workspace, each repo's + live git state, configuration, announcements, and safe next actions. +- Global `--format json` (`-o json`) on every command: a versioned response + envelope with stable error codes, semantic exit codes, and `next_actions`. + stdout carries exactly one JSON document; progress, warnings, and hook output + go to stderr. See [docs/agent-cli.md](docs/agent-cli.md). - `gw announce` / `gw announcements`: cross-workspace coordination for agents working in parallel, replacing the MCP server's `announce` / `get_announcements` tools. Notes are keyed by normalized repo remote, expire diff --git a/cmd/plan.go b/cmd/plan.go new file mode 100644 index 0000000..d1d3a39 --- /dev/null +++ b/cmd/plan.go @@ -0,0 +1,260 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/nicksenap/grove/internal/config" + "github.com/nicksenap/grove/internal/console" + "github.com/nicksenap/grove/internal/discover" + "github.com/nicksenap/grove/internal/machine" + "github.com/nicksenap/grove/internal/models" + "github.com/nicksenap/grove/internal/workspace" + "github.com/spf13/cobra" +) + +// `gw plan` previews a mutation; `gw apply` executes a previewed one. Together +// they give an agent a review step before destructive work, and a guarantee that +// what runs is what was reviewed (see internal/workspace/plan.go). + +var ( + planBranch string + planRepos string + planPreset string + planAll bool + planTrack bool + planSourceURL string + planSourceProv string + planSourceRef string + planSourceTitle string +) + +var planCmd = &cobra.Command{ + Use: "plan", + Short: "Preview a mutation without performing it", + Long: `Produce a reviewable description of what a command would change. + +A plan lists every repository, path, and branch that would be created or +destroyed, and carries a fingerprint of the state it was computed against. +"gw apply" refuses the plan if that state has changed. + +Plans are non-interactive by design: pass repos and branch explicitly.`, + Example: ` gw plan create feat-x -r svc-auth,api-gateway -b feat/x --format json > plan.json + gw plan delete feat-x --format json + gw apply plan.json --format json`, +} + +var planCreateCmd = &cobra.Command{ + Use: "create NAME", + Short: "Preview creating a workspace", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + name := args[0] + cfg := config.RequireConfig() + repos := discover.FindAllRepos(cfg.RepoDirs) + repoMap := discover.RepoMap(repos) + + repoNames := planRepoNames(cfg, repos) + + opts := workspace.CreateOpts{ + Branch: planBranch, + Repos: repoNames, + RepoMap: repoMap, + Cfg: cfg, + Source: planSource(), + } + if planTrack { + opts.BranchMode = workspace.BranchModeTrack + } + + plan, err := workspace.NewService().PlanCreate(name, opts, Version) + if err != nil { + fail(err) + } + emitPlan(plan) + }, +} + +var planDeleteCmd = &cobra.Command{ + Use: "delete [NAME]", + Short: "Preview deleting a workspace", + Long: "Auto-detects the workspace from cwd if NAME is omitted.", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + name := "" + if len(args) > 0 { + name = args[0] + } + ws, err := workspace.ResolveWorkspace(name) + if err != nil { + fail(err) + } + + plan, err := workspace.NewService().PlanDelete(ws.Name, Version) + if err != nil { + fail(err) + } + emitPlan(plan) + }, + ValidArgsFunction: completeWorkspaceNames, +} + +var applyCmd = &cobra.Command{ + Use: "apply PLAN", + Short: "Execute a plan produced by gw plan", + Long: `Execute a previously reviewed plan. + +The plan is re-validated against current state and refused with STATE_CHANGED if +anything relevant has moved, so a reviewed plan cannot execute against a +different world. Accepts a plan file, a saved "--format json" envelope, or "-" +for stdin.`, + Example: ` gw plan delete feat-x --format json > plan.json + gw apply plan.json --format json + gw plan create feat-x -r api -b feat/x --format json | gw apply - --format json`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + plan, err := workspace.LoadPlan(args[0], os.Stdin) + if err != nil { + fail(err) + } + + result, err := workspace.NewService().Apply(plan, Version) + if err != nil { + fail(err) + } + + if machine.Enabled() { + machine.Emit(result, applyNextActions(plan)...) + return + } + console.Successf("Applied %s plan for %s", plan.Kind, plan.Workspace) + }, +} + +// planRepoNames resolves the repo set from flags only. Plans intentionally do not +// fall back to interactive selection: a plan exists to be reviewed and replayed, +// which requires it to be reproducible from its inputs. +func planRepoNames(cfg *models.Config, repos []discover.Repo) []string { + switch { + case planPreset != "": + preset, ok := cfg.Presets[planPreset] + if !ok { + fail(machine.Errorf(machine.CodeUsage, "preset %s not found", planPreset). + WithActions(machine.NextAction("List presets", "gw preset list --format json"))) + } + return preset.Repos + case planAll: + names := make([]string, len(repos)) + for i, r := range repos { + names[i] = r.Name + } + return names + case planRepos != "": + names := strings.Split(planRepos, ",") + for i := range names { + names[i] = strings.TrimSpace(names[i]) + } + return names + default: + fail(machine.Errorf(machine.CodeUsage, "repos are required when planning"). + WithFix("Pass --repos / -r, --preset / -p, or --all"). + WithActions(machine.NextAction("List discovered repos", "gw repos --format json"))) + return nil + } +} + +func planSource() *models.WorkspaceSource { + if planSourceURL == "" && planSourceProv == "" { + return nil + } + return &models.WorkspaceSource{ + Provider: planSourceProv, + URL: planSourceURL, + Ref: planSourceRef, + Title: planSourceTitle, + } +} + +func emitPlan(plan *workspace.Plan) { + if machine.Enabled() { + machine.Emit(plan, planNextActions(plan)...) + return + } + printPlan(plan) +} + +func planNextActions(plan *workspace.Plan) []machine.Action { + return []machine.Action{ + machine.NextAction("Apply this plan after review", + fmt.Sprintf("gw plan %s %s --format json > plan.json && gw apply plan.json --format json", + plan.Kind, plan.Workspace)), + } +} + +func applyNextActions(plan *workspace.Plan) []machine.Action { + if plan.Kind == workspace.PlanKindDelete { + return []machine.Action{ + machine.NextAction("List remaining workspaces", "gw list --format json"), + } + } + return []machine.Action{ + machine.NextAction("Inspect repo state", "gw status "+plan.Workspace+" --format json"), + } +} + +func printPlan(plan *workspace.Plan) { + fmt.Fprintf(os.Stdout, "Plan: %s %s\n", plan.Kind, plan.Workspace) + fmt.Fprintf(os.Stdout, "Path: %s\n", plan.Path) + if plan.Branch != "" { + fmt.Fprintf(os.Stdout, "Branch: %s\n", plan.Branch) + } + if plan.Destructive { + fmt.Fprintf(os.Stdout, "Destructive: yes — %d of %d changes destroy data\n", + len(plan.DestructiveChanges()), len(plan.Changes)) + } + fmt.Fprintln(os.Stdout) + + table := console.NewTable(os.Stdout, []string{"", "Action", "Repo", "Target", "Detail"}) + for _, c := range plan.Changes { + marker := "+" + if c.Destructive { + marker = "-" + } + target := c.Path + if target == "" { + target = c.Branch + } + table.AddRow([]string{marker, c.Action, c.Repo, target, c.Detail}) + } + table.Render() + + for _, w := range plan.Warnings { + console.Warning(w) + } + console.Infof("fingerprint %s", plan.Fingerprint[:min(12, len(plan.Fingerprint))]) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func init() { + planCreateCmd.Flags().StringVarP(&planBranch, "branch", "b", "", "Branch name") + planCreateCmd.Flags().StringVarP(&planRepos, "repos", "r", "", "Comma-separated repo names") + planCreateCmd.Flags().StringVarP(&planPreset, "preset", "p", "", "Use named preset") + planCreateCmd.Flags().BoolVar(&planAll, "all", false, "Use all discovered repos") + planCreateCmd.Flags().BoolVar(&planTrack, "track", false, + "Check out an existing remote branch (e.g. a PR head) instead of creating a new one") + planCreateCmd.Flags().StringVar(&planSourceURL, "source-url", "", "Record the source URL this workspace was seeded from") + planCreateCmd.Flags().StringVar(&planSourceProv, "source-provider", "", "Source provider label (e.g. github, notion, slack)") + planCreateCmd.Flags().StringVar(&planSourceRef, "source-ref", "", "Source ref (PR number, page id, message ts)") + planCreateCmd.Flags().StringVar(&planSourceTitle, "source-title", "", "Human-readable source title for display") + planCreateCmd.RegisterFlagCompletionFunc("repos", completeRepoNames) + planCreateCmd.RegisterFlagCompletionFunc("preset", completePresetNames) + + planCmd.AddCommand(planCreateCmd, planDeleteCmd) +} diff --git a/cmd/root.go b/cmd/root.go index 619f83b..37be02e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -67,6 +67,8 @@ func init() { contextCmd, announceCmd, announcementsCmd, + planCmd, + applyCmd, createCmd, listCmd, wsCmd, diff --git a/docs/agent-cli.md b/docs/agent-cli.md index afe809c..cc570c7 100644 --- a/docs/agent-cli.md +++ b/docs/agent-cli.md @@ -174,6 +174,56 @@ gw apply plan.json --format json Every step returns one envelope; a non-zero exit tells the caller which class of recovery to attempt. +## Plan and apply + +Mutating operations have a review step. `gw plan` describes what would change; +`gw apply` executes a plan that has been reviewed. + +```bash +gw plan create feat-x -r svc-auth,api-gateway -b feat/x --format json > plan.json +gw plan delete feat-x --format json +gw apply plan.json --format json +gw plan create feat-x -r api -b feat/x --format json | gw apply - --format json +``` + +A plan lists every repository, path, and branch it would touch, with each change +marked `destructive` or not: + +```json +{ + "schema_version": 1, + "kind": "delete", + "workspace": "feat-x", + "destructive": true, + "changes": [ + { "action": "remove_worktree", "repo": "api", "path": "/…/feat-x/api", "destructive": true }, + { "action": "delete_branch", "repo": "api", "branch": "feat/x", "destructive": true, + "detail": "force-deleted, including unmerged commits" } + ], + "warnings": ["api has uncommitted changes that would be destroyed"], + "fingerprint": "3b1985…" +} +``` + +Two guarantees make a plan worth more than a printed warning: + +1. **Same validation path.** A plan is produced by the checks execution runs, so + a plan that succeeds cannot fail validation at apply time. +2. **State pinning.** The `fingerprint` covers the state the plan depends on — + including whether each repo is dirty. `gw apply` recomputes it and fails with + `STATE_CHANGED` (exit 4) rather than applying a plan that was reviewed against + a different world. If an agent starts editing after the plan was produced, the + delete is refused and the work survives. + +`gw apply` accepts a bare plan document, a saved `--format json` envelope, or `-` +for stdin. A saved *failure* envelope is refused rather than parsed as an empty +plan. `schema_version` versions the plan document independently of the response +envelope; an unrecognized version is refused instead of misread. + +Plans are non-interactive by design: `gw plan create` requires `--repos`, +`--preset`, or `--all` rather than falling back to a picker, because a plan has +to be reproducible from its inputs. + ## Cross-agent coordination When several agents work in parallel workspaces on the same repos, they can leave diff --git a/internal/workspace/plan.go b/internal/workspace/plan.go new file mode 100644 index 0000000..58c2500 --- /dev/null +++ b/internal/workspace/plan.go @@ -0,0 +1,529 @@ +package workspace + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "time" + + "github.com/nicksenap/grove/internal/gitops" + "github.com/nicksenap/grove/internal/machine" + "github.com/nicksenap/grove/internal/models" +) + +// Plans let an agent (or a human) review a mutation before it happens, and then +// execute exactly what was reviewed. +// +// Two properties make a plan worth more than a printed warning: +// +// 1. It is produced by the same validation path as execution, so "the plan +// succeeded" means the same thing as "validation passed". +// 2. It carries a fingerprint of the state it depends on. Apply recomputes that +// fingerprint and refuses with STATE_CHANGED if anything relevant moved, so a +// reviewed plan can never quietly execute against a different world. +// +// The transactional execution and rollback semantics underneath belong with the +// transactional-operations epic; this file owns the reviewable contract. + +// PlanSchemaVersion versions the plan document itself, independently of the CLI +// response envelope. Apply refuses a version it does not understand rather than +// misreading fields. +const PlanSchemaVersion = 1 + +// PlanKind identifies which operation a plan describes. +type PlanKind string + +const ( + PlanKindCreate PlanKind = "create" + PlanKindDelete PlanKind = "delete" +) + +// Planned action verbs. These are stable identifiers an agent can branch on to +// decide whether a plan needs human review. +const ( + ActionCreateWorkspaceDir = "create_workspace_dir" + ActionCreateBranch = "create_branch" + ActionTrackBranch = "track_remote_branch" + ActionCreateWorktree = "create_worktree" + ActionRunSetupHook = "run_setup_hook" + ActionRunTeardownHook = "run_teardown_hook" + ActionRemoveWorktree = "remove_worktree" + ActionDeleteBranch = "delete_branch" + ActionRemoveWorkspaceDir = "remove_workspace_dir" + ActionRemoveStateEntry = "remove_state_entry" +) + +// PlannedChange is one concrete mutation. Every repository, path, and branch a +// plan would touch appears here — a destructive plan that summarized itself as +// "delete workspace x" would not be reviewable. +type PlannedChange struct { + Action string `json:"action"` + Repo string `json:"repo,omitempty"` + Path string `json:"path,omitempty"` + Branch string `json:"branch,omitempty"` + SourceRepo string `json:"source_repo,omitempty"` + Destructive bool `json:"destructive"` + Detail string `json:"detail,omitempty"` +} + +// Plan is a reviewable description of a mutation. +type Plan struct { + SchemaVersion int `json:"schema_version"` + Kind PlanKind `json:"kind"` + Workspace string `json:"workspace"` + Branch string `json:"branch,omitempty"` + Path string `json:"path"` + CreatedAt time.Time `json:"created_at"` + GroveVersion string `json:"grove_version,omitempty"` + + // Destructive is true when any change destroys work: removing worktrees, + // deleting branches, or deleting directories. + Destructive bool `json:"destructive"` + Changes []PlannedChange `json:"changes"` + + // Source is carried so applying a plan preserves provenance. + Source *models.WorkspaceSource `json:"source,omitempty"` + + // Fingerprint pins the state this plan was computed against. + Fingerprint string `json:"fingerprint"` + + // Warnings are conditions that do not block planning but deserve attention + // before applying — e.g. a repo with uncommitted changes in a delete plan. + Warnings []string `json:"warnings,omitempty"` +} + +// DestructiveChanges returns only the changes that destroy something. +func (p *Plan) DestructiveChanges() []PlannedChange { + var out []PlannedChange + for _, c := range p.Changes { + if c.Destructive { + out = append(out, c) + } + } + return out +} + +// --------------------------------------------------------------------------- +// Planning +// --------------------------------------------------------------------------- + +// PlanCreate validates a create request and describes what it would do, without +// touching anything. It runs validateCreate — the same check CreateWithOpts runs +// — so a plan cannot succeed where execution would fail validation. +func (s *Service) PlanCreate(name string, opts CreateOpts, version string) (*Plan, error) { + if err := s.validateCreate(name, opts); err != nil { + return nil, err + } + + wsPath := filepath.Join(opts.Cfg.WorkspaceDir, name) + plan := &Plan{ + SchemaVersion: PlanSchemaVersion, + Kind: PlanKindCreate, + Workspace: name, + Branch: opts.Branch, + Path: wsPath, + CreatedAt: time.Now().UTC(), + GroveVersion: version, + Source: opts.Source, + } + + plan.Changes = append(plan.Changes, PlannedChange{ + Action: ActionCreateWorkspaceDir, + Path: wsPath, + }) + + for _, repoName := range opts.Repos { + sourcePath := opts.RepoMap[repoName] + wtPath := filepath.Join(wsPath, repoName) + + // Report the branch action the executor would actually take, resolved the + // same way provisionWorktreeNoFetch resolves it. + mode := opts.BranchMode + if opts.TrackBranchRepo != "" && repoName != opts.TrackBranchRepo { + mode = BranchModeCreate + } + switch { + case gitops.BranchExists(sourcePath, opts.Branch): + plan.Changes = append(plan.Changes, PlannedChange{ + Action: ActionCreateWorktree, + Repo: repoName, + Path: wtPath, + Branch: opts.Branch, + SourceRepo: sourcePath, + Detail: "branch already exists locally; it will be checked out, not created", + }) + case mode == BranchModeTrack && gitops.RemoteBranchExists(sourcePath, opts.Branch): + plan.Changes = append(plan.Changes, + PlannedChange{ + Action: ActionTrackBranch, + Repo: repoName, + Branch: opts.Branch, + SourceRepo: sourcePath, + Detail: "tracking existing remote branch", + }, + PlannedChange{ + Action: ActionCreateWorktree, Repo: repoName, Path: wtPath, + Branch: opts.Branch, SourceRepo: sourcePath, + }) + default: + base, err := gitops.ResolveBaseBranch(sourcePath) + if err != nil { + base = "HEAD" + plan.Warnings = append(plan.Warnings, + fmt.Sprintf("%s: could not resolve a base branch; the new branch would start from HEAD", repoName)) + } + if mode == BranchModeTrack { + plan.Warnings = append(plan.Warnings, + fmt.Sprintf("%s: remote branch %s not found; a new branch would be created from %s instead", + repoName, opts.Branch, base)) + } + plan.Changes = append(plan.Changes, + PlannedChange{ + Action: ActionCreateBranch, Repo: repoName, Branch: opts.Branch, + SourceRepo: sourcePath, Detail: "from " + base, + }, + PlannedChange{ + Action: ActionCreateWorktree, Repo: repoName, Path: wtPath, + Branch: opts.Branch, SourceRepo: sourcePath, + }) + } + + if cfg, _ := gitops.ReadGroveConfig(sourcePath); cfg != nil && len(cfg.Setup) > 0 { + for _, cmdStr := range cfg.Setup { + plan.Changes = append(plan.Changes, PlannedChange{ + Action: ActionRunSetupHook, + Repo: repoName, + Path: wtPath, + Detail: cmdStr, + }) + } + } + } + + plan.Fingerprint = s.createFingerprint(name, opts) + return plan, nil +} + +// PlanDelete describes everything deleting a workspace would destroy. +func (s *Service) PlanDelete(name, version string) (*Plan, error) { + ws, err := s.State.GetWorkspace(name) + if err != nil { + return nil, err + } + if ws == nil { + return nil, ErrWorkspaceNotFound(name) + } + + plan := &Plan{ + SchemaVersion: PlanSchemaVersion, + Kind: PlanKindDelete, + Workspace: ws.Name, + Branch: ws.Branch, + Path: ws.Path, + CreatedAt: time.Now().UTC(), + GroveVersion: version, + Destructive: true, + Source: ws.Source, + } + + for _, r := range ws.Repos { + if cfg, _ := gitops.ReadGroveConfig(r.SourceRepo); cfg != nil && cfg.Teardown != "" { + plan.Changes = append(plan.Changes, PlannedChange{ + Action: ActionRunTeardownHook, Repo: r.RepoName, Path: r.WorktreePath, Detail: cfg.Teardown, + }) + } + plan.Changes = append(plan.Changes, + PlannedChange{ + Action: ActionRemoveWorktree, Repo: r.RepoName, Path: r.WorktreePath, + Branch: r.Branch, SourceRepo: r.SourceRepo, Destructive: true, + }, + PlannedChange{ + Action: ActionDeleteBranch, Repo: r.RepoName, Branch: r.Branch, + SourceRepo: r.SourceRepo, Destructive: true, + Detail: "force-deleted, including unmerged commits", + }) + + // Uncommitted work is the thing a reviewer most needs to know about. + if status, err := gitops.RepoStatus(r.WorktreePath); err == nil && status != "" { + plan.Warnings = append(plan.Warnings, + fmt.Sprintf("%s has uncommitted changes that would be destroyed", r.RepoName)) + } + if ahead, _, err := gitops.CommitsAheadBehind(r.WorktreePath, "origin/"+r.Branch); err == nil && ahead > 0 { + plan.Warnings = append(plan.Warnings, + fmt.Sprintf("%s has %d unpushed commit(s) on %s", r.RepoName, ahead, r.Branch)) + } + } + + plan.Changes = append(plan.Changes, + PlannedChange{Action: ActionRemoveWorkspaceDir, Path: ws.Path, Destructive: true}, + PlannedChange{Action: ActionRemoveStateEntry, Detail: ws.Name, Destructive: true}, + ) + + plan.Fingerprint = s.deleteFingerprint(ws) + return plan, nil +} + +// validateCreate holds the pre-flight checks shared by planning and execution. +// Keeping them in one place is what makes a plan trustworthy: there is no +// validation an apply performs that a plan did not. +func (s *Service) validateCreate(name string, opts CreateOpts) error { + if opts.Cfg == nil { + return machine.Errorf(machine.CodeNotInitialized, "grove is not configured"). + WithActions(machine.NextAction("Initialize Grove", "gw init ")) + } + if name == "" { + return machine.Errorf(machine.CodeUsage, "workspace name is required") + } + if opts.Branch == "" { + return machine.Errorf(machine.CodeUsage, "branch is required") + } + if len(opts.Repos) == 0 { + return machine.Errorf(machine.CodeUsage, "at least one repo is required"). + WithActions(machine.NextAction("List discovered repos", "gw repos --format json")) + } + + existing, err := s.State.GetWorkspace(name) + if err != nil { + return err + } + if existing != nil { + return ErrWorkspaceExists(name) + } + + for _, repoName := range opts.Repos { + sourcePath, ok := opts.RepoMap[repoName] + if !ok { + return ErrRepoNotFound(repoName) + } + if hasWT, _ := gitops.WorktreeHasBranch(sourcePath, opts.Branch); hasWT { + return ErrWorktreeExists(opts.Branch, repoName) + } + } + return nil +} + +// --------------------------------------------------------------------------- +// Fingerprints +// --------------------------------------------------------------------------- + +// createFingerprint pins what a create plan assumes: the target name is free and +// each repo's source path and branch situation are unchanged. +func (s *Service) createFingerprint(name string, opts CreateOpts) string { + input := []any{"create", name, opts.Branch} + + repos := make([]string, len(opts.Repos)) + copy(repos, opts.Repos) + sort.Strings(repos) + for _, repoName := range repos { + sourcePath := opts.RepoMap[repoName] + input = append(input, []any{ + repoName, + sourcePath, + gitops.BranchExists(sourcePath, opts.Branch), + gitops.RemoteBranchExists(sourcePath, opts.Branch), + }) + } + return hashOf(input) +} + +// deleteFingerprint pins what a delete plan assumes. It includes each repo's +// dirty state on purpose: if work appears after the plan was reviewed, applying +// would destroy something nobody agreed to lose, so the plan must expire. +func (s *Service) deleteFingerprint(ws *models.Workspace) string { + input := []any{"delete", ws.Name, ws.Path} + + repos := make([]models.RepoWorktree, len(ws.Repos)) + copy(repos, ws.Repos) + sort.Slice(repos, func(i, j int) bool { return repos[i].RepoName < repos[j].RepoName }) + + for _, r := range repos { + status, _ := gitops.RepoStatus(r.WorktreePath) + input = append(input, []any{r.RepoName, r.WorktreePath, r.Branch, r.SourceRepo, status != ""}) + } + return hashOf(input) +} + +func hashOf(v any) string { + data, err := json.Marshal(v) + if err != nil { + return "" + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +// --------------------------------------------------------------------------- +// Applying +// --------------------------------------------------------------------------- + +// ApplyResult reports what an applied plan did. Exactly one of Created or Deleted +// is set, matching the plan's kind. +type ApplyResult struct { + Kind PlanKind `json:"kind"` + Created *CreateResult `json:"created,omitempty"` + Deleted *DeleteResult `json:"deleted,omitempty"` +} + +// Apply executes a previously produced plan, or fails without touching anything. +// +// It re-plans from current state and compares fingerprints, so a plan that was +// reviewed against a different world is refused with STATE_CHANGED rather than +// applied approximately. Repo source paths come from the plan itself, so what +// runs is what was reviewed even if repo discovery would now resolve differently. +func (s *Service) Apply(plan *Plan, version string) (*ApplyResult, error) { + if plan == nil { + return nil, machine.Errorf(machine.CodeUsage, "no plan provided") + } + if plan.SchemaVersion != PlanSchemaVersion { + return nil, machine.Errorf(machine.CodeUsage, + "unsupported plan schema_version %d (this Grove understands %d)", + plan.SchemaVersion, PlanSchemaVersion). + WithFix("Regenerate the plan with this version of gw") + } + + switch plan.Kind { + case PlanKindCreate: + return s.applyCreate(plan, version) + case PlanKindDelete: + return s.applyDelete(plan, version) + default: + return nil, machine.Errorf(machine.CodeUsage, "unknown plan kind %q", plan.Kind) + } +} + +func (s *Service) applyCreate(plan *Plan, version string) (*ApplyResult, error) { + opts := createOptsFromPlan(plan) + + current, err := s.PlanCreate(plan.Workspace, opts, version) + if err != nil { + return nil, err + } + if err := assertUnchanged(plan, current); err != nil { + return nil, err + } + + result, err := s.CreateWithOpts(plan.Workspace, opts) + if err != nil { + return nil, err + } + return &ApplyResult{Kind: PlanKindCreate, Created: result}, nil +} + +func (s *Service) applyDelete(plan *Plan, version string) (*ApplyResult, error) { + current, err := s.PlanDelete(plan.Workspace, version) + if err != nil { + return nil, err + } + if err := assertUnchanged(plan, current); err != nil { + return nil, err + } + + result, err := s.Delete(plan.Workspace) + if err != nil { + return nil, err + } + return &ApplyResult{Kind: PlanKindDelete, Deleted: result}, nil +} + +// createOptsFromPlan reconstructs the create request from the plan document, so +// apply executes the reviewed repos and source paths rather than re-discovering +// them. +func createOptsFromPlan(plan *Plan) CreateOpts { + repoMap := map[string]string{} + var repos []string + for _, c := range plan.Changes { + if c.Action != ActionCreateWorktree || c.Repo == "" { + continue + } + if _, seen := repoMap[c.Repo]; !seen { + repos = append(repos, c.Repo) + } + repoMap[c.Repo] = c.SourceRepo + } + + return CreateOpts{ + Branch: plan.Branch, + Repos: repos, + RepoMap: repoMap, + Source: plan.Source, + // The workspace directory comes from the plan's own path, so a config + // change between plan and apply cannot silently relocate the workspace. + Cfg: &models.Config{WorkspaceDir: filepath.Dir(plan.Path)}, + } +} + +// assertUnchanged compares a saved plan against a freshly computed one. +func assertUnchanged(saved, current *Plan) error { + if saved.Fingerprint == "" { + return machine.Errorf(machine.CodeUsage, "plan has no fingerprint, so it cannot be verified"). + WithFix("Regenerate the plan with gw plan") + } + if saved.Fingerprint == current.Fingerprint { + return nil + } + return machine.Errorf(machine.CodeStateChanged, + "state changed since the plan was created, so it was not applied"). + WithDetails(map[string]any{ + "plan_fingerprint": saved.Fingerprint, + "current_fingerprint": current.Fingerprint, + "plan_created_at": saved.CreatedAt, + "current_warnings": current.Warnings, + }). + WithFix("Re-plan and review the new plan before applying"). + WithActions(machine.NextAction("Regenerate the plan", + fmt.Sprintf("gw plan %s %s --format json", saved.Kind, saved.Workspace))) +} + +// LoadPlan reads a plan from a file, or from stdin when path is "-". +// +// It accepts both a bare plan document and a full CLI response envelope, because +// the natural way to save a plan is `gw plan ... --format json > plan.json`, which +// writes the envelope. +func LoadPlan(path string, stdin io.Reader) (*Plan, error) { + var data []byte + var err error + if path == "-" { + data, err = io.ReadAll(stdin) + } else { + data, err = os.ReadFile(path) + } + if err != nil { + return nil, machine.Wrap(machine.CodeUsage, err, "reading plan: %s", err) + } + return ParsePlan(data) +} + +// ParsePlan decodes a plan from bare or envelope-wrapped JSON. +func ParsePlan(data []byte) (*Plan, error) { + var envelope struct { + OK bool `json:"ok"` + Result json.RawMessage `json:"result"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(data, &envelope); err == nil { + if envelope.Error != nil { + return nil, machine.Errorf(machine.CodeUsage, + "refusing to apply a failed plan (%s: %s)", envelope.Error.Code, envelope.Error.Message) + } + if len(envelope.Result) > 0 { + data = envelope.Result + } + } + + var plan Plan + if err := json.Unmarshal(data, &plan); err != nil { + return nil, machine.Wrap(machine.CodeUsage, err, "plan is not valid JSON: %s", err) + } + if plan.Kind == "" { + return nil, machine.Errorf(machine.CodeUsage, "not a plan document (no kind field)") + } + return &plan, nil +} diff --git a/internal/workspace/plan_test.go b/internal/workspace/plan_test.go new file mode 100644 index 0000000..ac45dc5 --- /dev/null +++ b/internal/workspace/plan_test.go @@ -0,0 +1,425 @@ +package workspace + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/nicksenap/grove/internal/machine" + "github.com/nicksenap/grove/internal/models" +) + +func actionsOf(plan *Plan) []string { + out := make([]string, len(plan.Changes)) + for i, c := range plan.Changes { + out[i] = c.Action + } + return out +} + +func hasAction(plan *Plan, action string) bool { + for _, c := range plan.Changes { + if c.Action == action { + return true + } + } + return false +} + +// --------------------------------------------------------------------------- +// Planning +// --------------------------------------------------------------------------- + +func TestPlanCreateDescribesChangesWithoutMutating(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + + plan, err := env.svc.PlanCreate("planned", CreateOpts{ + Branch: "feat/planned", + Repos: []string{"api"}, + RepoMap: env.repoMap, + Cfg: env.cfg, + }, "test") + if err != nil { + t.Fatalf("plan: %v", err) + } + + if plan.Kind != PlanKindCreate || plan.Workspace != "planned" { + t.Errorf("plan identity = %s/%s", plan.Kind, plan.Workspace) + } + if plan.Destructive { + t.Error("creating a workspace destroys nothing") + } + if plan.Fingerprint == "" { + t.Error("a plan without a fingerprint cannot be verified") + } + for _, want := range []string{ActionCreateWorkspaceDir, ActionCreateBranch, ActionCreateWorktree} { + if !hasAction(plan, want) { + t.Errorf("plan missing %s: %v", want, actionsOf(plan)) + } + } + + // Nothing may exist yet — a plan is a preview. + if _, err := os.Stat(filepath.Join(env.wsDir, "planned")); !os.IsNotExist(err) { + t.Error("planning must not create the workspace directory") + } + if ws, _ := env.svc.State.GetWorkspace("planned"); ws != nil { + t.Error("planning must not write state") + } +} + +// A destructive plan has to enumerate every path and branch it would destroy; +// "delete workspace x" is not reviewable. +func TestPlanDeleteEnumeratesDestruction(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.createRepo("web") + env.svc.Create("doomed", "feat/doomed", []string{"api", "web"}, env.repoMap, env.cfg) + + plan, err := env.svc.PlanDelete("doomed", "test") + if err != nil { + t.Fatalf("plan: %v", err) + } + if !plan.Destructive { + t.Error("a delete plan must be marked destructive") + } + + worktrees, branches := 0, 0 + for _, c := range plan.Changes { + switch c.Action { + case ActionRemoveWorktree: + worktrees++ + if c.Path == "" || !c.Destructive { + t.Errorf("worktree removal must name a destructive path: %+v", c) + } + case ActionDeleteBranch: + branches++ + if c.Branch == "" { + t.Errorf("branch deletion must name the branch: %+v", c) + } + } + } + if worktrees != 2 || branches != 2 { + t.Errorf("expected 2 worktree + 2 branch deletions, got %d + %d", worktrees, branches) + } + if !hasAction(plan, ActionRemoveStateEntry) { + t.Error("plan should include removing the state entry") + } + + // Still there — planning is read-only. + if ws, _ := env.svc.State.GetWorkspace("doomed"); ws == nil { + t.Error("planning must not delete anything") + } +} + +// The warning is the whole reason to review a delete. +func TestPlanDeleteWarnsAboutUncommittedWork(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.svc.Create("risky", "feat/risky", []string{"api"}, env.repoMap, env.cfg) + + wt := filepath.Join(env.wsDir, "risky", "api") + os.WriteFile(filepath.Join(wt, "wip.txt"), []byte("unsaved"), 0o644) + + plan, err := env.svc.PlanDelete("risky", "test") + if err != nil { + t.Fatalf("plan: %v", err) + } + found := false + for _, w := range plan.Warnings { + if strings.Contains(w, "uncommitted") { + found = true + } + } + if !found { + t.Errorf("expected an uncommitted-changes warning, got %v", plan.Warnings) + } +} + +// Planning must not succeed where execution would fail validation, or a plan is +// worthless as a check. +func TestPlanCreateSharesValidationWithExecution(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.svc.Create("taken", "feat/taken", []string{"api"}, env.repoMap, env.cfg) + + cases := []struct { + name string + opts CreateOpts + code machine.Code + }{ + {"duplicate name", CreateOpts{Branch: "feat/x", Repos: []string{"api"}, RepoMap: env.repoMap, Cfg: env.cfg}, machine.CodeWorkspaceExists}, + {"unknown repo", CreateOpts{Branch: "feat/y", Repos: []string{"ghost"}, RepoMap: env.repoMap, Cfg: env.cfg}, machine.CodeRepoNotFound}, + {"no branch", CreateOpts{Repos: []string{"api"}, RepoMap: env.repoMap, Cfg: env.cfg}, machine.CodeUsage}, + {"no repos", CreateOpts{Branch: "feat/z", RepoMap: env.repoMap, Cfg: env.cfg}, machine.CodeUsage}, + {"branch already worktreed", CreateOpts{Branch: "feat/taken", Repos: []string{"api"}, RepoMap: env.repoMap, Cfg: env.cfg}, machine.CodeWorktreeExists}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + wsName := "taken" + if tc.code != machine.CodeWorkspaceExists { + wsName = "fresh-" + strings.ReplaceAll(tc.name, " ", "-") + } + + _, planErr := env.svc.PlanCreate(wsName, tc.opts, "test") + if machine.CodeFor(planErr) != tc.code { + t.Fatalf("plan error = %v (%s), want %s", planErr, machine.CodeFor(planErr), tc.code) + } + // Execution must reject it the same way. + _, execErr := env.svc.CreateWithOpts(wsName, tc.opts) + if machine.CodeFor(execErr) != tc.code { + t.Errorf("execution error = %v (%s), want %s", execErr, machine.CodeFor(execErr), tc.code) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Applying +// --------------------------------------------------------------------------- + +func TestApplyCreateExecutesThePlan(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.createRepo("web") + + plan, err := env.svc.PlanCreate("applied", CreateOpts{ + Branch: "feat/applied", + Repos: []string{"api", "web"}, + RepoMap: env.repoMap, + Cfg: env.cfg, + Source: &models.WorkspaceSource{Provider: "github", URL: "https://example.test/pr/1"}, + }, "test") + if err != nil { + t.Fatalf("plan: %v", err) + } + + result, err := env.svc.Apply(plan, "test") + if err != nil { + t.Fatalf("apply: %v", err) + } + if result.Kind != PlanKindCreate || result.Created == nil { + t.Fatalf("result = %+v", result) + } + if len(result.Created.Repos) != 2 { + t.Errorf("expected 2 repos created, got %d", len(result.Created.Repos)) + } + + ws, _ := env.svc.State.GetWorkspace("applied") + if ws == nil { + t.Fatal("workspace should exist after apply") + } + // Provenance recorded in the plan survives the round trip. + if ws.Source == nil || ws.Source.Provider != "github" { + t.Errorf("source = %+v, want the planned provenance", ws.Source) + } + if ws.Path != plan.Path { + t.Errorf("applied path %s != planned path %s", ws.Path, plan.Path) + } +} + +func TestApplyDeleteExecutesThePlan(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.svc.Create("bye", "feat/bye", []string{"api"}, env.repoMap, env.cfg) + + plan, err := env.svc.PlanDelete("bye", "test") + if err != nil { + t.Fatalf("plan: %v", err) + } + result, err := env.svc.Apply(plan, "test") + if err != nil { + t.Fatalf("apply: %v", err) + } + if result.Deleted == nil || !result.Deleted.StateRemoved { + t.Fatalf("result = %+v", result) + } + if ws, _ := env.svc.State.GetWorkspace("bye"); ws != nil { + t.Error("workspace should be gone") + } +} + +// The core safety property: work appearing after review must invalidate the plan +// rather than being destroyed by it. +func TestApplyRefusesPlanAfterStateChanged(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.svc.Create("guarded", "feat/guarded", []string{"api"}, env.repoMap, env.cfg) + + plan, err := env.svc.PlanDelete("guarded", "test") + if err != nil { + t.Fatalf("plan: %v", err) + } + + // An agent starts editing after the plan was reviewed. + wt := filepath.Join(env.wsDir, "guarded", "api") + os.WriteFile(filepath.Join(wt, "new-work.txt"), []byte("precious"), 0o644) + + _, err = env.svc.Apply(plan, "test") + if machine.CodeFor(err) != machine.CodeStateChanged { + t.Fatalf("apply error = %v (%s), want %s", err, machine.CodeFor(err), machine.CodeStateChanged) + } + if ws, _ := env.svc.State.GetWorkspace("guarded"); ws == nil { + t.Error("a refused apply must not have deleted anything") + } + if _, err := os.Stat(filepath.Join(wt, "new-work.txt")); err != nil { + t.Error("the uncommitted work must survive") + } +} + +func TestApplyRefusesPlanWhenRepoSetChanged(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.createRepo("web") + env.svc.Create("shifting", "feat/shifting", []string{"api"}, env.repoMap, env.cfg) + + plan, err := env.svc.PlanDelete("shifting", "test") + if err != nil { + t.Fatalf("plan: %v", err) + } + + if _, err := env.svc.AddRepos("shifting", []string{"web"}, env.repoMap); err != nil { + t.Fatalf("add-repo: %v", err) + } + + if _, err := env.svc.Apply(plan, "test"); machine.CodeFor(err) != machine.CodeStateChanged { + t.Fatalf("apply error = %v (%s), want %s", err, machine.CodeFor(err), machine.CodeStateChanged) + } +} + +func TestApplyRefusesUnknownSchemaVersion(t *testing.T) { + env := setupTestEnv(t) + plan := &Plan{SchemaVersion: PlanSchemaVersion + 1, Kind: PlanKindDelete, Workspace: "x"} + + if _, err := env.svc.Apply(plan, "test"); machine.CodeFor(err) != machine.CodeUsage { + t.Errorf("error = %v (%s), want %s", err, machine.CodeFor(err), machine.CodeUsage) + } +} + +func TestApplyRefusesPlanWithoutFingerprint(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.svc.Create("nofp", "feat/nofp", []string{"api"}, env.repoMap, env.cfg) + + plan, _ := env.svc.PlanDelete("nofp", "test") + plan.Fingerprint = "" + + if _, err := env.svc.Apply(plan, "test"); machine.CodeFor(err) != machine.CodeUsage { + t.Errorf("error = %v (%s), want %s", err, machine.CodeFor(err), machine.CodeUsage) + } +} + +// --------------------------------------------------------------------------- +// Plan documents +// --------------------------------------------------------------------------- + +// `gw plan ... --format json > plan.json` writes an envelope, so apply must +// accept that as readily as a bare plan. +func TestParsePlanAcceptsEnvelopeAndBareDocument(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.svc.Create("wrapped", "feat/wrapped", []string{"api"}, env.repoMap, env.cfg) + plan, _ := env.svc.PlanDelete("wrapped", "test") + + bare, _ := json.Marshal(plan) + fromBare, err := ParsePlan(bare) + if err != nil { + t.Fatalf("bare plan: %v", err) + } + if fromBare.Fingerprint != plan.Fingerprint { + t.Error("bare round trip lost the fingerprint") + } + + envelope, _ := json.Marshal(map[string]any{ + "ok": true, "schemaVersion": 1, "result": plan, "next_actions": []any{}, + }) + fromEnvelope, err := ParsePlan(envelope) + if err != nil { + t.Fatalf("envelope plan: %v", err) + } + if fromEnvelope.Fingerprint != plan.Fingerprint || fromEnvelope.Kind != plan.Kind { + t.Errorf("envelope round trip = %+v", fromEnvelope) + } +} + +// Applying a saved *failure* envelope must be refused rather than parsed into an +// empty plan. +func TestParsePlanRejectsFailureEnvelope(t *testing.T) { + data := []byte(`{"ok":false,"schemaVersion":1,"error":{"code":"WORKSPACE_EXISTS","message":"nope"}}`) + if _, err := ParsePlan(data); machine.CodeFor(err) != machine.CodeUsage { + t.Errorf("error = %v, want a USAGE refusal", err) + } +} + +func TestParsePlanRejectsNonPlanJSON(t *testing.T) { + for _, data := range [][]byte{ + []byte(`{"hello":"world"}`), + []byte(`not json at all`), + } { + if _, err := ParsePlan(data); err == nil { + t.Errorf("expected a rejection for %s", data) + } + } +} + +func TestLoadPlanFromFileAndStdin(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.svc.Create("loadable", "feat/loadable", []string{"api"}, env.repoMap, env.cfg) + plan, _ := env.svc.PlanDelete("loadable", "test") + data, _ := json.Marshal(plan) + + path := filepath.Join(env.dir, "plan.json") + os.WriteFile(path, data, 0o644) + + fromFile, err := LoadPlan(path, nil) + if err != nil { + t.Fatalf("from file: %v", err) + } + if fromFile.Workspace != "loadable" { + t.Errorf("workspace = %s", fromFile.Workspace) + } + + fromStdin, err := LoadPlan("-", strings.NewReader(string(data))) + if err != nil { + t.Fatalf("from stdin: %v", err) + } + if fromStdin.Fingerprint != plan.Fingerprint { + t.Error("stdin round trip lost the fingerprint") + } + + if _, err := LoadPlan(filepath.Join(env.dir, "missing.json"), nil); machine.CodeFor(err) != machine.CodeUsage { + t.Errorf("a missing plan file should be a USAGE error, got %v", err) + } +} + +// Apply rebuilds the repo set from the plan, not from current discovery, so what +// runs is what was reviewed. +func TestApplyUsesPlanRepoSourcePaths(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + + plan, err := env.svc.PlanCreate("pinned", CreateOpts{ + Branch: "feat/pinned", + Repos: []string{"api"}, + RepoMap: env.repoMap, + Cfg: env.cfg, + }, "test") + if err != nil { + t.Fatalf("plan: %v", err) + } + + opts := createOptsFromPlan(plan) + if len(opts.Repos) != 1 || opts.Repos[0] != "api" { + t.Fatalf("repos = %v", opts.Repos) + } + if opts.RepoMap["api"] != env.repoMap["api"] { + t.Errorf("source path = %q, want %q", opts.RepoMap["api"], env.repoMap["api"]) + } + if opts.Cfg.WorkspaceDir != filepath.Dir(plan.Path) { + t.Errorf("workspace dir = %q, want %q", opts.Cfg.WorkspaceDir, filepath.Dir(plan.Path)) + } +} diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index fcb221f..4ff8681 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -80,14 +80,12 @@ func (s *Service) CreateWithOpts(name string, opts CreateOpts) (*CreateResult, e repoMap := opts.RepoMap cfg := opts.Cfg - // Check duplicate - existing, err := s.State.GetWorkspace(name) - if err != nil { + // The same pre-flight checks `gw plan create` runs, so a reviewed plan and a + // direct create agree on what is valid — and so validation failures happen + // before anything is written to disk. + if err := s.validateCreate(name, opts); err != nil { return nil, err } - if existing != nil { - return nil, ErrWorkspaceExists(name) - } logging.Info("creating workspace %q (branch=%s, repos=%v)", name, branch, repoNames) From 1b265eb93213fc68a5502f8e6abcd62ba8a053fd Mon Sep 17 00:00:00 2001 From: Nick Song Date: Fri, 31 Jul 2026 19:32:00 +0200 Subject: [PATCH 08/21] Simplify the agent-CLI code without changing behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A readability pass over the code added in this branch. No behavior changes: the full suite passes untouched, and human output for list -s / ws show / repos / context is byte-identical against the previous binary. Deduplication (the substantive part): - state.PathContains is now the single definition of "is this path inside this workspace?". state.FindWorkspaceByPath and gw context each had their own implementation — prefix-matching versus filepath.Rel — which is exactly the kind of divergence that makes two commands disagree about where the caller is. Each keeps its own selection policy (first match for state lookups, deepest match for context, which matters for nested workspaces) but they now share the subtle part: absolute-path resolution, symlink resolution, and rejecting "../" so a sibling like feat-other never counts as inside feat. A table test pins those edge cases. - announce's List and Prune both walked the directory and decoded files; they now share Store.each, so "an announcement we can act on" has one definition. - cmd's home-directory-to-~ rewrite existed in four copies; now shortenPath. Decomposition: - PlanCreate (cyclomatic 13) split into planRepoProvisioning / planBranchProvisioning / effectiveBranchMode / resolveBaseForPlan / planSetupHooks. The three-way branch resolution is the part worth reading on its own, since it has to mirror provisionWorktreeNoFetch. - PlanDelete (10) split into planRepoDestruction / unsavedWorkWarnings, which also makes it symmetric with the create path. - announce.List (19) split into a named filter type plus Store.each, leaving List about assembling and ordering results. - announce.Publish (13) extracted writeNew, removing a nested re-marshal in the ID-collision retry and putting the O_EXCL rationale next to the syscall. Also: dropped a hand-rolled min() that shadowed the builtin, and threaded BranchExists through planRepoProvisioning instead of calling it twice — the first decomposition attempt added a redundant git subprocess per repo. gocyclo -over 20 and staticcheck are clean. --- cmd/context.go | 20 +-- cmd/list.go | 29 +---- cmd/plan.go | 7 - cmd/repos.go | 8 +- cmd/root.go | 11 ++ internal/announce/announce.go | 169 ++++++++++++++---------- internal/state/state.go | 44 +++++-- internal/state/state_test.go | 33 +++++ internal/workspace/context.go | 43 ++----- internal/workspace/plan.go | 236 +++++++++++++++++++++------------- 10 files changed, 353 insertions(+), 247 deletions(-) diff --git a/cmd/context.go b/cmd/context.go index 7695a46..d15bfc5 100644 --- a/cmd/context.go +++ b/cmd/context.go @@ -91,29 +91,21 @@ func printContext(ctx *workspace.Context) { return } - home, _ := os.UserHomeDir() - short := func(p string) string { - if home != "" && p != "" { - return strings.Replace(p, home, "~", 1) - } - return p - } - fmt.Fprintf(os.Stdout, "Grove: %s\n", ctx.GroveVersion) - fmt.Fprintf(os.Stdout, "Config: %s\n", short(ctx.ConfigPath)) - fmt.Fprintf(os.Stdout, "Repo dirs: %s\n", strings.Join(shortAll(ctx.RepoDirs, short), ", ")) + fmt.Fprintf(os.Stdout, "Config: %s\n", shortenPath(ctx.ConfigPath)) + fmt.Fprintf(os.Stdout, "Repo dirs: %s\n", strings.Join(shortenPaths(ctx.RepoDirs), ", ")) if len(ctx.Presets) > 0 { fmt.Fprintf(os.Stdout, "Presets: %s\n", strings.Join(ctx.Presets, ", ")) } fmt.Fprintf(os.Stdout, "Workspaces: %d\n", ctx.WorkspaceCount) if ctx.Workspace == nil { - fmt.Fprintf(os.Stdout, "\nNot inside a workspace (%s)\n", short(ctx.Cwd)) + fmt.Fprintf(os.Stdout, "\nNot inside a workspace (%s)\n", shortenPath(ctx.Cwd)) return } ws := ctx.Workspace - fmt.Fprintf(os.Stdout, "\nWorkspace: %s (%s)\n", ws.Name, short(ws.Path)) + fmt.Fprintf(os.Stdout, "\nWorkspace: %s (%s)\n", ws.Name, shortenPath(ws.Path)) fmt.Fprintf(os.Stdout, "Branch: %s\n", ws.Branch) if ws.Source != nil && ws.Source.URL != "" { fmt.Fprintf(os.Stdout, "Source: %s\n", ws.Source.URL) @@ -131,10 +123,10 @@ func printContext(ctx *workspace.Context) { table.Render() } -func shortAll(paths []string, short func(string) string) []string { +func shortenPaths(paths []string) []string { out := make([]string, len(paths)) for i, p := range paths { - out[i] = short(p) + out[i] = shortenPath(p) } return out } diff --git a/cmd/list.go b/cmd/list.go index ebe3b9f..952e7c9 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -154,14 +154,9 @@ func listWithStatus() { return } - home, _ := os.UserHomeDir() table := console.NewTable(os.Stdout, []string{"Name", "Branch", "Repos", "Status", "Path"}) for _, s := range summaries { - path := s.Path - if home != "" { - path = strings.Replace(path, home, "~", 1) - } - table.AddRow([]string{s.Name, s.Branch, fmt.Sprintf("%d", s.Repos), s.Status, path}) + table.AddRow([]string{s.Name, s.Branch, fmt.Sprintf("%d", s.Repos), s.Status, shortenPath(s.Path)}) } table.Render() } @@ -191,32 +186,20 @@ func doShowOne(name string) { created = created[:19] } - home, _ := os.UserHomeDir() - wsPath := ws.Path - if home != "" { - wsPath = strings.Replace(wsPath, home, "~", 1) - } - fmt.Fprintf(os.Stderr, "Name: %s\n", ws.Name) fmt.Fprintf(os.Stderr, "Branch: %s\n", ws.Branch) - fmt.Fprintf(os.Stderr, "Path: %s\n", wsPath) + fmt.Fprintf(os.Stderr, "Path: %s\n", shortenPath(ws.Path)) fmt.Fprintf(os.Stderr, "Created: %s\n", created) fmt.Fprintf(os.Stderr, "Repos: %d\n\n", len(ws.Repos)) wsPrefix := ws.Path + "/" table := console.NewTable(os.Stderr, []string{"Repo", "Branch", "Worktree", "Source"}) for _, r := range ws.Repos { - wt := r.WorktreePath - if after, ok := strings.CutPrefix(wt, wsPrefix); ok { - wt = after - } else if home != "" { - wt = strings.Replace(wt, home, "~", 1) - } - src := r.SourceRepo - if home != "" { - src = strings.Replace(src, home, "~", 1) + wt, relative := strings.CutPrefix(r.WorktreePath, wsPrefix) + if !relative { + wt = shortenPath(r.WorktreePath) } - table.AddRow([]string{r.RepoName, r.Branch, wt, src}) + table.AddRow([]string{r.RepoName, r.Branch, wt, shortenPath(r.SourceRepo)}) } table.Render() } diff --git a/cmd/plan.go b/cmd/plan.go index d1d3a39..a9c8226 100644 --- a/cmd/plan.go +++ b/cmd/plan.go @@ -235,13 +235,6 @@ func printPlan(plan *workspace.Plan) { console.Infof("fingerprint %s", plan.Fingerprint[:min(12, len(plan.Fingerprint))]) } -func min(a, b int) int { - if a < b { - return a - } - return b -} - func init() { planCreateCmd.Flags().StringVarP(&planBranch, "branch", "b", "", "Branch name") planCreateCmd.Flags().StringVarP(&planRepos, "repos", "r", "", "Comma-separated repo names") diff --git a/cmd/repos.go b/cmd/repos.go index 3d87008..8c2ec36 100644 --- a/cmd/repos.go +++ b/cmd/repos.go @@ -2,7 +2,6 @@ package cmd import ( "os" - "strings" "github.com/nicksenap/grove/internal/config" "github.com/nicksenap/grove/internal/console" @@ -67,14 +66,9 @@ var reposCmd = &cobra.Command{ return } - home, _ := os.UserHomeDir() table := console.NewTable(os.Stdout, []string{"Name", "Owner/Repo", "Path"}) for _, e := range entries { - path := e.Path - if home != "" { - path = strings.Replace(path, home, "~", 1) - } - table.AddRow([]string{e.Name, e.DisplayName, path}) + table.AddRow([]string{e.Name, e.DisplayName, shortenPath(e.Path)}) } table.Render() }, diff --git a/cmd/root.go b/cmd/root.go index 37be02e..aec81b9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -181,6 +181,17 @@ func pluginArgs(name string) []string { return nil } +// shortenPath abbreviates the user's home directory to "~" for human output. +// Machine output always carries absolute paths — an agent must not have to expand +// them. +func shortenPath(path string) string { + home, err := os.UserHomeDir() + if err != nil || home == "" || path == "" { + return path + } + return strings.Replace(path, home, "~", 1) +} + // legacyJSONUsage documents the pre-envelope `--json` flag. It still emits the // old bare shapes so existing scripts and plugins keep working; `--format json` // is the versioned contract described in docs/agent-cli.md. diff --git a/internal/announce/announce.go b/internal/announce/announce.go index 89eae3f..ad9393a 100644 --- a/internal/announce/announce.go +++ b/internal/announce/announce.go @@ -139,41 +139,46 @@ func (s *Store) Publish(workspace, repo, category, message string) (*Announcemen CreatedAt: created, } - data, err := json.Marshal(a) - if err != nil { - return nil, err - } - - // O_EXCL makes the publish atomic and collision-proof: if two agents somehow - // generate the same ID, the loser retries with a fresh one instead of - // overwriting the winner's note. + // Retry on the astronomically unlikely ID collision rather than overwriting + // another agent's note. See writeNew for why creation is exclusive. for attempt := 0; ; attempt++ { - path := filepath.Join(s.Dir, a.ID+".json") - f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + err := s.writeNew(a) if err == nil { - _, werr := f.Write(data) - cerr := f.Close() - if werr != nil { - return nil, werr - } - if cerr != nil { - return nil, cerr - } break } if !os.IsExist(err) || attempt >= 5 { return nil, fmt.Errorf("writing announcement: %w", err) } a.ID = newID(created) - if data, err = json.Marshal(a); err != nil { - return nil, err - } } s.Prune() return &a, nil } +// writeNew writes one announcement to a file that must not already exist. +// +// O_EXCL is what makes publishing safe for concurrent agents: creation either +// wins outright or fails, so there is no read-modify-write window and no lock to +// take. It is also why an ID collision surfaces as os.IsExist rather than +// silently replacing someone else's note. +func (s *Store) writeNew(a Announcement) error { + data, err := json.Marshal(a) + if err != nil { + return err + } + + f, err := os.OpenFile(filepath.Join(s.Dir, a.ID+".json"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return err + } + if _, err := f.Write(data); err != nil { + f.Close() + return err + } + return f.Close() +} + // ListOptions filters a read. A zero value returns everything unexpired. type ListOptions struct { // Repos limits results to these coordination keys. Empty means every repo. @@ -191,47 +196,16 @@ type ListOptions struct { // file is skipped rather than failing the read: coordination data is advisory, // and one bad file must not blind an agent to the rest. func (s *Store) List(opts ListOptions) ([]Announcement, error) { - entries, err := os.ReadDir(s.Dir) - if err != nil { - if os.IsNotExist(err) { - return []Announcement{}, nil - } - return nil, err - } - - wanted := make(map[string]bool, len(opts.Repos)) - for _, r := range opts.Repos { - wanted[NormalizeRepo(r)] = true - } - - cutoff := s.now().UTC().Add(-s.maxAge()) + filter := s.newFilter(opts) results := []Announcement{} - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { - continue - } - data, err := os.ReadFile(filepath.Join(s.Dir, entry.Name())) - if err != nil { - continue - } - var a Announcement - if err := json.Unmarshal(data, &a); err != nil { - continue - } - if a.CreatedAt.Before(cutoff) { - continue - } - if len(wanted) > 0 && !wanted[a.Repo] { - continue + err := s.each(func(a Announcement, path string) { + if filter.matches(a) { + results = append(results, a) } - if opts.ExcludeWorkspace != "" && a.Workspace == opts.ExcludeWorkspace { - continue - } - if !opts.Since.IsZero() && a.CreatedAt.Before(opts.Since) { - continue - } - results = append(results, a) + }) + if err != nil { + return nil, err } sort.Slice(results, func(i, j int) bool { @@ -249,14 +223,34 @@ func (s *Store) List(opts ListOptions) ([]Announcement, error) { // Prune deletes expired announcements and reports how many were removed. // Unlinking is safe while other processes read or publish. +// +// Unparseable files are left alone: they might belong to another tool, and +// deleting data we cannot read is not ours to decide. func (s *Store) Prune() int { + cutoff := s.cutoff() + + removed := 0 + s.each(func(a Announcement, path string) { + if a.CreatedAt.Before(cutoff) && os.Remove(path) == nil { + removed++ + } + }) + return removed +} + +// each visits every readable announcement in the store. Unreadable and +// unparseable files are skipped, so both reading and pruning share one +// definition of "an announcement we can act on". A missing store directory is +// normal on a fresh machine and yields no visits. +func (s *Store) each(visit func(a Announcement, path string)) error { entries, err := os.ReadDir(s.Dir) if err != nil { - return 0 + if os.IsNotExist(err) { + return nil + } + return err } - cutoff := s.now().UTC().Add(-s.maxAge()) - removed := 0 for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { continue @@ -268,15 +262,54 @@ func (s *Store) Prune() int { } var a Announcement if err := json.Unmarshal(data, &a); err != nil { - // Unparseable files are left alone: they might be another tool's, and - // deleting data we cannot read is not ours to decide. continue } - if a.CreatedAt.Before(cutoff) && os.Remove(path) == nil { - removed++ - } + visit(a, path) } - return removed + return nil +} + +// filter decides whether one announcement belongs in a List result. Naming the +// criteria separately keeps List about assembling results. +type filter struct { + repos map[string]bool + excludeWorkspace string + since time.Time + cutoff time.Time +} + +func (s *Store) newFilter(opts ListOptions) filter { + repos := make(map[string]bool, len(opts.Repos)) + for _, r := range opts.Repos { + repos[NormalizeRepo(r)] = true + } + return filter{ + repos: repos, + excludeWorkspace: opts.ExcludeWorkspace, + since: opts.Since, + cutoff: s.cutoff(), + } +} + +func (f filter) matches(a Announcement) bool { + if a.CreatedAt.Before(f.cutoff) { + return false + } + if len(f.repos) > 0 && !f.repos[a.Repo] { + return false + } + if f.excludeWorkspace != "" && a.Workspace == f.excludeWorkspace { + return false + } + if !f.since.IsZero() && a.CreatedAt.Before(f.since) { + return false + } + return true +} + +// cutoff is the age at which an announcement stops being visible. +func (s *Store) cutoff() time.Time { + return s.now().UTC().Add(-s.maxAge()) } // newID builds a lexically sortable, collision-resistant id. The timestamp uses diff --git a/internal/state/state.go b/internal/state/state.go index b30444e..229f290 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -159,22 +159,48 @@ func (s *Store) FindWorkspaceByPath(path string) (*models.Workspace, error) { if err != nil { return nil, err } - resolved, err := filepath.EvalSymlinks(path) - if err != nil { - resolved = path - } for i := range workspaces { - wsResolved, err := filepath.EvalSymlinks(workspaces[i].Path) - if err != nil { - wsResolved = workspaces[i].Path - } - if resolved == wsResolved || strings.HasPrefix(resolved, wsResolved+string(filepath.Separator)) { + if PathContains(workspaces[i].Path, path) { return &workspaces[i], nil } } return nil, nil } +// PathContains reports whether path is wsPath itself or lies inside it. +// +// This is the one definition of "am I in this workspace?", shared by state lookups +// and `gw context`, so the two can never disagree about where the caller is. Both +// sides are made absolute and symlink-resolved first (macOS /var vs /private/var +// would otherwise hide a match), and the containment test rejects "../" results so +// a sibling like ".../feat-other" never counts as inside ".../feat". +func PathContains(wsPath, path string) bool { + resolved := resolvePath(path) + wsResolved := resolvePath(wsPath) + if resolved == wsResolved { + return true + } + + rel, err := filepath.Rel(wsResolved, resolved) + if err != nil || filepath.IsAbs(rel) || rel == ".." { + return false + } + return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +// resolvePath makes a path absolute and resolves symlinks, falling back to the +// closest form available when either step fails (e.g. the path does not exist). +func resolvePath(path string) string { + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + return resolved + } + return abs +} + // --- Package-level convenience functions using config.GroveDir --- // These delegate to a Store created from the global config. // CLI code can use these; test code should create Store directly. diff --git a/internal/state/state_test.go b/internal/state/state_test.go index a0d0890..bd02308 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -281,3 +281,36 @@ func TestAtomicWrite(t *testing.T) { t.Error("temp file should be cleaned up") } } + +// PathContains is the single definition of "am I in this workspace?", shared by +// state lookups and gw context, so its edge cases are pinned here. +func TestPathContains(t *testing.T) { + dir := t.TempDir() + ws := filepath.Join(dir, "feat") + sibling := filepath.Join(dir, "feat-other") + os.MkdirAll(filepath.Join(ws, "api", "src"), 0o755) + os.MkdirAll(sibling, 0o755) + + tests := []struct { + name string + wsPath string + path string + wantInside bool + }{ + {"the workspace itself", ws, ws, true}, + {"a repo inside it", ws, filepath.Join(ws, "api"), true}, + {"a nested directory", ws, filepath.Join(ws, "api", "src"), true}, + {"the parent directory", ws, dir, false}, + {"a sibling sharing a name prefix", ws, sibling, false}, + {"an unrelated path", ws, filepath.Join(dir, "elsewhere"), false}, + {"a non-normalized path that resolves inside", ws, filepath.Join(ws, "api", "..", "api"), true}, + {"a non-normalized path that escapes", ws, filepath.Join(ws, "..", "feat-other"), false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := PathContains(tt.wsPath, tt.path); got != tt.wantInside { + t.Errorf("PathContains(%q, %q) = %v, want %v", tt.wsPath, tt.path, got, tt.wantInside) + } + }) + } +} diff --git a/internal/workspace/context.go b/internal/workspace/context.go index e5ab6e3..db7a147 100644 --- a/internal/workspace/context.go +++ b/internal/workspace/context.go @@ -1,9 +1,7 @@ package workspace import ( - "path/filepath" "sort" - "strings" "sync" "time" @@ -12,6 +10,7 @@ import ( "github.com/nicksenap/grove/internal/gitops" "github.com/nicksenap/grove/internal/logging" "github.com/nicksenap/grove/internal/models" + "github.com/nicksenap/grove/internal/state" ) // Context is the answer to "where am I and what can I do?" — the single @@ -193,42 +192,20 @@ func (s *Service) repoContexts(repos []models.RepoWorktree) []RepoContext { } // findWorkspaceByPath resolves the innermost workspace containing path. It works -// on an already-loaded slice so Context does a single state read. +// on an already-loaded slice so Context does a single state read, and it defers +// the containment test to state.PathContains so context and workspace resolution +// agree on where the caller is. func findWorkspaceByPath(all []models.Workspace, path string) *models.Workspace { - abs, err := filepath.Abs(path) - if err != nil { - abs = path - } - if resolved, err := filepath.EvalSymlinks(abs); err == nil { - abs = resolved - } - var best *models.Workspace for i := range all { - wsPath := all[i].Path - if resolved, err := filepath.EvalSymlinks(wsPath); err == nil { - wsPath = resolved + if !state.PathContains(all[i].Path, path) { + continue } - if abs == wsPath || isSubPath(wsPath, abs) { - // Prefer the deepest match, so a workspace nested inside another - // workspace's directory still resolves to itself. - if best == nil || len(wsPath) > len(best.Path) { - best = &all[i] - } + // Prefer the deepest match, so a workspace nested inside another + // workspace's directory still resolves to itself. + if best == nil || len(all[i].Path) > len(best.Path) { + best = &all[i] } } return best } - -// isSubPath reports whether child is inside parent. It rejects ".." results so a -// sibling directory sharing a name prefix is never mistaken for a child. -func isSubPath(parent, child string) bool { - rel, err := filepath.Rel(parent, child) - if err != nil { - return false - } - if filepath.IsAbs(rel) || rel == ".." { - return false - } - return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) -} diff --git a/internal/workspace/plan.go b/internal/workspace/plan.go index 58c2500..7d087ed 100644 --- a/internal/workspace/plan.go +++ b/internal/workspace/plan.go @@ -138,75 +138,121 @@ func (s *Service) PlanCreate(name string, opts CreateOpts, version string) (*Pla }) for _, repoName := range opts.Repos { - sourcePath := opts.RepoMap[repoName] - wtPath := filepath.Join(wsPath, repoName) + changes, warnings := planRepoProvisioning(repoName, wsPath, opts) + plan.Changes = append(plan.Changes, changes...) + plan.Warnings = append(plan.Warnings, warnings...) + } - // Report the branch action the executor would actually take, resolved the - // same way provisionWorktreeNoFetch resolves it. - mode := opts.BranchMode - if opts.TrackBranchRepo != "" && repoName != opts.TrackBranchRepo { - mode = BranchModeCreate - } - switch { - case gitops.BranchExists(sourcePath, opts.Branch): - plan.Changes = append(plan.Changes, PlannedChange{ - Action: ActionCreateWorktree, + plan.Fingerprint = s.createFingerprint(name, opts) + return plan, nil +} + +// planRepoProvisioning describes what provisioning one repo would do. It mirrors +// provisionWorktreeNoFetch's branch resolution, so a plan reports the action the +// executor would actually take rather than a guess. +func planRepoProvisioning(repoName, wsPath string, opts CreateOpts) ([]PlannedChange, []string) { + sourcePath := opts.RepoMap[repoName] + wtPath := filepath.Join(wsPath, repoName) + + // Resolved once and threaded through: each of these queries is a git + // subprocess, and planning should not pay for the same answer twice. + branchExists := gitops.BranchExists(sourcePath, opts.Branch) + + changes, warnings := planBranchProvisioning(repoName, sourcePath, branchExists, opts) + + worktree := PlannedChange{ + Action: ActionCreateWorktree, + Repo: repoName, + Path: wtPath, + Branch: opts.Branch, + SourceRepo: sourcePath, + } + if branchExists { + // Otherwise a reused branch is indistinguishable from a created one. + worktree.Detail = "branch already exists locally; it will be checked out, not created" + } + + changes = append(changes, worktree) + return append(changes, planSetupHooks(repoName, wtPath, sourcePath)...), warnings +} + +// planBranchProvisioning covers the branch half of provisioning: nothing when the +// branch already exists locally, a tracking checkout when track mode finds it on +// the remote, or a new branch from the resolved base. +func planBranchProvisioning(repoName, sourcePath string, branchExists bool, opts CreateOpts) ([]PlannedChange, []string) { + if branchExists { + return nil, nil + } + + if effectiveBranchMode(repoName, opts) == BranchModeTrack { + if gitops.RemoteBranchExists(sourcePath, opts.Branch) { + return []PlannedChange{{ + Action: ActionTrackBranch, Repo: repoName, - Path: wtPath, Branch: opts.Branch, SourceRepo: sourcePath, - Detail: "branch already exists locally; it will be checked out, not created", - }) - case mode == BranchModeTrack && gitops.RemoteBranchExists(sourcePath, opts.Branch): - plan.Changes = append(plan.Changes, - PlannedChange{ - Action: ActionTrackBranch, - Repo: repoName, - Branch: opts.Branch, - SourceRepo: sourcePath, - Detail: "tracking existing remote branch", - }, - PlannedChange{ - Action: ActionCreateWorktree, Repo: repoName, Path: wtPath, - Branch: opts.Branch, SourceRepo: sourcePath, - }) - default: - base, err := gitops.ResolveBaseBranch(sourcePath) - if err != nil { - base = "HEAD" - plan.Warnings = append(plan.Warnings, - fmt.Sprintf("%s: could not resolve a base branch; the new branch would start from HEAD", repoName)) - } - if mode == BranchModeTrack { - plan.Warnings = append(plan.Warnings, - fmt.Sprintf("%s: remote branch %s not found; a new branch would be created from %s instead", - repoName, opts.Branch, base)) - } - plan.Changes = append(plan.Changes, - PlannedChange{ - Action: ActionCreateBranch, Repo: repoName, Branch: opts.Branch, - SourceRepo: sourcePath, Detail: "from " + base, - }, - PlannedChange{ - Action: ActionCreateWorktree, Repo: repoName, Path: wtPath, - Branch: opts.Branch, SourceRepo: sourcePath, - }) + Detail: "tracking existing remote branch", + }}, nil } + // Track mode falls back to creating a branch, which is a surprise worth + // surfacing before the plan is applied. + base, warnings := resolveBaseForPlan(repoName, sourcePath) + warnings = append(warnings, fmt.Sprintf( + "%s: remote branch %s not found; a new branch would be created from %s instead", + repoName, opts.Branch, base)) + return []PlannedChange{newBranchChange(repoName, sourcePath, opts.Branch, base)}, warnings + } - if cfg, _ := gitops.ReadGroveConfig(sourcePath); cfg != nil && len(cfg.Setup) > 0 { - for _, cmdStr := range cfg.Setup { - plan.Changes = append(plan.Changes, PlannedChange{ - Action: ActionRunSetupHook, - Repo: repoName, - Path: wtPath, - Detail: cmdStr, - }) - } - } + base, warnings := resolveBaseForPlan(repoName, sourcePath) + return []PlannedChange{newBranchChange(repoName, sourcePath, opts.Branch, base)}, warnings +} + +// effectiveBranchMode resolves opts.BranchMode for one repo: with TrackBranchRepo +// set, only that repo tracks and the rest get fresh branches. +func effectiveBranchMode(repoName string, opts CreateOpts) BranchMode { + if opts.TrackBranchRepo != "" && repoName != opts.TrackBranchRepo { + return BranchModeCreate } + return opts.BranchMode +} - plan.Fingerprint = s.createFingerprint(name, opts) - return plan, nil +// resolveBaseForPlan reports the base branch a new branch would start from, +// warning when it had to fall back to HEAD. +func resolveBaseForPlan(repoName, sourcePath string) (string, []string) { + base, err := gitops.ResolveBaseBranch(sourcePath) + if err != nil { + return "HEAD", []string{fmt.Sprintf( + "%s: could not resolve a base branch; the new branch would start from HEAD", repoName)} + } + return base, nil +} + +func newBranchChange(repoName, sourcePath, branch, base string) PlannedChange { + return PlannedChange{ + Action: ActionCreateBranch, + Repo: repoName, + Branch: branch, + SourceRepo: sourcePath, + Detail: "from " + base, + } +} + +// planSetupHooks lists the .grove.toml setup commands provisioning would run. +func planSetupHooks(repoName, wtPath, sourcePath string) []PlannedChange { + cfg, _ := gitops.ReadGroveConfig(sourcePath) + if cfg == nil { + return nil + } + changes := make([]PlannedChange, 0, len(cfg.Setup)) + for _, cmdStr := range cfg.Setup { + changes = append(changes, PlannedChange{ + Action: ActionRunSetupHook, + Repo: repoName, + Path: wtPath, + Detail: cmdStr, + }) + } + return changes } // PlanDelete describes everything deleting a workspace would destroy. @@ -232,31 +278,9 @@ func (s *Service) PlanDelete(name, version string) (*Plan, error) { } for _, r := range ws.Repos { - if cfg, _ := gitops.ReadGroveConfig(r.SourceRepo); cfg != nil && cfg.Teardown != "" { - plan.Changes = append(plan.Changes, PlannedChange{ - Action: ActionRunTeardownHook, Repo: r.RepoName, Path: r.WorktreePath, Detail: cfg.Teardown, - }) - } - plan.Changes = append(plan.Changes, - PlannedChange{ - Action: ActionRemoveWorktree, Repo: r.RepoName, Path: r.WorktreePath, - Branch: r.Branch, SourceRepo: r.SourceRepo, Destructive: true, - }, - PlannedChange{ - Action: ActionDeleteBranch, Repo: r.RepoName, Branch: r.Branch, - SourceRepo: r.SourceRepo, Destructive: true, - Detail: "force-deleted, including unmerged commits", - }) - - // Uncommitted work is the thing a reviewer most needs to know about. - if status, err := gitops.RepoStatus(r.WorktreePath); err == nil && status != "" { - plan.Warnings = append(plan.Warnings, - fmt.Sprintf("%s has uncommitted changes that would be destroyed", r.RepoName)) - } - if ahead, _, err := gitops.CommitsAheadBehind(r.WorktreePath, "origin/"+r.Branch); err == nil && ahead > 0 { - plan.Warnings = append(plan.Warnings, - fmt.Sprintf("%s has %d unpushed commit(s) on %s", r.RepoName, ahead, r.Branch)) - } + changes, warnings := planRepoDestruction(r) + plan.Changes = append(plan.Changes, changes...) + plan.Warnings = append(plan.Warnings, warnings...) } plan.Changes = append(plan.Changes, @@ -268,6 +292,46 @@ func (s *Service) PlanDelete(name, version string) (*Plan, error) { return plan, nil } +// planRepoDestruction describes what deleting one repo's worktree would do, and +// warns about the work that would be lost with it. +func planRepoDestruction(r models.RepoWorktree) ([]PlannedChange, []string) { + var changes []PlannedChange + + if cfg, _ := gitops.ReadGroveConfig(r.SourceRepo); cfg != nil && cfg.Teardown != "" { + changes = append(changes, PlannedChange{ + Action: ActionRunTeardownHook, Repo: r.RepoName, Path: r.WorktreePath, Detail: cfg.Teardown, + }) + } + + changes = append(changes, + PlannedChange{ + Action: ActionRemoveWorktree, Repo: r.RepoName, Path: r.WorktreePath, + Branch: r.Branch, SourceRepo: r.SourceRepo, Destructive: true, + }, + PlannedChange{ + Action: ActionDeleteBranch, Repo: r.RepoName, Branch: r.Branch, + SourceRepo: r.SourceRepo, Destructive: true, + Detail: "force-deleted, including unmerged commits", + }) + + return changes, unsavedWorkWarnings(r) +} + +// unsavedWorkWarnings reports work a delete would destroy: uncommitted changes +// and commits that exist nowhere else. This is what a reviewer most needs to see. +func unsavedWorkWarnings(r models.RepoWorktree) []string { + var warnings []string + if status, err := gitops.RepoStatus(r.WorktreePath); err == nil && status != "" { + warnings = append(warnings, + fmt.Sprintf("%s has uncommitted changes that would be destroyed", r.RepoName)) + } + if ahead, _, err := gitops.CommitsAheadBehind(r.WorktreePath, "origin/"+r.Branch); err == nil && ahead > 0 { + warnings = append(warnings, + fmt.Sprintf("%s has %d unpushed commit(s) on %s", r.RepoName, ahead, r.Branch)) + } + return warnings +} + // validateCreate holds the pre-flight checks shared by planning and execution. // Keeping them in one place is what makes a plan trustworthy: there is no // validation an apply performs that a plan did not. From d76132251ec70c74f9f28aa17d06a57cd4124f39 Mon Sep 17 00:00:00 2001 From: Nick Song Date: Fri, 31 Jul 2026 19:48:20 +0200 Subject: [PATCH 09/21] Warn about commits that exist nowhere else in delete plans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A delete plan asked `rev-list origin/...HEAD` and ignored the error. For a branch that was never pushed, origin/ does not exist, so the call failed and the plan reported no warnings at all: committed locally, never pushed: work that exists nowhere else gw plan delete losswork → {"warnings": null} The risk was inverted. A pushed branch that was merely ahead of its remote got a warning, while commits that exist nowhere but that worktree — the only truly unrecoverable case — got silence. A plan whose purpose is to say what will be destroyed must not be quietest when the stakes are highest. Now the comparison target depends on whether the remote branch exists: against origin/ when it does, and against the resolved base branch when it does not, which is what makes the never-pushed case visible. Both failure paths also warn instead of staying silent: - an unreadable worktree ("could not check for uncommitted changes") is not evidence of a clean one; - a branch with no remote and no resolvable base still says its commits may exist only here. Tests cover never-pushed, pushed-and-ahead, fully-pushed (which must stay quiet so the warning keeps meaning something), and an unreadable worktree. --- internal/workspace/plan.go | 56 +++++++++++++++++--- internal/workspace/plan_test.go | 94 +++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 7 deletions(-) diff --git a/internal/workspace/plan.go b/internal/workspace/plan.go index 7d087ed..9d34f96 100644 --- a/internal/workspace/plan.go +++ b/internal/workspace/plan.go @@ -317,19 +317,61 @@ func planRepoDestruction(r models.RepoWorktree) ([]PlannedChange, []string) { return changes, unsavedWorkWarnings(r) } -// unsavedWorkWarnings reports work a delete would destroy: uncommitted changes -// and commits that exist nowhere else. This is what a reviewer most needs to see. +// unsavedWorkWarnings reports work a delete would destroy: uncommitted changes, +// and commits that exist nowhere but this worktree. This is what a reviewer most +// needs to see, so every branch of it errs toward warning. func unsavedWorkWarnings(r models.RepoWorktree) []string { var warnings []string - if status, err := gitops.RepoStatus(r.WorktreePath); err == nil && status != "" { + + status, err := gitops.RepoStatus(r.WorktreePath) + switch { + case err != nil: + // An unreadable worktree is not evidence of a clean one. Saying nothing + // here would be a plan claiming there is nothing to lose. + warnings = append(warnings, + fmt.Sprintf("%s: could not check for uncommitted changes (%s) — assume there may be work to lose", r.RepoName, err)) + case status != "": warnings = append(warnings, fmt.Sprintf("%s has uncommitted changes that would be destroyed", r.RepoName)) } - if ahead, _, err := gitops.CommitsAheadBehind(r.WorktreePath, "origin/"+r.Branch); err == nil && ahead > 0 { - warnings = append(warnings, - fmt.Sprintf("%s has %d unpushed commit(s) on %s", r.RepoName, ahead, r.Branch)) + + return append(warnings, unpushedCommitWarnings(r)...) +} + +// unpushedCommitWarnings reports commits that deleting the branch would discard. +// +// The remote-tracking branch is the right comparison only when it exists. A branch +// that was never pushed has no origin/ at all — which is the *most* +// dangerous case, since its commits exist nowhere else — so falling back to the +// base branch is what makes that case visible instead of silent. +func unpushedCommitWarnings(r models.RepoWorktree) []string { + if gitops.RemoteBranchExists(r.SourceRepo, r.Branch) { + ahead, _, err := gitops.CommitsAheadBehind(r.WorktreePath, "origin/"+r.Branch) + if err != nil { + return []string{fmt.Sprintf( + "%s: could not compare %s against origin (%s) — assume there may be unpushed commits", + r.RepoName, r.Branch, err)} + } + if ahead > 0 { + return []string{fmt.Sprintf("%s has %d unpushed commit(s) on %s", r.RepoName, ahead, r.Branch)} + } + return nil + } + + base, err := gitops.ResolveBaseBranch(r.SourceRepo) + if err != nil { + return []string{fmt.Sprintf( + "%s: %s was never pushed and has no comparable base branch — its commits may exist only here", + r.RepoName, r.Branch)} + } + + ahead, _, err := gitops.CommitsAheadBehind(r.WorktreePath, base) + if err != nil || ahead == 0 { + return nil } - return warnings + return []string{fmt.Sprintf( + "%s: %s was never pushed — %d commit(s) exist only in this worktree and would be lost", + r.RepoName, r.Branch, ahead)} } // validateCreate holds the pre-flight checks shared by planning and execution. diff --git a/internal/workspace/plan_test.go b/internal/workspace/plan_test.go index ac45dc5..2c148d4 100644 --- a/internal/workspace/plan_test.go +++ b/internal/workspace/plan_test.go @@ -423,3 +423,97 @@ func TestApplyUsesPlanRepoSourcePaths(t *testing.T) { t.Errorf("workspace dir = %q, want %q", opts.Cfg.WorkspaceDir, filepath.Dir(plan.Path)) } } + +// --------------------------------------------------------------------------- +// Unsaved-work warnings +// --------------------------------------------------------------------------- + +func warningsContaining(plan *Plan, substr string) []string { + var out []string + for _, w := range plan.Warnings { + if strings.Contains(w, substr) { + out = append(out, w) + } + } + return out +} + +// The most dangerous case: commits that were never pushed exist nowhere else, so +// a delete plan that stayed silent about them would be actively misleading. +func TestPlanDeleteWarnsAboutNeverPushedCommits(t *testing.T) { + env := setupTestEnv(t) + env.createRepoWithRemote("api") + env.svc.Create("unpushed", "feat/unpushed", []string{"api"}, env.repoMap, env.cfg) + + wt := filepath.Join(env.wsDir, "unpushed", "api") + os.WriteFile(filepath.Join(wt, "only-here.txt"), []byte("irreplaceable"), 0o644) + env.run(wt, "git", "add", ".") + env.run(wt, "git", "commit", "-q", "-m", "work that exists nowhere else") + + plan, err := env.svc.PlanDelete("unpushed", "test") + if err != nil { + t.Fatalf("plan: %v", err) + } + if got := warningsContaining(plan, "never pushed"); len(got) == 0 { + t.Errorf("expected a never-pushed warning, got %v", plan.Warnings) + } +} + +// Once the branch exists on the remote, the comparison is against it. +func TestPlanDeleteWarnsAboutUnpushedCommitsOnPushedBranch(t *testing.T) { + env := setupTestEnv(t) + env.createRepoWithRemote("api") + env.svc.Create("ahead", "feat/ahead", []string{"api"}, env.repoMap, env.cfg) + + wt := filepath.Join(env.wsDir, "ahead", "api") + env.run(wt, "git", "push", "-q", "-u", "origin", "feat/ahead") + + os.WriteFile(filepath.Join(wt, "later.txt"), []byte("after push"), 0o644) + env.run(wt, "git", "add", ".") + env.run(wt, "git", "commit", "-q", "-m", "committed after pushing") + + plan, err := env.svc.PlanDelete("ahead", "test") + if err != nil { + t.Fatalf("plan: %v", err) + } + if got := warningsContaining(plan, "unpushed commit"); len(got) != 1 { + t.Errorf("expected one unpushed-commit warning, got %v", plan.Warnings) + } +} + +// A fully pushed branch has nothing to lose, so the plan should stay quiet rather +// than crying wolf on every delete. +func TestPlanDeleteQuietWhenEverythingIsPushed(t *testing.T) { + env := setupTestEnv(t) + env.createRepoWithRemote("api") + env.svc.Create("clean", "feat/clean", []string{"api"}, env.repoMap, env.cfg) + + wt := filepath.Join(env.wsDir, "clean", "api") + env.run(wt, "git", "push", "-q", "-u", "origin", "feat/clean") + + plan, err := env.svc.PlanDelete("clean", "test") + if err != nil { + t.Fatalf("plan: %v", err) + } + if len(plan.Warnings) != 0 { + t.Errorf("expected no warnings for a fully pushed branch, got %v", plan.Warnings) + } +} + +// An unreadable worktree is not evidence of a clean one. +func TestPlanDeleteWarnsWhenStatusCannotBeChecked(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.svc.Create("broken", "feat/broken", []string{"api"}, env.repoMap, env.cfg) + + // Remove the worktree directory out from under Grove, so git status fails. + os.RemoveAll(filepath.Join(env.wsDir, "broken", "api")) + + plan, err := env.svc.PlanDelete("broken", "test") + if err != nil { + t.Fatalf("plan: %v", err) + } + if got := warningsContaining(plan, "could not check"); len(got) == 0 { + t.Errorf("expected a warning about the failed check, got %v", plan.Warnings) + } +} From 7b482329fcf89844f451ff250a9da89d2bc907ab Mon Sep 17 00:00:00 2001 From: Nick Song Date: Fri, 31 Jul 2026 19:53:33 +0200 Subject: [PATCH 10/21] Stop resolving the same base branch twice in gw context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Counted with a PATH shim over real git, `gw context` on a two-repo workspace made 20 git invocations, 6 of them redundant: collectRepoStatus resolves the base branch to compute ahead/behind, then repoContexts resolved it again for RepoContext.BaseBranch. Resolution alone is up to three subprocesses (symbolic-ref, then probing origin/main and origin/master). The fix is data flow, not caching: RepoStatus now carries the BaseBranch that ahead/behind were measured against, and RepoContext gets it through the embedded struct. 20 → 14 invocations. That field is worth having on its own — `gw status --format json` reported "ahead: 2, behind: 1" without saying what they were relative to, which is not interpretable by an agent. It is an additive field, compatible under the schema policy in docs/agent-cli.md. Memoizing ResolveBaseBranch was the obvious alternative, so I implemented and measured it: it changed nothing. Every remaining repeat resolution in a single process is separated by a gitops.Fetch, which can change the answer and would have to invalidate the cache anyway (`gw apply` = plan, fetch, provision). I reverted it rather than ship a cache with invalidation coupling and no measured benefit, and left the cost note in gitops.go instead, since the durable problem is that these functions read like cheap accessors and are not. --- internal/gitops/gitops.go | 10 ++++++++++ internal/workspace/context.go | 9 +++------ internal/workspace/workspace.go | 17 +++++++++++------ 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/internal/gitops/gitops.go b/internal/gitops/gitops.go index a1ff639..0bb892b 100644 --- a/internal/gitops/gitops.go +++ b/internal/gitops/gitops.go @@ -71,6 +71,16 @@ func init() { devNull, _ = os.Open(os.DevNull) } +// Cost note: every exported function here shells out to git at least once, even +// the ones that read like cheap accessors. ResolveBaseBranch is the most +// expensive — up to three subprocesses (symbolic-ref, then probing origin/main and +// origin/master) — and callers have repeatedly resolved the same value twice +// without noticing. Prefer passing a resolved value down over re-asking for it. +// +// Memoization was measured and deliberately not added: the remaining repeat +// resolutions are separated by a Fetch, which changes the answer and would have to +// invalidate the cache anyway. + // runGit executes a git command in the given directory. func runGit(dir string, args ...string) (string, error) { cmd := exec.Command("git", args...) diff --git a/internal/workspace/context.go b/internal/workspace/context.go index db7a147..5c4bee6 100644 --- a/internal/workspace/context.go +++ b/internal/workspace/context.go @@ -61,13 +61,14 @@ type WorkspaceContext struct { // RepoContext is one repo's live state. It embeds RepoStatus so `gw context` and // `gw status` report identical git state for the same repo — two commands -// describing the same thing differently is how agents get confused. +// describing the same thing differently is how agents get confused. That embedding +// also carries BaseBranch, so context does not re-resolve what the status +// collection already resolved. type RepoContext struct { RepoStatus SourceRepo string `json:"source_repo"` Path string `json:"path"` Remote string `json:"remote,omitempty"` - BaseBranch string `json:"base_branch,omitempty"` Dirty bool `json:"dirty"` } @@ -174,15 +175,11 @@ func (s *Service) repoContexts(repos []models.RepoWorktree) []RepoContext { go func(idx int, repo models.RepoWorktree) { defer wg.Done() status := collectRepoStatus(repo) - // The same resolution sync uses, so the reported base branch is the - // one a rebase would actually target. - base, _ := gitops.ResolveBaseBranch(repo.SourceRepo) out[idx] = RepoContext{ RepoStatus: status, SourceRepo: repo.SourceRepo, Path: repo.WorktreePath, Remote: gitops.RemoteURL(repo.WorktreePath, "origin"), - BaseBranch: base, Dirty: !status.Clean(), } }(i, r) diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 4ff8681..dfaba62 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -672,12 +672,16 @@ func (s *Service) Sync(wsName string) (*SyncResult, error) { // strings because "-" means "could not be determined" — a distinct outcome from // zero that agents need to see rather than have flattened into 0. type RepoStatus struct { - Repo string `json:"repo"` - Branch string `json:"branch"` - Status string `json:"status"` - Ahead string `json:"ahead"` - Behind string `json:"behind"` - PR *gitops.PRInfo `json:"pr,omitempty"` + Repo string `json:"repo"` + Branch string `json:"branch"` + Status string `json:"status"` + Ahead string `json:"ahead"` + Behind string `json:"behind"` + // BaseBranch is what Ahead/Behind were measured against. Without it those + // numbers are uninterpretable, and callers that need it would otherwise + // resolve it a second time. + BaseBranch string `json:"base_branch,omitempty"` + PR *gitops.PRInfo `json:"pr,omitempty"` } // Clean reports whether the worktree has no uncommitted changes. @@ -743,6 +747,7 @@ func collectRepoStatus(r models.RepoWorktree) RepoStatus { if upstream == "" { upstream = "origin/main" } + rs.BaseBranch = upstream ahead, behind, err := gitops.CommitsAheadBehind(r.WorktreePath, upstream) if err == nil { rs.Ahead = fmt.Sprintf("%d", ahead) From 3456ac8e336c3d157d494486fdc802b7ee08de52 Mon Sep 17 00:00:00 2001 From: Nick Song Date: Fri, 31 Jul 2026 19:56:19 +0200 Subject: [PATCH 11/21] Parse git URLs in one place, and recognize ssh:// as a URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There were two implementations of "reduce a git remote to owner/repo": gitops.ParseRemoteName (string splitting) and announce.NormalizeRepo (regexes, added in this branch). They agreed on common forms and disagreed on case and on non-URL input — and the announce copy exists to build a *coordination key*, so a disagreement would mean two agents on the same repo silently never seeing each other's notes. announce.NormalizeRepo now delegates the parse and keeps only its own policy: lowercasing (so a case-different remote still matches) and the bare-name fallback for repos with no remote. Two bugs surfaced while consolidating: - IsGitURL did not recognize ssh:// — it required a colon *without* a scheme for the scp-like form, and only allowed https/http/file otherwise. So `gw create -r ssh://git@host/org/repo.git` treated the URL as a repo name and failed with "repo not found" instead of cloning. It now accepts explicit git transports (https, http, ssh, git, git+ssh, file) and still requires "@" for the scp-like shorthand. - ParseRemoteName turned "C:/repos/api" into "repos/api", reading a Windows drive letter as scp-like syntax. It was only harmless because callers happened to gate on IsGitURL first; it nearly became load-bearing when announce delegated to it. It now returns "" for anything IsGitURL rejects. Tests: ssh:// with and without a port, git://, git+ssh://, an unknown scheme, a Windows path, and an absolute path joined the existing IsGitURL table; new tests assert every URL form of one upstream reduces to the same identity, that nested GitLab groups survive, and that non-URLs return "" rather than a mangled guess. Also documented the deliberate duplicate `git worktree list` in the create path: validateCreate fails fast before any directory or fetch exists (and lets a plan report WORKTREE_EXISTS), while provisionWorktreeNoFetch re-checks immediately before mutating to guard against a concurrent gw. Removing a guard that protects a mutation to save one subprocess ahead of a network fetch is not a trade worth making. --- internal/announce/announce.go | 26 +++++------ internal/gitops/gitops.go | 82 ++++++++++++++++++---------------- internal/gitops/gitops_test.go | 50 +++++++++++++++++++++ internal/workspace/plan.go | 5 +++ 4 files changed, 110 insertions(+), 53 deletions(-) diff --git a/internal/announce/announce.go b/internal/announce/announce.go index ad9393a..4f9dea6 100644 --- a/internal/announce/announce.go +++ b/internal/announce/announce.go @@ -27,10 +27,11 @@ import ( "fmt" "os" "path/filepath" - "regexp" "sort" "strings" "time" + + "github.com/nicksenap/grove/internal/gitops" ) // DefaultMaxAge is how long an announcement stays visible. Coordination notes @@ -320,25 +321,22 @@ func newID(t time.Time) string { return t.Format("20060102T150405.000000000") + "-" + hex.EncodeToString(buf[:]) } -var ( - sshPattern = regexp.MustCompile(`^(?:ssh://)?git@[^:/]+[:/](.+?)(?:\.git)?$`) - httpsPattern = regexp.MustCompile(`^https?://(?:[^@/]+@)?[^/]+/(.+?)(?:\.git)?$`) -) - // NormalizeRepo reduces a git remote URL to "owner/repo" so that SSH and HTTPS -// remotes for the same upstream produce the same coordination key. A value that -// is not a URL (e.g. a bare repo name) is returned lowercased and trimmed, which -// keeps the store usable for repos with no remote. +// remotes for the same upstream produce the same coordination key. A value that is +// not a URL (e.g. a bare repo name) is returned lowercased and trimmed, which keeps +// the store usable for repos with no remote. +// +// The URL parsing itself is gitops.ParseRemoteName. Announcements only add policy +// on top — lowercasing, so a case-different remote still matches, and the bare-name +// fallback. A second URL parser here would be one more thing to keep in agreement +// with the one Grove already had. func NormalizeRepo(repo string) string { repo = strings.TrimSpace(repo) if repo == "" { return "" } - if m := sshPattern.FindStringSubmatch(repo); len(m) == 2 { - return strings.ToLower(strings.Trim(m[1], "/")) - } - if m := httpsPattern.FindStringSubmatch(repo); len(m) == 2 { - return strings.ToLower(strings.Trim(m[1], "/")) + if parsed := gitops.ParseRemoteName(repo); parsed != "" { + return strings.ToLower(parsed) } return strings.ToLower(strings.TrimSuffix(repo, ".git")) } diff --git a/internal/gitops/gitops.go b/internal/gitops/gitops.go index 0bb892b..c734cb9 100644 --- a/internal/gitops/gitops.go +++ b/internal/gitops/gitops.go @@ -300,56 +300,60 @@ func RemoteURL(path, remote string) string { return out } -// ParseRemoteName extracts "owner/repo" from an SSH or HTTPS git URL. -// Returns "" if the URL cannot be parsed. +// ParseRemoteName extracts "owner/repo" (or "group/sub/repo") from a git URL. +// Returns "" if s is not a URL it can parse. +// +// This is the one place that turns a remote into a repo identity. It is also used +// as the coordination key for cross-workspace announcements, so ssh:// and https:// +// forms of the same upstream must reduce to the same string. func ParseRemoteName(url string) string { url = strings.TrimSpace(url) - if url == "" { + if !IsGitURL(url) { return "" } - // SSH: git@github.com:owner/repo.git - if strings.Contains(url, ":") && !strings.Contains(url, "://") { - parts := strings.SplitN(url, ":", 2) - if len(parts) == 2 { - path := strings.TrimSuffix(parts[1], ".git") - path = strings.TrimPrefix(path, "/") - return path - } - } - - // HTTPS: https://github.com/owner/repo.git - if strings.Contains(url, "://") { - // Remove scheme + host - idx := strings.Index(url, "://") - rest := url[idx+3:] - slashIdx := strings.Index(rest, "/") - if slashIdx >= 0 { - path := rest[slashIdx+1:] - path = strings.TrimSuffix(path, ".git") - path = strings.TrimPrefix(path, "/") - return path + path := url + if _, after, found := strings.Cut(url, "://"); found { + // Strip scheme, then optional user@host — everything up to the first "/". + _, path, found = strings.Cut(after, "/") + if !found { + return "" } + } else if _, after, found := strings.Cut(url, ":"); found { + // scp-like shorthand: git@host:owner/repo.git + path = after } - return "" + path = strings.TrimPrefix(path, "/") + path = strings.TrimSuffix(path, "/") + return strings.TrimSuffix(path, ".git") } -// IsGitURL returns true if s looks like a remote git URL (HTTPS, SSH, or file://). +// IsGitURL returns true if s looks like a remote git URL. +// +// Two forms exist: an explicit scheme (https://, ssh://, git://, file://…) and +// git's scp-like shorthand (git@github.com:owner/repo.git), which has a colon but +// no scheme. Requiring "@" for the shorthand keeps a Windows path like +// C:/repos/api from being mistaken for a URL. func IsGitURL(s string) bool { - // HTTPS/HTTP: https://github.com/owner/repo.git - if strings.HasPrefix(s, "https://") || strings.HasPrefix(s, "http://") { - return true - } - // file:// protocol (local bare repos, testing) - if strings.HasPrefix(s, "file://") { - return true - } - // SSH: git@github.com:owner/repo.git - if strings.Contains(s, ":") && !strings.Contains(s, "://") && strings.Contains(s, "@") { - return true - } - return false + if scheme, _, found := strings.Cut(s, "://"); found { + return gitURLSchemes[strings.ToLower(scheme)] + } + // scp-like shorthand: user@host:path + return strings.Contains(s, ":") && strings.Contains(s, "@") +} + +// gitURLSchemes are the transports git understands in URL form. ssh:// belongs +// here: it was previously missing, so `gw create -r ssh://git@host/org/repo.git` +// treated the URL as a repo name and failed with "repo not found" instead of +// cloning it. +var gitURLSchemes = map[string]bool{ + "https": true, + "http": true, + "ssh": true, + "git": true, + "git+ssh": true, + "file": true, } // RepoNameFromURL extracts the repository name from a git URL. diff --git a/internal/gitops/gitops_test.go b/internal/gitops/gitops_test.go index 109e1f4..74d232f 100644 --- a/internal/gitops/gitops_test.go +++ b/internal/gitops/gitops_test.go @@ -539,6 +539,18 @@ func TestIsGitURL(t *testing.T) { {"", false}, {"file:///tmp/repos/my-repo.git", true}, {"https://", true}, // degenerate but still a URL + // Explicit-scheme forms. ssh:// used to be unrecognized, so + // `gw create -r ssh://git@host/org/repo.git` failed as "repo not found" + // instead of cloning. + {"ssh://git@github.com/org/api.git", true}, + {"ssh://git@github.com:2222/org/api.git", true}, + {"git://github.com/org/api.git", true}, + {"git+ssh://git@github.com/org/api", true}, + {"unknownscheme://github.com/org/api", false}, + // A Windows path has a colon but is not a URL. + {"C:/repos/api", false}, + {"/absolute/local/path", false}, + {"org/api", false}, } for _, tt := range tests { got := IsGitURL(tt.input) @@ -667,3 +679,41 @@ func currentBranch(t *testing.T, repo string) string { t.Helper() return run(t, repo, "git", "branch", "--show-current") } + +// --------------------------------------------------------------------------- +// URL recognition and repo identity +// --------------------------------------------------------------------------- + +// Every URL form of one upstream must reduce to the same identity, because this +// value is used as a coordination key across workspaces. +func TestParseRemoteNameAgreesAcrossURLForms(t *testing.T) { + for _, url := range []string{ + "git@github.com:org/api.git", + "git@github.com:org/api", + "ssh://git@github.com/org/api.git", + "https://github.com/org/api.git", + "https://github.com/org/api", + "https://user@github.com/org/api.git", + "git://github.com/org/api.git", + } { + if got := ParseRemoteName(url); got != "org/api" { + t.Errorf("ParseRemoteName(%q) = %q, want org/api", url, got) + } + } +} + +func TestParseRemoteNameKeepsNestedGroups(t *testing.T) { + if got := ParseRemoteName("git@gitlab.com:group/sub/api.git"); got != "group/sub/api" { + t.Errorf("got %q, want group/sub/api", got) + } +} + +// Non-URLs return "" rather than a mangled guess. This used to turn a Windows +// path into "repos/api" by treating the drive colon as scp-like syntax. +func TestParseRemoteNameRejectsNonURLs(t *testing.T) { + for _, input := range []string{"org/api", "api", "C:/repos/api", "/srv/git/api", ""} { + if got := ParseRemoteName(input); got != "" { + t.Errorf("ParseRemoteName(%q) = %q, want empty", input, got) + } + } +} diff --git a/internal/workspace/plan.go b/internal/workspace/plan.go index 9d34f96..a846b46 100644 --- a/internal/workspace/plan.go +++ b/internal/workspace/plan.go @@ -406,6 +406,11 @@ func (s *Service) validateCreate(name string, opts CreateOpts) error { if !ok { return ErrRepoNotFound(repoName) } + // provisionWorktreeNoFetch checks this again immediately before mutating. + // The duplicate `git worktree list` is deliberate: this one fails fast + // before any directory or fetch happens (and lets a plan report + // WORKTREE_EXISTS), while that one guards the mutation itself against a + // concurrent gw creating the same worktree in between. if hasWT, _ := gitops.WorktreeHasBranch(sourcePath, opts.Branch); hasWT { return ErrWorktreeExists(opts.Branch, repoName) } From b36ea5a261825161e94d879379d719f6cc896d14 Mon Sep 17 00:00:00 2001 From: Nick Song Date: Fri, 31 Jul 2026 19:57:56 +0200 Subject: [PATCH 12/21] Remove dead models.ToJSON and finish the legacy-JSON dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit models.ToJSON had zero callers while ten sites marshalled inline; a helper nobody calls is an invitation to a second convention rather than a shared one. Its test went with it — the behavior it covered (Workspace round-tripping through JSON) is already covered by TestWorkspaceJSONRoundTrip. preset list, preset show, and plugin list each hand-rolled MarshalIndent + "failed to marshal JSON" + Println behind the legacy --json flag; they now use emitLegacyJSON like list/status/doctor/repos, so the deprecated output path has one implementation. Verified byte-identical output against the previous binary for each command. --- cmd/plugin.go | 7 +------ cmd/preset.go | 17 ++--------------- internal/models/models.go | 6 ------ internal/models/models_test.go | 17 ----------------- 4 files changed, 3 insertions(+), 44 deletions(-) diff --git a/cmd/plugin.go b/cmd/plugin.go index 17674a4..390a318 100644 --- a/cmd/plugin.go +++ b/cmd/plugin.go @@ -1,7 +1,6 @@ package cmd import ( - "encoding/json" "fmt" "os" @@ -75,11 +74,7 @@ var pluginListCmd = &cobra.Command{ } if pluginListJSON { - data, err := json.MarshalIndent(plugins, "", " ") - if err != nil { - exitError(fmt.Sprintf("failed to marshal JSON: %s", err)) - } - fmt.Println(string(data)) + emitLegacyJSON(plugins) return } diff --git a/cmd/preset.go b/cmd/preset.go index 09d225b..b535027 100644 --- a/cmd/preset.go +++ b/cmd/preset.go @@ -1,7 +1,6 @@ package cmd import ( - "encoding/json" "fmt" "os" "strings" @@ -104,11 +103,7 @@ var presetListCmd = &cobra.Command{ } if presetListJSON { - data, err := json.MarshalIndent(cfg.Presets, "", " ") - if err != nil { - exitError(fmt.Sprintf("failed to marshal JSON: %s", err)) - } - fmt.Println(string(data)) + emitLegacyJSON(cfg.Presets) return } @@ -138,15 +133,7 @@ var presetShowCmd = &cobra.Command{ } if presetShowJSON { - out := map[string]any{ - "name": args[0], - "repos": preset.Repos, - } - data, err := json.MarshalIndent(out, "", " ") - if err != nil { - exitError(fmt.Sprintf("failed to marshal JSON: %s", err)) - } - fmt.Println(string(data)) + emitLegacyJSON(map[string]any{"name": args[0], "repos": preset.Repos}) return } diff --git a/internal/models/models.go b/internal/models/models.go index d1c922a..f3fd3e8 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -1,7 +1,6 @@ package models import ( - "encoding/json" "fmt" "strconv" "strings" @@ -232,8 +231,3 @@ type DoctorIssue struct { Issue string `json:"issue"` SuggestedAction string `json:"suggested_action"` } - -// ToJSON marshals to indented JSON. -func ToJSON(v interface{}) ([]byte, error) { - return json.MarshalIndent(v, "", " ") -} diff --git a/internal/models/models_test.go b/internal/models/models_test.go index 28087cc..9f1c9c7 100644 --- a/internal/models/models_test.go +++ b/internal/models/models_test.go @@ -256,20 +256,3 @@ func TestDoctorIssueJSON(t *testing.T) { t.Errorf("expected nil repo, got %v", issue3.Repo) } } - -func TestToJSON(t *testing.T) { - ws := Workspace{Name: "test", Path: "/tmp/test", Branch: "main"} - data, err := ToJSON(ws) - if err != nil { - t.Fatalf("ToJSON: %v", err) - } - if len(data) == 0 { - t.Error("expected non-empty JSON output") - } - - // Verify it's valid JSON - var m map[string]interface{} - if err := json.Unmarshal(data, &m); err != nil { - t.Errorf("ToJSON output is not valid JSON: %v", err) - } -} From a149a0fb11bb489fa07518c3f455bc349227e06d Mon Sep 17 00:00:00 2001 From: Nick Song Date: Fri, 31 Jul 2026 19:59:01 +0200 Subject: [PATCH 13/21] Regenerate OpenWiki for the CLI-only agent interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Output of `openwiki --update`, covering the MCP removal, the versioned machine contract, structured per-repo results, gw context, announcements, and plan/apply. Generated pages, not hand-written — this also replaces the openwiki pages I hand-edited earlier in this branch, which CLAUDE.md asks us not to do. Adds openwiki/index.md and openwiki/INSTRUCTIONS.md, and drops the tool's temporary _plan.md. docs/agent-cli.md remains the authoritative source for the error codes and exit classes; the wiki links to it rather than restating it. --- openwiki/.last-update.json | 4 ++-- openwiki/architecture.md | 38 ++++++++++++++++++++++++++++++++++++-- openwiki/index.md | 10 +++++----- openwiki/integrations.md | 6 +++--- openwiki/operations.md | 12 +++++++++--- openwiki/quickstart.md | 20 +++++++++++++------- openwiki/workflows.md | 37 ++++++++++++++++++++++++++++++++++--- 7 files changed, 102 insertions(+), 25 deletions(-) diff --git a/openwiki/.last-update.json b/openwiki/.last-update.json index 0fa9b2a..a81fcf8 100644 --- a/openwiki/.last-update.json +++ b/openwiki/.last-update.json @@ -1,7 +1,7 @@ { - "updatedAt": "2026-07-31T16:30:06.977Z", + "updatedAt": "2026-07-31T17:45:18.784Z", "command": "update", - "gitHead": "bb672b4cf542679b1a2bed674f3b3460db9f404a", + "gitHead": "377e382aa3fc74e4602d56d93f9286d162b59a12", "model": "gpt-5.6-luna", "status": "complete", "language": "en" diff --git a/openwiki/architecture.md b/openwiki/architecture.md index 9907ff6..0ea6a66 100644 --- a/openwiki/architecture.md +++ b/openwiki/architecture.md @@ -1,8 +1,8 @@ --- type: "Reference" title: "Architecture" -description: "Architecture of Grove's CLI, workspace orchestration, Git operation wrappers, persisted state, repository discovery, lifecycle hooks, plugins, and MCP integration." -tags: [grove, architecture, cli, workspaces, git] +description: "Grove's layered CLI and workspace orchestration architecture, including the machine-readable agent boundary, structured multi-repo results, and reviewable plan/apply mutations." +tags: ["architecture", "cli", "workspaces", "agents"] --- # Architecture @@ -48,8 +48,21 @@ Cobra command handlers that: - `cmd/sync_cmd.go` — Rebase all repos - `cmd/add_repo.go`, `cmd/remove_repo.go` — Modify existing workspace - `cmd/run.go` — Launch interactive TUI for running per-repo processes +- `cmd/context.go` — Produce a read-only workspace/repository orientation view +- `cmd/announce.go` — Publish and list cross-workspace coordination notes +- `cmd/plan.go` — Produce and apply reviewable mutation plans - `cmd/preset.go` — Manage presets (named repo groups) +Agent-facing commands use the CLI itself as the integration boundary. `internal/machine/` emits a versioned JSON envelope for `--format json`, while `internal/workspace/results.go` preserves per-repository outcomes for concurrent operations instead of collapsing partial success into one error. `internal/workspace/plan.go` adds a separate versioned plan document and fingerprints the relevant state so `gw apply` can reject stale plans with `STATE_CHANGED`. + +### Agent CLI boundary (`internal/machine/`) + +The CLI is Grove's only first-party agent interface; the built-in MCP server was removed. A global `--format json`/`-o json` flag switches commands from human text to one stdout response envelope. The envelope has `ok`, `schemaVersion` (currently `1`), command-specific `result`, structured `error`, optional `warnings`/`fix`, and `next_actions`; progress and diagnostics go to stderr. The [Agent CLI contract](../docs/agent-cli.md) is authoritative for stable error codes and exit classes, while [workflows](workflows.md) explains how agents use context, announcements, and plan/apply. + +### Structured multi-repo results (`internal/workspace/results.go`) + +Concurrent mutations return a result containing one `RepoResult` per repository rather than reducing partial success to a single boolean. Outcomes include `created`, `added`, `already_present`, `removed`, `not_found`, `rebased`, `up_to_date`, `skipped`, `exited`, and `failed`; run results also carry each repo's `exit_code`. This is part of the machine-mode contract and lets agents decide what to retry or repair. + ### 3. **Core Layer** (`internal/workspace/`) **`workspace.Service`** is the orchestrator: - `Create()` / `CreateWithOpts()` — Create a workspace with worktrees @@ -183,6 +196,27 @@ Structured debug logging: - `Info()`, `Debug()`, `Error()` — Log at appropriate levels - Disabled by default; enabled with `--verbose` flag +## Agent request flow + +The agent-facing path keeps machine output separate from human interaction and validates review artifacts before mutation: + +```mermaid +sequenceDiagram + participant Agent + participant CLI as cmd layer + participant Machine as internal machine + participant Service as workspace Service + participant State as local state + Agent->>CLI: gw plan or gw apply --format json + CLI->>Machine: select JSON envelope + CLI->>Service: validate or execute workspace operation + Service->>State: read workspace and repository state + Service-->>CLI: result or STATE_CHANGED + CLI-->>Agent: one versioned JSON envelope +``` + +Caption: machine mode wraps workspace validation and mutation while preserving a single parseable stdout response. + ## Data Flow Example: `gw create my-feature -b feat/login -r svc-a,svc-b` 1. **cmd/create.go** diff --git a/openwiki/index.md b/openwiki/index.md index 6f1769e..bd77e33 100644 --- a/openwiki/index.md +++ b/openwiki/index.md @@ -4,8 +4,8 @@ okf_version: "0.1" # Files -- [Architecture](architecture.md) - Architecture of Grove's CLI, workspace orchestration, Git operation wrappers, persisted state, repository discovery, lifecycle hooks, plugins, and MCP integration. -- [Integrations](integrations.md) - Grove integration points, including external plugins, lifecycle hooks, Claude Code support, Zellij workflows, and the MCP server. -- [Operations](operations.md) - Operational guidance for installing, configuring, maintaining, troubleshooting, and validating Grove workspaces and its local runtime state. -- [Grove Documentation](quickstart.md) - Entry point for Grove, a CLI that creates and manages multi-repository Git worktree workspaces, with setup commands, configuration, architecture pointers, workflows, operations, integrations, and testing guidance. -- [Workflows](workflows.md) - Main Grove user workflows, from repository discovery and workspace creation through navigation, synchronization, execution, cleanup, and recovery. +- [Architecture](architecture.md) - Grove's layered CLI and workspace orchestration architecture, including the machine-readable agent boundary, structured multi-repo results, and reviewable plan/apply mutations. +- [Integrations](integrations.md) - Grove integration points, including external plugins and the CLI-only agent interface after built-in MCP removal. +- [Operations](operations.md) - Grove installation, configuration, state maintenance, troubleshooting, and doctor-based migration checks. +- [Grove Documentation](quickstart.md) - Entry point for Grove, a Git worktree workspace orchestrator, including setup, multi-repository workflows, operations, integrations, and the CLI contract for agents. +- [Workflows](workflows.md) - Operational Grove workflows for setup, workspace lifecycle, multi-repo operations, agent context, coordination announcements, and reviewable plan/apply mutations. diff --git a/openwiki/integrations.md b/openwiki/integrations.md index 9d15187..46daa0e 100644 --- a/openwiki/integrations.md +++ b/openwiki/integrations.md @@ -1,13 +1,13 @@ --- type: "Reference" title: "Integrations" -description: "Grove integration points, including external plugins, lifecycle hooks, Claude Code support, Zellij workflows, and the MCP server." -tags: [grove, integrations, plugins, hooks, mcp] +description: "Grove integration points, including external plugins and the CLI-only agent interface after built-in MCP removal." +tags: ["integrations", "agents", "plugins", "mcp-migration"] --- # Integrations -This page covers external integrations: plugins, AI tools, and the MCP server. +This page covers external integrations: lifecycle plugins and the CLI-only agent interface. Grove no longer ships a built-in MCP server; agents with shell access use the machine-readable `gw` contract described in [Agent CLI contract](../docs/agent-cli.md). ## Plugin Ecosystem Overview diff --git a/openwiki/operations.md b/openwiki/operations.md index efeecf6..f90e637 100644 --- a/openwiki/operations.md +++ b/openwiki/operations.md @@ -1,8 +1,8 @@ --- type: "Reference" title: "Operations" -description: "Operational guidance for installing, configuring, maintaining, troubleshooting, and validating Grove workspaces and its local runtime state." -tags: [grove, operations, configuration, troubleshooting, maintenance] +description: "Grove installation, configuration, state maintenance, troubleshooting, and doctor-based migration checks." +tags: ["operations", "configuration", "troubleshooting"] --- # Operations @@ -438,7 +438,13 @@ Checks for: - Worktree conflicts (path collisions) - Config issues (missing repo_dirs, invalid presets) -Suggests fixes for each issue. +Suggests fixes for each issue. After the built-in MCP server was removed, `gw doctor` also reports stale Grove entries in workspace `.mcp.json` files. Use the targeted migration repair to remove only Grove's entry while preserving other MCP servers: + +```bash +gw doctor --fix +``` + +For agent and CI callers, use `--format json`; the result identifies findings without mixing diagnostics into stdout. ### Common Issues diff --git a/openwiki/quickstart.md b/openwiki/quickstart.md index 447fb5f..f49041c 100644 --- a/openwiki/quickstart.md +++ b/openwiki/quickstart.md @@ -1,8 +1,8 @@ --- type: "Reference" title: "Grove Documentation" -description: "Entry point for Grove, a CLI that creates and manages multi-repository Git worktree workspaces, with setup commands, configuration, architecture pointers, workflows, operations, integrations, and testing guidance." -tags: [grove, cli, git, worktrees, quickstart] +description: "Entry point for Grove, a Git worktree workspace orchestrator, including setup, multi-repository workflows, operations, integrations, and the CLI contract for agents." +tags: ["grove", "git-worktree", "cli", "agents"] --- # Grove Documentation @@ -127,7 +127,12 @@ setup = ["npm install", "npm run build"] # run after worktree creation | `gw delete ` | Clean up workspace (worktrees + branches) | | `gw plugin install ` | Install a plugin from GitHub | | `gw wizard` | Interactive setup of plugins and hooks | -| `gw doctor` | Diagnose workspace issues | +| `gw doctor` | Diagnose workspace issues; `--fix` removes stale Grove MCP entries | +| `gw context --format json` | One-call agent orientation: current workspace, repo state, announcements, and next actions | +| `gw announce` / `gw announcements` | Publish or read advisory notes shared across workspaces | +| `gw plan` / `gw apply` | Review and execute state-pinned mutations | + +For automation, add the global `--format json` (or `-o json`) flag. It emits one versioned envelope on stdout with stable error codes and exit classes; see [Integrations](integrations.md) and the authoritative [Agent CLI contract](../docs/agent-cli.md). ## Architecture Overview @@ -138,15 +143,16 @@ Grove is organized into clear layers: - **Core logic**: `internal/workspace/` orchestrates git worktrees and manages workspace state - **Configuration**: `internal/config/` loads global config; `internal/gitops/` reads per-repo `.grove.toml` - **Data**: `internal/state/` persists workspace list to `~/.grove/state.json` -- **Integrations**: `internal/lifecycle/` runs hooks; `internal/plugin/` manages plugins; agents drive Grove through the CLI itself +- **Integrations**: `internal/lifecycle/` runs hooks; `internal/plugin/` manages plugins; `internal/machine/` defines the versioned JSON agent contract; `internal/announce/` stores cross-workspace notes; agents drive Grove through the CLI itself (the built-in MCP server was removed) All repos are discovered once at command start via `internal/discover/`, then matched to the requested repos by name. Multi-repo operations use goroutines for concurrent execution. ## Next Steps -- **Understand the design**: Read [architecture.md](architecture.md) -- **Learn workflows**: Read [workflows.md](workflows.md) -- **Configure and integrate**: Read [operations.md](operations.md) and [integrations.md](integrations.md) +- **Understand the design**: Read [architecture.md](architecture.md), including the CLI-only agent boundary and structured per-repo results. +- **Learn workflows**: Read [workflows.md](workflows.md) for context, announcements, and plan/apply. +- **Configure and integrate**: Read [operations.md](operations.md) and [integrations.md](integrations.md), including MCP migration. +- **Automate safely**: Read the [Agent CLI contract](../docs/agent-cli.md) for versioned JSON envelopes, error/exit codes, announcements, and state-pinned plans. ## Requirements diff --git a/openwiki/workflows.md b/openwiki/workflows.md index 7d75147..9c1f3c0 100644 --- a/openwiki/workflows.md +++ b/openwiki/workflows.md @@ -1,8 +1,8 @@ --- type: "Reference" title: "Workflows" -description: "Main Grove user workflows, from repository discovery and workspace creation through navigation, synchronization, execution, cleanup, and recovery." -tags: [grove, workflows, workspaces, git, cli] +description: "Operational Grove workflows for setup, workspace lifecycle, multi-repo operations, agent context, coordination announcements, and reviewable plan/apply mutations." +tags: ["workflows", "workspaces", "agents", "coordination"] --- # Workflows @@ -196,11 +196,42 @@ gw remove-repo feat-login -r svc-a ### Key Decisions When Modifying - **Concurrency**: `Status()` and `Sync()` use `sync.WaitGroup` to parallelize git operations; each repo is independent -- **Error handling**: Multi-repo operations continue even if one repo fails; errors are accumulated and reported +- **Error handling**: Multi-repo operations continue even if one repo fails; errors are accumulated and reported in structured per-repo results - **Destructive operations**: `RemoveRepo()` deletes the worktree and branch; consider making this optional --- +## Workflow: Agent Orientation and Coordination + +Agents should begin with the read-only context call, which identifies whether Grove is initialized, the containing workspace, live branch and dirty/ahead/behind state for each repo, configured repo directories and presets, recent announcements, and safe next actions: + +```bash +gw context --format json +``` + +Agents working in parallel can publish advisory, repo-keyed notes without sharing a database: + +```bash +gw announce -c breaking_change -m "auth tokens are now opaque strings" --format json +gw announcements --format json +``` + +Announcements are stored as JSON files under `~/.grove/announcements/`, keyed by normalized remotes, excluded from the publishing workspace, visible for 30 days via the dedicated read, and limited to the recent seven-day/20-note context view. The coordination implementation is `internal/announce/`; unreadable announcement storage degrades to no notes rather than failing the main command. See [Integrations](integrations.md) for the CLI-only agent boundary and MCP migration. + +## Workflow: Reviewable Mutations + +Use `gw plan` when an agent or reviewer needs to inspect a mutation before execution: + +```bash +gw plan create feat-x -r svc-auth,api-gateway -b feat/x --format json > plan.json +gw plan delete feat-x --format json +gw apply plan.json --format json +``` + +Plans are independently versioned (`schema_version: 1`), enumerate every repo/path/branch action, identify destructive changes, and use the same validation path as execution. A fingerprint captures relevant workspace and repository state, including dirtiness. `gw apply` recomputes it and refuses with `STATE_CHANGED` (exit 4) if the state changed after review. Applying a saved failure envelope is rejected; plans can also be passed through stdin with `gw apply -`. + +--- + ## Workflow: Run Dev Processes **Goal**: Run per-repo commands (e.g., tests, builds) in a coordinated way. From b5e94681036afaeae238f480368623f0973d24ce Mon Sep 17 00:00:00 2001 From: Nick Song Date: Fri, 31 Jul 2026 20:11:33 +0200 Subject: [PATCH 14/21] Add e2e coverage for the machine contract, and sandbox the suite properly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two parts: what the suite tests, and where it runs. ## Coverage A machine-contract section drives the whole agent surface through JSON only: - envelope invariants across every read command, as a table-driven loop, so a command added without machine support fails here instead of needing its own hand-written test; - stdout purity, proven by giving a repo a setup hook that writes to stdout — it must appear on stderr and never inside the envelope; - a degraded-but-successful sync: ok stays true, the warning rides in the envelope, and the per-repo outcome names the dirty repo and why it skipped; - error codes mapped to exit classes: WORKSPACE_NOT_FOUND 3, WORKSPACE_EXISTS 4, REPO_NOT_FOUND 3, USAGE 2 (missing flag, bad --format, unknown command), STATE_CHANGED 4; - destructive refusals: delete and remove-repo without --force fail as USAGE and leave the workspace intact; - plan/apply including apply-after-change (and that the new work survives), re-plan surfacing the warning the stale plan lacked, never-pushed commits being named, a saved failure envelope being rejected, and plan | apply over a pipe; - the full create → inspect → sync → add → remove → delete lifecycle parsing only JSON, including running a next_actions command verbatim to prove those strings are executable rather than descriptive; - text mode and legacy --json unchanged. Three real defects surfaced while writing it, each fixed with unit tests: - An invalid --format value produced no envelope at all — bare text on stderr in exactly the case where the caller explicitly asked for machine output. Passing --format now enables machine mode even when the value is rejected, so the rejection is parseable. - The delete fingerprint stored only a boolean "is dirty", so work added to an already-dirty repo did not invalidate a reviewed plan. Since coding agents routinely start from a workspace that already has scratch files, this was the common case, not the edge case. It now fingerprints the actual status output and HEAD commit, which also catches a commit made after review — that would previously have left the worktree clean and slipped through entirely. - My earlier assertion assumed only the dirty repo would skip during sync; a second repo skipped for an unrelated reason. Now asserted on the reason. ## Isolation Sandboxing rested entirely on `export HOME`, which is not enough for git: it prefers $XDG_CONFIG_HOME/git/config when that is set, so on a developer machine with XDG configured `git config --global` in the setup block would have edited their real config. The suite now pins GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM (git 2.32+), clears XDG and GIT_DIR-style variables, keeps TMPDIR and all test artifacts inside the sandbox (several of my earlier additions wrote predictable /tmp paths), reaps child processes on exit so a failed run cannot leave `gw run` processes behind, refuses to rm -rf a path it did not create, and asserts the boundary holds before any test relies on it. A container mode runs the same script — not a variant, which would drift — for what sandboxing cannot give: nothing from a host reachable, a pinned git, Linux filesystem semantics, and a non-root user so permission bugs are not masked. CI now runs the suite on ubuntu and macos (macOS matters: path resolution there goes through the /var -> /private/var symlink) plus the container offline with --network=none. Note: the image build is unverified locally — no Docker daemon on this machine — so the e2e-container job is its first real execution. 218 assertions pass. --- .github/workflows/ci.yml | 25 +- Justfile | 8 +- docs/agent-cli.md | 11 +- e2e/Dockerfile | 43 ++++ e2e/run.sh | 399 +++++++++++++++++++++++++++++-- internal/gitops/gitops.go | 10 + internal/machine/machine.go | 21 +- internal/machine/machine_test.go | 27 +++ internal/workspace/plan.go | 25 +- internal/workspace/plan_test.go | 52 ++++ 10 files changed, 587 insertions(+), 34 deletions(-) create mode 100644 e2e/Dockerfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb395c5..0c37b35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,9 +21,17 @@ jobs: env: GIT_CEILING_DIRECTORIES: / + # The suite sandboxes itself (own HOME, GIT_CONFIG_GLOBAL, TMPDIR), so it runs + # directly on both platforms. macOS matters on its own: path resolution there + # goes through the /var -> /private/var symlink, and Apple Git differs from + # Debian's. e2e: needs: check - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] steps: - uses: actions/checkout@v7 with: @@ -41,3 +49,18 @@ jobs: run: bash e2e/run.sh env: GW_BIN: ${{ github.workspace }}/gw + + # Same suite, fully hermetic: nothing from a host is reachable, git is pinned, + # and it runs as a non-root user so permission bugs are not masked. + # --network=none proves the suite needs no network. + e2e-container: + needs: check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Build e2e image + run: docker build -f e2e/Dockerfile -t grove-e2e . + + - name: Run e2e tests in an offline container + run: docker run --rm --network=none grove-e2e diff --git a/Justfile b/Justfile index d5f1885..5b6aafd 100644 --- a/Justfile +++ b/Justfile @@ -49,10 +49,16 @@ staticcheck: build: go build -ldflags "-X github.com/nicksenap/grove/cmd.Version=$(git describe --tags --always)" -o gw ./cmd/gw -# Run e2e tests +# Run e2e tests (sandboxed: own HOME, git config, and TMPDIR) e2e: build bash e2e/run.sh +# Run the same e2e suite inside a container: fully hermetic, Linux, non-root. +# Use --network=none to prove the suite needs no network. +e2e-docker *args: + docker build -f e2e/Dockerfile -t grove-e2e . + docker run --rm {{ args }} grove-e2e + # Set up dev environment (git hooks) dev: git config core.hooksPath .githooks diff --git a/docs/agent-cli.md b/docs/agent-cli.md index cc570c7..6f67bf2 100644 --- a/docs/agent-cli.md +++ b/docs/agent-cli.md @@ -209,11 +209,12 @@ Two guarantees make a plan worth more than a printed warning: 1. **Same validation path.** A plan is produced by the checks execution runs, so a plan that succeeds cannot fail validation at apply time. -2. **State pinning.** The `fingerprint` covers the state the plan depends on — - including whether each repo is dirty. `gw apply` recomputes it and fails with - `STATE_CHANGED` (exit 4) rather than applying a plan that was reviewed against - a different world. If an agent starts editing after the plan was produced, the - delete is refused and the work survives. +2. **State pinning.** The `fingerprint` covers the state the plan depends on. For + a delete that includes each repo's exact uncommitted changes and current + commit, so work added after review — even to a repo that was already dirty, or + a commit made on a clean one — invalidates the plan. `gw apply` recomputes it + and fails with `STATE_CHANGED` (exit 4) rather than applying a plan that was + reviewed against a different world. `gw apply` accepts a bare plan document, a saved `--format json` envelope, or `-` for stdin. A saved *failure* envelope is refused rather than parsed as an empty diff --git a/e2e/Dockerfile b/e2e/Dockerfile new file mode 100644 index 0000000..5d74a8c --- /dev/null +++ b/e2e/Dockerfile @@ -0,0 +1,43 @@ +# Hermetic environment for the e2e suite. +# +# e2e/run.sh sandboxes itself (its own HOME, GIT_CONFIG_GLOBAL, TMPDIR) and is the +# suite we run on macOS and in CI directly. This image exists for what sandboxing +# cannot cover: a guarantee that nothing on the host is reachable at all, a pinned +# git version, and a Linux filesystem where symlink and permission behavior differ +# from macOS. +# +# It deliberately runs the same script rather than a container-specific variant — +# two suites would drift, and the point is one suite in two environments. +# +# docker build -f e2e/Dockerfile -t grove-e2e . +# docker run --rm grove-e2e +# +# Or: just e2e-docker + +FROM golang:1.26-bookworm + +# git is the software under orchestration; jq drives the assertions. +RUN apt-get update \ + && apt-get install -y --no-install-recommends git jq ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src + +# Dependencies first, so source edits do not re-download the module cache. +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN go build -ldflags "-X github.com/nicksenap/grove/cmd.Version=e2e-docker" -o /usr/local/bin/gw ./cmd/gw + +# Run as a non-root user. root masks permission bugs, because it can write +# anywhere regardless of the modes Grove sets. +RUN useradd --create-home --shell /bin/bash grove \ + && chown -R grove:grove /src +USER grove + +ENV GW_BIN=/usr/local/bin/gw + +# The one network-dependent test (plugin install over HTTPS) skips itself when +# GitHub is unreachable, so this passes under --network=none too. +CMD ["bash", "e2e/run.sh"] diff --git a/e2e/run.sh b/e2e/run.sh index 6bb67c2..40b4954 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -11,6 +11,65 @@ pass() { PASS=$((PASS + 1)); echo " ✓ $1"; } fail() { FAIL=$((FAIL + 1)); ERRORS+=("$1"); echo " ✗ $1"; } section() { echo; echo "── $1 ──"; } +# --- machine-contract helpers ------------------------------------------------ +# run_json runs a command with stdin closed (machine mode must never need a TTY), +# capturing stdout, stderr, and the exit code separately. Keeping the streams apart +# is the point: the contract says stdout carries exactly one JSON envelope and +# everything else goes to stderr. +run_json() { + JSON_ERR_FILE="${SCRATCH:-${TMPDIR:-/tmp}}/stderr.txt" + JSON_CODE=0 + JSON_OUT=$("$@" 2>"${JSON_ERR_FILE}" /dev/null 2>&1; then + fail "${label}: stdout is not a valid envelope: $(printf '%s' "${JSON_OUT}" | head -c 160)" + return 1 + fi + if [ "$(printf '%s' "${JSON_OUT}" | jq -s 'length' 2>/dev/null)" != "1" ]; then + fail "${label}: stdout carried more than one JSON document" + return 1 + fi + pass "${label}: single valid envelope" +} + +# assert_ok asserts a successful envelope and a zero exit. +assert_ok() { + local label="$1" + assert_envelope "${label}" || return 1 + if [ "${JSON_CODE}" != "0" ]; then + fail "${label}: exit ${JSON_CODE}, want 0" + return 1 + fi + if [ "$(printf '%s' "${JSON_OUT}" | jq -r '.ok')" != "true" ]; then + fail "${label}: ok=false — $(printf '%s' "${JSON_OUT}" | jq -c '.error')" + return 1 + fi + pass "${label}: ok" +} + +# assert_failure asserts a structured failure with a specific code and exit class. +assert_failure() { + local label="$1" want_code="$2" want_exit="$3" + assert_envelope "${label}" || return 1 + local got_code + got_code=$(printf '%s' "${JSON_OUT}" | jq -r '.error.code // ""') + if [ "${got_code}" != "${want_code}" ]; then + fail "${label}: error.code=${got_code}, want ${want_code}" + return 1 + fi + if [ "${JSON_CODE}" != "${want_exit}" ]; then + fail "${label}: exit ${JSON_CODE}, want ${want_exit} for ${want_code}" + return 1 + fi + pass "${label}: ${want_code} → exit ${want_exit}" +} + # Path to the Go binary — override with GW_BIN env var GW_BIN="${GW_BIN:-$(cd "$(dirname "$0")/.." && pwd)/gw}" if [ ! -x "${GW_BIN}" ]; then @@ -37,10 +96,43 @@ fi # --------------------------------------------------------------------------- section "Setup" -export GROVE_HOME=$(mktemp -d /tmp/grove-e2e.XXXXXX) +# Everything the suite touches lives in one sandbox directory. +# +# Overriding HOME is not sufficient isolation on its own. git prefers +# $XDG_CONFIG_HOME/git/config over $HOME/.gitconfig when that variable is set, so +# on a developer machine with XDG configured, `git config --global` below would +# edit their real config. GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM (git 2.32+) pin +# config resolution to the sandbox regardless of the host's environment. +E2E_TMP="${TMPDIR:-/tmp}" +E2E_TMP="${E2E_TMP%/}" # macOS TMPDIR has a trailing slash; doubled separators break path comparisons +export GROVE_HOME=$(mktemp -d "${E2E_TMP}/grove-e2e.XXXXXX") export HOME="${GROVE_HOME}" +export TMPDIR="${GROVE_HOME}/tmp" +export GIT_CONFIG_GLOBAL="${GROVE_HOME}/.gitconfig" +export GIT_CONFIG_SYSTEM=/dev/null +export GIT_TERMINAL_PROMPT=0 +# Scratch space for test artifacts, so nothing is written to a predictable path +# in the shared /tmp. +SCRATCH="${GROVE_HOME}/scratch" +mkdir -p "${TMPDIR}" "${SCRATCH}" +touch "${GIT_CONFIG_GLOBAL}" + +# Host environment that would otherwise leak into the run. +unset XDG_CONFIG_HOME XDG_DATA_HOME XDG_CACHE_HOME +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE unset ZELLIJ_SESSION_NAME # prevent host env from leaking into doctor checks -trap 'rm -rf "${GROVE_HOME}"' EXIT + +cleanup() { + # Reap anything `gw run` spawned, so a failed suite cannot leave background + # processes on the host. + pkill -P $$ > /dev/null 2>&1 || true + # Only ever remove a path we created. + case "${GROVE_HOME}" in + */grove-e2e.*) rm -rf "${GROVE_HOME}" ;; + *) echo "refusing to remove unexpected sandbox path: ${GROVE_HOME}" ;; + esac +} +trap cleanup EXIT REPOS_DIR="${GROVE_HOME}/repos" mkdir -p "${REPOS_DIR}" @@ -50,6 +142,17 @@ git config --global user.email "e2e@grove.test" git config --global user.name "Grove E2E" git config --global init.defaultBranch main +# Fail fast if the host leaked in: every later assertion trusts this boundary. +if [ "${HOME}" != "${GROVE_HOME}" ]; then + echo "ERROR: HOME is not sandboxed (${HOME})" + exit 1 +fi +config_origin=$(git config --global --show-origin user.email 2>/dev/null | awk '{print $1}') +case "${config_origin}" in + *"${GROVE_HOME}"*) pass "git config writes are sandboxed" ;; + *) echo "ERROR: git --global resolved outside the sandbox: ${config_origin}"; exit 1 ;; +esac + # Simple repos with minimal history for repo in svc-auth svc-api svc-gateway; do git init -q "${REPOS_DIR}/${repo}" @@ -1037,6 +1140,268 @@ fi gw delete mcp-ws --force 2>&1 +# --------------------------------------------------------------------------- +# Test: machine-readable CLI contract (docs/agent-cli.md) +# --------------------------------------------------------------------------- +section "Machine contract: envelope invariants" + +gw create mc-ws --branch feat/machine --repos svc-auth,svc-api 2>&1 > /dev/null + +# Every read command must satisfy the same envelope invariants. Looping is the +# point: a new command added without machine support shows up here rather than +# needing its own hand-written test. +while IFS='|' read -r label cmd; do + [ -z "${label}" ] && continue + # shellcheck disable=SC2086 + run_json gw ${cmd} --format json + assert_ok "${label}" +done <<'MATRIX' +context|context +list|list +list --status|list --status +ws show|ws show mc-ws +status|status mc-ws +repos|repos +doctor|doctor +preset list|preset list +plugin list|plugin list +announcements --global|announcements --global +plan delete|plan delete mc-ws +MATRIX + +# Reading notes "about my repos" is undefined outside a workspace, so it is a +# structured failure that names the fix rather than a silent empty list. +run_json gw announcements --format json +assert_failure "announcements outside a workspace" "WORKSPACE_NOT_FOUND" 3 +if printf '%s' "${JSON_OUT}" | jq -e '.fix | contains("--repos")' > /dev/null 2>&1; then + pass "announcements failure names the fix" +else + fail "announcements failure should suggest --repos: $(printf '%s' "${JSON_OUT}" | jq -c '.fix')" +fi + +section "Machine contract: stdout purity" + +# A repo whose setup hook writes to stdout would corrupt the envelope if hook +# output were not redirected to stderr. +cat > "${REPOS_DIR}/svc-gateway/.grove.toml" <<'TOML' +setup = "echo NOISE_FROM_SETUP_HOOK_ON_STDOUT" +TOML +(cd "${REPOS_DIR}/svc-gateway" && git add .grove.toml && git commit -q -m "add noisy setup hook") + +run_json gw create noisy-ws --branch feat/noisy --repos svc-gateway --format json +assert_ok "create with a stdout-writing setup hook" +if printf '%s' "${JSON_OUT}" | grep -q "NOISE_FROM_SETUP_HOOK_ON_STDOUT"; then + fail "hook output leaked into the envelope on stdout" +else + pass "hook output kept off stdout" +fi +if grep -q "NOISE_FROM_SETUP_HOOK_ON_STDOUT" "${JSON_ERR_FILE}"; then + pass "hook output visible on stderr" +else + fail "hook output disappeared entirely (should be on stderr)" +fi + +# Warnings must ride along in the envelope while ok stays true. +echo "dirt" > "${GROVE_HOME}/.grove/workspaces/mc-ws/svc-auth/dirty.txt" +run_json gw sync mc-ws --format json +assert_ok "sync with a dirty repo still succeeds" +if printf '%s' "${JSON_OUT}" | jq -e '.warnings | length > 0' > /dev/null 2>&1; then + pass "degraded run reports warnings in the envelope" +else + fail "expected warnings for a dirty repo: $(printf '%s' "${JSON_OUT}" | jq -c '.warnings')" +fi +if printf '%s' "${JSON_OUT}" | jq -e '[.result.repos[] | select(.outcome == "skipped" and .detail == "dirty working tree")] | length == 1' > /dev/null 2>&1; then + pass "per-repo outcome names the dirty repo and why it was skipped" +else + fail "expected one repo skipped as dirty: $(printf '%s' "${JSON_OUT}" | jq -c '.result.repos')" +fi +rm -f "${GROVE_HOME}/.grove/workspaces/mc-ws/svc-auth/dirty.txt" + +section "Machine contract: error codes and exit classes" + +run_json gw status no-such-workspace --format json +assert_failure "unknown workspace" "WORKSPACE_NOT_FOUND" 3 + +run_json gw create mc-ws --branch feat/dupe --repos svc-auth --format json +assert_failure "duplicate workspace" "WORKSPACE_EXISTS" 4 + +run_json gw create needs-branch --repos svc-auth --format json +assert_failure "missing --branch" "USAGE" 2 + +run_json gw create ghost-ws --branch feat/ghost --repos no-such-repo --format json +assert_failure "unknown repo" "REPO_NOT_FOUND" 3 + +run_json gw list --format yaml +assert_failure "unknown --format value" "USAGE" 2 + +run_json gw frobnicate --format json +assert_failure "unknown command" "USAGE" 2 + +# Destructive commands must not treat a prompt they cannot show as consent. +run_json gw delete mc-ws --format json +assert_failure "delete without --force" "USAGE" 2 +if gw list --format json /dev/null | jq -e '.result.workspaces[] | select(.name == "mc-ws")' > /dev/null; then + pass "refused delete left the workspace intact" +else + fail "workspace disappeared after a refused delete" +fi + +run_json gw remove-repo mc-ws --repos svc-api --format json +assert_failure "remove-repo without --force" "USAGE" 2 + +section "Machine contract: plan and apply" + +run_json gw plan delete mc-ws --format json +assert_ok "plan delete" +printf '%s' "${JSON_OUT}" > "${SCRATCH}/plan.json" +if printf '%s' "${JSON_OUT}" | jq -e '.result.destructive == true and ([.result.changes[] | select(.destructive)] | length) > 0' > /dev/null 2>&1; then + pass "plan marks destructive changes" +else + fail "plan should identify destructive changes" +fi +if printf '%s' "${JSON_OUT}" | jq -e '[.result.changes[] | select(.action == "remove_worktree")] | length == 2' > /dev/null 2>&1; then + pass "plan enumerates every worktree removal" +else + fail "plan should list one removal per repo: $(printf '%s' "${JSON_OUT}" | jq -c '[.result.changes[].action]')" +fi + +# State changing after review must invalidate the plan rather than be destroyed by it. +echo "work added after the plan was reviewed" > "${GROVE_HOME}/.grove/workspaces/mc-ws/svc-auth/new-work.txt" +run_json gw apply "${SCRATCH}/plan.json" --format json +assert_failure "apply a plan after state changed" "STATE_CHANGED" 4 +if [ -f "${GROVE_HOME}/.grove/workspaces/mc-ws/svc-auth/new-work.txt" ]; then + pass "refused apply preserved the new work" +else + fail "refused apply destroyed uncommitted work" +fi + +# Re-planning surfaces the risk the stale plan did not know about. +run_json gw plan delete mc-ws --format json +assert_ok "re-plan after the change" +if printf '%s' "${JSON_OUT}" | jq -e '[.result.warnings[] | select(contains("uncommitted"))] | length > 0' > /dev/null 2>&1; then + pass "fresh plan warns about uncommitted changes" +else + fail "fresh plan should warn: $(printf '%s' "${JSON_OUT}" | jq -c '.result.warnings')" +fi + +# A plan for a branch that was never pushed must name the commits at risk. +gw create unpushed-ws --branch feat/unpushed --repos grove 2>&1 > /dev/null +(cd "${GROVE_HOME}/.grove/workspaces/unpushed-ws/grove" \ + && echo "exists nowhere else" > only-here.txt \ + && git add . && git commit -q -m "local-only work") +run_json gw plan delete unpushed-ws --format json +assert_ok "plan delete for a never-pushed branch" +if printf '%s' "${JSON_OUT}" | jq -e '[.result.warnings[] | select(contains("never pushed"))] | length > 0' > /dev/null 2>&1; then + pass "plan warns that commits exist only locally" +else + fail "plan must warn about never-pushed commits: $(printf '%s' "${JSON_OUT}" | jq -c '.result.warnings')" +fi +gw delete unpushed-ws --force 2>&1 > /dev/null + +# A saved failure envelope is not a plan. +run_json gw status no-such-workspace --format json +printf '%s' "${JSON_OUT}" > "${SCRATCH}/failure.json" +run_json gw apply "${SCRATCH}/failure.json" --format json +assert_failure "apply a saved failure envelope" "USAGE" 2 + +# plan | apply over a pipe, the way an agent would chain them. +run_json gw plan create piped-ws --repos svc-api --branch feat/piped --format json +assert_ok "plan create" +JSON_CODE=0 +APPLY_OUT=$(printf '%s' "${JSON_OUT}" | gw apply - --format json 2>/dev/null) || JSON_CODE=$? +JSON_OUT="${APPLY_OUT}" +assert_ok "apply a plan from stdin" +if gw list --format json /dev/null | jq -e '.result.workspaces[] | select(.name == "piped-ws")' > /dev/null; then + pass "piped plan created the workspace" +else + fail "piped plan did not create the workspace" +fi +gw delete piped-ws --force 2>&1 > /dev/null + +section "Machine contract: agent lifecycle without human output" + +# The acceptance criterion: create → inspect → sync → delete, parsing only JSON. +run_json gw create life-ws --repos svc-auth,svc-api --branch feat/life --format json +assert_ok "lifecycle: create" +if printf '%s' "${JSON_OUT}" | jq -e '[.result.repos[] | select(.outcome == "created")] | length == 2' > /dev/null 2>&1; then + pass "lifecycle: per-repo create outcomes" +else + fail "lifecycle: expected 2 created repos: $(printf '%s' "${JSON_OUT}" | jq -c '.result.repos')" +fi + +# next_actions must be runnable as-is, not a description of a command. +NEXT=$(printf '%s' "${JSON_OUT}" | jq -r '.next_actions[0].command // empty') +if [ -n "${NEXT}" ]; then + JSON_CODE=0 + JSON_OUT=$(eval "${NEXT/#gw/${GW_BIN}}" 2>/dev/null /dev/null 2>&1; then + pass "lifecycle: status reports ahead/behind against a named base branch" +else + fail "lifecycle: status missing base_branch/ahead/behind" +fi + +run_json gw sync life-ws --format json +assert_ok "lifecycle: sync" + +run_json gw add-repo life-ws --repos svc-gateway --format json +assert_ok "lifecycle: add-repo" +run_json gw remove-repo life-ws --repos svc-gateway --force --format json +assert_ok "lifecycle: remove-repo" +if printf '%s' "${JSON_OUT}" | jq -e '[.result.repos[] | select(.outcome == "removed")] | length == 1' > /dev/null 2>&1; then + pass "lifecycle: remove-repo reports the removal" +else + fail "lifecycle: remove-repo outcome missing" +fi + +# Idempotence: repeating a removal converges instead of failing. +run_json gw remove-repo life-ws --repos svc-gateway --force --format json +assert_ok "lifecycle: repeated remove-repo converges" +if printf '%s' "${JSON_OUT}" | jq -e '[.result.repos[] | select(.outcome == "not_found")] | length == 1' > /dev/null 2>&1; then + pass "lifecycle: already-removed repo reports not_found, not an error" +else + fail "lifecycle: expected not_found: $(printf '%s' "${JSON_OUT}" | jq -c '.result.repos')" +fi + +run_json gw delete life-ws --force --format json +assert_ok "lifecycle: delete" +if printf '%s' "${JSON_OUT}" | jq -e '.result.deleted[0].state_removed == true' > /dev/null 2>&1; then + pass "lifecycle: delete confirms state removal" +else + fail "lifecycle: delete should report state_removed" +fi + +section "Machine contract: text mode unaffected" + +# Human output must stay on the human path: no envelope, and a table on stdout. +list_text=$(gw list 2>/dev/null /dev/null 2>&1; then + fail "text mode emitted JSON" +else + pass "text mode emits no envelope" +fi +if printf '%s' "${list_text}" | grep -q "NAME"; then + pass "text mode still prints a table" +else + fail "text mode lost its table header" +fi + +# The legacy --json flag keeps its pre-envelope shape for existing scripts. +if gw list --json /dev/null | jq -e 'type == "array"' > /dev/null; then + pass "legacy --json still emits a bare array" +else + fail "legacy --json changed shape" +fi + +gw delete mc-ws --force 2>&1 > /dev/null +gw delete noisy-ws --force 2>&1 > /dev/null + # --------------------------------------------------------------------------- # Test: cross-workspace agent coordination # --------------------------------------------------------------------------- @@ -1046,43 +1411,43 @@ section "Announcements" gw create ann-alpha --branch feat/ann-alpha --repos svc-auth 2>&1 gw create ann-beta --branch feat/ann-beta --repos svc-auth 2>&1 -(cd "${GROVE_HOME}/.grove/workspaces/ann-alpha" && gw announce -c breaking_change -m "auth tokens are opaque now" --format json > /tmp/ann-publish.json 2>/dev/null) +(cd "${GROVE_HOME}/.grove/workspaces/ann-alpha" && gw announce -c breaking_change -m "auth tokens are opaque now" --format json > ${SCRATCH}/ann-publish.json 2>/dev/null) -if jq -e '.ok == true and .result.count == 1' /tmp/ann-publish.json > /dev/null 2>&1; then +if jq -e '.ok == true and .result.count == 1' ${SCRATCH}/ann-publish.json > /dev/null 2>&1; then pass "gw announce publishes an envelope" else - fail "gw announce failed: $(cat /tmp/ann-publish.json)" + fail "gw announce failed: $(cat ${SCRATCH}/ann-publish.json)" fi # The other agent receives the note while simply orienting. -(cd "${GROVE_HOME}/.grove/workspaces/ann-beta" && gw context --format json > /tmp/ann-context.json 2>/dev/null) -if jq -e '[.result.announcements[] | select(.workspace == "ann-alpha")] | length == 1' /tmp/ann-context.json > /dev/null 2>&1; then +(cd "${GROVE_HOME}/.grove/workspaces/ann-beta" && gw context --format json > ${SCRATCH}/ann-context.json 2>/dev/null) +if jq -e '[.result.announcements[] | select(.workspace == "ann-alpha")] | length == 1' ${SCRATCH}/ann-context.json > /dev/null 2>&1; then pass "gw context surfaces another workspace's announcement" else - fail "context missing announcement: $(jq -c .result.announcements /tmp/ann-context.json)" + fail "context missing announcement: $(jq -c .result.announcements ${SCRATCH}/ann-context.json)" fi -(cd "${GROVE_HOME}/.grove/workspaces/ann-beta" && gw announcements --format json > /tmp/ann-read.json 2>/dev/null) -if jq -e '.result.count == 1 and .result.announcements[0].category == "breaking_change"' /tmp/ann-read.json > /dev/null 2>&1; then +(cd "${GROVE_HOME}/.grove/workspaces/ann-beta" && gw announcements --format json > ${SCRATCH}/ann-read.json 2>/dev/null) +if jq -e '.result.count == 1 and .result.announcements[0].category == "breaking_change"' ${SCRATCH}/ann-read.json > /dev/null 2>&1; then pass "gw announcements reads the note" else - fail "gw announcements failed: $(cat /tmp/ann-read.json)" + fail "gw announcements failed: $(cat ${SCRATCH}/ann-read.json)" fi # A workspace never sees its own notes — that is noise, not coordination. -(cd "${GROVE_HOME}/.grove/workspaces/ann-alpha" && gw announcements --format json > /tmp/ann-own.json 2>/dev/null) -if jq -e '.result.count == 0' /tmp/ann-own.json > /dev/null 2>&1; then +(cd "${GROVE_HOME}/.grove/workspaces/ann-alpha" && gw announcements --format json > ${SCRATCH}/ann-own.json 2>/dev/null) +if jq -e '.result.count == 0' ${SCRATCH}/ann-own.json > /dev/null 2>&1; then pass "announcements exclude the publishing workspace" else - fail "publisher should not see its own note: $(cat /tmp/ann-own.json)" + fail "publisher should not see its own note: $(cat ${SCRATCH}/ann-own.json)" fi # Invalid category is a structured USAGE failure, not a stored note. -(cd "${GROVE_HOME}/.grove/workspaces/ann-alpha" && gw announce -c gossip -m "nope" --format json > /tmp/ann-bad.json 2>/dev/null) || true -if jq -e '.ok == false and .error.code == "USAGE"' /tmp/ann-bad.json > /dev/null 2>&1; then +(cd "${GROVE_HOME}/.grove/workspaces/ann-alpha" && gw announce -c gossip -m "nope" --format json > ${SCRATCH}/ann-bad.json 2>/dev/null) || true +if jq -e '.ok == false and .error.code == "USAGE"' ${SCRATCH}/ann-bad.json > /dev/null 2>&1; then pass "invalid announcement category returns USAGE" else - fail "expected USAGE for a bad category: $(cat /tmp/ann-bad.json)" + fail "expected USAGE for a bad category: $(cat ${SCRATCH}/ann-bad.json)" fi gw delete ann-alpha --force 2>&1 diff --git a/internal/gitops/gitops.go b/internal/gitops/gitops.go index c734cb9..4edd611 100644 --- a/internal/gitops/gitops.go +++ b/internal/gitops/gitops.go @@ -291,6 +291,16 @@ func CommitsAheadBehind(path, upstream string) (int, int, error) { return ahead, behind, nil } +// HeadCommit returns the current commit SHA, or "" if it cannot be determined +// (e.g. an unborn branch). +func HeadCommit(path string) string { + out, err := runGit(path, "rev-parse", "HEAD") + if err != nil { + return "" + } + return out +} + // RemoteURL returns the URL for a remote. Returns "" on any error. func RemoteURL(path, remote string) string { out, err := runGit(path, "remote", "get-url", remote) diff --git a/internal/machine/machine.go b/internal/machine/machine.go index bb71401..5569d59 100644 --- a/internal/machine/machine.go +++ b/internal/machine/machine.go @@ -115,23 +115,34 @@ func Enabled() bool { return Current() == FormatJSON } // DetectEarly scans raw args for the format flag before Cobra parses them, so // pre-command output (like the update notice) can honor machine mode. Cobra -// remains the source of truth and will reject invalid values later; anything -// unparseable here is ignored. +// remains the source of truth and still validates the value. +// +// An unrecognized value enables machine mode anyway. Passing --format at all is an +// explicit request for a parseable answer, so the rejection has to be parseable +// too — otherwise the one case where a client most needs a machine-readable error +// (it asked for a format Grove does not have) is the one case it would get bare +// text on stderr. func DetectEarly(args []string) { for i, a := range args { switch { case a == "--format" || a == "-o": if i+1 < len(args) { - SetFormat(args[i+1]) + applyEarlyFormat(args[i+1]) } case strings.HasPrefix(a, "--format="): - SetFormat(strings.TrimPrefix(a, "--format=")) + applyEarlyFormat(strings.TrimPrefix(a, "--format=")) case strings.HasPrefix(a, "-o="): - SetFormat(strings.TrimPrefix(a, "-o=")) + applyEarlyFormat(strings.TrimPrefix(a, "-o=")) } } } +func applyEarlyFormat(value string) { + if err := SetFormat(value); err != nil { + setFormat(FormatJSON) + } +} + // Warn records a warning for inclusion in the envelope. Callers still print it // to stderr; this only makes it machine-visible. func Warn(msg string) { diff --git a/internal/machine/machine_test.go b/internal/machine/machine_test.go index 4ddaf14..f84378f 100644 --- a/internal/machine/machine_test.go +++ b/internal/machine/machine_test.go @@ -269,3 +269,30 @@ func TestEnvelopeKeySet(t *testing.T) { } } } + +// A client that passed --format asked for a parseable answer, so the rejection of +// an unknown format must be parseable too. +func TestDetectEarlyEnablesMachineModeForInvalidFormat(t *testing.T) { + t.Cleanup(Reset) + for _, args := range [][]string{ + {"list", "--format", "yaml"}, + {"list", "--format=tabel"}, + {"list", "-o", "xml"}, + } { + Reset() + DetectEarly(args) + if !Enabled() { + t.Errorf("DetectEarly(%v): an invalid format must still yield a machine-readable error", args) + } + } +} + +// --format text is valid and must stay on the human path. +func TestDetectEarlyKeepsTextMode(t *testing.T) { + t.Cleanup(Reset) + Reset() + DetectEarly([]string{"list", "--format", "text"}) + if Enabled() { + t.Error("--format text must not enable machine mode") + } +} diff --git a/internal/workspace/plan.go b/internal/workspace/plan.go index a846b46..b158c47 100644 --- a/internal/workspace/plan.go +++ b/internal/workspace/plan.go @@ -442,9 +442,12 @@ func (s *Service) createFingerprint(name string, opts CreateOpts) string { return hashOf(input) } -// deleteFingerprint pins what a delete plan assumes. It includes each repo's -// dirty state on purpose: if work appears after the plan was reviewed, applying -// would destroy something nobody agreed to lose, so the plan must expire. +// deleteFingerprint pins what a delete plan assumes. +// +// It captures each repo's uncommitted changes and current commit, not merely a +// "is it dirty" flag. A boolean is too coarse for an irreversible operation: work +// added to an already-dirty repo, or a commit made on a clean one, would leave the +// flag unchanged and let a reviewed plan destroy work nobody agreed to lose. func (s *Service) deleteFingerprint(ws *models.Workspace) string { input := []any{"delete", ws.Name, ws.Path} @@ -453,8 +456,20 @@ func (s *Service) deleteFingerprint(ws *models.Workspace) string { sort.Slice(repos, func(i, j int) bool { return repos[i].RepoName < repos[j].RepoName }) for _, r := range repos { - status, _ := gitops.RepoStatus(r.WorktreePath) - input = append(input, []any{r.RepoName, r.WorktreePath, r.Branch, r.SourceRepo, status != ""}) + status, statusErr := gitops.RepoStatus(r.WorktreePath) + if statusErr != nil { + // An unreadable worktree is its own state: fingerprinting the error + // means a plan made while it was readable will not silently apply. + status = "unreadable: " + statusErr.Error() + } + input = append(input, []any{ + r.RepoName, + r.WorktreePath, + r.Branch, + r.SourceRepo, + status, + gitops.HeadCommit(r.WorktreePath), + }) } return hashOf(input) } diff --git a/internal/workspace/plan_test.go b/internal/workspace/plan_test.go index 2c148d4..e2b8f94 100644 --- a/internal/workspace/plan_test.go +++ b/internal/workspace/plan_test.go @@ -517,3 +517,55 @@ func TestPlanDeleteWarnsWhenStatusCannotBeChecked(t *testing.T) { t.Errorf("expected a warning about the failed check, got %v", plan.Warnings) } } + +// A boolean "is dirty" fingerprint would let work added to an already-dirty repo +// slip through, which is the case a coding agent actually produces: it starts from +// a workspace that already had scratch files. +func TestApplyRefusesWhenAlreadyDirtyRepoGainsMoreWork(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.svc.Create("dirtier", "feat/dirtier", []string{"api"}, env.repoMap, env.cfg) + + wt := filepath.Join(env.wsDir, "dirtier", "api") + os.WriteFile(filepath.Join(wt, "scratch.txt"), []byte("pre-existing mess"), 0o644) + + plan, err := env.svc.PlanDelete("dirtier", "test") + if err != nil { + t.Fatalf("plan: %v", err) + } + + // Still dirty, but now dirty with something else. + os.WriteFile(filepath.Join(wt, "precious.txt"), []byte("added after review"), 0o644) + + if _, err := env.svc.Apply(plan, "test"); machine.CodeFor(err) != machine.CodeStateChanged { + t.Fatalf("apply error = %v (%s), want %s", err, machine.CodeFor(err), machine.CodeStateChanged) + } + if _, err := os.Stat(filepath.Join(wt, "precious.txt")); err != nil { + t.Error("the work added after review must survive") + } +} + +// Committing leaves the worktree clean, so a commit made after review must be +// caught by the commit SHA rather than by dirtiness. +func TestApplyRefusesWhenNewCommitAppearsAfterPlan(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.svc.Create("committed", "feat/committed", []string{"api"}, env.repoMap, env.cfg) + + plan, err := env.svc.PlanDelete("committed", "test") + if err != nil { + t.Fatalf("plan: %v", err) + } + + wt := filepath.Join(env.wsDir, "committed", "api") + os.WriteFile(filepath.Join(wt, "feature.txt"), []byte("new work"), 0o644) + env.run(wt, "git", "add", ".") + env.run(wt, "git", "commit", "-q", "-m", "work committed after the plan was reviewed") + + if _, err := env.svc.Apply(plan, "test"); machine.CodeFor(err) != machine.CodeStateChanged { + t.Fatalf("apply error = %v (%s), want %s", err, machine.CodeFor(err), machine.CodeStateChanged) + } + if ws, _ := env.svc.State.GetWorkspace("committed"); ws == nil { + t.Error("a refused apply must not have deleted the workspace") + } +} From df163887c85f2a3d2ba213aa01a60b3210536fc8 Mon Sep 17 00:00:00 2001 From: Nick Song Date: Fri, 31 Jul 2026 20:17:08 +0200 Subject: [PATCH 15/21] Remove code that coverage proved unreachable, and cover the human renderers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Building the CLI with `go build -cover -coverpkg=./...` and running the e2e suite against it turns the bash suite into a coverage report, which doubles as a dead-code detector. It found three things I had added speculatively: - workspace.anyFailed — superseded by FailedRepos, never called. - workspace.ErrWorktreeDirty — never called. - machine.Emitted — added to guard against emitting a second envelope, a guard no command ever needed. Its `emitted` bookkeeping went with it. Removing ErrWorktreeDirty made WORKTREE_DIRTY unreachable: no operation could return it, yet docs/agent-cli.md listed it as a code to branch on. A documented code that cannot occur is worse than a missing one — it invites dead branches in client code — so the code and its table row are gone, and the docs now explain where dirtiness actually surfaces instead: `gw sync` reports a per-repo `skipped` outcome with a reason, `gw plan delete` warns about work that would be destroyed, and applying a plan after a repo changed is STATE_CHANGED. Adding a code back later is a compatible change under the policy; advertising a phantom one is not. The envelope example in the docs now uses a code that can actually be received. The profile also showed printContext, printPlan, and humanizeAge at 0% — the human renderers are a separate path from the envelope and nothing exercised them. The e2e suite now asserts the context summary, the plan table with its destructive markers, and announcements with a relative age. e2e-only coverage 57.1% → 59.0%; 65.0% combined with the unit tests. One fix in kind: the new text-mode announcement assertion polluted the later coordination section, since the announcement store is shared for the whole run. It now publishes about a repo no other section uses, and the coordination assertions filter by workspace instead of asserting exact counts, so a later section publishing a note cannot fail an earlier one. 221 assertions pass. --- cmd/machine_test.go | 6 +++--- docs/agent-cli.md | 15 ++++++++++----- e2e/run.sh | 29 +++++++++++++++++++++++++++++ internal/machine/errors.go | 3 --- internal/machine/machine.go | 13 ------------- internal/machine/machine_test.go | 16 ++++++---------- internal/workspace/errors.go | 8 -------- internal/workspace/results.go | 10 ---------- internal/workspace/results_test.go | 7 ++----- 9 files changed, 50 insertions(+), 57 deletions(-) diff --git a/cmd/machine_test.go b/cmd/machine_test.go index 5fe52c9..e0b91da 100644 --- a/cmd/machine_test.go +++ b/cmd/machine_test.go @@ -35,9 +35,9 @@ func TestClassifyCommandErrLeavesOtherErrorsAlone(t *testing.T) { // An already-classified error keeps its code — classification happens closest to // the cause, and the CLI boundary must not overwrite it. func TestClassifyCommandErrPreservesClassification(t *testing.T) { - err := machine.Errorf(machine.CodeWorktreeDirty, "api has uncommitted changes") - if got := machine.CodeFor(classifyCommandErr(err)); got != machine.CodeWorktreeDirty { - t.Errorf("code = %s, want %s", got, machine.CodeWorktreeDirty) + err := machine.Errorf(machine.CodeWorktreeExists, "api already has a worktree") + if got := machine.CodeFor(classifyCommandErr(err)); got != machine.CodeWorktreeExists { + t.Errorf("code = %s, want %s", got, machine.CodeWorktreeExists) } } diff --git a/docs/agent-cli.md b/docs/agent-cli.md index 6f67bf2..3e1319f 100644 --- a/docs/agent-cli.md +++ b/docs/agent-cli.md @@ -57,16 +57,22 @@ Failure: "ok": false, "schemaVersion": 1, "error": { - "code": "WORKTREE_DIRTY", - "message": "api has uncommitted changes" + "code": "STATE_CHANGED", + "message": "state changed since the plan was created, so it was not applied" }, - "fix": "Commit, stash, or explicitly force deletion", + "fix": "Re-plan and review the new plan before applying", "next_actions": [ - { "description": "Inspect changes", "command": "gw status api --format json" } + { "description": "Regenerate the plan", "command": "gw plan delete api --format json" } ] } ``` +Uncommitted changes are reported as data rather than as an error: `gw sync` returns +a per-repo `skipped` outcome with a reason, and `gw plan delete` warns about work +that would be destroyed. There is no `WORKTREE_DIRTY` code, because no command +returns one — a documented code that cannot occur only invites dead branches in +client code. + Fields: | Field | Type | Notes | @@ -96,7 +102,6 @@ Codes are stable identifiers. Branch on `error.code`, never on `error.message`. | `NO_WORKSPACES` | 3 | The operation needs at least one workspace to exist. | | `WORKSPACE_EXISTS` | 4 | A workspace with that name already exists. | | `WORKTREE_EXISTS` | 4 | The branch already has a worktree in that repo. | -| `WORKTREE_DIRTY` | 4 | Uncommitted changes block the operation. | | `BRANCH_CONFLICT` | 4 | The requested branch state conflicts with the repo's. | | `STATE_CHANGED` | 4 | Relevant state changed since a plan was produced (see `gw apply`). | | `NOT_INITIALIZED` | 5 | Grove has no config yet — run `gw init `. | diff --git a/e2e/run.sh b/e2e/run.sh index 40b4954..2a3031a 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -1392,6 +1392,35 @@ else fail "text mode lost its table header" fi +# The human renderers are a separate code path from the envelope, so they need +# their own exercise — a coverage profile of this suite showed printContext and +# printPlan at zero before these assertions existed. +context_text=$(cd "${GROVE_HOME}/.grove/workspaces/mc-ws" && gw context 2>/dev/null /dev/null &1 /dev/null /dev/null | jq -e 'type == "array"' > /dev/null; then pass "legacy --json still emits a bare array" diff --git a/internal/machine/errors.go b/internal/machine/errors.go index e57becd..e05d5f5 100644 --- a/internal/machine/errors.go +++ b/internal/machine/errors.go @@ -27,7 +27,6 @@ const ( // Conflicts: the request collides with existing state. CodeWorkspaceExists Code = "WORKSPACE_EXISTS" CodeWorktreeExists Code = "WORKTREE_EXISTS" - CodeWorktreeDirty Code = "WORKTREE_DIRTY" CodeBranchConflict Code = "BRANCH_CONFLICT" CodeStateChanged Code = "STATE_CHANGED" @@ -71,7 +70,6 @@ var exitCodes = map[Code]int{ CodeNoWorkspaces: ExitNotFound, CodeWorkspaceExists: ExitConflict, CodeWorktreeExists: ExitConflict, - CodeWorktreeDirty: ExitConflict, CodeBranchConflict: ExitConflict, CodeStateChanged: ExitConflict, CodeNotInitialized: ExitPrecondition, @@ -93,7 +91,6 @@ func AllCodes() []Code { CodeNoWorkspaces, CodeWorkspaceExists, CodeWorktreeExists, - CodeWorktreeDirty, CodeBranchConflict, CodeStateChanged, CodeNotInitialized, diff --git a/internal/machine/machine.go b/internal/machine/machine.go index 5569d59..1598f5d 100644 --- a/internal/machine/machine.go +++ b/internal/machine/machine.go @@ -80,7 +80,6 @@ var ( mu sync.RWMutex format = FormatText warnings []string - emitted bool ) // SetFormat sets the output mode from a user-supplied string. @@ -160,7 +159,6 @@ func Reset() { defer mu.Unlock() format = FormatText warnings = nil - emitted = false } // --------------------------------------------------------------------------- @@ -233,17 +231,6 @@ func write(w io.Writer, env Envelope) { return } fmt.Fprintln(w, string(data)) - mu.Lock() - emitted = true - mu.Unlock() -} - -// Emitted reports whether an envelope has already been written, so a command -// can avoid producing a second one on a later failure. -func Emitted() bool { - mu.RLock() - defer mu.RUnlock() - return emitted } func takeWarnings() []string { diff --git a/internal/machine/machine_test.go b/internal/machine/machine_test.go index f84378f..eba906e 100644 --- a/internal/machine/machine_test.go +++ b/internal/machine/machine_test.go @@ -70,8 +70,8 @@ func TestNextActionsAlwaysPresent(t *testing.T) { func TestErrorEnvelopeShape(t *testing.T) { t.Cleanup(Reset) - err := Errorf(CodeWorktreeDirty, "api has uncommitted changes"). - WithFix("Commit, stash, or explicitly force deletion"). + err := Errorf(CodeWorktreeExists, "api already has a worktree for that branch"). + WithFix("Use a different branch name, or remove the existing worktree"). WithActions(NextAction("inspect changes", "gw status api --format json")) env := ErrorEnvelope(err) @@ -85,13 +85,13 @@ func TestErrorEnvelopeShape(t *testing.T) { if !ok { t.Fatalf("error body missing: %v", got) } - if body["code"] != string(CodeWorktreeDirty) { - t.Errorf("code = %v, want %s", body["code"], CodeWorktreeDirty) + if body["code"] != string(CodeWorktreeExists) { + t.Errorf("code = %v, want %s", body["code"], CodeWorktreeExists) } - if body["message"] != "api has uncommitted changes" { + if body["message"] != "api already has a worktree for that branch" { t.Errorf("message = %v", body["message"]) } - if got["fix"] != "Commit, stash, or explicitly force deletion" { + if got["fix"] != "Use a different branch name, or remove the existing worktree" { t.Errorf("fix = %v", got["fix"]) } if _, ok := got["result"]; ok { @@ -130,7 +130,6 @@ func TestExitCodeClasses(t *testing.T) { CodeUsage: ExitUsage, CodeWorkspaceNotFound: ExitNotFound, CodeWorkspaceExists: ExitConflict, - CodeWorktreeDirty: ExitConflict, CodeStateChanged: ExitConflict, CodeNotInitialized: ExitPrecondition, CodePermission: ExitPermission, @@ -216,9 +215,6 @@ func TestEmitSilentInTextMode(t *testing.T) { if buf.Len() != 0 { t.Errorf("text mode wrote to stdout: %q", buf.String()) } - if Emitted() { - t.Error("nothing was emitted, Emitted() should be false") - } } func TestWarningsAttachToEnvelopeOnce(t *testing.T) { diff --git a/internal/workspace/errors.go b/internal/workspace/errors.go index 373a04f..e4dd96d 100644 --- a/internal/workspace/errors.go +++ b/internal/workspace/errors.go @@ -60,14 +60,6 @@ func ErrWorktreeExists(branch, repo string) *machine.Error { ) } -// ErrWorktreeDirty reports uncommitted changes blocking an operation. -func ErrWorktreeDirty(repos []string) *machine.Error { - return machine.Errorf(machine.CodeWorktreeDirty, - "uncommitted changes in: %v", repos). - WithDetails(map[string]any{"dirty_repos": repos}). - WithFix("Commit or stash the changes, or re-run with --force") -} - // ErrGit wraps a failed git subprocess. func ErrGit(err error, format string, args ...any) *machine.Error { return machine.Wrap(machine.CodeGitFailed, err, "%s: %s", fmt.Sprintf(format, args...), err) diff --git a/internal/workspace/results.go b/internal/workspace/results.go index dd457a3..353fd5d 100644 --- a/internal/workspace/results.go +++ b/internal/workspace/results.go @@ -90,16 +90,6 @@ type RunRepoResult struct { Detail string `json:"detail,omitempty"` } -// anyFailed reports whether any repo failed. -func anyFailed(results []RepoResult) bool { - for _, r := range results { - if r.Failed() { - return true - } - } - return false -} - // FailedRepos returns the names of repos whose operation failed. func FailedRepos(results []RepoResult) []string { var names []string diff --git a/internal/workspace/results_test.go b/internal/workspace/results_test.go index 77c45da..2c27b38 100644 --- a/internal/workspace/results_test.go +++ b/internal/workspace/results_test.go @@ -228,10 +228,7 @@ func TestFailedReposFiltersResults(t *testing.T) { if len(failed) != 2 || failed[0] != "b" || failed[1] != "c" { t.Errorf("FailedRepos = %v, want [b c]", failed) } - if !anyFailed(results) { - t.Error("anyFailed should be true") - } - if anyFailed(results[:1]) { - t.Error("anyFailed should be false when nothing failed") + if FailedRepos(results[:1]) != nil { + t.Error("FailedRepos should be empty when nothing failed") } } From 9b24792d0b03d3c884224adf372b6bce6c819a94 Mon Sep 17 00:00:00 2001 From: Nick Song Date: Fri, 31 Jul 2026 20:21:03 +0200 Subject: [PATCH 16/21] Share workspace selection and repo-list parsing across commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applying the same audit to code I did not write. The "load workspaces, bail if none, build choices, PickOne" block existed four times in create/delete/rename/ add-repo/remove-repo, and the copies had drifted apart in a way that matters now that error codes are contract: gw rename → INTERNAL (exit 1) gw remove-repo → NO_WORKSPACES (exit 3) gw add-repo → NO_WORKSPACES (exit 3) Same condition, three different answers, so the code told an agent nothing unless it already knew which command it had run. All three now go through one helper and report NO_WORKSPACES with a fix and a next action. (gw delete stays USAGE/2 in machine mode: its condition is a missing NAME argument, not missing workspaces.) The `--repos` flag was split and trimmed in six places, and every copy kept empty entries, so `-r "api,"` produced a repo named "" and the error `repo not found`: before: gw create x -r "api," -b b → REPO_NOT_FOUND: repo not found after: gw plan create x -r "api, ,web" -b b → repos [api web] parseRepoList drops blank entries once for create, plan, add-repo, remove-repo, preset add, and announce. Both were found by looking for the pattern behind the earlier PathContains fix — one concept implemented several times drifts, and the drift shows up as inconsistent behavior rather than as duplication anyone notices. --- cmd/addrepo.go | 25 ++------------ cmd/announce.go | 6 +--- cmd/create.go | 5 +-- cmd/delete.go | 19 +---------- cmd/plan.go | 7 +--- cmd/preset.go | 5 +-- cmd/removerepo.go | 22 ++---------- cmd/rename.go | 19 +---------- cmd/select.go | 84 ++++++++++++++++++++++++++++++++++++++++++++++ cmd/select_test.go | 55 ++++++++++++++++++++++++++++++ 10 files changed, 149 insertions(+), 98 deletions(-) create mode 100644 cmd/select.go create mode 100644 cmd/select_test.go diff --git a/cmd/addrepo.go b/cmd/addrepo.go index 0dc192e..1c78339 100644 --- a/cmd/addrepo.go +++ b/cmd/addrepo.go @@ -2,7 +2,6 @@ package cmd import ( "os" - "strings" "github.com/nicksenap/grove/internal/config" "github.com/nicksenap/grove/internal/console" @@ -35,24 +34,7 @@ var addRepoCmd = &cobra.Command{ } if wsName == "" { - workspaces, err := state.Load() - if err != nil { - exitError(err.Error()) - } - if len(workspaces) == 0 { - fail(machine.Errorf(machine.CodeNoWorkspaces, "no workspaces exist"). - WithActions(machine.NextAction("Create one", - "gw create -r -b --format json"))) - } - choices := make([]string, len(workspaces)) - for i, ws := range workspaces { - choices[i] = ws.Name - } - selected, err := picker.PickOne("Select workspace:", choices) - if err != nil { - exitOnPickerErr(err) - } - wsName = selected + wsName = pickWorkspaceName("Select workspace:") } } @@ -62,10 +44,7 @@ var addRepoCmd = &cobra.Command{ var repoNames []string if addRepoRepos != "" { - repoNames = strings.Split(addRepoRepos, ",") - for i := range repoNames { - repoNames[i] = strings.TrimSpace(repoNames[i]) - } + repoNames = parseRepoList(addRepoRepos) // Clone any remote URLs into the first repo_dir for i, name := range repoNames { if gitops.IsGitURL(name) { diff --git a/cmd/announce.go b/cmd/announce.go index 0b3d3ef..90b8ccd 100644 --- a/cmd/announce.go +++ b/cmd/announce.go @@ -153,11 +153,7 @@ func resolveAnnounceTargets(reposFlag string) (wsName string, keys []string) { // Explicit repos: accept names (resolved against the workspace for their // remote) or full remote URLs, so a caller outside any workspace still works. if reposFlag != "" { - for _, raw := range strings.Split(reposFlag, ",") { - name := strings.TrimSpace(raw) - if name == "" { - continue - } + for _, name := range parseRepoList(reposFlag) { keys = append(keys, keyForRepo(ws, name)) } if len(keys) == 0 { diff --git a/cmd/create.go b/cmd/create.go index 78a6407..8870b30 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -57,10 +57,7 @@ var createCmd = &cobra.Command{ repoNames = append(repoNames, r.Name) } } else if createRepos != "" { - repoNames = strings.Split(createRepos, ",") - for i := range repoNames { - repoNames[i] = strings.TrimSpace(repoNames[i]) - } + repoNames = parseRepoList(createRepos) // Clone any remote git URLs into the first repo_dir (mirrors add-repo). // This lets a resolver pass an unmatched repo as a clone URL. for i, name := range repoNames { diff --git a/cmd/delete.go b/cmd/delete.go index 94e8319..9cd82d7 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -8,7 +8,6 @@ import ( "github.com/nicksenap/grove/internal/console" "github.com/nicksenap/grove/internal/lifecycle" "github.com/nicksenap/grove/internal/machine" - "github.com/nicksenap/grove/internal/picker" "github.com/nicksenap/grove/internal/state" "github.com/nicksenap/grove/internal/workspace" "github.com/spf13/cobra" @@ -46,23 +45,7 @@ func doDelete(args []string, force bool) { names = []string{args[0]} } else { requireArgs("NAME", "gw delete --force --format json") - // Interactive multi-select - workspaces, err := state.Load() - if err != nil { - fail(err) - } - if len(workspaces) == 0 { - fail(machine.Errorf(machine.CodeNoWorkspaces, "no workspaces to delete")) - } - choices := make([]string, len(workspaces)) - for i, ws := range workspaces { - choices[i] = ws.Name - } - selected, err := picker.PickMany("Select workspaces to delete:", choices) - if err != nil { - exitOnPickerErr(err) - } - names = selected + names = pickWorkspaceNames("Select workspaces to delete:") } if !force { diff --git a/cmd/plan.go b/cmd/plan.go index a9c8226..9dca99a 100644 --- a/cmd/plan.go +++ b/cmd/plan.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "os" - "strings" "github.com/nicksenap/grove/internal/config" "github.com/nicksenap/grove/internal/console" @@ -151,11 +150,7 @@ func planRepoNames(cfg *models.Config, repos []discover.Repo) []string { } return names case planRepos != "": - names := strings.Split(planRepos, ",") - for i := range names { - names[i] = strings.TrimSpace(names[i]) - } - return names + return parseRepoList(planRepos) default: fail(machine.Errorf(machine.CodeUsage, "repos are required when planning"). WithFix("Pass --repos / -r, --preset / -p, or --all"). diff --git a/cmd/preset.go b/cmd/preset.go index b535027..fe0f202 100644 --- a/cmd/preset.go +++ b/cmd/preset.go @@ -49,10 +49,7 @@ var presetAddCmd = &cobra.Command{ var repoNames []string if presetAddRepos != "" { - repoNames = strings.Split(presetAddRepos, ",") - for i := range repoNames { - repoNames[i] = strings.TrimSpace(repoNames[i]) - } + repoNames = parseRepoList(presetAddRepos) } else { // Interactive: pick repos available := discover.FindAllRepos(cfg.RepoDirs) diff --git a/cmd/removerepo.go b/cmd/removerepo.go index d38e941..ad03dbf 100644 --- a/cmd/removerepo.go +++ b/cmd/removerepo.go @@ -26,30 +26,12 @@ var removeRepoCmd = &cobra.Command{ if len(args) > 0 { wsName = args[0] } else { - workspaces, err := state.Load() - if err != nil { - exitError(err.Error()) - } - if len(workspaces) == 0 { - fail(machine.Errorf(machine.CodeNoWorkspaces, "no workspaces exist")) - } - choices := make([]string, len(workspaces)) - for i, ws := range workspaces { - choices[i] = ws.Name - } - selected, err := picker.PickOne("Select workspace:", choices) - if err != nil { - exitOnPickerErr(err) - } - wsName = selected + wsName = pickWorkspaceName("Select workspace:") } var repoNames []string if removeRepoRepos != "" { - repoNames = strings.Split(removeRepoRepos, ",") - for i := range repoNames { - repoNames[i] = strings.TrimSpace(repoNames[i]) - } + repoNames = parseRepoList(removeRepoRepos) } else { // Interactive: pick from repos in workspace ws, err := state.GetWorkspace(wsName) diff --git a/cmd/rename.go b/cmd/rename.go index b5607d2..1e14b7a 100644 --- a/cmd/rename.go +++ b/cmd/rename.go @@ -1,8 +1,6 @@ package cmd import ( - "github.com/nicksenap/grove/internal/picker" - "github.com/nicksenap/grove/internal/state" "github.com/nicksenap/grove/internal/workspace" "github.com/spf13/cobra" ) @@ -18,22 +16,7 @@ var renameCmd = &cobra.Command{ if len(args) > 0 { name = args[0] } else { - workspaces, err := state.Load() - if err != nil { - exitError(err.Error()) - } - if len(workspaces) == 0 { - exitError("No workspaces") - } - choices := make([]string, len(workspaces)) - for i, ws := range workspaces { - choices[i] = ws.Name - } - selected, err := picker.PickOne("Select workspace to rename:", choices) - if err != nil { - exitOnPickerErr(err) - } - name = selected + name = pickWorkspaceName("Select workspace to rename:") } if renameTo == "" { diff --git a/cmd/select.go b/cmd/select.go new file mode 100644 index 0000000..5dd9685 --- /dev/null +++ b/cmd/select.go @@ -0,0 +1,84 @@ +package cmd + +import ( + "strings" + + "github.com/nicksenap/grove/internal/machine" + "github.com/nicksenap/grove/internal/picker" + "github.com/nicksenap/grove/internal/state" +) + +// Shared argument resolution for the commands that take a workspace name and a +// repo list. These were near-copies in create/delete/rename/add-repo/remove-repo, +// and the copies had drifted: the same "no workspaces exist" condition produced +// NO_WORKSPACES (exit 3) in two commands and INTERNAL (exit 1) in another, which +// makes the error code unusable to an agent that does not know which command it +// happened to call. + +// pickWorkspaceName resolves a workspace name interactively. In machine mode the +// picker refuses to run and returns a USAGE error, so the caller does not need to +// special-case that. +func pickWorkspaceName(prompt string) string { + workspaces, err := state.Load() + if err != nil { + fail(err) + } + if len(workspaces) == 0 { + fail(noWorkspacesErr()) + } + + choices := make([]string, len(workspaces)) + for i, ws := range workspaces { + choices[i] = ws.Name + } + + selected, err := picker.PickOne(prompt, choices) + if err != nil { + exitOnPickerErr(err) + } + return selected +} + +// pickWorkspaceNames resolves one or more workspace names interactively. +func pickWorkspaceNames(prompt string) []string { + workspaces, err := state.Load() + if err != nil { + fail(err) + } + if len(workspaces) == 0 { + fail(noWorkspacesErr()) + } + + choices := make([]string, len(workspaces)) + for i, ws := range workspaces { + choices[i] = ws.Name + } + + selected, err := picker.PickMany(prompt, choices) + if err != nil { + exitOnPickerErr(err) + } + return selected +} + +// noWorkspacesErr is the single definition of "there is nothing to operate on". +func noWorkspacesErr() *machine.Error { + return machine.Errorf(machine.CodeNoWorkspaces, "no workspaces exist"). + WithFix("Create one first"). + WithActions(machine.NextAction("Create a workspace", + "gw create -r -b --format json")) +} + +// parseRepoList splits a comma-separated --repos value. +// +// It drops empty entries, so `-r "api,"` or `-r "api, ,web"` names the repos the +// user meant instead of reporting `repo not found` for an empty string. +func parseRepoList(value string) []string { + var repos []string + for _, raw := range strings.Split(value, ",") { + if name := strings.TrimSpace(raw); name != "" { + repos = append(repos, name) + } + } + return repos +} diff --git a/cmd/select_test.go b/cmd/select_test.go new file mode 100644 index 0000000..956b181 --- /dev/null +++ b/cmd/select_test.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "testing" + + "github.com/nicksenap/grove/internal/machine" +) + +func TestParseRepoList(t *testing.T) { + tests := []struct { + name string + input string + want []string + }{ + {"single", "api", []string{"api"}}, + {"several", "api,web,worker", []string{"api", "web", "worker"}}, + {"padded", " api , web ", []string{"api", "web"}}, + // Trailing and doubled separators used to yield an empty repo name, which + // surfaced as the useless error `repo not found`. + {"trailing comma", "api,", []string{"api"}}, + {"leading comma", ",api", []string{"api"}}, + {"empty middle entry", "api, ,web", []string{"api", "web"}}, + {"only separators", ",,", nil}, + {"empty", "", nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseRepoList(tt.input) + if len(got) != len(tt.want) { + t.Fatalf("parseRepoList(%q) = %v, want %v", tt.input, got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("parseRepoList(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) + } + } + }) + } +} + +// "There are no workspaces" must report one code no matter which command hit it — +// gw rename used to report INTERNAL (exit 1) while add-repo and remove-repo +// reported NO_WORKSPACES (exit 3), so the code told an agent nothing. +func TestNoWorkspacesErrIsOneClassifiedError(t *testing.T) { + err := noWorkspacesErr() + if machine.CodeFor(err) != machine.CodeNoWorkspaces { + t.Errorf("code = %s, want %s", machine.CodeFor(err), machine.CodeNoWorkspaces) + } + if machine.ExitCodeFor(err) != machine.ExitNotFound { + t.Errorf("exit = %d, want %d", machine.ExitCodeFor(err), machine.ExitNotFound) + } + if err.Fix == "" || len(err.NextActions) == 0 { + t.Error("the error should tell the caller how to proceed") + } +} From 4a736ef859d53bbf4284b30c5a9a88c721ddcfb2 Mon Sep 17 00:00:00 2001 From: Nick Song Date: Fri, 31 Jul 2026 20:28:38 +0200 Subject: [PATCH 17/21] Decompose createCmd.Run into named steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createCmd.Run was 240 lines in one closure — the largest readability problem in the repo, and in the command humans and agents use most. gocyclo never flagged it because the complexity lived inside a function literal, so the ceiling of 20 that guards every other function did not apply to the worst offender. It is now a 32-line orchestrator over named steps: resolveCreateRepos (with reposFromPreset / reposFromFlag / cloneRepo / reposInteractively / pickPreset / pickRepos / offerPresetSave), requireKnownRepos, resolveCreateBranch, buildCreateOpts, createSource, replaceCurrentWorkspace, failCreate, and the two hook helpers. Highest remaining complexity in the file is 8. The ordering is the part that needed protecting, so it is now stated at the top of Run rather than being implicit in 240 lines of flow: repos are resolved (and possibly cloned) before validation, the branch is resolved after the name argument is read because it seeds the prompt default, and --replace runs after the new name is known but before anything is created — so a name collision is caught before the old workspace is destroyed. Verified with a 19-invocation golden baseline covering every non-interactive path (explicit repos, --all, --preset, unknown preset, derived name, name from branch, duplicate, unknown repo, missing branch, trailing separators, clone URL, --track, source flags, and three --format json cases): stdout, stderr, and exit codes are byte-identical before and after, modulo timestamps. e2e's 221 assertions still pass. The payoff beyond readability is testability: repo-selection precedence (preset > --all > --repos), provenance assembly, --track mode, and explicit-branch handling now have unit tests, where previously nothing in the file could be tested without running the whole command. buildCreateOpts and createSource are at 100%. Two behavior-preserving details worth noting. Declining the --replace confirmation previously returned from the Run closure; it is now an explicit os.Exit(0), which is the same observable outcome (exit 0, nothing created) from a helper that cannot return to skip the rest. The "Pick manually…" sentinel is now a named constant instead of a string repeated in the comparison and the choice list. --- cmd/create.go | 548 +++++++++++++++++++++++++++------------------ cmd/create_test.go | 144 ++++++++++++ 2 files changed, 476 insertions(+), 216 deletions(-) diff --git a/cmd/create.go b/cmd/create.go index 8870b30..12238a6 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -33,245 +33,45 @@ var ( createSourceTitle string ) +// pickManuallyChoice is the escape hatch appended to the preset picker. +const pickManuallyChoice = "Pick manually…" + var createCmd = &cobra.Command{ Use: "create [NAME]", Short: "Create a new workspace", Args: cobra.MaximumNArgs(1), + // The steps are ordered, and the order is load-bearing: repos are resolved + // (and possibly cloned) before validation, the branch is resolved before the + // name can be derived from it, and --replace runs after the new name is known + // but before anything is created — so a collision is caught before the old + // workspace is destroyed. Run: func(cmd *cobra.Command, args []string) { cfg := config.RequireConfig() repos := discover.FindAllRepos(cfg.RepoDirs) repoMap := discover.RepoMap(repos) - var repoNames []string - - // Resolve repos from preset - if createPreset != "" { - preset, ok := cfg.Presets[createPreset] - if !ok { - fail(machine.Errorf(machine.CodeUsage, "preset %s not found", createPreset). - WithActions(machine.NextAction("List presets", "gw preset list --format json"))) - } - repoNames = preset.Repos - } else if createAll { - for _, r := range repos { - repoNames = append(repoNames, r.Name) - } - } else if createRepos != "" { - repoNames = parseRepoList(createRepos) - // Clone any remote git URLs into the first repo_dir (mirrors add-repo). - // This lets a resolver pass an unmatched repo as a clone URL. - for i, name := range repoNames { - if !gitops.IsGitURL(name) { - continue - } - if len(cfg.RepoDirs) == 0 { - fail(machine.Errorf(machine.CodeNotInitialized, "no repo_dirs configured — cannot clone %s", name). - WithActions(machine.NextAction("Add a repo directory", "gw add-dir "))) - } - console.Infof("Cloning %s ...", name) - clonedPath, repoName, err := gitops.Clone(name, cfg.RepoDirs[0]) - if err != nil { - fail(machine.Wrap(machine.CodeTransient, err, "cloning %s: %s", name, err). - WithFix("Check network access and repository permissions, then retry")) - } - if existing, ok := repoMap[repoName]; ok && existing != clonedPath { - fail(machine.Errorf(machine.CodeBranchConflict, - "repo name conflict: %s already exists locally at %s", repoName, existing)) - } - repoMap[repoName] = clonedPath - repoNames[i] = repoName - console.Successf("Cloned %s into %s", repoName, clonedPath) - } - } else { - // Interactive - repoChoices := make([]string, len(repos)) - for i, r := range repos { - repoChoices[i] = r.Name - } - - // If presets exist, offer them first with a "Pick manually..." escape hatch - if len(cfg.Presets) > 0 { - presetNames := make([]string, 0, len(cfg.Presets)) - presetChoices := make([]string, 0, len(cfg.Presets)) - for name, p := range cfg.Presets { - presetNames = append(presetNames, name) - presetChoices = append(presetChoices, name+" ("+strings.Join(p.Repos, ", ")+")") - } - presetChoices = append(presetChoices, "Pick manually…") - - choice, err := picker.PickOne("Select repos from:", presetChoices) - if err != nil { - exitOnPickerErr(err) - } - - if choice != "Pick manually…" { - // Extract preset name (before the double space) - for i, display := range presetChoices { - if display == choice && i < len(presetNames) { - repoNames = cfg.Presets[presetNames[i]].Repos - break - } - } - } else { - selected, err := picker.PickMany("Select repos for workspace:", repoChoices) - if err != nil { - exitOnPickerErr(err) - } - repoNames = selected - } - } else { - selected, err := picker.PickMany("Select repos for workspace:", repoChoices) - if err != nil { - exitOnPickerErr(err) - } - repoNames = selected - - // Offer to save as preset when none exist yet - if console.IsTerminal(os.Stdin) && len(selected) < len(repos) { - if console.Confirm("Save this selection as a preset?", false) { - presetName := console.Prompt("Preset name") - if presetName != "" { - if cfg.Presets == nil { - cfg.Presets = make(map[string]models.Preset) - } - cfg.Presets[presetName] = models.Preset{Repos: repoNames} - if err := config.Save(cfg); err != nil { - console.Warningf("Could not save preset: %s", err) - } else { - console.Successf("Saved preset %q", presetName) - } - } - } - } - } - } - - // Validate repos exist - for _, name := range repoNames { - if _, ok := repoMap[name]; !ok { - fail(workspace.ErrRepoNotFound(name). - WithDetails(map[string]any{"available": repoNamesList(repos)})) - } - } + repoNames := resolveCreateRepos(cfg, repos, repoMap) + requireKnownRepos(repoNames, repoMap, repos) - // Name — known early so the branch prompt can default to it. - var name string + name := "" if len(args) > 0 { name = args[0] } - - // Branch — prompt if omitted and in a terminal. - branch := createBranch - if branch == "" { - requireArgs("--branch", "gw create "+name+" -b feat/x --format json") - if console.IsTerminal(os.Stdin) { - branch = console.PromptDefault("Branch name", name) - } - if branch == "" { - fail(machine.Errorf(machine.CodeUsage, "branch is required"). - WithFix("Pass --branch / -b")) - } - } + branch := resolveCreateBranch(name) if name == "" { name = deriveName(branch) } - // --replace: delete the current workspace (detected from cwd) before creating the new one. - replacedName := "" - if createReplace { - cwd, err := os.Getwd() - if err != nil { - fail(machine.Wrap(machine.CodeInternal, err, "cannot determine working directory: %s", err)) - } - currentWs, _ := state.FindWorkspaceByPath(cwd) - if currentWs == nil { - fail(machine.Errorf(machine.CodeUsage, - "--replace requires running from inside an existing workspace"). - WithActions(machine.NextAction("Discover current context", "gw context --format json"))) - } - if currentWs.Name == name { - fail(machine.Errorf(machine.CodeWorkspaceExists, - "--replace would collide: new workspace name matches the current one (%s)", name). - WithFix("Pass a different NAME")) - } - // --replace deletes an existing workspace, so machine mode demands the - // destructive intent be explicit rather than inferred from a skipped - // prompt. - if !createForce { - requireArgs("--force (with --replace)", "gw create "+name+" -b --replace --force --format json") - if !console.Confirm("Delete workspace "+currentWs.Name+" and replace with "+name+"?", false) { - return - } - } - console.Infof("Replacing workspace: deleting %s", currentWs.Name) - vars := lifecycle.Vars{Name: currentWs.Name, Path: currentWs.Path, Branch: currentWs.Branch} - if err := lifecycle.Run("pre_delete", vars); err != nil && !errors.Is(err, lifecycle.ErrNoHook) { - if lifecycle.ShouldAbort(err) { - fail(machine.Wrap(machine.CodeHookFailed, err, "%s", err)) - } - console.Warning(err.Error()) - } - if _, err := workspace.NewService().Delete(currentWs.Name); err != nil { - fail(machine.Wrap(machine.CodeFor(err), err, "failed to delete current workspace: %s", err)) - } - replacedName = currentWs.Name - } - - // Build provenance + branch-mode options. A source URL is opaque to core; - // --track checks out an existing remote branch (e.g. a PR head) instead - // of creating a new one, falling back to create-mode if it is missing. - var source *models.WorkspaceSource - if createSourceURL != "" || createSourceProvide != "" { - source = &models.WorkspaceSource{ - Provider: createSourceProvide, - URL: createSourceURL, - Ref: createSourceRef, - Title: createSourceTitle, - } - } - opts := workspace.CreateOpts{ - Branch: branch, - Repos: repoNames, - RepoMap: repoMap, - Cfg: cfg, - Source: source, - } - if createTrack { - opts.BranchMode = workspace.BranchModeTrack - } + replacedName := replaceCurrentWorkspace(name) - result, err := workspace.NewService().CreateWithOpts(name, opts) + result, err := workspace.NewService().CreateWithOpts(name, buildCreateOpts(cfg, branch, repoNames, repoMap)) if err != nil { - if replacedName != "" { - // The replaced workspace is already gone, so this is not a no-op - // failure — say so explicitly instead of leaving the caller to - // assume nothing changed. - fail(machine.Wrap(machine.CodeFor(err), err, - "failed to create new workspace (old workspace %s was already deleted): %s", replacedName, err). - WithDetails(map[string]any{"deleted_workspace": replacedName})) - } - fail(err) + failCreate(err, replacedName) } result.Replaced = replacedName - // Fire post_create hook if configured wsPath := filepath.Join(cfg.WorkspaceDir, name) - vars := lifecycle.Vars{Name: name, Path: wsPath, Branch: branch} - if source != nil { - vars.SourceURL = source.URL - vars.SourceRef = source.Ref - vars.SourceTitle = source.Title - } - if err := lifecycle.Run("post_create", vars); err != nil && !errors.Is(err, lifecycle.ErrNoHook) { - if lifecycle.ShouldAbort(err) { - // The workspace exists; the hook is what failed. Report the - // workspace in details so the caller does not retry create. - fail(machine.Wrap(machine.CodeHookFailed, err, "%s", err). - WithDetails(map[string]any{"workspace": name, "path": wsPath}). - WithFix("Fix the post_create hook, or re-run with --no-hooks")) - } - console.Warning(err.Error()) - } + firePostCreateHook(name, wsPath, branch) machine.Emit(result, machine.NextAction("Inspect repo state", "gw status "+name+" --format json"), @@ -280,6 +80,322 @@ var createCmd = &cobra.Command{ }, } +// --------------------------------------------------------------------------- +// Repo selection +// --------------------------------------------------------------------------- + +// resolveCreateRepos determines which repos the workspace will contain. The four +// sources are mutually exclusive and checked in precedence order: an explicit +// preset, every discovered repo, an explicit list, then interactive selection. +// +// It may add entries to repoMap, since an explicit list can name a clone URL. +func resolveCreateRepos(cfg *models.Config, repos []discover.Repo, repoMap map[string]string) []string { + switch { + case createPreset != "": + return reposFromPreset(cfg) + case createAll: + return repoNamesList(repos) + case createRepos != "": + return reposFromFlag(cfg, repoMap) + default: + return reposInteractively(cfg, repos) + } +} + +func reposFromPreset(cfg *models.Config) []string { + preset, ok := cfg.Presets[createPreset] + if !ok { + fail(machine.Errorf(machine.CodeUsage, "preset %s not found", createPreset). + WithActions(machine.NextAction("List presets", "gw preset list --format json"))) + } + return preset.Repos +} + +// reposFromFlag reads --repos, cloning any entry that is a git URL. That lets a +// resolver (e.g. a PR-to-workspace plugin) pass a repo Grove has never seen. +func reposFromFlag(cfg *models.Config, repoMap map[string]string) []string { + repoNames := parseRepoList(createRepos) + for i, name := range repoNames { + if gitops.IsGitURL(name) { + repoNames[i] = cloneRepo(cfg, repoMap, name) + } + } + return repoNames +} + +// cloneRepo clones a URL into the first configured repo dir and registers it in +// repoMap, returning the local repo name. +func cloneRepo(cfg *models.Config, repoMap map[string]string, url string) string { + if len(cfg.RepoDirs) == 0 { + fail(machine.Errorf(machine.CodeNotInitialized, "no repo_dirs configured — cannot clone %s", url). + WithActions(machine.NextAction("Add a repo directory", "gw add-dir "))) + } + + console.Infof("Cloning %s ...", url) + clonedPath, repoName, err := gitops.Clone(url, cfg.RepoDirs[0]) + if err != nil { + fail(machine.Wrap(machine.CodeTransient, err, "cloning %s: %s", url, err). + WithFix("Check network access and repository permissions, then retry")) + } + // A different local clone under the same name would make the workspace + // ambiguous about which checkout it is using. + if existing, ok := repoMap[repoName]; ok && existing != clonedPath { + fail(machine.Errorf(machine.CodeBranchConflict, + "repo name conflict: %s already exists locally at %s", repoName, existing)) + } + + repoMap[repoName] = clonedPath + console.Successf("Cloned %s into %s", repoName, clonedPath) + return repoName +} + +// reposInteractively asks the user. With presets configured it offers those first +// (with a manual escape hatch); otherwise it goes straight to the repo list and +// offers to save the selection as a preset. +// +// In machine mode the pickers refuse to run and return a USAGE error, so no branch +// here can block on input. +func reposInteractively(cfg *models.Config, repos []discover.Repo) []string { + repoChoices := repoNamesList(repos) + + if len(cfg.Presets) == 0 { + selected := pickRepos(repoChoices) + offerPresetSave(cfg, selected, len(repos)) + return selected + } + + if fromPreset, ok := pickPreset(cfg); ok { + return fromPreset + } + return pickRepos(repoChoices) +} + +func pickRepos(repoChoices []string) []string { + selected, err := picker.PickMany("Select repos for workspace:", repoChoices) + if err != nil { + exitOnPickerErr(err) + } + return selected +} + +// pickPreset offers the configured presets. The second return is false when the +// user chose to pick repos manually instead. +func pickPreset(cfg *models.Config) ([]string, bool) { + names := make([]string, 0, len(cfg.Presets)) + choices := make([]string, 0, len(cfg.Presets)+1) + for name, p := range cfg.Presets { + names = append(names, name) + choices = append(choices, name+" ("+strings.Join(p.Repos, ", ")+")") + } + choices = append(choices, pickManuallyChoice) + + choice, err := picker.PickOne("Select repos from:", choices) + if err != nil { + exitOnPickerErr(err) + } + if choice == pickManuallyChoice { + return nil, false + } + + for i, display := range choices { + if display == choice && i < len(names) { + return cfg.Presets[names[i]].Repos, true + } + } + return nil, false +} + +// offerPresetSave suggests saving a partial selection as a preset, so the next +// create can skip the picker. Only worth asking for a real subset, and only when +// there is a human to ask. +func offerPresetSave(cfg *models.Config, selected []string, totalRepos int) { + if !console.IsTerminal(os.Stdin) || len(selected) >= totalRepos { + return + } + if !console.Confirm("Save this selection as a preset?", false) { + return + } + + presetName := console.Prompt("Preset name") + if presetName == "" { + return + } + + if cfg.Presets == nil { + cfg.Presets = make(map[string]models.Preset) + } + cfg.Presets[presetName] = models.Preset{Repos: selected} + if err := config.Save(cfg); err != nil { + console.Warningf("Could not save preset: %s", err) + return + } + console.Successf("Saved preset %q", presetName) +} + +// requireKnownRepos rejects names that are not discoverable, listing what is +// available so the caller can correct itself in one round trip. +func requireKnownRepos(repoNames []string, repoMap map[string]string, repos []discover.Repo) { + for _, name := range repoNames { + if _, ok := repoMap[name]; !ok { + fail(workspace.ErrRepoNotFound(name). + WithDetails(map[string]any{"available": repoNamesList(repos)})) + } + } +} + +// --------------------------------------------------------------------------- +// Name, branch, and provenance +// --------------------------------------------------------------------------- + +// resolveCreateBranch returns the branch to create, prompting when a human omitted +// --branch. name seeds the prompt's default, which is why the branch is resolved +// after the name argument is read. +func resolveCreateBranch(name string) string { + if createBranch != "" { + return createBranch + } + + requireArgs("--branch", "gw create "+name+" -b feat/x --format json") + + branch := "" + if console.IsTerminal(os.Stdin) { + branch = console.PromptDefault("Branch name", name) + } + if branch == "" { + fail(machine.Errorf(machine.CodeUsage, "branch is required"). + WithFix("Pass --branch / -b")) + } + return branch +} + +// buildCreateOpts assembles the service request. Source provenance is opaque to +// Grove core — recorded and passed to hooks, never interpreted. +func buildCreateOpts(cfg *models.Config, branch string, repoNames []string, repoMap map[string]string) workspace.CreateOpts { + opts := workspace.CreateOpts{ + Branch: branch, + Repos: repoNames, + RepoMap: repoMap, + Cfg: cfg, + Source: createSource(), + } + // --track checks out an existing remote branch (e.g. a PR head) instead of + // creating one, falling back to create-mode when it is missing. + if createTrack { + opts.BranchMode = workspace.BranchModeTrack + } + return opts +} + +func createSource() *models.WorkspaceSource { + if createSourceURL == "" && createSourceProvide == "" { + return nil + } + return &models.WorkspaceSource{ + Provider: createSourceProvide, + URL: createSourceURL, + Ref: createSourceRef, + Title: createSourceTitle, + } +} + +// --------------------------------------------------------------------------- +// --replace +// --------------------------------------------------------------------------- + +// replaceCurrentWorkspace handles --replace: delete the workspace containing the +// cwd before creating the new one. It returns the deleted workspace's name, or "" +// when --replace was not requested. +// +// Every check happens before the deletion, because once the old workspace is gone +// a later failure leaves the user with neither workspace. +func replaceCurrentWorkspace(name string) string { + if !createReplace { + return "" + } + + cwd, err := os.Getwd() + if err != nil { + fail(machine.Wrap(machine.CodeInternal, err, "cannot determine working directory: %s", err)) + } + + currentWs, _ := state.FindWorkspaceByPath(cwd) + if currentWs == nil { + fail(machine.Errorf(machine.CodeUsage, + "--replace requires running from inside an existing workspace"). + WithActions(machine.NextAction("Discover current context", "gw context --format json"))) + } + if currentWs.Name == name { + fail(machine.Errorf(machine.CodeWorkspaceExists, + "--replace would collide: new workspace name matches the current one (%s)", name). + WithFix("Pass a different NAME")) + } + + // --replace deletes an existing workspace, so machine mode demands the + // destructive intent be explicit rather than inferred from a skipped prompt. + if !createForce { + requireArgs("--force (with --replace)", "gw create "+name+" -b --replace --force --format json") + if !console.Confirm("Delete workspace "+currentWs.Name+" and replace with "+name+"?", false) { + os.Exit(0) + } + } + + console.Infof("Replacing workspace: deleting %s", currentWs.Name) + firePreDeleteHook(*currentWs) + + if _, err := workspace.NewService().Delete(currentWs.Name); err != nil { + fail(machine.Wrap(machine.CodeFor(err), err, "failed to delete current workspace: %s", err)) + } + return currentWs.Name +} + +// failCreate reports a creation failure, making it explicit when --replace has +// already destroyed the previous workspace — that is not a no-op failure, and the +// caller must not assume nothing changed. +func failCreate(err error, replacedName string) { + if replacedName == "" { + fail(err) + } + fail(machine.Wrap(machine.CodeFor(err), err, + "failed to create new workspace (old workspace %s was already deleted): %s", replacedName, err). + WithDetails(map[string]any{"deleted_workspace": replacedName})) +} + +// --------------------------------------------------------------------------- +// Hooks +// --------------------------------------------------------------------------- + +func firePreDeleteHook(ws models.Workspace) { + vars := lifecycle.Vars{Name: ws.Name, Path: ws.Path, Branch: ws.Branch} + if err := lifecycle.Run("pre_delete", vars); err != nil && !errors.Is(err, lifecycle.ErrNoHook) { + if lifecycle.ShouldAbort(err) { + fail(machine.Wrap(machine.CodeHookFailed, err, "%s", err)) + } + console.Warning(err.Error()) + } +} + +func firePostCreateHook(name, wsPath, branch string) { + vars := lifecycle.Vars{Name: name, Path: wsPath, Branch: branch} + if source := createSource(); source != nil { + vars.SourceURL = source.URL + vars.SourceRef = source.Ref + vars.SourceTitle = source.Title + } + + err := lifecycle.Run("post_create", vars) + if err == nil || errors.Is(err, lifecycle.ErrNoHook) { + return + } + if lifecycle.ShouldAbort(err) { + // The workspace exists; the hook is what failed. Report the workspace in + // details so the caller does not retry create. + fail(machine.Wrap(machine.CodeHookFailed, err, "%s", err). + WithDetails(map[string]any{"workspace": name, "path": wsPath}). + WithFix("Fix the post_create hook, or re-run with --no-hooks")) + } + console.Warning(err.Error()) +} + func init() { createCmd.Flags().StringVarP(&createBranch, "branch", "b", "", "Branch name") createCmd.Flags().StringVarP(&createRepos, "repos", "r", "", "Comma-separated repo names") diff --git a/cmd/create_test.go b/cmd/create_test.go index f25ab2b..6e1dc39 100644 --- a/cmd/create_test.go +++ b/cmd/create_test.go @@ -4,6 +4,8 @@ import ( "testing" "github.com/nicksenap/grove/internal/discover" + "github.com/nicksenap/grove/internal/models" + "github.com/nicksenap/grove/internal/workspace" ) func TestDeriveName(t *testing.T) { @@ -55,3 +57,145 @@ func TestRepoNamesList_Empty(t *testing.T) { t.Errorf("len = %d, want 0", len(got)) } } + +// --------------------------------------------------------------------------- +// Repo selection precedence +// --------------------------------------------------------------------------- + +// resetCreateFlags clears the package-level flag state between cases, since Cobra +// binds these globally. +func resetCreateFlags(t *testing.T) { + t.Helper() + t.Cleanup(func() { + createPreset, createRepos, createBranch = "", "", "" + createAll, createTrack, createReplace, createForce = false, false, false, false + createSourceURL, createSourceProvide, createSourceRef, createSourceTitle = "", "", "", "" + }) + createPreset, createRepos, createBranch = "", "", "" + createAll, createTrack, createReplace, createForce = false, false, false, false + createSourceURL, createSourceProvide, createSourceRef, createSourceTitle = "", "", "", "" +} + +func TestResolveCreateReposPrecedence(t *testing.T) { + repos := []discover.Repo{{Name: "api"}, {Name: "web"}, {Name: "worker"}} + repoMap := map[string]string{"api": "/r/api", "web": "/r/web", "worker": "/r/worker"} + cfg := &models.Config{ + RepoDirs: []string{"/r"}, + Presets: map[string]models.Preset{"backend": {Repos: []string{"api", "worker"}}}, + } + + t.Run("preset wins over --all and --repos", func(t *testing.T) { + resetCreateFlags(t) + createPreset, createAll, createRepos = "backend", true, "web" + got := resolveCreateRepos(cfg, repos, repoMap) + if len(got) != 2 || got[0] != "api" || got[1] != "worker" { + t.Errorf("got %v, want [api worker]", got) + } + }) + + t.Run("--all wins over --repos", func(t *testing.T) { + resetCreateFlags(t) + createAll, createRepos = true, "web" + got := resolveCreateRepos(cfg, repos, repoMap) + if len(got) != 3 { + t.Errorf("got %v, want every discovered repo", got) + } + }) + + t.Run("--repos list", func(t *testing.T) { + resetCreateFlags(t) + createRepos = "web, worker" + got := resolveCreateRepos(cfg, repos, repoMap) + if len(got) != 2 || got[0] != "web" || got[1] != "worker" { + t.Errorf("got %v, want [web worker]", got) + } + }) +} + +// --------------------------------------------------------------------------- +// Provenance +// --------------------------------------------------------------------------- + +// Source is opaque to core, but its presence rule matters: a workspace with no +// source flags must record nil rather than an empty struct, or every workspace +// would look like it came from somewhere. +func TestCreateSource(t *testing.T) { + t.Run("absent without flags", func(t *testing.T) { + resetCreateFlags(t) + if got := createSource(); got != nil { + t.Errorf("got %+v, want nil", got) + } + }) + + t.Run("url alone is enough", func(t *testing.T) { + resetCreateFlags(t) + createSourceURL = "https://github.com/org/repo/pull/42" + got := createSource() + if got == nil || got.URL != createSourceURL { + t.Fatalf("got %+v", got) + } + }) + + t.Run("provider alone is enough", func(t *testing.T) { + resetCreateFlags(t) + createSourceProvide = "notion" + if got := createSource(); got == nil || got.Provider != "notion" { + t.Fatalf("got %+v", got) + } + }) + + t.Run("all fields recorded", func(t *testing.T) { + resetCreateFlags(t) + createSourceURL, createSourceProvide = "https://x/pull/1", "github" + createSourceRef, createSourceTitle = "1", "Add login" + got := createSource() + if got.Ref != "1" || got.Title != "Add login" || got.Provider != "github" { + t.Errorf("got %+v", got) + } + }) +} + +func TestBuildCreateOpts(t *testing.T) { + cfg := &models.Config{WorkspaceDir: "/ws"} + repoMap := map[string]string{"api": "/r/api"} + + t.Run("defaults to creating branches", func(t *testing.T) { + resetCreateFlags(t) + opts := buildCreateOpts(cfg, "feat/x", []string{"api"}, repoMap) + if opts.BranchMode != workspace.BranchModeCreate { + t.Errorf("BranchMode = %v, want create", opts.BranchMode) + } + if opts.Branch != "feat/x" || opts.Cfg != cfg || opts.Source != nil { + t.Errorf("opts = %+v", opts) + } + }) + + t.Run("--track switches to tracking mode", func(t *testing.T) { + resetCreateFlags(t) + createTrack = true + if opts := buildCreateOpts(cfg, "feat/x", []string{"api"}, repoMap); opts.BranchMode != workspace.BranchModeTrack { + t.Errorf("BranchMode = %v, want track", opts.BranchMode) + } + }) +} + +// --------------------------------------------------------------------------- +// Branch resolution +// --------------------------------------------------------------------------- + +// An explicit --branch must be used verbatim, without consulting the terminal. +func TestResolveCreateBranchUsesFlag(t *testing.T) { + resetCreateFlags(t) + createBranch = "feat/explicit" + if got := resolveCreateBranch("ignored-name"); got != "feat/explicit" { + t.Errorf("got %q, want feat/explicit", got) + } +} + +// --replace is inert unless requested; it must not touch state or the cwd. +func TestReplaceCurrentWorkspaceNoopWithoutFlag(t *testing.T) { + resetCreateFlags(t) + if got := replaceCurrentWorkspace("anything"); got != "" { + t.Errorf("got %q, want empty", got) + } +} From d1fa40f03fd4ddc8c978a3911aa76ddcbfc0ff70 Mon Sep 17 00:00:00 2001 From: Nick Song Date: Fri, 31 Jul 2026 20:29:30 +0200 Subject: [PATCH 18/21] Record the audit fixes in the changelog The feature entries were there; the defects found while writing the e2e suite and auditing for duplication were not. Also corrects the plan fingerprint description, which now covers each repo's exact changes and commit rather than a dirtiness flag. --- CHANGELOG.md | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b16c078..aa50871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,9 +16,9 @@ - `gw plan create` / `gw plan delete` and `gw apply`: preview a mutation, review every repository, path, and branch it would touch, then execute exactly what was reviewed. Plans carry a fingerprint of the state they assume (including - per-repo dirtiness), and `gw apply` refuses with `STATE_CHANGED` if anything - relevant moved — so work created after a plan was reviewed is never destroyed - by it. + each repo's exact uncommitted changes and current commit), and `gw apply` + refuses with `STATE_CHANGED` if anything relevant moved — so work created after + a plan was reviewed is never destroyed by it. - `gw context`: one read-only call reporting the current workspace, each repo's live git state, configuration, announcements, and safe next actions. - Global `--format json` (`-o json`) on every command: a versioned response @@ -32,10 +32,34 @@ them while orienting. Backed by a lock-free directory of JSON files under `~/.grove/announcements/` — no SQLite. +### Fixes + +- `gw create -r ssh://git@host/org/repo.git` now clones the URL. `IsGitURL` did + not recognize `ssh://`, so the URL was treated as a repo name and failed with + "repo not found". `git://`, `git+ssh://`, and `ssh://` with a port are + recognized too. +- `gw plan delete` now warns about commits that were never pushed. It compared + against `origin/` and ignored the error when that ref did not exist, so + the only truly unrecoverable case — commits that exist nowhere else — produced + no warning at all. An unreadable worktree now warns instead of appearing clean. +- "No workspaces exist" reports one error code across commands. `gw rename` + returned `INTERNAL` (exit 1) where `gw add-repo` and `gw remove-repo` returned + `NO_WORKSPACES` (exit 3). +- `--repos "api,"` no longer produces a repo named "" and the error + `repo not found`; blank entries are dropped. +- `gw status --format json` reports `base_branch`, so `ahead`/`behind` say what + they are relative to. + ### Maintenance - Dropped the `modernc.org/sqlite` dependency tree; the release binary shrank - from 13.0 MB to 9.0 MB (-31%). + from 13,030,674 to 9,102,482 bytes (-30%) on darwin/arm64 with `-s -w`. +- `gw context` makes 14 git invocations for a two-repo workspace instead of 20; + the base branch was resolved twice per repo. +- e2e suite: added machine-contract coverage (221 assertions), sandboxed the + suite properly (it pinned `HOME` but not `GIT_CONFIG_GLOBAL`, so a host with + `XDG_CONFIG_HOME` set could have had its real git config modified), and added a + container mode plus a macOS CI job. ## v1.1.11 From 09f67fcb34ffcfebd626cf2eccab4b58ebf0d772 Mon Sep 17 00:00:00 2001 From: Nick Song Date: Fri, 31 Jul 2026 20:37:41 +0200 Subject: [PATCH 19/21] Add a cleanup path for what the MCP server left behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two artifacts outlive the removed MCP server, and only one was handled: the `grove` entry in each workspace's `.mcp.json`, and the announcements SQLite database at ~/.grove/messages.db. On my own machine the database is still there from May, 16 KB plus a -wal and -shm sidecar, and nothing in Grove can read it now that the driver is gone — so it is dead weight rather than data at risk. `gw doctor` now reports and `--fix` removes it, alongside the .mcp.json entries. But doctor only visits workspaces Grove still tracks, which misses the cases most likely to have leftovers: directories abandoned by workspaces removed from state, and checkouts outside the configured workspace directory. So scripts/cleanup-mcp-migration.sh covers those, and works without upgrading first. Design choices worth stating: - It reports and changes nothing unless given --apply, matching the plan/apply posture of the rest of this work. A cleanup that deletes files on first run is not something to paste from a README. - It removes a `grove` entry only when that entry launches `gw mcp-serve`, so an external MCP adapter that happens to be named `grove` is left alone — the same rule internal/workspace/mcpmigrate.go applies. - A `.mcp.json` that is not valid JSON is skipped and reported, never rewritten. - The file is deleted only when Grove's entry was the only thing in it; otherwise the other servers are preserved. - Without jq it refuses to edit JSON and points at `gw doctor --fix` rather than attempting text surgery on a config file. - It reads workspace_dir from config.toml, since the workspace directory need not live under the Grove home, and accepts extra directories to scan. e2e covers the script end to end: dry run changing nothing, the grove-only file removed, other servers preserved, a foreign adapter untouched, an untracked directory reached, the database deleted, and a second run reporting no work. Plus doctor's own path for the database. 231 assertions. --- CHANGELOG.md | 6 +- docs/agent-cli.md | 17 ++- docs/ai-tools.md | 9 +- e2e/run.sh | 87 ++++++++++++ internal/workspace/workspace.go | 51 +++++++ internal/workspace/workspace_test.go | 45 +++++++ scripts/cleanup-mcp-migration.sh | 192 +++++++++++++++++++++++++++ 7 files changed, 404 insertions(+), 3 deletions(-) create mode 100755 scripts/cleanup-mcp-migration.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index aa50871..661e1eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,11 @@ `get_announcements` MCP tools are gone — the latter two return as the `gw announce` / `gw announcements` commands below. The `gw` CLI is now the only first-party agent interface. Run `gw doctor --fix` to strip the stale `grove` entry from - `.mcp.json` files in existing workspaces (other MCP servers are preserved). + `.mcp.json` files in existing workspaces (other MCP servers are preserved), and + to delete the orphaned `~/.grove/messages.db`. For workspaces Grove no longer + tracks, or trees outside the workspace directory, run + `scripts/cleanup-mcp-migration.sh` — it reports before changing anything and + only touches entries that launch `gw mcp-serve`. ### Features diff --git a/docs/agent-cli.md b/docs/agent-cli.md index 3e1319f..1eee34e 100644 --- a/docs/agent-cli.md +++ b/docs/agent-cli.md @@ -261,10 +261,25 @@ entry into each workspace's `.mcp.json`. Both were removed — the CLI covers th same ground for any client with shell access. ```bash -gw doctor # reports leftover .mcp.json grove entries +gw doctor # reports leftover .mcp.json grove entries and the old database gw doctor --fix # removes only Grove's entry, preserving other MCP servers ``` +`gw doctor` only visits workspaces Grove still tracks. For directories left behind +by workspaces removed from state, checkouts outside the configured workspace +directory, or a machine you would rather not upgrade first, there is a standalone +script that reports before it changes anything: + +```bash +scripts/cleanup-mcp-migration.sh # dry run +scripts/cleanup-mcp-migration.sh --apply # do it +scripts/cleanup-mcp-migration.sh --apply ~/projects # also scan another tree +``` + +It removes only entries that launch `gw mcp-serve`, so another tool's server — +even one named `grove` — is left alone, and it deletes the orphaned +`~/.grove/messages.db` (plus `-wal`/`-shm`), which no current Grove can read. + The MCP server's `announce` / `get_announcements` tools live on as `gw announce` and `gw announcements` (see above). Coordination came from a shared store on disk, not from the protocol, so the CLI provides it to any agent with a shell — diff --git a/docs/ai-tools.md b/docs/ai-tools.md index 3dfea8c..027dcad 100644 --- a/docs/ai-tools.md +++ b/docs/ai-tools.md @@ -92,10 +92,17 @@ Workspaces created by Grove ≤ 1.1.11 have a `grove` entry in their `.mcp.json` pointing at the removed `gw mcp-serve`. Clean it up with: ```bash -gw doctor # reports stale entries +gw doctor # reports stale entries and the orphaned announcements database gw doctor --fix # removes only the grove entry, keeping other servers ``` +For workspaces Grove no longer tracks, or trees outside the workspace directory: + +```bash +scripts/cleanup-mcp-migration.sh # dry run +scripts/cleanup-mcp-migration.sh --apply +``` + Cross-workspace coordination survived the removal as first-class commands: ```bash diff --git a/e2e/run.sh b/e2e/run.sh index 2a3031a..a0c4460 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -1431,6 +1431,93 @@ fi gw delete mc-ws --force 2>&1 > /dev/null gw delete noisy-ws --force 2>&1 > /dev/null +# --------------------------------------------------------------------------- +# Test: MCP migration cleanup script +# --------------------------------------------------------------------------- +section "MCP cleanup script" + +CLEANUP_SCRIPT="$(cd "$(dirname "$0")" && pwd)/../scripts/cleanup-mcp-migration.sh" +MIG_DIR="${SCRATCH}/migration" +mkdir -p "${MIG_DIR}/workspaces/only-grove" "${MIG_DIR}/workspaces/shared" \ + "${MIG_DIR}/workspaces/external" "${MIG_DIR}/orphaned" + +# A workspace where grove was the only server; one where it shares the file; one +# where an unrelated adapter is also called "grove"; and a directory Grove no +# longer tracks at all, which is the case `gw doctor` cannot reach. +echo '{"mcpServers":{"grove":{"command":"gw","args":["mcp-serve","--workspace","only-grove"]}}}' \ + > "${MIG_DIR}/workspaces/only-grove/.mcp.json" +echo '{"mcpServers":{"grove":{"command":"gw","args":["mcp-serve","--workspace","shared"]},"keeper":{"command":"keep-me","args":[]}}}' \ + > "${MIG_DIR}/workspaces/shared/.mcp.json" +echo '{"mcpServers":{"grove":{"command":"grove-mcp-adapter","args":["serve"]}}}' \ + > "${MIG_DIR}/workspaces/external/.mcp.json" +echo '{"mcpServers":{"grove":{"command":"gw","args":["mcp-serve","--workspace","gone"]}}}' \ + > "${MIG_DIR}/orphaned/.mcp.json" +printf 'SQLite format 3\0' > "${MIG_DIR}/messages.db" +printf '' > "${MIG_DIR}/messages.db-wal" + +# Dry run must report without touching anything. +dry_out=$(bash "${CLEANUP_SCRIPT}" --grove-dir "${MIG_DIR}" "${MIG_DIR}/workspaces" "${MIG_DIR}/orphaned" 2>&1) +if echo "${dry_out}" | grep -q "would remove"; then + pass "cleanup script dry run reports work" +else + fail "dry run reported nothing: ${dry_out}" +fi +if [ -f "${MIG_DIR}/workspaces/only-grove/.mcp.json" ] && [ -f "${MIG_DIR}/messages.db" ]; then + pass "cleanup script dry run changes nothing" +else + fail "dry run modified the filesystem" +fi + +bash "${CLEANUP_SCRIPT}" --apply --grove-dir "${MIG_DIR}" "${MIG_DIR}/workspaces" "${MIG_DIR}/orphaned" > /dev/null 2>&1 + +if [ ! -f "${MIG_DIR}/workspaces/only-grove/.mcp.json" ]; then + pass "cleanup removes a .mcp.json that held only grove" +else + fail "grove-only .mcp.json should be deleted" +fi +if jq -e '.mcpServers | has("grove") == false and has("keeper")' "${MIG_DIR}/workspaces/shared/.mcp.json" > /dev/null 2>&1; then + pass "cleanup preserves other MCP servers" +else + fail "shared .mcp.json lost its other server: $(cat "${MIG_DIR}/workspaces/shared/.mcp.json")" +fi +if jq -e '.mcpServers.grove.command == "grove-mcp-adapter"' "${MIG_DIR}/workspaces/external/.mcp.json" > /dev/null 2>&1; then + pass "cleanup leaves a foreign server named grove alone" +else + fail "external adapter was modified" +fi +if [ ! -f "${MIG_DIR}/orphaned/.mcp.json" ]; then + pass "cleanup reaches directories Grove no longer tracks" +else + fail "orphaned .mcp.json was not cleaned" +fi +if [ ! -f "${MIG_DIR}/messages.db" ] && [ ! -f "${MIG_DIR}/messages.db-wal" ]; then + pass "cleanup removes the legacy announcements database" +else + fail "legacy database survived" +fi + +# Idempotent: a second run finds nothing to do. +rerun_out=$(bash "${CLEANUP_SCRIPT}" --grove-dir "${MIG_DIR}" "${MIG_DIR}/workspaces" "${MIG_DIR}/orphaned" 2>&1) +if echo "${rerun_out}" | grep -q "Would remove 0 item"; then + pass "cleanup script is idempotent" +else + fail "second run still reports work: ${rerun_out}" +fi + +# gw doctor covers the database for anyone who just upgrades. +printf 'SQLite format 3\0' > "${GROVE_HOME}/.grove/messages.db" +if gw doctor --format json /dev/null | jq -e '[.result.issues[] | select(.issue | contains("messages.db"))] | length == 1' > /dev/null; then + pass "doctor reports the legacy announcements database" +else + fail "doctor did not report messages.db" +fi +gw doctor --fix > /dev/null 2>&1 || true +if [ ! -f "${GROVE_HOME}/.grove/messages.db" ]; then + pass "doctor --fix removes the legacy announcements database" +else + fail "doctor --fix left messages.db behind" +fi + # --------------------------------------------------------------------------- # Test: cross-workspace agent coordination # --------------------------------------------------------------------------- diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index dfaba62..f8cb846 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -1010,6 +1010,10 @@ func (s *Service) Doctor(fix bool) ([]models.DoctorIssue, int, error) { var issues []models.DoctorIssue fixed := 0 + f, iss := s.checkLegacyAnnouncementDB(fix) + fixed += f + issues = append(issues, iss...) + for _, ws := range workspaces { f, iss := s.checkWorkspaceExists(ws, fix) if f > 0 { @@ -1032,6 +1036,53 @@ func (s *Service) Doctor(fix bool) ([]models.DoctorIssue, int, error) { return issues, fixed, nil } +// legacyAnnouncementDBFiles are the SQLite database and its sidecars that the +// removed MCP server used for cross-workspace announcements. +var legacyAnnouncementDBFiles = []string{"messages.db", "messages.db-wal", "messages.db-shm", "messages.db-journal"} + +// checkLegacyAnnouncementDB flags the announcements database left behind by the +// removed MCP server. Nothing can read it any more — the SQLite driver is gone — +// so it is dead weight rather than data at risk, and `--fix` deletes it. +// +// Announcements now live in ~/.grove/announcements/ as one JSON file per note. +func (s *Service) checkLegacyAnnouncementDB(fix bool) (int, []models.DoctorIssue) { + groveDir := filepath.Dir(s.State.Path) + + var present []string + for _, name := range legacyAnnouncementDBFiles { + if _, err := os.Stat(filepath.Join(groveDir, name)); err == nil { + present = append(present, name) + } + } + if len(present) == 0 { + return 0, nil + } + + issue := models.DoctorIssue{ + Workspace: "(global)", + Repo: nil, + Issue: "leftover announcements database from the removed MCP server (" + strings.Join(present, ", ") + ")", + SuggestedAction: "delete it; notes now live in announcements/", + } + if !fix { + return 0, []models.DoctorIssue{issue} + } + + removed := 0 + for _, name := range present { + path := filepath.Join(groveDir, name) + if err := os.Remove(path); err != nil { + logging.Warn("could not remove %s: %s", path, err) + continue + } + removed++ + } + if removed == 0 { + return 0, []models.DoctorIssue{issue} + } + return 1, []models.DoctorIssue{issue} +} + // checkStaleMCPConfig flags the legacy `grove` entry in a workspace's // `.mcp.json`, left behind by Grove versions that shipped a built-in MCP // server. `--fix` removes just that entry. diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index 52ad3e6..149934a 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -1754,3 +1754,48 @@ func TestDeleteForceDeletesUnmergedBranch(t *testing.T) { t.Error("branch feat/unmerged should have been force-deleted from source repo") } } + +// The removed MCP server's SQLite database outlives it, and nothing can read it +// now, so doctor should offer to clean it up rather than leaving it forever. +func TestDoctorReportsAndRemovesLegacyAnnouncementDB(t *testing.T) { + env := setupTestEnv(t) + + dbPath := filepath.Join(env.groveDir, "messages.db") + os.WriteFile(dbPath, []byte("SQLite format 3\x00"), 0o644) + os.WriteFile(dbPath+"-wal", []byte(""), 0o644) + os.WriteFile(dbPath+"-shm", []byte("x"), 0o644) + + issues, fixed, err := env.svc.Doctor(false) + if err != nil { + t.Fatalf("doctor: %v", err) + } + if len(issues) != 1 || fixed != 0 { + t.Fatalf("expected 1 unfixed issue, got %d issues / %d fixed: %+v", len(issues), fixed, issues) + } + if !strings.Contains(issues[0].Issue, "messages.db") { + t.Errorf("issue should name the files: %q", issues[0].Issue) + } + + // A report is not a mutation. + if _, err := os.Stat(dbPath); err != nil { + t.Error("doctor without --fix must not delete anything") + } + + if _, fixed, err = env.svc.Doctor(true); err != nil { + t.Fatalf("doctor --fix: %v", err) + } + if fixed != 1 { + t.Errorf("fixed = %d, want 1", fixed) + } + for _, suffix := range []string{"", "-wal", "-shm"} { + if _, err := os.Stat(dbPath + suffix); !os.IsNotExist(err) { + t.Errorf("messages.db%s should be gone", suffix) + } + } + + // Idempotent: a clean machine reports nothing. + issues, _, _ = env.svc.Doctor(false) + if len(issues) != 0 { + t.Errorf("expected no issues after cleanup, got %+v", issues) + } +} diff --git a/scripts/cleanup-mcp-migration.sh b/scripts/cleanup-mcp-migration.sh new file mode 100755 index 0000000..63826d5 --- /dev/null +++ b/scripts/cleanup-mcp-migration.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +# Clean up what Grove's removed MCP server left behind. +# +# Grove ≤ 1.1.11 shipped a built-in MCP server. Two artifacts outlive it: +# +# 1. a "grove" entry in each workspace's .mcp.json, pointing at the removed +# `gw mcp-serve` command; +# 2. an announcements SQLite database at ~/.grove/messages.db (plus its -wal and +# -shm sidecars), which nothing can read now that the driver is gone. +# +# `gw doctor --fix` handles both for workspaces Grove still tracks. This script +# exists for the cases it cannot reach: directories left behind by workspaces that +# were removed from state, checkouts outside the configured workspace directory, +# and machines where you would rather not upgrade first. +# +# It reports what it would do and changes nothing unless you pass --apply. +# +# Usage: +# scripts/cleanup-mcp-migration.sh [--apply] [--grove-dir DIR] [SEARCH_DIR...] +# +# --apply make the changes (default is a dry run) +# --grove-dir DIR Grove home (default: $GROVE_HOME or ~/.grove) +# SEARCH_DIR... extra directories to scan for .mcp.json +# (default: the configured workspace dir, else /workspaces) +# +# Examples: +# scripts/cleanup-mcp-migration.sh # show what would change +# scripts/cleanup-mcp-migration.sh --apply # do it +# scripts/cleanup-mcp-migration.sh --apply ~/projects # also scan another tree + +set -euo pipefail + +APPLY=0 +GROVE_DIR="${GROVE_HOME:-${HOME}/.grove}" +SEARCH_DIRS=() + +while [ $# -gt 0 ]; do + case "$1" in + --apply) APPLY=1; shift ;; + --grove-dir) GROVE_DIR="${2:?--grove-dir needs a path}"; shift 2 ;; + -h|--help) sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -*) echo "unknown option: $1" >&2; exit 2 ;; + *) SEARCH_DIRS+=("$1"); shift ;; + esac +done + +CHANGED=0 +WOULD_CHANGE=0 +SKIPPED=0 + +note() { printf ' %s\n' "$1"; } +acted() { CHANGED=$((CHANGED + 1)); printf ' removed %s\n' "$1"; } +would() { WOULD_CHANGE=$((WOULD_CHANGE + 1)); printf ' would remove %s\n' "$1"; } + +# --------------------------------------------------------------------------- +# Where to look +# --------------------------------------------------------------------------- + +if [ ${#SEARCH_DIRS[@]} -eq 0 ]; then + # Prefer the configured workspace_dir, since it may not be under the Grove dir. + configured="" + if [ -f "${GROVE_DIR}/config.toml" ]; then + configured=$(sed -n 's/^[[:space:]]*workspace_dir[[:space:]]*=[[:space:]]*"\(.*\)"[[:space:]]*$/\1/p' \ + "${GROVE_DIR}/config.toml" | head -1) + # Expand a leading ~ the way the config loader does. + case "${configured}" in "~"/*) configured="${HOME}/${configured#\~/}" ;; esac + fi + if [ -n "${configured}" ] && [ -d "${configured}" ]; then + SEARCH_DIRS=("${configured}") + else + SEARCH_DIRS=("${GROVE_DIR}/workspaces") + fi +fi + +echo "Grove MCP cleanup" +echo " grove dir: ${GROVE_DIR}" +echo " scanning: ${SEARCH_DIRS[*]}" +if [ "${APPLY}" -eq 0 ]; then + echo " mode: dry run (pass --apply to make changes)" +else + echo " mode: applying changes" +fi + +# --------------------------------------------------------------------------- +# 1. Stale "grove" entries in .mcp.json +# --------------------------------------------------------------------------- + +echo +echo "── .mcp.json entries ──" + +have_jq=1 +command -v jq > /dev/null 2>&1 || have_jq=0 + +if [ "${have_jq}" -eq 0 ]; then + note "jq not found — cannot safely edit .mcp.json files." + note "Install jq, or run: gw doctor --fix" +else + # A "grove" entry is only ours if it launches gw mcp-serve. Anything else with + # that name belongs to the user (for example an external MCP adapter), and is + # not this script's to touch. + is_ours='(.mcpServers.grove.command // "") as $c + | (($c == "gw") or ($c | endswith("/gw"))) + and ((.mcpServers.grove.args // []) | index("mcp-serve") != null)' + + found_any=0 + while IFS= read -r cfg; do + [ -n "${cfg}" ] || continue + found_any=1 + + if ! jq -e . "${cfg}" > /dev/null 2>&1; then + SKIPPED=$((SKIPPED + 1)) + note "skipped (not valid JSON): ${cfg}" + continue + fi + if ! jq -e "${is_ours}" "${cfg}" > /dev/null 2>&1; then + continue + fi + + remaining=$(jq '(.mcpServers | del(.grove)) | length' "${cfg}") + other_keys=$(jq '. | del(.mcpServers) | length' "${cfg}") + + if [ "${remaining}" -eq 0 ] && [ "${other_keys}" -eq 0 ]; then + # Grove was the only thing in the file. + if [ "${APPLY}" -eq 1 ]; then + rm -f "${cfg}" && acted "${cfg} (file: grove was its only server)" + else + would "${cfg} (file: grove was its only server)" + fi + continue + fi + + if [ "${APPLY}" -eq 1 ]; then + tmp="${cfg}.grove-cleanup.$$" + if jq 'del(.mcpServers.grove)' "${cfg}" > "${tmp}" && mv "${tmp}" "${cfg}"; then + acted "${cfg} (grove entry; ${remaining} other server(s) kept)" + else + rm -f "${tmp}" + SKIPPED=$((SKIPPED + 1)) + note "failed to rewrite: ${cfg}" + fi + else + would "${cfg} (grove entry; ${remaining} other server(s) would be kept)" + fi + done </dev/null +done) +EOF + + if [ "${found_any}" -eq 0 ]; then + note "no .mcp.json files found" + fi +fi + +# --------------------------------------------------------------------------- +# 2. The orphaned announcements database +# --------------------------------------------------------------------------- + +echo +echo "── announcements database ──" + +db="${GROVE_DIR}/messages.db" +db_found=0 +for f in "${db}" "${db}-wal" "${db}-shm" "${db}-journal"; do + [ -e "${f}" ] || continue + db_found=1 + if [ "${APPLY}" -eq 1 ]; then + rm -f "${f}" && acted "${f}" + else + would "${f}" + fi +done + +if [ "${db_found}" -eq 0 ]; then + note "no legacy database found" +elif [ "${APPLY}" -eq 0 ]; then + note "cross-workspace notes now live in ${GROVE_DIR}/announcements/ (one JSON file per note)" + note "the old database is unreadable by current Grove, so nothing is migrated out of it" +fi + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + +echo +if [ "${APPLY}" -eq 1 ]; then + echo "Removed ${CHANGED} item(s)." +else + echo "Would remove ${WOULD_CHANGE} item(s). Re-run with --apply to do it." +fi +[ "${SKIPPED}" -gt 0 ] && echo "Skipped ${SKIPPED} file(s) — see above." +exit 0 From d30ced8f3d0e16e82b56b4f31ca074fbea08b296 Mon Sep 17 00:00:00 2001 From: Nick Song Date: Fri, 31 Jul 2026 21:05:45 +0200 Subject: [PATCH 20/21] Make plan approval binding on the commands it displayed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plan listed the setup commands it would run, but the fingerprint did not cover them, so approving a plan approved nothing about the code it would execute: plan says: touch SETUP_RAN_ORIGINAL ← what the reviewer sees .grove.toml edited to: touch PWNED gw apply plan.json → ok: true, PWNED created Everything else a plan describes is git work Grove controls; run_setup_hook and run_teardown_hook are arbitrary code from a repo's .grove.toml. Those are exactly what a reviewer is being asked to approve, so plannedCommands now folds them into both fingerprints and apply refuses with STATE_CHANGED when they change. Fingerprinting the plan's own displayed commands, rather than re-deriving what to hash, means the guarantee tracks the plan: whatever a plan claims it will execute is what it is pinned to. This is also the mechanism blueprints need (#61: "changed remote setup commands invalidate prior approval"), where the commands arrive from a shared or remote file rather than a local repo — the same check, with a blueprint digest and resolved commit added to the fingerprint input. docs/agent-cli.md now states what the fingerprint covers per plan kind, and adds the section that was missing: when plan/apply is worth the extra round trip. It is for the gap between deciding and doing — human review, arbitrary setup commands, a concurrent mutator, or irreversible destruction. For an agent that plans and applies in the same breath with nothing reading the plan, it is overhead, and `gw delete --force --format json` already returns per-repo results. Overselling a two-step as universally better would earn it a reputation as ceremony. e2e covers the approval path end to end. --- CHANGELOG.md | 7 ++- docs/agent-cli.md | 36 +++++++++-- e2e/run.sh | 30 +++++++++ internal/workspace/plan.go | 35 ++++++++--- internal/workspace/plan_test.go | 106 ++++++++++++++++++++++++++++++++ 5 files changed, 197 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 661e1eb..5cfec59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,9 +20,10 @@ - `gw plan create` / `gw plan delete` and `gw apply`: preview a mutation, review every repository, path, and branch it would touch, then execute exactly what was reviewed. Plans carry a fingerprint of the state they assume (including - each repo's exact uncommitted changes and current commit), and `gw apply` - refuses with `STATE_CHANGED` if anything relevant moved — so work created after - a plan was reviewed is never destroyed by it. + each repo's exact uncommitted changes and current commit, and the shell commands + the plan displayed), and `gw apply` refuses with `STATE_CHANGED` if anything + relevant moved — so work created after a plan was reviewed is never destroyed by + it, and a `.grove.toml` setup command edited after review is never executed. - `gw context`: one read-only call reporting the current workspace, each repo's live git state, configuration, announcements, and safe next actions. - Global `--format json` (`-o json`) on every command: a versioned response diff --git a/docs/agent-cli.md b/docs/agent-cli.md index 1eee34e..391cf2e 100644 --- a/docs/agent-cli.md +++ b/docs/agent-cli.md @@ -214,12 +214,36 @@ Two guarantees make a plan worth more than a printed warning: 1. **Same validation path.** A plan is produced by the checks execution runs, so a plan that succeeds cannot fail validation at apply time. -2. **State pinning.** The `fingerprint` covers the state the plan depends on. For - a delete that includes each repo's exact uncommitted changes and current - commit, so work added after review — even to a repo that was already dirty, or - a commit made on a clean one — invalidates the plan. `gw apply` recomputes it - and fails with `STATE_CHANGED` (exit 4) rather than applying a plan that was - reviewed against a different world. +2. **State pinning.** The `fingerprint` covers the state the plan depends on, and + `gw apply` recomputes it and fails with `STATE_CHANGED` (exit 4) rather than + applying a plan that was reviewed against a different world. + +What the fingerprint covers: + +| Plan | Pinned | +| --- | --- | +| both | the repos involved, their source paths, and the shell commands the plan displayed | +| `create` | the target name being free, and each repo's local/remote branch situation | +| `delete` | each repo's exact uncommitted changes and current commit | + +The commands matter most. Everything else in a plan describes git work Grove +controls; `run_setup_hook` and `run_teardown_hook` are arbitrary code from a repo's +`.grove.toml`. Pinning them is what makes review binding — otherwise a plan can +display `go mod download` and apply something else, and approving it means nothing. + +### When this is worth the extra round trip + +Plan/apply exists for the gap between deciding and doing. It pays for itself when: + +- a human (or a second agent) reviews before execution; +- the operation runs arbitrary setup commands you want to see first; +- something else may touch the workspace in between — another agent, or you; +- the operation is destructive and irreversible. + +For an agent that plans and applies in the same breath with no review in between, +it is pure overhead: `gw delete --force --format json` already reports per-repo +results, and `gw sync` already reports why a repo was skipped. Use plan/apply when +the plan will actually be *read*. `gw apply` accepts a bare plan document, a saved `--format json` envelope, or `-` for stdin. A saved *failure* envelope is refused rather than parsed as an empty diff --git a/e2e/run.sh b/e2e/run.sh index a0c4460..66ae8ee 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -1298,6 +1298,36 @@ else fi gw delete unpushed-ws --force 2>&1 > /dev/null +# Approval is binding: a plan displays the setup commands it will run, and a plan +# whose commands changed after review must be refused rather than executing code +# nobody approved. +cat > "${REPOS_DIR}/svc-gateway/.grove.toml" <<'TOML' +setup = "touch SETUP_APPROVED" +TOML +(cd "${REPOS_DIR}/svc-gateway" && git add .grove.toml && git commit -q -m "approved setup command") + +run_json gw plan create approval-ws -r svc-gateway -b feat/approval --format json +assert_ok "plan create shows its setup command" +if printf '%s' "${JSON_OUT}" | jq -e '[.result.changes[] | select(.action == "run_setup_hook") | .detail] == ["touch SETUP_APPROVED"]' > /dev/null 2>&1; then + pass "plan displays the setup command a reviewer approves" +else + fail "plan should display the setup command: $(printf '%s' "${JSON_OUT}" | jq -c '[.result.changes[] | select(.action == "run_setup_hook")]')" +fi +printf '%s' "${JSON_OUT}" > "${SCRATCH}/approval-plan.json" + +cat > "${REPOS_DIR}/svc-gateway/.grove.toml" <<'TOML' +setup = "touch SETUP_UNAPPROVED" +TOML +(cd "${REPOS_DIR}/svc-gateway" && git add .grove.toml && git commit -q -m "changed setup command") + +run_json gw apply "${SCRATCH}/approval-plan.json" --format json +assert_failure "apply after the setup command changed" "STATE_CHANGED" 4 +if [ ! -d "${GROVE_HOME}/.grove/workspaces/approval-ws" ]; then + pass "refused apply ran no unapproved command" +else + fail "workspace was created despite a changed setup command" +fi + # A saved failure envelope is not a plan. run_json gw status no-such-workspace --format json printf '%s' "${JSON_OUT}" > "${SCRATCH}/failure.json" diff --git a/internal/workspace/plan.go b/internal/workspace/plan.go index b158c47..752dce1 100644 --- a/internal/workspace/plan.go +++ b/internal/workspace/plan.go @@ -143,7 +143,7 @@ func (s *Service) PlanCreate(name string, opts CreateOpts, version string) (*Pla plan.Warnings = append(plan.Warnings, warnings...) } - plan.Fingerprint = s.createFingerprint(name, opts) + plan.Fingerprint = s.createFingerprint(name, opts, plan.Changes) return plan, nil } @@ -288,7 +288,7 @@ func (s *Service) PlanDelete(name, version string) (*Plan, error) { PlannedChange{Action: ActionRemoveStateEntry, Detail: ws.Name, Destructive: true}, ) - plan.Fingerprint = s.deleteFingerprint(ws) + plan.Fingerprint = s.deleteFingerprint(ws, plan.Changes) return plan, nil } @@ -422,10 +422,11 @@ func (s *Service) validateCreate(name string, opts CreateOpts) error { // Fingerprints // --------------------------------------------------------------------------- -// createFingerprint pins what a create plan assumes: the target name is free and -// each repo's source path and branch situation are unchanged. -func (s *Service) createFingerprint(name string, opts CreateOpts) string { - input := []any{"create", name, opts.Branch} +// createFingerprint pins what a create plan assumes: the target name is free, +// each repo's source path and branch situation are unchanged, and the setup +// commands are the ones the plan displayed. +func (s *Service) createFingerprint(name string, opts CreateOpts, changes []PlannedChange) string { + input := []any{"create", name, opts.Branch, plannedCommands(changes)} repos := make([]string, len(opts.Repos)) copy(repos, opts.Repos) @@ -448,8 +449,8 @@ func (s *Service) createFingerprint(name string, opts CreateOpts) string { // "is it dirty" flag. A boolean is too coarse for an irreversible operation: work // added to an already-dirty repo, or a commit made on a clean one, would leave the // flag unchanged and let a reviewed plan destroy work nobody agreed to lose. -func (s *Service) deleteFingerprint(ws *models.Workspace) string { - input := []any{"delete", ws.Name, ws.Path} +func (s *Service) deleteFingerprint(ws *models.Workspace, changes []PlannedChange) string { + input := []any{"delete", ws.Name, ws.Path, plannedCommands(changes)} repos := make([]models.RepoWorktree, len(ws.Repos)) copy(repos, ws.Repos) @@ -474,6 +475,24 @@ func (s *Service) deleteFingerprint(ws *models.Workspace) string { return hashOf(input) } +// plannedCommands lists the shell commands a plan would execute, in order. +// +// These are the part of a plan a reviewer most needs to have approved: everything +// else describes git operations Grove controls, while these are arbitrary code from +// a repo's .grove.toml (and, once blueprints land, from a shared or remote file). +// Including them in the fingerprint is what makes approval binding — otherwise a +// plan can display one command and apply another, and the review means nothing. +func plannedCommands(changes []PlannedChange) []string { + var cmds []string + for _, c := range changes { + switch c.Action { + case ActionRunSetupHook, ActionRunTeardownHook: + cmds = append(cmds, c.Action+" "+c.Repo+": "+c.Detail) + } + } + return cmds +} + func hashOf(v any) string { data, err := json.Marshal(v) if err != nil { diff --git a/internal/workspace/plan_test.go b/internal/workspace/plan_test.go index e2b8f94..72cd75a 100644 --- a/internal/workspace/plan_test.go +++ b/internal/workspace/plan_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/nicksenap/grove/internal/gitops" "github.com/nicksenap/grove/internal/machine" "github.com/nicksenap/grove/internal/models" ) @@ -569,3 +570,108 @@ func TestApplyRefusesWhenNewCommitAppearsAfterPlan(t *testing.T) { t.Error("a refused apply must not have deleted the workspace") } } + +// --------------------------------------------------------------------------- +// Approval binds to the commands the plan displayed +// --------------------------------------------------------------------------- + +// A plan lists the setup commands it will run, which is the part a reviewer most +// needs to have approved: everything else is git work Grove controls, while these +// are arbitrary code from a repo's .grove.toml. If the command can change between +// review and apply, the review means nothing. +func TestApplyRefusesWhenSetupCommandChangedAfterPlan(t *testing.T) { + env := setupTestEnv(t) + repoPath := env.createRepo("api") + writeGroveConfig(t, env, repoPath, `setup = "touch APPROVED"`) + + plan, err := env.svc.PlanCreate("approved", CreateOpts{ + Branch: "feat/approved", + Repos: []string{"api"}, + RepoMap: env.repoMap, + Cfg: env.cfg, + }, "test") + if err != nil { + t.Fatalf("plan: %v", err) + } + + // The plan must show what it intends to run. + var shown []string + for _, c := range plan.Changes { + if c.Action == ActionRunSetupHook { + shown = append(shown, c.Detail) + } + } + if len(shown) != 1 || shown[0] != "touch APPROVED" { + t.Fatalf("plan should display the setup command, got %v", shown) + } + + // Someone edits the command after the plan was reviewed. + writeGroveConfig(t, env, repoPath, `setup = "touch UNAPPROVED"`) + + if _, err := env.svc.Apply(plan, "test"); machine.CodeFor(err) != machine.CodeStateChanged { + t.Fatalf("apply error = %v (%s), want %s", err, machine.CodeFor(err), machine.CodeStateChanged) + } + if ws, _ := env.svc.State.GetWorkspace("approved"); ws != nil { + t.Error("a refused apply must not have created the workspace") + } + if _, err := os.Stat(filepath.Join(env.wsDir, "approved", "api", "UNAPPROVED")); err == nil { + t.Error("the unapproved command must not have run") + } +} + +// The same guarantee for teardown commands, which delete plans display and run. +func TestApplyRefusesWhenTeardownCommandChangedAfterPlan(t *testing.T) { + env := setupTestEnv(t) + repoPath := env.createRepo("api") + writeGroveConfig(t, env, repoPath, `teardown = "echo approved"`) + env.svc.Create("teardown-ws", "feat/teardown", []string{"api"}, env.repoMap, env.cfg) + + plan, err := env.svc.PlanDelete("teardown-ws", "test") + if err != nil { + t.Fatalf("plan: %v", err) + } + + writeGroveConfig(t, env, repoPath, `teardown = "echo unapproved"`) + + if _, err := env.svc.Apply(plan, "test"); machine.CodeFor(err) != machine.CodeStateChanged { + t.Fatalf("apply error = %v (%s), want %s", err, machine.CodeFor(err), machine.CodeStateChanged) + } + if ws, _ := env.svc.State.GetWorkspace("teardown-ws"); ws == nil { + t.Error("a refused apply must not have deleted the workspace") + } +} + +// An unchanged command must still apply, or the guard would make plans unusable. +func TestApplySucceedsWhenSetupCommandUnchanged(t *testing.T) { + env := setupTestEnv(t) + repoPath := env.createRepo("api") + writeGroveConfig(t, env, repoPath, `setup = "touch APPROVED"`) + + plan, err := env.svc.PlanCreate("stable", CreateOpts{ + Branch: "feat/stable", + Repos: []string{"api"}, + RepoMap: env.repoMap, + Cfg: env.cfg, + }, "test") + if err != nil { + t.Fatalf("plan: %v", err) + } + + if _, err := env.svc.Apply(plan, "test"); err != nil { + t.Fatalf("apply: %v", err) + } + if _, err := os.Stat(filepath.Join(env.wsDir, "stable", "api", "APPROVED")); err != nil { + t.Errorf("the approved setup command should have run: %v", err) + } +} + +// writeGroveConfig writes a repo's .grove.toml and drops the read cache, since +// production reads it once per process and tests need to simulate an edit between +// two separate gw invocations. +func writeGroveConfig(t *testing.T, env *testEnv, repoPath, contents string) { + t.Helper() + if err := os.WriteFile(filepath.Join(repoPath, ".grove.toml"), []byte(contents+"\n"), 0o644); err != nil { + t.Fatalf("write .grove.toml: %v", err) + } + gitops.ClearGroveConfigCache() +} From 9c8c9493b1f54c9407ef1d5510069d3085781ca5 Mon Sep 17 00:00:00 2001 From: Nick Song Date: Fri, 31 Jul 2026 21:05:45 +0200 Subject: [PATCH 21/21] Add a Prompter seam so interactive flows can be tested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interactive paths in create were the last 0%-coverage code in the agent surface, and not because nobody wrote tests: they need a terminal, so a non-interactive suite could not reach them at all. Choosing a preset, taking the "pick manually" escape hatch, being offered a preset save and declining it, the branch prompt defaulting to the workspace name — all unverified. Prompter is one interface for every interaction that needs a human: Interactive, PickOne, PickMany, Confirm, Prompt. All 25 call sites across 11 files in cmd/ now go through it rather than reaching into picker and console directly, so "asks a human" has one definition instead of two packages' worth. The production implementation is a pure delegation holding no logic, so substituting it in a test cannot change what the code under test does. Machine-mode enforcement stays in picker and console, where a caller that bypasses this seam still cannot block on input. The test double is strict: an unscripted prompt fails the test rather than returning a zero value, because a silent default would let a flow take a branch nobody wrote a case for and still pass. It answers by prompt substring rather than call order, records what was asked, and rejects a scripted answer that is not among the choices actually offered — a test that "picks" an option the user could never see is testing nothing. That strictness immediately caught a wrong assumption of mine: I wrote a test asserting a sole workspace requires no prompt, which is picker's auto-select behavior, not the cmd layer's. The seam stops at the cmd boundary deliberately, so that shortcut stays picker's responsibility (and its own tests); the test now asserts what the cmd layer offers instead. Coverage of the previously unreachable paths: reposInteractively 100%, pickPreset 87%, offerPresetSave 86%, resolveCreateBranch 89%, pickWorkspaceName/Names 75%. Behavior is unchanged: the 19-invocation create baseline is still byte-identical to the pre-refactor binary, and e2e passes 237 assertions. --- cmd/addrepo.go | 3 +- cmd/bug_report.go | 2 +- cmd/create.go | 17 ++-- cmd/create_test.go | 199 +++++++++++++++++++++++++++++++++++++++++++++ cmd/delete.go | 2 +- cmd/dirs.go | 3 +- cmd/go_cmd.go | 7 +- cmd/preset.go | 9 +- cmd/prompt.go | 64 +++++++++++++++ cmd/prompt_test.go | 152 ++++++++++++++++++++++++++++++++++ cmd/removerepo.go | 6 +- cmd/select.go | 5 +- cmd/select_test.go | 75 +++++++++++++++++ cmd/wizard.go | 10 +-- 14 files changed, 518 insertions(+), 36 deletions(-) create mode 100644 cmd/prompt.go create mode 100644 cmd/prompt_test.go diff --git a/cmd/addrepo.go b/cmd/addrepo.go index 1c78339..fdcbb0c 100644 --- a/cmd/addrepo.go +++ b/cmd/addrepo.go @@ -8,7 +8,6 @@ import ( "github.com/nicksenap/grove/internal/discover" "github.com/nicksenap/grove/internal/gitops" "github.com/nicksenap/grove/internal/machine" - "github.com/nicksenap/grove/internal/picker" "github.com/nicksenap/grove/internal/state" "github.com/nicksenap/grove/internal/workspace" "github.com/spf13/cobra" @@ -88,7 +87,7 @@ var addRepoCmd = &cobra.Command{ "all discovered repos are already in the workspace — nothing to add"). WithActions(machine.NextAction("Discover more repo directories", "gw add-dir "))) } - selected, err := picker.PickMany("Select repos to add:", choices) + selected, err := prompter.PickMany("Select repos to add:", choices) if err != nil { exitOnPickerErr(err) } diff --git a/cmd/bug_report.go b/cmd/bug_report.go index 7aa60bc..4666988 100644 --- a/cmd/bug_report.go +++ b/cmd/bug_report.go @@ -31,7 +31,7 @@ Use --print to output the report to stdout instead of opening a browser.`, Run: func(cmd *cobra.Command, args []string) { report := collectReport() - if bugReportPrint || !console.IsTerminal(os.Stdin) { + if bugReportPrint || !prompter.Interactive() { fmt.Println(report) return } diff --git a/cmd/create.go b/cmd/create.go index 12238a6..12d5109 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -13,7 +13,6 @@ import ( "github.com/nicksenap/grove/internal/lifecycle" "github.com/nicksenap/grove/internal/machine" "github.com/nicksenap/grove/internal/models" - "github.com/nicksenap/grove/internal/picker" "github.com/nicksenap/grove/internal/state" "github.com/nicksenap/grove/internal/workspace" "github.com/spf13/cobra" @@ -171,7 +170,7 @@ func reposInteractively(cfg *models.Config, repos []discover.Repo) []string { } func pickRepos(repoChoices []string) []string { - selected, err := picker.PickMany("Select repos for workspace:", repoChoices) + selected, err := prompter.PickMany("Select repos for workspace:", repoChoices) if err != nil { exitOnPickerErr(err) } @@ -189,7 +188,7 @@ func pickPreset(cfg *models.Config) ([]string, bool) { } choices = append(choices, pickManuallyChoice) - choice, err := picker.PickOne("Select repos from:", choices) + choice, err := prompter.PickOne("Select repos from:", choices) if err != nil { exitOnPickerErr(err) } @@ -209,14 +208,14 @@ func pickPreset(cfg *models.Config) ([]string, bool) { // create can skip the picker. Only worth asking for a real subset, and only when // there is a human to ask. func offerPresetSave(cfg *models.Config, selected []string, totalRepos int) { - if !console.IsTerminal(os.Stdin) || len(selected) >= totalRepos { + if !prompter.Interactive() || len(selected) >= totalRepos { return } - if !console.Confirm("Save this selection as a preset?", false) { + if !prompter.Confirm("Save this selection as a preset?", false) { return } - presetName := console.Prompt("Preset name") + presetName := prompter.Prompt("Preset name", "") if presetName == "" { return } @@ -258,8 +257,8 @@ func resolveCreateBranch(name string) string { requireArgs("--branch", "gw create "+name+" -b feat/x --format json") branch := "" - if console.IsTerminal(os.Stdin) { - branch = console.PromptDefault("Branch name", name) + if prompter.Interactive() { + branch = prompter.Prompt("Branch name", name) } if branch == "" { fail(machine.Errorf(machine.CodeUsage, "branch is required"). @@ -334,7 +333,7 @@ func replaceCurrentWorkspace(name string) string { // destructive intent be explicit rather than inferred from a skipped prompt. if !createForce { requireArgs("--force (with --replace)", "gw create "+name+" -b --replace --force --format json") - if !console.Confirm("Delete workspace "+currentWs.Name+" and replace with "+name+"?", false) { + if !prompter.Confirm("Delete workspace "+currentWs.Name+" and replace with "+name+"?", false) { os.Exit(0) } } diff --git a/cmd/create_test.go b/cmd/create_test.go index 6e1dc39..77f1eca 100644 --- a/cmd/create_test.go +++ b/cmd/create_test.go @@ -1,13 +1,25 @@ package cmd import ( + "os" + "path/filepath" "testing" + "github.com/nicksenap/grove/internal/config" "github.com/nicksenap/grove/internal/discover" "github.com/nicksenap/grove/internal/models" "github.com/nicksenap/grove/internal/workspace" ) +// withConfigPath points config.Save at a temp file so a test can exercise a flow +// that persists configuration without touching the real one. +func withConfigPath(t *testing.T, path string) func() { + t.Helper() + original := config.ConfigPath + config.ConfigPath = path + return func() { config.ConfigPath = original } +} + func TestDeriveName(t *testing.T) { tests := []struct { in, want string @@ -199,3 +211,190 @@ func TestReplaceCurrentWorkspaceNoopWithoutFlag(t *testing.T) { t.Errorf("got %q, want empty", got) } } + +// --------------------------------------------------------------------------- +// Interactive repo selection +// --------------------------------------------------------------------------- +// +// These paths were untestable before the Prompter seam: they need a terminal, so a +// non-interactive suite could only leave them uncovered. + +func TestReposInteractivelyPicksAPreset(t *testing.T) { + resetCreateFlags(t) + p := newScriptedPrompter(t) + p.picks["Select repos from"] = "backend (api, worker)" + withPrompter(t, p) + + cfg := &models.Config{Presets: map[string]models.Preset{ + "backend": {Repos: []string{"api", "worker"}}, + }} + repos := []discover.Repo{{Name: "api"}, {Name: "web"}, {Name: "worker"}} + + got := reposInteractively(cfg, repos) + if len(got) != 2 || got[0] != "api" || got[1] != "worker" { + t.Errorf("got %v, want the preset's repos [api worker]", got) + } + if p.wasAsked("Select repos for workspace") { + t.Errorf("choosing a preset should not also ask for individual repos: %s", p.askedList()) + } +} + +// The escape hatch has to actually escape: choosing it must fall through to the +// repo list rather than returning an empty selection. +func TestReposInteractivelyFallsThroughToManualSelection(t *testing.T) { + resetCreateFlags(t) + p := newScriptedPrompter(t) + p.picks["Select repos from"] = pickManuallyChoice + p.multi["Select repos for workspace"] = []string{"web"} + withPrompter(t, p) + + cfg := &models.Config{Presets: map[string]models.Preset{ + "backend": {Repos: []string{"api", "worker"}}, + }} + repos := []discover.Repo{{Name: "api"}, {Name: "web"}, {Name: "worker"}} + + got := reposInteractively(cfg, repos) + if len(got) != 1 || got[0] != "web" { + t.Errorf("got %v, want the manual selection [web]", got) + } +} + +// With no presets configured there is nothing to offer, so the preset menu must be +// skipped entirely rather than shown empty. +func TestReposInteractivelySkipsPresetMenuWhenNoneExist(t *testing.T) { + resetCreateFlags(t) + p := newScriptedPrompter(t) + p.multi["Select repos for workspace"] = []string{"api", "web"} + p.confirms["Save this selection as a preset"] = false + withPrompter(t, p) + + cfg := &models.Config{} + repos := []discover.Repo{{Name: "api"}, {Name: "web"}, {Name: "worker"}} + + got := reposInteractively(cfg, repos) + if len(got) != 2 { + t.Fatalf("got %v, want two repos", got) + } + if p.wasAsked("Select repos from") { + t.Errorf("no presets exist, so no preset menu should appear: %s", p.askedList()) + } +} + +// --------------------------------------------------------------------------- +// Offering to save a preset +// --------------------------------------------------------------------------- + +func TestOfferPresetSaveWritesTheConfig(t *testing.T) { + resetCreateFlags(t) + dir := t.TempDir() + restore := withConfigPath(t, filepath.Join(dir, "config.toml")) + defer restore() + + p := newScriptedPrompter(t) + p.confirms["Save this selection as a preset"] = true + p.inputs["Preset name"] = "backend" + withPrompter(t, p) + + cfg := &models.Config{RepoDirs: []string{dir}, WorkspaceDir: dir} + offerPresetSave(cfg, []string{"api", "worker"}, 3) + + preset, ok := cfg.Presets["backend"] + if !ok { + t.Fatalf("preset not saved: %+v", cfg.Presets) + } + if len(preset.Repos) != 2 || preset.Repos[0] != "api" { + t.Errorf("preset repos = %v, want [api worker]", preset.Repos) + } + if _, err := os.Stat(filepath.Join(dir, "config.toml")); err != nil { + t.Errorf("config should have been written: %v", err) + } +} + +func TestOfferPresetSaveDeclined(t *testing.T) { + resetCreateFlags(t) + p := newScriptedPrompter(t) + p.confirms["Save this selection as a preset"] = false + withPrompter(t, p) + + cfg := &models.Config{} + offerPresetSave(cfg, []string{"api"}, 3) + if len(cfg.Presets) != 0 { + t.Errorf("declining must not save anything, got %+v", cfg.Presets) + } +} + +// An empty name is a change of mind, not a preset called "". +func TestOfferPresetSaveEmptyName(t *testing.T) { + resetCreateFlags(t) + p := newScriptedPrompter(t) + p.confirms["Save this selection as a preset"] = true + p.inputs["Preset name"] = "" + withPrompter(t, p) + + cfg := &models.Config{} + offerPresetSave(cfg, []string{"api"}, 3) + if len(cfg.Presets) != 0 { + t.Errorf("an empty name must not create a preset, got %+v", cfg.Presets) + } +} + +// Saving the full set as a preset is pointless, and asking is noise. +func TestOfferPresetSaveNotOfferedForEverything(t *testing.T) { + resetCreateFlags(t) + p := newScriptedPrompter(t) + withPrompter(t, p) + + cfg := &models.Config{} + offerPresetSave(cfg, []string{"api", "web", "worker"}, 3) + if p.wasAsked("Save this selection") { + t.Errorf("selecting every repo should not prompt to save a preset: %s", p.askedList()) + } +} + +// No human, no question — and no config write. +func TestOfferPresetSaveSkippedWhenNotInteractive(t *testing.T) { + resetCreateFlags(t) + p := newScriptedPrompter(t) + p.interactive = false + withPrompter(t, p) + + cfg := &models.Config{} + offerPresetSave(cfg, []string{"api"}, 3) + if len(p.asked) != 0 { + t.Errorf("nothing should be asked without a terminal: %s", p.askedList()) + } +} + +// --------------------------------------------------------------------------- +// Branch prompting +// --------------------------------------------------------------------------- + +// The branch prompt defaults to the workspace name, which is why the name argument +// is read before the branch is resolved. +func TestResolveCreateBranchPromptsWithNameDefault(t *testing.T) { + resetCreateFlags(t) + p := newScriptedPrompter(t) + p.inputs["Branch name"] = "feat/from-prompt" + withPrompter(t, p) + + if got := resolveCreateBranch("my-workspace"); got != "feat/from-prompt" { + t.Errorf("got %q, want the prompted branch", got) + } + if !p.wasAsked("Branch name") { + t.Errorf("expected a branch prompt: %s", p.askedList()) + } +} + +func TestResolveCreateBranchSkipsPromptWhenFlagGiven(t *testing.T) { + resetCreateFlags(t) + createBranch = "feat/flag" + p := newScriptedPrompter(t) + withPrompter(t, p) + + if got := resolveCreateBranch("name"); got != "feat/flag" { + t.Errorf("got %q, want feat/flag", got) + } + if len(p.asked) != 0 { + t.Errorf("an explicit --branch must not prompt: %s", p.askedList()) + } +} diff --git a/cmd/delete.go b/cmd/delete.go index 9cd82d7..fcd2ce5 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -52,7 +52,7 @@ func doDelete(args []string, force bool) { // Deletion destroys worktrees and branches. In machine mode a skipped // prompt must never be read as consent, so --force is mandatory. requireArgs("--force", "gw delete "+names[0]+" --force --format json") - if !console.Confirm(fmt.Sprintf("Delete %s?", strings.Join(names, ", ")), false) { + if !prompter.Confirm(fmt.Sprintf("Delete %s?", strings.Join(names, ", ")), false) { return } } diff --git a/cmd/dirs.go b/cmd/dirs.go index 3a637b5..eb217c2 100644 --- a/cmd/dirs.go +++ b/cmd/dirs.go @@ -6,7 +6,6 @@ import ( "github.com/nicksenap/grove/internal/config" "github.com/nicksenap/grove/internal/console" "github.com/nicksenap/grove/internal/discover" - "github.com/nicksenap/grove/internal/picker" "github.com/spf13/cobra" ) @@ -52,7 +51,7 @@ var removeDirCmd = &cobra.Command{ if len(args) > 0 { absPath, _ = filepath.Abs(args[0]) } else { - selected, err := picker.PickOne("Select directory to remove:", cfg.RepoDirs) + selected, err := prompter.PickOne("Select directory to remove:", cfg.RepoDirs) if err != nil { exitOnPickerErr(err) } diff --git a/cmd/go_cmd.go b/cmd/go_cmd.go index ca4959a..404eb9f 100644 --- a/cmd/go_cmd.go +++ b/cmd/go_cmd.go @@ -13,7 +13,6 @@ import ( "github.com/nicksenap/grove/internal/console" "github.com/nicksenap/grove/internal/lifecycle" "github.com/nicksenap/grove/internal/logging" - "github.com/nicksenap/grove/internal/picker" "github.com/nicksenap/grove/internal/state" "github.com/spf13/cobra" ) @@ -134,7 +133,7 @@ func resolveGoBack() string { } // Multiple parent dirs — let user pick - picked, err := picker.PickOne("Select repo directory:", parentList) + picked, err := prompter.PickOne("Select repo directory:", parentList) if err != nil { exitOnPickerErr(err) } @@ -165,7 +164,7 @@ func pickWorkspaceForGo() string { choices = append(choices, backToRepos) } - picked, err := picker.PickOne("Select workspace", choices) + picked, err := prompter.PickOne("Select workspace", choices) if err != nil { exitOnPickerErr(err) } @@ -175,7 +174,7 @@ func pickWorkspaceForGo() string { if len(cfg.RepoDirs) == 1 { return cfg.RepoDirs[0] } else if len(cfg.RepoDirs) > 1 { - dir, err := picker.PickOne("Select repo directory", cfg.RepoDirs) + dir, err := prompter.PickOne("Select repo directory", cfg.RepoDirs) if err != nil { exitOnPickerErr(err) } diff --git a/cmd/preset.go b/cmd/preset.go index fe0f202..49c0092 100644 --- a/cmd/preset.go +++ b/cmd/preset.go @@ -10,7 +10,6 @@ import ( "github.com/nicksenap/grove/internal/discover" "github.com/nicksenap/grove/internal/machine" "github.com/nicksenap/grove/internal/models" - "github.com/nicksenap/grove/internal/picker" "github.com/spf13/cobra" ) @@ -36,8 +35,8 @@ var presetAddCmd = &cobra.Command{ name := "" if len(args) > 0 { name = args[0] - } else if console.IsTerminal(os.Stdin) { - name = console.Prompt("Preset name") + } else if prompter.Interactive() { + name = prompter.Prompt("Preset name", "") } if name == "" { exitError("preset name required") @@ -60,7 +59,7 @@ var presetAddCmd = &cobra.Command{ for i, r := range available { choices[i] = r.Name } - selected, err := picker.PickMany("Select repos for preset:", choices) + selected, err := prompter.PickMany("Select repos for preset:", choices) if err != nil { exitOnPickerErr(err) } @@ -164,7 +163,7 @@ var presetRemoveCmd = &cobra.Command{ for n := range cfg.Presets { names = append(names, n) } - selected, err := picker.PickOne("Select preset to remove:", names) + selected, err := prompter.PickOne("Select preset to remove:", names) if err != nil { exitOnPickerErr(err) } diff --git a/cmd/prompt.go b/cmd/prompt.go new file mode 100644 index 0000000..1faa7d6 --- /dev/null +++ b/cmd/prompt.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "os" + + "github.com/nicksenap/grove/internal/console" + "github.com/nicksenap/grove/internal/picker" +) + +// Prompter is the seam for every interaction that needs a human: menus, +// confirmations, free-text input, and the question of whether a human is there at +// all. +// +// Commands go through this instead of calling picker/console directly for two +// reasons. It makes interactive flows testable — a scripted Prompter can answer +// "pick the second preset, then decline the save" without a terminal, which is the +// only way to cover the branches that a non-interactive test suite otherwise +// cannot reach. And it puts every "asks a human" call behind one interface, so the +// rule that machine mode never prompts has one place to hold rather than being +// re-derived at each call site. +// +// Machine-mode enforcement still lives in picker and console, so a plugin or +// future caller that bypasses this seam cannot accidentally block on input. +type Prompter interface { + // Interactive reports whether there is a human to ask. + Interactive() bool + // PickOne shows a single-select menu. + PickOne(prompt string, choices []string) (string, error) + // PickMany shows a multi-select menu. + PickMany(prompt string, choices []string) ([]string, error) + // Confirm asks a yes/no question, returning defaultYes on empty input. + Confirm(prompt string, defaultYes bool) bool + // Prompt asks for text, returning defaultValue on empty input. Pass "" for no + // default. + Prompt(label, defaultValue string) string +} + +// prompter is the active Prompter. Tests replace it; production never does. +var prompter Prompter = terminalPrompter{} + +// terminalPrompter is the production implementation: a thin delegation to the +// picker and console packages, holding no logic of its own so that swapping it out +// in a test cannot change what the code under test does. +type terminalPrompter struct{} + +func (terminalPrompter) Interactive() bool { + return console.IsTerminal(os.Stdin) +} + +func (terminalPrompter) PickOne(prompt string, choices []string) (string, error) { + return picker.PickOne(prompt, choices) +} + +func (terminalPrompter) PickMany(prompt string, choices []string) ([]string, error) { + return picker.PickMany(prompt, choices) +} + +func (terminalPrompter) Confirm(prompt string, defaultYes bool) bool { + return console.Confirm(prompt, defaultYes) +} + +func (terminalPrompter) Prompt(label, defaultValue string) string { + return console.PromptDefault(label, defaultValue) +} diff --git a/cmd/prompt_test.go b/cmd/prompt_test.go new file mode 100644 index 0000000..d3fae36 --- /dev/null +++ b/cmd/prompt_test.go @@ -0,0 +1,152 @@ +package cmd + +import ( + "fmt" + "strings" + "testing" +) + +// scriptedPrompter is a Prompter that answers from a script instead of a terminal, +// so interactive flows can be tested without one. +// +// It is strict on purpose: an unscripted question fails the test rather than +// returning a zero value. A silent default would let a flow take a branch nobody +// wrote a case for and still pass. +type scriptedPrompter struct { + t *testing.T + + interactive bool + // Answers keyed by prompt substring, so a test says what it is answering + // rather than depending on call order. + picks map[string]string + multi map[string][]string + confirms map[string]bool + inputs map[string]string + // errs forces a failure from a specific prompt (e.g. picker.ErrCancelled). + errs map[string]error + + // asked records every prompt shown, in order, so a test can assert that a + // question was or was not put to the user. + asked []string + // offered records the choices presented for each prompt, so a test can assert + // what the user was actually given to choose from. + offered map[string][]string +} + +func newScriptedPrompter(t *testing.T) *scriptedPrompter { + return &scriptedPrompter{ + t: t, + interactive: true, + picks: map[string]string{}, + multi: map[string][]string{}, + confirms: map[string]bool{}, + inputs: map[string]string{}, + errs: map[string]error{}, + offered: map[string][]string{}, + } +} + +// withPrompter installs a prompter for the duration of a test. +func withPrompter(t *testing.T, p Prompter) { + t.Helper() + original := prompter + prompter = p + t.Cleanup(func() { prompter = original }) +} + +func (s *scriptedPrompter) Interactive() bool { return s.interactive } + +// match finds the scripted answer whose key is a substring of the prompt. +func match[T any](s *scriptedPrompter, kind, prompt string, table map[string]T) (T, bool) { + s.asked = append(s.asked, prompt) + for key, value := range table { + if strings.Contains(prompt, key) { + return value, true + } + } + var zero T + return zero, false +} + +func (s *scriptedPrompter) PickOne(prompt string, choices []string) (string, error) { + s.offered[prompt] = choices + if err, ok := match(s, "err", prompt, s.errs); ok { + return "", err + } + answer, ok := match(s, "pick", prompt, s.picks) + if !ok { + s.t.Fatalf("unscripted PickOne(%q) with choices %v", prompt, choices) + } + // The script names an answer; it must be one the user could actually choose. + for _, c := range choices { + if c == answer { + return answer, nil + } + } + s.t.Fatalf("scripted answer %q is not among the offered choices %v for %q", answer, choices, prompt) + return "", nil +} + +func (s *scriptedPrompter) PickMany(prompt string, choices []string) ([]string, error) { + s.offered[prompt] = choices + if err, ok := match(s, "err", prompt, s.errs); ok { + return nil, err + } + answer, ok := match(s, "multi", prompt, s.multi) + if !ok { + s.t.Fatalf("unscripted PickMany(%q) with choices %v", prompt, choices) + } + for _, a := range answer { + found := false + for _, c := range choices { + if c == a { + found = true + break + } + } + if !found { + s.t.Fatalf("scripted answer %q is not among the offered choices %v for %q", a, choices, prompt) + } + } + return answer, nil +} + +func (s *scriptedPrompter) Confirm(prompt string, defaultYes bool) bool { + answer, ok := match(s, "confirm", prompt, s.confirms) + if !ok { + s.t.Fatalf("unscripted Confirm(%q)", prompt) + } + return answer +} + +func (s *scriptedPrompter) Prompt(label, defaultValue string) string { + answer, ok := match(s, "input", label, s.inputs) + if !ok { + s.t.Fatalf("unscripted Prompt(%q)", label) + } + return answer +} + +// wasAsked reports whether any prompt contained the given substring. +func (s *scriptedPrompter) wasAsked(substr string) bool { + for _, prompt := range s.asked { + if strings.Contains(prompt, substr) { + return true + } + } + return false +} + +// choicesFor returns the choices offered for the prompt containing substr. +func (s *scriptedPrompter) choicesFor(substr string) []string { + for prompt, choices := range s.offered { + if strings.Contains(prompt, substr) { + return choices + } + } + return nil +} + +func (s *scriptedPrompter) askedList() string { + return fmt.Sprintf("%v", s.asked) +} diff --git a/cmd/removerepo.go b/cmd/removerepo.go index ad03dbf..f7d8013 100644 --- a/cmd/removerepo.go +++ b/cmd/removerepo.go @@ -4,9 +4,7 @@ import ( "fmt" "strings" - "github.com/nicksenap/grove/internal/console" "github.com/nicksenap/grove/internal/machine" - "github.com/nicksenap/grove/internal/picker" "github.com/nicksenap/grove/internal/state" "github.com/nicksenap/grove/internal/workspace" "github.com/spf13/cobra" @@ -45,7 +43,7 @@ var removeRepoCmd = &cobra.Command{ fail(machine.Errorf(machine.CodeRepoNotFound, "workspace %s has no repos", wsName)) } choices := ws.RepoNames() - selected, err := picker.PickMany("Select repos to remove:", choices) + selected, err := prompter.PickMany("Select repos to remove:", choices) if err != nil { exitOnPickerErr(err) } @@ -57,7 +55,7 @@ var removeRepoCmd = &cobra.Command{ // destructive intent to be explicit instead of assumed from a prompt // it is not allowed to show. requireArgs("--force", "gw remove-repo "+wsName+" -r --force --format json") - if !console.Confirm(fmt.Sprintf("Remove %s from %s?", strings.Join(repoNames, ", "), wsName), false) { + if !prompter.Confirm(fmt.Sprintf("Remove %s from %s?", strings.Join(repoNames, ", "), wsName), false) { return } } diff --git a/cmd/select.go b/cmd/select.go index 5dd9685..b77edf5 100644 --- a/cmd/select.go +++ b/cmd/select.go @@ -4,7 +4,6 @@ import ( "strings" "github.com/nicksenap/grove/internal/machine" - "github.com/nicksenap/grove/internal/picker" "github.com/nicksenap/grove/internal/state" ) @@ -32,7 +31,7 @@ func pickWorkspaceName(prompt string) string { choices[i] = ws.Name } - selected, err := picker.PickOne(prompt, choices) + selected, err := prompter.PickOne(prompt, choices) if err != nil { exitOnPickerErr(err) } @@ -54,7 +53,7 @@ func pickWorkspaceNames(prompt string) []string { choices[i] = ws.Name } - selected, err := picker.PickMany(prompt, choices) + selected, err := prompter.PickMany(prompt, choices) if err != nil { exitOnPickerErr(err) } diff --git a/cmd/select_test.go b/cmd/select_test.go index 956b181..2bc2ff9 100644 --- a/cmd/select_test.go +++ b/cmd/select_test.go @@ -1,9 +1,14 @@ package cmd import ( + "encoding/json" + "os" + "path/filepath" "testing" + "github.com/nicksenap/grove/internal/config" "github.com/nicksenap/grove/internal/machine" + "github.com/nicksenap/grove/internal/models" ) func TestParseRepoList(t *testing.T) { @@ -53,3 +58,73 @@ func TestNoWorkspacesErrIsOneClassifiedError(t *testing.T) { t.Error("the error should tell the caller how to proceed") } } + +// --------------------------------------------------------------------------- +// Interactive workspace selection +// --------------------------------------------------------------------------- + +// withGroveDir points state lookups at a temp dir holding the given workspaces. +func withGroveDir(t *testing.T, workspaces []models.Workspace) { + t.Helper() + dir := t.TempDir() + data, err := json.MarshalIndent(workspaces, "", " ") + if err != nil { + t.Fatalf("marshal state: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "state.json"), data, 0o644); err != nil { + t.Fatalf("write state: %v", err) + } + + original := config.GroveDir + config.GroveDir = dir + t.Cleanup(func() { config.GroveDir = original }) +} + +func TestPickWorkspaceNameOffersEveryWorkspace(t *testing.T) { + withGroveDir(t, []models.Workspace{ + {Name: "alpha", Branch: "feat/a"}, + {Name: "beta", Branch: "feat/b"}, + }) + + p := newScriptedPrompter(t) + p.picks["Select workspace"] = "beta" + withPrompter(t, p) + + if got := pickWorkspaceName("Select workspace:"); got != "beta" { + t.Errorf("got %q, want beta", got) + } +} + +func TestPickWorkspaceNamesSupportsMultiSelect(t *testing.T) { + withGroveDir(t, []models.Workspace{ + {Name: "alpha"}, {Name: "beta"}, {Name: "gamma"}, + }) + + p := newScriptedPrompter(t) + p.multi["Select workspaces"] = []string{"alpha", "gamma"} + withPrompter(t, p) + + got := pickWorkspaceNames("Select workspaces to delete:") + if len(got) != 2 || got[0] != "alpha" || got[1] != "gamma" { + t.Errorf("got %v, want [alpha gamma]", got) + } +} + +// The cmd layer offers every workspace and nothing more; whether a sole choice is +// auto-selected is picker's own behavior, covered by picker's tests. The seam stops +// at this boundary deliberately — reimplementing that shortcut here would be a +// second definition of it. +func TestPickWorkspaceNameOffersTheOnlyWorkspace(t *testing.T) { + withGroveDir(t, []models.Workspace{{Name: "only"}}) + + p := newScriptedPrompter(t) + p.picks["Select workspace"] = "only" + withPrompter(t, p) + + if got := pickWorkspaceName("Select workspace:"); got != "only" { + t.Errorf("got %q, want only", got) + } + if offered := p.choicesFor("Select workspace"); len(offered) != 1 || offered[0] != "only" { + t.Errorf("offered %v, want exactly [only]", offered) + } +} diff --git a/cmd/wizard.go b/cmd/wizard.go index 40e6607..91fd2dc 100644 --- a/cmd/wizard.go +++ b/cmd/wizard.go @@ -39,7 +39,7 @@ var wizardCmd = &cobra.Command{ claudeInstalled := claudeErr == nil if !claudeInstalled { - if console.Confirm("Claude Code detected. Install gw-claude plugin?", true) { + if prompter.Confirm("Claude Code detected. Install gw-claude plugin?", true) { if err := plugin.Install("nicksenap/gw-claude"); err != nil { console.Warningf("install failed: %s", err) } else { @@ -53,7 +53,7 @@ var wizardCmd = &cobra.Command{ if claudeInstalled { // Offer to configure hooks if _, ok := cfg.Hooks["post_create"]; !ok { - if console.Confirm("Configure Claude memory sync hooks?", true) { + if prompter.Confirm("Configure Claude memory sync hooks?", true) { if cfg.Hooks == nil { cfg.Hooks = make(map[string]models.Hook) } @@ -67,7 +67,7 @@ var wizardCmd = &cobra.Command{ } // Offer to register Claude Code event hooks - if console.Confirm("Register Claude Code session tracking hooks?", true) { + if prompter.Confirm("Register Claude Code session tracking hooks?", true) { pluginPath, findErr := plugin.Find("claude") if findErr != nil { console.Warningf("cannot find gw-claude: %s", findErr) @@ -93,7 +93,7 @@ var wizardCmd = &cobra.Command{ zellijInstalled := zellijErr == nil if !zellijInstalled { - if console.Confirm("Zellij detected. Install gw-zellij plugin?", true) { + if prompter.Confirm("Zellij detected. Install gw-zellij plugin?", true) { if err := plugin.Install("nicksenap/gw-zellij"); err != nil { console.Warningf("install failed: %s", err) } else { @@ -106,7 +106,7 @@ var wizardCmd = &cobra.Command{ if zellijInstalled { if _, ok := cfg.Hooks["on_close"]; !ok { - if console.Confirm("Configure on_close hook for Zellij?", true) { + if prompter.Confirm("Configure on_close hook for Zellij?", true) { if cfg.Hooks == nil { cfg.Hooks = make(map[string]models.Hook) }