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/AGENTS.md b/AGENTS.md index 1660d7d..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,7 +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/mcp/** — MCP JSON-RPC server exposing workspace state to Codex. +- **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 476384b..5cfec59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,71 @@ # 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` 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), 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 + +- `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 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 + 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 + 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. + +### 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,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 ### Features diff --git a/CLAUDE.md b/CLAUDE.md index 9c6c003..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,7 +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/mcp/** — MCP JSON-RPC server exposing workspace state to Claude Code. +- **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/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/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/addrepo.go b/cmd/addrepo.go index 7605832..fdcbb0c 100644 --- a/cmd/addrepo.go +++ b/cmd/addrepo.go @@ -2,13 +2,12 @@ package cmd import ( "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/gitops" - "github.com/nicksenap/grove/internal/picker" + "github.com/nicksenap/grove/internal/machine" "github.com/nicksenap/grove/internal/state" "github.com/nicksenap/grove/internal/workspace" "github.com/spf13/cobra" @@ -34,22 +33,7 @@ var addRepoCmd = &cobra.Command{ } if wsName == "" { - 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:", choices) - if err != nil { - exitOnPickerErr(err) - } - wsName = selected + wsName = pickWorkspaceName("Select workspace:") } } @@ -59,10 +43,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) { @@ -102,18 +83,23 @@ 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) + selected, err := prompter.PickMany("Select repos to add:", choices) if err != nil { exitOnPickerErr(err) } 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/announce.go b/cmd/announce.go new file mode 100644 index 0000000..90b8ccd --- /dev/null +++ b/cmd/announce.go @@ -0,0 +1,236 @@ +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 _, name := range parseRepoList(reposFlag) { + 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/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/context.go b/cmd/context.go new file mode 100644 index 0000000..d15bfc5 --- /dev/null +++ b/cmd/context.go @@ -0,0 +1,132 @@ +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 + } + + fmt.Fprintf(os.Stdout, "Grove: %s\n", ctx.GroveVersion) + 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", shortenPath(ctx.Cwd)) + return + } + + ws := ctx.Workspace + 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) + } + 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 shortenPaths(paths []string) []string { + out := make([]string, len(paths)) + for i, p := range paths { + out[i] = shortenPath(p) + } + return out +} diff --git a/cmd/create.go b/cmd/create.go index 9e161c5..12d5109 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -11,8 +11,8 @@ 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" "github.com/nicksenap/grove/internal/workspace" "github.com/spf13/cobra" @@ -32,223 +32,367 @@ 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 { - exitError("Preset not found: " + createPreset) - } - repoNames = preset.Repos - } else if createAll { - for _, r := range repos { - repoNames = append(repoNames, r.Name) - } - } else if createRepos != "" { - repoNames = strings.Split(createRepos, ",") - for i := range repoNames { - repoNames[i] = strings.TrimSpace(repoNames[i]) - } - // 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 { - exitError("No repo_dirs configured — cannot clone remote repo") - } - console.Infof("Cloning %s ...", name) - clonedPath, repoName, err := gitops.Clone(name, cfg.RepoDirs[0]) - if err != nil { - exitError(err.Error()) - } - if existing, ok := repoMap[repoName]; ok && existing != clonedPath { - exitError("repo name conflict: " + repoName + " already exists locally at " + 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 { - exitError("Unknown repo: " + name + ". Available: " + strings.Join(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 == "" { - if console.IsTerminal(os.Stdin) { - branch = console.PromptDefault("Branch name", name) - } - if branch == "" { - exitError("Branch is required: --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 { - exitError("cannot determine working directory: " + err.Error()) - } - currentWs, _ := state.FindWorkspaceByPath(cwd) - if currentWs == nil { - exitError("--replace requires running from inside an existing workspace") - } - if currentWs.Name == name { - exitError("--replace would collide: new workspace name matches the current one (" + name + "). Pass a different NAME.") - } - if !createForce { - 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) { - exitError(err.Error()) - } - console.Warning(err.Error()) - } - if err := workspace.NewService().Delete(currentWs.Name); err != nil { - exitError("failed to delete current workspace: " + err.Error()) - } - replacedName = currentWs.Name - } + replacedName := replaceCurrentWorkspace(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, - } + result, err := workspace.NewService().CreateWithOpts(name, buildCreateOpts(cfg, branch, repoNames, repoMap)) + if err != nil { + failCreate(err, replacedName) } - opts := workspace.CreateOpts{ - Branch: branch, - Repos: repoNames, - RepoMap: repoMap, - Cfg: cfg, - Source: source, + result.Replaced = replacedName + + wsPath := filepath.Join(cfg.WorkspaceDir, name) + firePostCreateHook(name, wsPath, branch) + + machine.Emit(result, + machine.NextAction("Inspect repo state", "gw status "+name+" --format json"), + machine.NextAction("Run configured processes", "gw run "+name+" --format json"), + ) + }, +} + +// --------------------------------------------------------------------------- +// 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) } - if createTrack { - opts.BranchMode = workspace.BranchModeTrack + } + 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 := prompter.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 := prompter.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 !prompter.Interactive() || len(selected) >= totalRepos { + return + } + if !prompter.Confirm("Save this selection as a preset?", false) { + return + } + + presetName := prompter.Prompt("Preset name", "") + if presetName == "" { + return + } - if err := workspace.NewService().CreateWithOpts(name, opts); err != nil { - if replacedName != "" { - exitError("failed to create new workspace (old workspace " + replacedName + " was already deleted): " + err.Error()) - } - exitError(err.Error()) + 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)})) } + } +} - // 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 +// --------------------------------------------------------------------------- +// 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 prompter.Interactive() { + branch = prompter.Prompt("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 !prompter.Confirm("Delete workspace "+currentWs.Name+" and replace with "+name+"?", false) { + os.Exit(0) } - if err := lifecycle.Run("post_create", vars); err != nil && !errors.Is(err, lifecycle.ErrNoHook) { - if lifecycle.ShouldAbort(err) { - exitError(err.Error()) - } - console.Warning(err.Error()) + } + + 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() { diff --git a/cmd/create_test.go b/cmd/create_test.go index f25ab2b..77f1eca 100644 --- a/cmd/create_test.go +++ b/cmd/create_test.go @@ -1,11 +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 @@ -55,3 +69,332 @@ 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) + } +} + +// --------------------------------------------------------------------------- +// 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 d5fe144..fcd2ce5 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -7,7 +7,7 @@ import ( "github.com/nicksenap/grove/internal/console" "github.com/nicksenap/grove/internal/lifecycle" - "github.com/nicksenap/grove/internal/picker" + "github.com/nicksenap/grove/internal/machine" "github.com/nicksenap/grove/internal/state" "github.com/nicksenap/grove/internal/workspace" "github.com/spf13/cobra" @@ -44,31 +44,20 @@ func doDelete(args []string, force bool) { if len(args) > 0 { names = []string{args[0]} } else { - // Interactive multi-select - workspaces, err := state.Load() - if err != nil { - exitError(err.Error()) - } - if len(workspaces) == 0 { - exitError("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 + requireArgs("NAME", "gw delete --force --format json") + names = pickWorkspaceNames("Select workspaces to delete:") } if !force { - if !console.Confirm(fmt.Sprintf("Delete %s?", strings.Join(names, ", ")), false) { + // 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 !prompter.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 +65,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/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/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/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/list.go b/cmd/list.go index defbaee..952e7c9 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 } @@ -116,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() } @@ -131,15 +164,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 } @@ -148,32 +186,31 @@ 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() } + +// 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..e0b91da --- /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.CodeWorktreeExists, "api already has a worktree") + if got := machine.CodeFor(classifyCommandErr(err)); got != machine.CodeWorktreeExists { + t.Errorf("code = %s, want %s", got, machine.CodeWorktreeExists) + } +} + +// 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/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/plan.go b/cmd/plan.go new file mode 100644 index 0000000..9dca99a --- /dev/null +++ b/cmd/plan.go @@ -0,0 +1,248 @@ +package cmd + +import ( + "fmt" + "os" + + "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 != "": + return parseRepoList(planRepos) + 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 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/plugin.go b/cmd/plugin.go index e87ed84..390a318 100644 --- a/cmd/plugin.go +++ b/cmd/plugin.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/plugin" "github.com/spf13/cobra" ) @@ -52,7 +52,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 { @@ -66,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 } @@ -120,6 +124,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..49c0092 100644 --- a/cmd/preset.go +++ b/cmd/preset.go @@ -1,7 +1,6 @@ package cmd import ( - "encoding/json" "fmt" "os" "strings" @@ -9,8 +8,8 @@ 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" ) @@ -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") @@ -49,10 +48,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) @@ -63,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) } @@ -83,6 +79,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") @@ -93,11 +99,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 } @@ -117,19 +119,17 @@ 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 { - 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 } @@ -163,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) } @@ -184,7 +184,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/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 4bdffc2..f7d8013 100644 --- a/cmd/removerepo.go +++ b/cmd/removerepo.go @@ -4,8 +4,7 @@ import ( "fmt" "strings" - "github.com/nicksenap/grove/internal/console" - "github.com/nicksenap/grove/internal/picker" + "github.com/nicksenap/grove/internal/machine" "github.com/nicksenap/grove/internal/state" "github.com/nicksenap/grove/internal/workspace" "github.com/spf13/cobra" @@ -25,44 +24,26 @@ 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 { - exitError("No workspaces") - } - 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) 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) + selected, err := prompter.PickMany("Select repos to remove:", choices) if err != nil { exitOnPickerErr(err) } @@ -70,14 +51,21 @@ var removeRepoCmd = &cobra.Command{ } if !removeRepoForce { - if !console.Confirm(fmt.Sprintf("Remove %s from %s?", strings.Join(repoNames, ", "), wsName), false) { + // 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 !prompter.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/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/repos.go b/cmd/repos.go index a063d55..8c2ec36 100644 --- a/cmd/repos.go +++ b/cmd/repos.go @@ -1,14 +1,12 @@ 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 +28,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 +51,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 } @@ -61,19 +66,14 @@ 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() }, } 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 bfe3e62..aec81b9 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") @@ -47,6 +64,11 @@ func init() { // Register all subcommands rootCmd.AddCommand( initCmd, + contextCmd, + announceCmd, + announcementsCmd, + planCmd, + applyCmd, createCmd, listCmd, wsCmd, @@ -66,7 +88,6 @@ func init() { removeDirCmd, runCmd, exploreCmd, - mcpServeCmd, pluginCmd, wizardCmd, bugReportCmd, @@ -74,9 +95,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 { @@ -91,18 +119,35 @@ 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. @@ -136,16 +181,60 @@ func pluginArgs(name string) []string { return nil } -// exitError prints error to stderr and exits. +// 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. +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/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/select.go b/cmd/select.go new file mode 100644 index 0000000..b77edf5 --- /dev/null +++ b/cmd/select.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "strings" + + "github.com/nicksenap/grove/internal/machine" + "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 := prompter.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 := prompter.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..2bc2ff9 --- /dev/null +++ b/cmd/select_test.go @@ -0,0 +1,130 @@ +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) { + 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") + } +} + +// --------------------------------------------------------------------------- +// 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/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/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/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) } diff --git a/docs/agent-cli.md b/docs/agent-cli.md new file mode 100644 index 0000000..391cf2e --- /dev/null +++ b/docs/agent-cli.md @@ -0,0 +1,315 @@ +# 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": "STATE_CHANGED", + "message": "state changed since the plan was created, so it was not applied" + }, + "fix": "Re-plan and review the new plan before applying", + "next_actions": [ + { "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 | +| --- | --- | --- | +| `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. | +| `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. + +## 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, 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 +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 +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` +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 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 — +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 1a6d40e..027dcad 100644 --- a/docs/ai-tools.md +++ b/docs/ai-tools.md @@ -70,8 +70,45 @@ 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 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 +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/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 65ddc49..66ae8ee 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}" @@ -124,21 +227,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 +# 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 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)" -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,81 +1094,510 @@ 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 +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 + +if gw doctor 2>&1 | grep -q "mcp.json"; then + pass "doctor reports stale .mcp.json grove entry" +else + fail "doctor did not report stale .mcp.json" +fi -# Inline MCP smoke test (no Python dependency needed) -MCP_ERRORS=0 +gw doctor --fix > /dev/null 2>&1 || true -# Helper to send JSON-RPC and read response -mcp_test() { - local input="$1" - local expected_id="$2" +if jq -e '.mcpServers.grove' "${MCP_WS_DIR}/.mcp.json" > /dev/null 2>&1; then + fail "doctor --fix should remove the grove entry" +else + pass "doctor --fix removes the grove entry" +fi - # Send all messages and capture output - echo "$input" | timeout_cmd 10 gw mcp-serve --workspace mcp-ws 2>/dev/null || true -} +if jq -e '.mcpServers.keeper' "${MCP_WS_DIR}/.mcp.json" > /dev/null 2>&1; then + pass "doctor --fix preserves other MCP servers" +else + fail "doctor --fix should preserve unrelated MCP servers" +fi + +if gw mcp-serve --workspace mcp-ws > /dev/null 2>&1; then + fail "gw mcp-serve should no longer exist" +else + pass "gw mcp-serve is gone" +fi -# 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 -) +gw delete mcp-ws --force 2>&1 -MCP_OUT=$(echo "${MCP_INPUT}" | timeout_cmd 10 "${GW_BIN}" mcp-serve --workspace mcp-ws 2>/dev/null || true) +# --------------------------------------------------------------------------- +# 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") -# Check initialize response -if echo "${MCP_OUT}" | grep -q '"protocolVersion"'; then - pass "MCP initialize" +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 "MCP initialize failed" + fail "hook output disappeared entirely (should be on stderr)" fi -# Check ping response (id: 99) -if echo "${MCP_OUT}" | grep -q '"id":99'; then - pass "MCP ping" +# 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 "MCP ping failed" + 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 -# 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" +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 "MCP tools/list missing tools" + fail "plan should list one removal per repo: $(printf '%s' "${JSON_OUT}" | jq -c '[.result.changes[].action]')" fi -# Check announce returned "published" -if echo "${MCP_OUT}" | grep -q 'published'; then - pass "MCP announce tool works" +# 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 "MCP announce failed" + fail "refused apply destroyed uncommitted work" fi -# Check get_announcements returns empty (same workspace excluded) -if echo "${MCP_OUT}" | grep -q '\[\]'; then - pass "MCP get_announcements excludes own workspace" +# 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 "MCP get_announcements should return empty" + fail "fresh plan should warn: $(printf '%s' "${JSON_OUT}" | jq -c '.result.warnings')" fi -# Check list_workspaces returns workspace name -if echo "${MCP_OUT}" | grep -q 'mcp-ws'; then - pass "MCP list_workspaces returns current workspace" +# 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 "MCP list_workspaces missing workspace" + 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 -gw delete mcp-ws --force 2>&1 +# 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" +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 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" +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: 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 +# --------------------------------------------------------------------------- +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 > ${SCRATCH}/ann-publish.json 2>/dev/null) + +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 ${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 > ${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 ${SCRATCH}/ann-context.json)" +fi + +(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 ${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 > ${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 ${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 > ${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 ${SCRATCH}/ann-bad.json)" +fi + +gw delete ann-alpha --force 2>&1 +gw delete ann-beta --force 2>&1 # --------------------------------------------------------------------------- # Test: plugin system 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/announce/announce.go b/internal/announce/announce.go new file mode 100644 index 0000000..4f9dea6 --- /dev/null +++ b/internal/announce/announce.go @@ -0,0 +1,353 @@ +// 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" + "sort" + "strings" + "time" + + "github.com/nicksenap/grove/internal/gitops" +) + +// 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, + } + + // 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++ { + err := s.writeNew(a) + if err == nil { + break + } + if !os.IsExist(err) || attempt >= 5 { + return nil, fmt.Errorf("writing announcement: %w", err) + } + a.ID = newID(created) + } + + 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. + 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) { + filter := s.newFilter(opts) + + results := []Announcement{} + err := s.each(func(a Announcement, path string) { + if filter.matches(a) { + results = append(results, a) + } + }) + if err != nil { + return nil, err + } + + 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. +// +// 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 { + if os.IsNotExist(err) { + return nil + } + return err + } + + 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 { + continue + } + visit(a, path) + } + 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 +// 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[:]) +} + +// 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. +// +// 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 parsed := gitops.ParseRemoteName(repo); parsed != "" { + return strings.ToLower(parsed) + } + 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/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/gitops/gitops.go b/internal/gitops/gitops.go index a1ff639..4edd611 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...) @@ -281,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) @@ -290,56 +310,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/machine/errors.go b/internal/machine/errors.go new file mode 100644 index 0000000..e05d5f5 --- /dev/null +++ b/internal/machine/errors.go @@ -0,0 +1,181 @@ +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" + 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, + 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, + 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..1598f5d --- /dev/null +++ b/internal/machine/machine.go @@ -0,0 +1,249 @@ +// 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 +) + +// 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 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) { + applyEarlyFormat(args[i+1]) + } + case strings.HasPrefix(a, "--format="): + applyEarlyFormat(strings.TrimPrefix(a, "--format=")) + case strings.HasPrefix(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) { + 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 +} + +// --------------------------------------------------------------------------- +// 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)) +} + +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..eba906e --- /dev/null +++ b/internal/machine/machine_test.go @@ -0,0 +1,294 @@ +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(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) + 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(CodeWorktreeExists) { + t.Errorf("code = %v, want %s", body["code"], CodeWorktreeExists) + } + if body["message"] != "api already has a worktree for that branch" { + t.Errorf("message = %v", body["message"]) + } + if got["fix"] != "Use a different branch name, or remove the existing worktree" { + 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, + 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()) + } +} + +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) + } + } +} + +// 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/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..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,18 +231,3 @@ type DoctorIssue struct { Issue string `json:"issue"` 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/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) - } -} 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/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 new file mode 100644 index 0000000..5c4bee6 --- /dev/null +++ b/internal/workspace/context.go @@ -0,0 +1,208 @@ +package workspace + +import ( + "sort" + "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" + "github.com/nicksenap/grove/internal/state" +) + +// 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"` + + // 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"` +} + +// 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. 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"` + 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{}, + Announcements: []announce.Announcement{}, + } + + 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), + } + 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. +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) + out[idx] = RepoContext{ + RepoStatus: status, + SourceRepo: repo.SourceRepo, + Path: repo.WorktreePath, + Remote: gitops.RemoteURL(repo.WorktreePath, "origin"), + 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, 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 { + var best *models.Workspace + for i := range all { + if !state.PathContains(all[i].Path, path) { + continue + } + // 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 +} diff --git a/internal/workspace/context_test.go b/internal/workspace/context_test.go new file mode 100644 index 0000000..0bd8ea4 --- /dev/null +++ b/internal/workspace/context_test.go @@ -0,0 +1,298 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/nicksenap/grove/internal/announce" + "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) + } +} + +// --------------------------------------------------------------------------- +// 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/errors.go b/internal/workspace/errors.go new file mode 100644 index 0000000..e4dd96d --- /dev/null +++ b/internal/workspace/errors.go @@ -0,0 +1,66 @@ +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"), + ) +} + +// 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/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/plan.go b/internal/workspace/plan.go new file mode 100644 index 0000000..752dce1 --- /dev/null +++ b/internal/workspace/plan.go @@ -0,0 +1,674 @@ +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 { + changes, warnings := planRepoProvisioning(repoName, wsPath, opts) + plan.Changes = append(plan.Changes, changes...) + plan.Warnings = append(plan.Warnings, warnings...) + } + + plan.Fingerprint = s.createFingerprint(name, opts, plan.Changes) + 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, + 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 + } + + 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 +} + +// 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. +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 { + changes, warnings := planRepoDestruction(r) + plan.Changes = append(plan.Changes, changes...) + plan.Warnings = append(plan.Warnings, warnings...) + } + + 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, plan.Changes) + 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 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 + + 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)) + } + + 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 []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. +// 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) + } + // 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) + } + } + return nil +} + +// --------------------------------------------------------------------------- +// Fingerprints +// --------------------------------------------------------------------------- + +// 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) + 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 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, changes []PlannedChange) string { + input := []any{"delete", ws.Name, ws.Path, plannedCommands(changes)} + + 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, 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) +} + +// 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 { + 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..72cd75a --- /dev/null +++ b/internal/workspace/plan_test.go @@ -0,0 +1,677 @@ +package workspace + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/nicksenap/grove/internal/gitops" + "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)) + } +} + +// --------------------------------------------------------------------------- +// 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) + } +} + +// 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") + } +} + +// --------------------------------------------------------------------------- +// 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() +} 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/results.go b/internal/workspace/results.go new file mode 100644 index 0000000..353fd5d --- /dev/null +++ b/internal/workspace/results.go @@ -0,0 +1,102 @@ +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"` +} + +// 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..2c27b38 --- /dev/null +++ b/internal/workspace/results_test.go @@ -0,0 +1,234 @@ +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 FailedRepos(results[:1]) != nil { + t.Error("FailedRepos should be empty 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..bfada47 100644 --- a/internal/workspace/service.go +++ b/internal/workspace/service.go @@ -4,7 +4,9 @@ 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" "github.com/nicksenap/grove/internal/stats" ) @@ -13,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 } @@ -22,15 +25,22 @@ func NewService() *Service { return &Service{ State: state.NewStore(config.GroveDir), Stats: stats.NewTracker(config.GroveDir), + Announce: announce.NewStore(config.GroveDir), RunCmd: prodRunCmd, RunCmdSilent: prodRunCmdSilent, } } +// 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 2dabc43..f8cb846 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,37 +57,41 @@ 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 cfg := opts.Cfg - // Check duplicate - existing, err := s.State.GetWorkspace(name) - if err != nil { - return err - } - if existing != nil { - return fmt.Errorf("workspace %s already exists", name) + // 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 } 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 +103,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 nil, ErrRepoNotFound(repoName) } sourcePaths[i] = sourcePath } @@ -132,7 +137,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,15 +154,12 @@ 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 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) @@ -166,7 +168,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) { @@ -180,7 +198,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 @@ -190,7 +208,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, @@ -216,7 +234,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) } } } @@ -224,7 +242,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{ @@ -271,150 +289,96 @@ 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 { +// 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 fmt.Errorf("workspace %s not found", name) + return nil, ErrWorkspaceNotFound(name) } logging.Info("deleting workspace %q", name) - removeMCPConfig(*ws) // 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. @@ -424,7 +388,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) @@ -432,7 +396,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 @@ -478,14 +442,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 fmt.Errorf("workspace %s not found", wsName) + return nil, ErrWorkspaceNotFound(wsName) } existing := make(map[string]bool) @@ -493,55 +459,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 fmt.Errorf("repo %s not found", 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 fmt.Errorf("workspace %s not found", wsName) + return nil, ErrWorkspaceNotFound(wsName) } + result := &ReposChangeResult{Workspace: wsName} + type removeItem struct { name string repo *models.RepoWorktree @@ -549,49 +534,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) } @@ -601,28 +584,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 != "" { @@ -632,52 +624,102 @@ 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 fmt.Errorf("workspace %s not found", 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 +// 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"` + // 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. +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"` } -type repoStatusResult 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"` +// 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 } -func collectRepoStatus(r models.RepoWorktree) repoStatusResult { - rs := repoStatusResult{ +// 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, } @@ -705,6 +747,7 @@ func collectRepoStatus(r models.RepoWorktree) repoStatusResult { 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) @@ -770,29 +813,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) @@ -808,26 +865,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) @@ -838,13 +887,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 { @@ -872,7 +921,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 } @@ -961,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 { @@ -971,6 +1024,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 +1036,72 @@ 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. +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..149934a 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, @@ -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 != "" { @@ -467,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) } @@ -488,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") } @@ -609,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) } @@ -650,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) } @@ -660,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") } @@ -677,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) } @@ -699,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) } @@ -714,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") } @@ -730,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) } @@ -756,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) } @@ -784,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) } @@ -1037,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) } @@ -1063,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) } @@ -1086,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) } @@ -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) + + 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") + } +} - ws, _ := env.svc.State.GetWorkspace("rmcp-ws") - removeMCPConfig(*ws) +func TestCleanStaleMCPEntryIgnoresForeignGroveEntry(t *testing.T) { + env := setupTestEnv(t) + env.createRepo("api") + env.svc.Create("ext-ws", "feat/ext", []string{"api"}, env.repoMap, env.cfg) - // "keeper" should remain, "grove" should be gone - data, err := os.ReadFile(mcpPath) + 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 _, ok := servers["keeper"]; !ok { - t.Error("'keeper' should be preserved") + if len(issues) != 1 || fixed != 1 { + t.Fatalf("expected 1 fixed issue, got %d issues / %d fixed", len(issues), fixed) + } + if _, err := os.Stat(filepath.Join(wsPath, ".mcp.json")); !os.IsNotExist(err) { + t.Error("doctor --fix should remove the stale .mcp.json") } } @@ -1208,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) } @@ -1418,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) @@ -1466,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") } @@ -1530,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) } @@ -1604,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) } @@ -1633,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) } @@ -1652,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) } @@ -1662,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) } @@ -1717,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/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 d508b09..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 @@ -163,13 +176,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/`) @@ -190,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** @@ -310,7 +337,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/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 eebbf18..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 @@ -197,123 +197,36 @@ 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 -} +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 ``` -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 -} -``` +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`. -#### `get_workspace` +Every machine-mode response uses one versioned envelope with stable error codes +and semantic exit codes — see [Agent CLI contract](../docs/agent-cli.md). -Get details of a specific workspace. - -```json -{ - "jsonrpc": "2.0", - "method": "get_workspace", - "params": { "name": "feat-login" }, - "id": 2 -} -``` - -#### `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 -} -``` +### Migrating off the removed MCP server -#### `get_workspace_status` +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. -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 - -### Custom Claude Code Setup - -If Claude Code is not auto-configured with Grove's MCP server, add to `.mcp.json` in your project root: - -```json -{ - "mcpServers": { - "grove": { - "command": "gw", - "args": ["mcp-serve"] - } - } -} -``` +The `announce` / `get_announcements` tools became `gw announce` / +`gw announcements`; their SQLite database was replaced by a lock-free directory of +JSON files. --- @@ -332,7 +245,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 +432,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/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 7e6bd94..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; `internal/mcp/` serves workspace state to Claude Code +- **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 @@ -168,7 +174,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 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. 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