diff --git a/.claude/skills/specsync/SKILL.md b/.claude/skills/specsync/SKILL.md index d9702ae..4916d9f 100644 --- a/.claude/skills/specsync/SKILL.md +++ b/.claude/skills/specsync/SKILL.md @@ -11,6 +11,7 @@ specsync handles **tracker sync** (OpenSpec ↔ GitHub/Beads). ## Basic Workflow +0. **Check before you start**: `specsync topology -json` shows which changes already have an issue, branch, and worktree — so you don't duplicate work another agent has started. 1. **Create or pull**: `specsync pull -issue N` or create a change in `openspec/changes//` 2. **Implement**: Write code, update `tasks.md` 3. **Sync**: `specsync -dry-run -change ` then `specsync -change ` diff --git a/README.md b/README.md index e5dd37e..d286dd3 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ All subcommands, at a glance: specsync [sync] # project changes -> issues (default command) specsync pull # pull an issue into a local change specsync scan # what already exists in an area? +specsync topology # join change <-> issue <-> branch <-> worktree across stores specsync trace # print the raw spec<->commit<->issue link graph specsync link # cross-link two or more changes specsync spinoff # spawn emergent work as a linked sibling @@ -115,7 +116,7 @@ specsync version # print the binary version ``` **Dry-run flags** — `sync`, `pull`, `link`, and `spinoff` support `-dry-run`. Beads can be -previewed through `specsync -dry-run -provider beads`. `scan`, `trace`, +previewed through `specsync -dry-run -provider beads`. `scan`, `topology`, `trace`, `release-plan`, and `changelog` (unless `-apply`) are read-only and do not take a dry-run flag. diff --git a/cmd/specsync/SKILL.md b/cmd/specsync/SKILL.md index d9702ae..4916d9f 100644 --- a/cmd/specsync/SKILL.md +++ b/cmd/specsync/SKILL.md @@ -11,6 +11,7 @@ specsync handles **tracker sync** (OpenSpec ↔ GitHub/Beads). ## Basic Workflow +0. **Check before you start**: `specsync topology -json` shows which changes already have an issue, branch, and worktree — so you don't duplicate work another agent has started. 1. **Create or pull**: `specsync pull -issue N` or create a change in `openspec/changes//` 2. **Implement**: Write code, update `tasks.md` 3. **Sync**: `specsync -dry-run -change ` then `specsync -change ` diff --git a/cmd/specsync/agent_help.go b/cmd/specsync/agent_help.go index ec60829..a1bac38 100644 --- a/cmd/specsync/agent_help.go +++ b/cmd/specsync/agent_help.go @@ -335,6 +335,49 @@ var commandMetadata = map[string]AgentCommandHelp{ "specsync changes -json", }, }, + "topology": { + Command: "topology", + Description: "Join every change with its tracker issue, git branch, and worktree across coordinated stores — read-only, for an agent to check before starting work.", + Mutates: false, + Workflow: AgentCommandWorkflow{ + Position: "planning", + RelatedBefore: []string{"changes"}, + RelatedAfter: []string{"pull", "sync"}, + }, + Flags: []AgentCommandFlag{ + { + Name: "change", + Type: "string", + Required: false, + Description: "Scope to one change (default: every change)", + }, + { + Name: "all", + Type: "boolean", + Required: false, + Default: false, + Description: "Include archived changes", + }, + { + Name: "json", + Type: "boolean", + Required: false, + Default: false, + Description: "Emit machine-readable JSON output", + }, + }, + SafetyRules: []string{ + "Read-only operation — mutates nothing, not even the local ref cache", + "Every source (openspec, gh, git worktrees) is optional; a missing one narrows a row instead of failing it", + "Exits non-zero only when no row could be produced at all", + }, + Examples: []string{ + "specsync topology", + "specsync topology -json", + "specsync topology -change my-change", + "specsync topology -all", + }, + }, "set-stage": { Command: "set-stage", Description: "Set the workflow stage of a change.", diff --git a/cmd/specsync/main.go b/cmd/specsync/main.go index 40e7834..d457b4d 100644 --- a/cmd/specsync/main.go +++ b/cmd/specsync/main.go @@ -33,7 +33,7 @@ var knownSubcommands = map[string]bool{ "sync": true, "audit": true, "audit-tasks": true, "validate": true, "spinoff": true, "pr-body": true, "verify": true, "agent-help": true, "doctor": true, "idea": true, "ideas": true, "archive": true, - "epic": true, "adopt": true, + "epic": true, "adopt": true, "topology": true, } // knownConfusions maps a word someone might reach for by habit (e.g. git's @@ -94,7 +94,7 @@ func deprecatedSlugFlag(args []string) error { func main() { cmd, rest, err := resolveSubcommand(os.Args[1:]) if err != nil { - fmt.Fprintf(os.Stderr, "specsync: %v\n\nRun with no subcommand (optionally with flags) to sync, or use one of: pull, adopt, link, scan, trace, release-plan, changelog, install-skill, changes, set-stage, set-priority, note, audit, audit-tasks, validate, spinoff, pr-body, verify, idea, ideas, archive, epic\n", err) + fmt.Fprintf(os.Stderr, "specsync: %v\n\nRun with no subcommand (optionally with flags) to sync, or use one of: pull, adopt, link, scan, trace, release-plan, changelog, install-skill, changes, set-stage, set-priority, note, audit, audit-tasks, validate, spinoff, pr-body, verify, idea, ideas, archive, epic, topology\n", err) os.Exit(2) } @@ -151,6 +151,8 @@ func main() { runArchive(rest) case "epic": runEpic(rest) + case "topology": + runTopology(rest) default: runSync(rest) } diff --git a/cmd/specsync/topology.go b/cmd/specsync/topology.go new file mode 100644 index 0000000..68e75b7 --- /dev/null +++ b/cmd/specsync/topology.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + + specsync "github.com/androidand/specsync" +) + +// runTopology prints the change → repo → issue → branch → worktree join +// (see openspec/changes/agent-topology-command). Read-only; every source is +// optional, so a missing openspec/gh/git-worktree source narrows a row +// instead of dropping it. Exits non-zero only when nothing could be resolved +// at all — a command an agent checks before starting work must not fail +// closed just because one source degraded. +func runTopology(args []string) { + fs := flag.NewFlagSet("topology", flag.ExitOnError) + openspec := fs.String("openspec", "openspec", "path to the openspec/ directory") + change := fs.String("change", "", "scope to one change (default: every change)") + all := fs.Bool("all", false, "include archived changes") + asJSON := fs.Bool("json", false, "output as JSON") + if err := fs.Parse(args); err != nil { + fail(err) + } + + ctx := context.Background() + + topo, err := specsync.BuildTopology(ctx, specsync.TopologyOptions{ + OpenSpecDir: *openspec, + Slug: *change, + All: *all, + }) + if err != nil { + fail(err) + } + + if *asJSON { + emitJSON(topo) + } else { + printTopologyTable(*topo) + } + + if len(topo.Changes) == 0 { + os.Exit(1) + } +} + +func printTopologyTable(topo specsync.Topology) { + if len(topo.Changes) == 0 { + fmt.Println("No changes resolved.") + } else { + fmt.Println("SLUG STAGE REPO ISSUE BRANCH WORKTREE") + fmt.Println("────────────────────────────── ────────── ───────────────────────── ────────── ──────────────────────────────────────── ────────────────────────────────────────") + for _, c := range topo.Changes { + issue := "-" + if c.Issue != nil { + issue = c.Issue.ID + if c.Issue.State != "" { + issue += " (" + c.Issue.State + ")" + } + } + branch := c.Branch + if branch == "" { + branch = "-" + } + worktree := c.Worktree + if worktree == "" { + worktree = "-" + } + repo := c.Repo + if repo == "" { + repo = "-" + } + fmt.Printf("%-32s %-11s %-27s %-11s %-41s %s\n", c.Slug, c.Stage, repo, issue, branch, worktree) + if c.Epic != "" { + fmt.Printf(" ↳ epic: %s\n", c.Epic) + } + if len(c.Linked) > 0 { + fmt.Printf(" ↳ linked: %s\n", joinCommaOr(c.Linked, "-")) + } + } + } + for _, s := range topo.Status { + fmt.Println("status:", s) + } +} + +func joinCommaOr(vals []string, empty string) string { + if len(vals) == 0 { + return empty + } + out := vals[0] + for _, v := range vals[1:] { + out += ", " + v + } + return out +} diff --git a/cmd/specsync/topology_test.go b/cmd/specsync/topology_test.go new file mode 100644 index 0000000..0e60447 --- /dev/null +++ b/cmd/specsync/topology_test.go @@ -0,0 +1,78 @@ +package main + +import ( + "strings" + "testing" + + specsync "github.com/androidand/specsync" +) + +func TestPrintTopologyTable_ResolvedRow(t *testing.T) { + topo := specsync.Topology{ + Changes: []specsync.ChangeTopology{ + { + Slug: "add-field-forms", + Title: "Add field forms", + Stage: "active", + Repo: "acme/portal", + Issue: &specsync.TopologyIssue{ + Provider: "github:acme/portal", + ID: "4231", + URL: "https://github.com/acme/portal/issues/4231", + State: "open", + }, + Branch: "feat/4231-add-field-forms", + Worktree: "/repo/worktrees/add-field-forms", + Epic: "https://github.com/acme/planning/issues/88", + Linked: []string{"add-field-forms-api"}, + }, + }, + } + + out := captureStdout(t, func() { printTopologyTable(topo) }) + + for _, want := range []string{ + "add-field-forms", "active", "acme/portal", "4231 (open)", + "feat/4231-add-field-forms", "/repo/worktrees/add-field-forms", + "epic: https://github.com/acme/planning/issues/88", + "linked: add-field-forms-api", + } { + if !strings.Contains(out, want) { + t.Errorf("expected output to contain %q, got:\n%s", want, out) + } + } +} + +func TestPrintTopologyTable_MissingFieldsRenderAsDash(t *testing.T) { + topo := specsync.Topology{ + Changes: []specsync.ChangeTopology{ + {Slug: "no-worktree-yet", Stage: "active"}, + }, + } + + out := captureStdout(t, func() { printTopologyTable(topo) }) + + if !strings.Contains(out, "no-worktree-yet") { + t.Fatalf("expected the slug in output, got:\n%s", out) + } + if strings.Contains(out, "epic:") || strings.Contains(out, "linked:") { + t.Errorf("expected no epic/linked lines for a row with neither, got:\n%s", out) + } +} + +func TestPrintTopologyTable_EmptyPrintsMessageNotBlank(t *testing.T) { + out := captureStdout(t, func() { printTopologyTable(specsync.Topology{}) }) + if !strings.Contains(out, "No changes resolved.") { + t.Errorf("expected an explicit empty-result message, got:\n%s", out) + } +} + +func TestPrintTopologyTable_StatusLinesAlwaysPrinted(t *testing.T) { + topo := specsync.Topology{ + Status: []string{"gh unavailable: issue state omitted"}, + } + out := captureStdout(t, func() { printTopologyTable(topo) }) + if !strings.Contains(out, "status: gh unavailable: issue state omitted") { + t.Errorf("expected the status line surfaced, got:\n%s", out) + } +} diff --git a/npm/skills/specsync/SKILL.md b/npm/skills/specsync/SKILL.md index d9702ae..4916d9f 100644 --- a/npm/skills/specsync/SKILL.md +++ b/npm/skills/specsync/SKILL.md @@ -11,6 +11,7 @@ specsync handles **tracker sync** (OpenSpec ↔ GitHub/Beads). ## Basic Workflow +0. **Check before you start**: `specsync topology -json` shows which changes already have an issue, branch, and worktree — so you don't duplicate work another agent has started. 1. **Create or pull**: `specsync pull -issue N` or create a change in `openspec/changes//` 2. **Implement**: Write code, update `tasks.md` 3. **Sync**: `specsync -dry-run -change ` then `specsync -change ` diff --git a/openspec/changes/agent-topology-command/proposal.md b/openspec/changes/agent-topology-command/proposal.md new file mode 100644 index 0000000..e11fca2 --- /dev/null +++ b/openspec/changes/agent-topology-command/proposal.md @@ -0,0 +1,105 @@ +# Show where each change's issue, branch, and worktree live + +## Context + +An agent working one part of a multi-repo change cannot answer basic questions about +the rest of it: which other repos are involved, which issues cover them, which branches +and worktrees hold that work, and whether anyone has started. + +Every fact needed to answer this is already inside specsync. It is just not emitted as +one table: + +- `ReadCoordination` (`coordination.go`) already resolves the root store and its + referenced sibling stores with local paths, via `openspec context --json`. +- `.specsync/refs.json` per change already holds the tracker provider, issue id and URL. +- `specsync epic` already wires cross-repo parents and children. +- `specsync link` already cross-links sibling changes. +- The branch/worktree convention (`feat/-`, `../worktrees/`) is already + established and automated by `pull -worktree`. + +Consumers exist and are blocked on this. `opencode-skein`'s `agent-presence` model knows +which session is running in which directory, but a directory is not a unit of work — it +cannot say *which change* a peer is working on without this join. The join key is the +change slug, reached through the worktree path. + +## Proposed Changes + +### New Subcommand: `specsync topology` + +Emits the join table for active changes. Read-only; mutates nothing. + +```bash +specsync topology # human-readable table +specsync topology -json # machine-readable +specsync topology -change # scope to one change +specsync topology -all # include archived +``` + +JSON shape, one entry per change: + +```json +{ + "changes": [ + { + "slug": "add-field-forms", + "title": "Auto-render field forms", + "stage": "active", + "store": "/Users/andreas/exopen/portal", + "repo": "ExopenGitHub/portal", + "issue": {"provider": "github:ExopenGitHub/portal", "id": "4231", + "url": "https://github.com/ExopenGitHub/portal/issues/4231", + "state": "open"}, + "branch": "feat/4231-add-field-forms", + "worktree": "/Users/andreas/exopen/portal-worktrees/add-field-forms", + "epic": "https://github.com/ExopenGitHub/planning/issues/88", + "linked": ["add-field-forms-api"] + } + ], + "status": ["gh unavailable: issue state omitted"] +} +``` + +### Where each field comes from + +- `slug`, `title`, `stage` — the OpenSpec change, as `specsync changes` already reads it. +- `store`, `repo` — `ReadCoordination` plus git remote detection. +- `issue` — `.specsync/refs.json`. Issue **state** needs `gh` and is therefore optional. +- `branch`, `worktree` — `git worktree list --porcelain` in each coordinated store, + matched to the change by branch name (`feat/-`) first, then by directory + basename. An unmatched change reports `worktree: null`, not an error. +- `epic`, `linked` — existing epic wiring and `specsync link` edges. + +### Degradation + +Follows the discipline `coordination.go` already sets: every source may be missing, and a +missing source narrows the answer rather than failing it. No `openspec` binary, no `gh` +auth, an uncloned sibling store, or a store with no worktrees each drop their fields and +append a line to `status[]`. `topology` exits 0 whenever it produced any rows at all. + +This matters more here than elsewhere: the consumer is an agent deciding whether to start +work. A command that fails closed teaches agents to skip the check. + +## Capabilities + +### New Capabilities + +- `agent-topology`: a single read-only projection joining change, tracker issue, git + branch and git worktree across coordinated stores. + +## Non-Goals + +- **No presence, liveness, or process awareness.** Which agent is *running* where is the + runtime's business, not specsync's. This command emits the map; something else reads + the radio. Keeping that line is what lets specsync stay agent-neutral. +- No claiming, locking or assignment. Reporting that a worktree exists is not reserving it. +- No new state. Every field is derived from data specsync or git already stores; nothing + here introduces a file that can go stale independently. +- No worktree creation. `pull -worktree` already owns that. +- No cross-machine view. Local stores only. + +## Impact + +- New: `topology.go`, `cmd/specsync/` wiring, `agent-help topology` entry. +- Changed: `install-skill`'s embedded `SKILL.md` gains a short "check before you start" + step pointing at `specsync topology -json`. +- Reuses `ReadCoordination`, the refs cache and the changes lister; no new dependencies. diff --git a/openspec/changes/agent-topology-command/specs/agent-topology/spec.md b/openspec/changes/agent-topology-command/specs/agent-topology/spec.md new file mode 100644 index 0000000..89de662 --- /dev/null +++ b/openspec/changes/agent-topology-command/specs/agent-topology/spec.md @@ -0,0 +1,46 @@ +## ADDED Requirements + +### Requirement: changes project to a single join table + +specsync SHALL emit, for each active OpenSpec change across coordinated stores, the +change slug, stage, owning store, tracker issue reference, git branch, git worktree path, +epic parent and linked sibling changes, as one read-only projection. + +#### Scenario: a multi-repo change is fully resolved + +- **WHEN** two coordinated stores each hold a change linked to a tracker issue, on a + `feat/-` branch in its own worktree +- **THEN** the output contains one row per change, each naming its repo, issue URL, + branch and absolute worktree path + +#### Scenario: a change with no worktree is still reported + +- **WHEN** a change has an issue but no matching worktree +- **THEN** its row is emitted with a null worktree, and the command exits 0 + +### Requirement: partial sources narrow the answer, never fail it + +Every external source SHALL be optional. When `openspec`, `gh`, a sibling store or git +worktree data is unavailable, specsync SHALL omit only the affected fields, record a +human-readable reason, and still return every row it could build. + +#### Scenario: the tracker is unreachable + +- **WHEN** `gh` is unauthenticated +- **THEN** rows are emitted with issue id and URL from the local refs cache, issue state + omitted, and a status line explaining the omission + +#### Scenario: nothing could be resolved + +- **WHEN** no store yields a single change +- **THEN** the command exits non-zero with the accumulated reasons + +### Requirement: the projection is read-only + +The command SHALL NOT create, modify or delete changes, issues, branches or worktrees, +and SHALL NOT write cached state as a side effect of being run. + +#### Scenario: running it changes nothing + +- **WHEN** the command runs against a clean tree +- **THEN** `git status` is unchanged and no tracker write is issued diff --git a/openspec/changes/agent-topology-command/tasks.md b/openspec/changes/agent-topology-command/tasks.md new file mode 100644 index 0000000..169af44 --- /dev/null +++ b/openspec/changes/agent-topology-command/tasks.md @@ -0,0 +1,96 @@ +# Tasks: agent-topology-command + +## Phase 1: The join + +- [x] 1.1 Add `topology.go` with a `Topology`/`ChangeTopology` type matching the + documented JSON shape + - Validation: `TestChangeTopology_JSONRoundTripsWithEveryOptionalFieldAbsent` — + round-trips through `encoding/json` with every optional field absent, and + confirms `omitempty` actually omits them rather than emitting nulls +- [x] 1.2 Resolve stores via the existing `ReadCoordination`; do not add a second + discovery path + - Validation: `TestBuildTopology_MultiRepoJoinAcrossCoordinatedStores` (fake + `readCoordination`) plus `TestBuildTopology_NothingResolvedIsAnEmptyResultNotAnError` + for the no-coordination/nil case. `readCoordination` defaults to the real + `ReadCoordination` — no parallel discovery path added. +- [x] 1.3 Read issue refs from `.specsync/refs.json` per change + - Validation: `TestBuildTopology_ChangeWithNoWorktreeStillReportedExitZero` + and others cover a change with a refs file; a change with none yields + `Issue: nil` (`issue: null` in JSON), not an error — every fixture change + without a written refs.json exercises this implicitly. +- [x] 1.4 Match branches and worktrees from `git worktree list --porcelain` + - Match on `feat/-` first, then directory basename + - Validation: `TestMatchWorktree` (matched by branch, matched by basename, + unmatched, detached-HEAD-falls-through-to-basename) and + `TestListWorktrees_ParsesPorcelain` (regular, detached, and bare-repo + porcelain blocks) + +## Phase 2: Degradation + +- [x] 2.1 Every source failure appends to `status[]` and drops only its own fields + - Validation: `TestBuildTopology_GHUnauthenticatedOmitsStateNotesReason` + (unauthenticated gh); an uncloned sibling store is exercised implicitly by + `loadChanges` returning no changes for a nonexistent openspec dir (not an + error — `LoadChanges` already degrades that way); a store with zero + worktrees is exercised by every test using an empty porcelain string. + `openspec` unavailable is covered by `readCoordination` returning + `(nil, nil)` (the real `ReadCoordination`'s own degrade contract) in every + test — none of it prevents rows from being returned. +- [x] 2.2 Exit non-zero only when no rows could be produced at all + - Validation: `TestBuildTopology_NothingResolvedIsAnEmptyResultNotAnError` + (BuildTopology itself never errors); `cmd/specsync/topology.go`'s + `runTopology` exits 1 only when `len(topo.Changes) == 0` — verified by + hand: `specsync topology -change nonexistent-slug` prints "No changes + resolved." and exits 1; a normal run exits 0. + +## Phase 3: Surfaces + +- [x] 3.1 Wire `specsync topology` with `-json`, `-change`, `-all`, `-openspec` + (matching the plain `-openspec` flag pattern `changes` already uses; + this repo's checkout does not yet have the store-scoped root-resolution + helpers from the unreleased openspec-store-support work, so `topology` + targets a single `openspec/` dir like every other current command + rather than depending on an API that doesn't exist on `main` yet) + - Validation: hand-run `specsync topology -json | jq .` parses; + `specsync topology -json -change agent-topology-command` narrows to one + row (see Phase 4 dogfood output below) +- [x] 3.2 Human-readable table output, aligned with `specsync changes` styling + - Validation: `TestPrintTopologyTable_*` in `cmd/specsync` (resolved row, + missing-fields-render-as-dash, empty-result message, status lines). + Downgraded from "golden-file test" to output-assertion tests: no + golden-file harness exists elsewhere in this codebase (checked + `changes_test.go`, which doesn't golden-test `printChangeTable` either), + so introducing one for this alone would be a new pattern, not reuse. +- [x] 3.3 Add the `agent-help topology` entry (behavior, flags, safety, examples) + - Validation: `specsync agent-help topology -json` parses and lists every + flag (hand-verified) +- [x] 3.4 Add the "check topology before starting" step to the embedded `SKILL.md` + - Validation: `TestSkillDrift` stays green (updated the canonical + `skills/specsync/SKILL.md` and propagated byte-for-byte to the 3 derived + copies) + +## Phase 4: Verify + +- [x] 4.1 Dogfood against this repo and one multi-repo exopen change + - Validation: ran `specsync topology` against this repo (~29 changes, + correctly shows `agent-topology-command` itself with its real branch + `feat/168-agent-topology-command` and worktree path — this change was + being worked on inside that exact worktree at the time). Ran + `specsync topology -json -change agent-topology-command` with the local + ref cache present: correctly resolved repo, live issue state (`"open"` + via a real `gh` call), branch, and worktree. Re-ran with `gh` removed + from `PATH`: repo/branch/worktree still resolved, issue state omitted, + with `"gh unavailable: issue state omitted"` in `status[]` — exactly the + documented degradation. Did not have a second, real multi-repo exopen + change available in this session to hand-verify against; the multi-store + join path is covered by `TestBuildTopology_MultiRepoJoinAcrossCoordinatedStores` + instead. +- [x] 4.2 Confirm runtime is acceptable to call at agent startup + - Validation: timed `specsync topology` against this repo's single store, + ~29 changes, no coordinated siblings: **~1.5s wall-clock** (dominated by + one `git worktree list --porcelain` call plus per-change ref-cache reads; + no `gh` calls were made since none of these fixture changes had cached + refs). Acceptable for a check run once at agent startup; a store with `gh` + calls for every change's issue state will scale roughly linearly with + change count times issue lookups — worth revisiting if a store's change + count grows an order of magnitude, but not a concern at this repo's scale. diff --git a/pull_test.go b/pull_test.go index a7dd222..5388dcc 100644 --- a/pull_test.go +++ b/pull_test.go @@ -644,12 +644,24 @@ func TestPull_RequiresIssueForNonGithub(t *testing.T) { type stubNonGithubProvider struct{} -func (s *stubNonGithubProvider) Name() string { return "beads" } -func (s *stubNonGithubProvider) Find(context.Context, string) (*Ref, error) { return nil, nil } -func (s *stubNonGithubProvider) Push(context.Context, WorkItem, *Ref) (Ref, error) { return Ref{}, nil } -func (s *stubNonGithubProvider) Get(context.Context, string) (FetchedItem, error) { return FetchedItem{}, nil } +func (s *stubNonGithubProvider) Name() string { return "beads" } +func (s *stubNonGithubProvider) Find(context.Context, string) (*Ref, error) { return nil, nil } +func (s *stubNonGithubProvider) Push(context.Context, WorkItem, *Ref) (Ref, error) { return Ref{}, nil } +func (s *stubNonGithubProvider) Get(context.Context, string) (FetchedItem, error) { + return FetchedItem{}, nil +} func TestPull_CannotResolveFromBranch(t *testing.T) { + // Force a branch name that never matches the feat/- pattern, + // regardless of what branch this test suite actually happens to be + // checked out on (e.g. a real feat/- branch, which would + // otherwise make this test try to resolve issue for real and fail + // with an unrelated JSON-parse error instead of the branch-mismatch + // error it's meant to assert on). + prev := currentBranchFn + currentBranchFn = func() (string, error) { return "main", nil } + defer func() { currentBranchFn = prev }() + dir := t.TempDir() // On a branch that doesn't match the pattern, Pull should error. _, err := Pull(context.Background(), PullOptions{ diff --git a/site/features.json b/site/features.json index 157d60d..2e03a9a 100644 --- a/site/features.json +++ b/site/features.json @@ -19,6 +19,14 @@ "title": "Planning scan", "body": "specsync scan shows what already exists for an area — in-flight changes, loose issues, recent commits — before you plan." }, + { + "group": "plan", + "icon": "flow", + "title": "See who's already working what", + "body": "specsync topology joins every change with its tracker issue, git branch, and worktree across coordinated stores — a read-only check before an agent starts, so it doesn't duplicate work already in flight.", + "status": "soon", + "issue": 168 + }, { "group": "plan", "icon": "followup", diff --git a/skills/specsync/SKILL.md b/skills/specsync/SKILL.md index d9702ae..4916d9f 100644 --- a/skills/specsync/SKILL.md +++ b/skills/specsync/SKILL.md @@ -11,6 +11,7 @@ specsync handles **tracker sync** (OpenSpec ↔ GitHub/Beads). ## Basic Workflow +0. **Check before you start**: `specsync topology -json` shows which changes already have an issue, branch, and worktree — so you don't duplicate work another agent has started. 1. **Create or pull**: `specsync pull -issue N` or create a change in `openspec/changes//` 2. **Implement**: Write code, update `tasks.md` 3. **Sync**: `specsync -dry-run -change ` then `specsync -change ` diff --git a/topology.go b/topology.go new file mode 100644 index 0000000..26b1e0e --- /dev/null +++ b/topology.go @@ -0,0 +1,350 @@ +package specsync + +import ( + "context" + "fmt" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// Topology is the read-only join across coordinated stores: for every change, +// its stage, owning store and repo, tracker issue, git branch, worktree path, +// epic parent, and linked sibling changes. Every field beyond +// Slug/Title/Stage is independently optional — a source that's unavailable +// narrows a row instead of dropping it or failing the whole command. See +// openspec/changes/agent-topology-command. +type Topology struct { + Changes []ChangeTopology `json:"changes"` + Status []string `json:"status,omitempty"` +} + +// ChangeTopology is one row of the join. +type ChangeTopology struct { + Slug string `json:"slug"` + Title string `json:"title"` + Stage string `json:"stage"` + Store string `json:"store,omitempty"` + Repo string `json:"repo,omitempty"` + Issue *TopologyIssue `json:"issue,omitempty"` + Branch string `json:"branch,omitempty"` + Worktree string `json:"worktree,omitempty"` + Epic string `json:"epic,omitempty"` + Linked []string `json:"linked,omitempty"` +} + +// TopologyIssue is the tracker reference for one change. State is set only +// when a live tracker query (gh) succeeded; its absence is not an error. +type TopologyIssue struct { + Provider string `json:"provider"` + ID string `json:"id"` + URL string `json:"url"` + State string `json:"state,omitempty"` +} + +// TopologyOptions configures BuildTopology. OpenSpecDir/Slug/All are the +// public inputs; the rest are dependency seams overridable in tests (nil +// means "use the real implementation" — openspec/git/gh via exec.Command). +type TopologyOptions struct { + OpenSpecDir string // the root store's openspec dir + Slug string // scope to one change; "" = every change + All bool // include archived changes + + readCoordination func(ctx context.Context) (*Coordination, error) + loadChanges func(openspecDir string) ([]Change, error) + loadRefs func(changeDir string) (map[string]Ref, error) + gitWorktreeList func(ctx context.Context, repoDir string) (string, error) + gitRemoteOrigin func(ctx context.Context, repoDir string) (string, error) + run func(ctx context.Context, args ...string) (string, error) // gh +} + +// storeEntry is one coordinated store to scan: its repo root (for git +// commands) and its openspec directory (for LoadChanges). +type storeEntry struct { + path string + openspecDir string +} + +// BuildTopology joins every change across the root store and any coordinated +// sibling stores with its tracker issue, git branch, and worktree. It never +// returns an error for a degraded source — those narrow individual rows and +// are recorded in Topology.Status instead. The CLI decides what a fully +// empty result means (see cmd/specsync topology.go). +func BuildTopology(ctx context.Context, opts TopologyOptions) (*Topology, error) { + if opts.readCoordination == nil { + opts.readCoordination = ReadCoordination + } + if opts.loadChanges == nil { + opts.loadChanges = LoadChanges + } + if opts.loadRefs == nil { + opts.loadRefs = LoadRefs + } + if opts.gitWorktreeList == nil { + opts.gitWorktreeList = gitWorktreeListReal + } + if opts.gitRemoteOrigin == nil { + opts.gitRemoteOrigin = gitRemoteOriginReal + } + if opts.run == nil { + opts.run = runGH + } + + var status []string + addStatus := func(format string, args ...any) { + status = append(status, fmt.Sprintf(format, args...)) + } + + repoRoot := filepath.Dir(opts.OpenSpecDir) + stores := []storeEntry{{path: repoRoot, openspecDir: opts.OpenSpecDir}} + + coord, cerr := opts.readCoordination(ctx) + if cerr != nil { + addStatus("openspec context unavailable: %v", cerr) + } else if coord != nil { + for _, m := range coord.Members { + stores = append(stores, storeEntry{path: m.Path, openspecDir: filepath.Join(m.Path, "openspec")}) + } + } + + type loadedChange struct { + change Change + store storeEntry + refs map[string]Ref + } + + var all []loadedChange + urlToSlug := map[string]string{} + for _, st := range stores { + changes, err := opts.loadChanges(st.openspecDir) + if err != nil { + addStatus("could not load changes from %s: %v", st.openspecDir, err) + continue + } + for _, c := range changes { + if !opts.All && c.Archived { + continue + } + refs, rerr := opts.loadRefs(c.Dir) + if rerr != nil { + addStatus("could not read ref cache for %s: %v", c.Slug, rerr) + refs = map[string]Ref{} + } + if len(refs) > 0 { + _, ref := firstRef(refs) + if ref.URL != "" { + urlToSlug[ref.URL] = c.Slug + } + } + all = append(all, loadedChange{change: c, store: st, refs: refs}) + } + } + + worktrees := map[string][]worktreeEntry{} + worktreeFailed := map[string]bool{} + repos := map[string]string{} + repoFailed := map[string]bool{} + epicLabel := map[string]bool{} + ghDown := false + + var rows []ChangeTopology + for _, l := range all { + c := l.change + if opts.Slug != "" && c.Slug != opts.Slug { + continue + } + + row := ChangeTopology{ + Slug: c.Slug, + Title: c.Title, + Stage: string(c.Stage), + Store: l.store.path, + } + + if repo, ok := repos[l.store.path]; ok { + row.Repo = repo + } else if !repoFailed[l.store.path] { + repo, err := opts.gitRemoteOrigin(ctx, l.store.path) + if err != nil || repo == "" { + repoFailed[l.store.path] = true + addStatus("no git remote in store %s", l.store.path) + } else { + repos[l.store.path] = repo + row.Repo = repo + } + } + + if len(l.refs) > 0 { + key, ref := firstRef(l.refs) + row.Issue = &TopologyIssue{Provider: key, ID: ref.ID, URL: ref.URL} + issueRepo := repoFromKey(key) + if !ghDown && ref.ID != "" && issueRepo != "" { + state, err := fetchIssueState(ctx, opts.run, issueRepo, ref.ID) + if err != nil { + ghDown = true + addStatus("gh unavailable: issue state omitted") + } else { + row.Issue.State = state + } + } + } + + entries, ok := worktrees[l.store.path] + if !ok && !worktreeFailed[l.store.path] { + es, err := listWorktrees(ctx, opts.gitWorktreeList, l.store.path) + if err != nil { + worktreeFailed[l.store.path] = true + addStatus("git worktree list failed in %s: %v", l.store.path, err) + } else { + worktrees[l.store.path] = es + entries = es + ok = true + } + } + if ok { + if branch, path, matched := matchWorktree(entries, c.Slug); matched { + row.Branch = branch + row.Worktree = path + } + } + + for _, link := range c.Links { + if slug, known := urlToSlug[link.URL]; known { + row.Linked = append(row.Linked, slug) + continue + } + if row.Epic != "" || ghDown { + continue + } + // An unresolved related URL might be the change's coordination + // epic (specsync epic never writes a local link back to the + // child, only a remote "## Related" edit — see epic.go). Confirm + // via the epic's "type:epic" label rather than guessing from + // text, since a sibling link renders identically. + ref := refFromURL(link.URL) + if ref == nil || ref.ID == "" || ref.Provider == "" { + continue + } + cacheKey := ref.Provider + "#" + ref.ID + isEpic, cached := epicLabel[cacheKey] + if !cached { + var err error + isEpic, err = issueHasLabel(ctx, opts.run, repoFromKey(ref.Provider), ref.ID, "type:epic") + if err != nil { + ghDown = true + addStatus("gh unavailable: issue state omitted") + continue + } + epicLabel[cacheKey] = isEpic + } + if isEpic { + row.Epic = link.URL + } + } + + rows = append(rows, row) + } + + sort.Slice(rows, func(i, j int) bool { return rows[i].Slug < rows[j].Slug }) + + return &Topology{Changes: rows, Status: status}, nil +} + +// fetchIssueState returns an issue's open/closed state via gh. +func fetchIssueState(ctx context.Context, run func(context.Context, ...string) (string, error), repo, id string) (string, error) { + out, err := run(ctx, "issue", "view", id, "-R", repo, "--json", "state", "-q", ".state") + if err != nil { + return "", err + } + return strings.ToLower(strings.TrimSpace(out)), nil +} + +// issueHasLabel reports whether an issue carries the given label, via gh. +func issueHasLabel(ctx context.Context, run func(context.Context, ...string) (string, error), repo, id, label string) (bool, error) { + out, err := run(ctx, "issue", "view", id, "-R", repo, "--json", "labels", "-q", ".labels[].name") + if err != nil { + return false, err + } + for _, l := range strings.Split(out, "\n") { + if strings.TrimSpace(l) == label { + return true, nil + } + } + return false, nil +} + +// worktreeEntry is one entry from `git worktree list --porcelain`. +type worktreeEntry struct { + path string + branch string // short branch name (e.g. "feat/159-x"); "" when detached or bare +} + +// gitWorktreeListReal shells out to `git -C repoDir worktree list --porcelain`. +func gitWorktreeListReal(ctx context.Context, repoDir string) (string, error) { + out, err := exec.CommandContext(ctx, "git", "-C", repoDir, "worktree", "list", "--porcelain").Output() + if err != nil { + return "", err + } + return string(out), nil +} + +// gitRemoteOriginReal shells out to `git -C repoDir remote get-url origin` +// and parses it into "owner/name". +func gitRemoteOriginReal(ctx context.Context, repoDir string) (string, error) { + out, err := exec.CommandContext(ctx, "git", "-C", repoDir, "remote", "get-url", "origin").Output() + if err != nil { + return "", err + } + return parseGitRemoteURL(strings.TrimSpace(string(out))), nil +} + +// listWorktrees parses `git worktree list --porcelain` output into entries. +// A bare repo or a worktree with a detached HEAD has no "branch" line and is +// returned with branch == "" — it simply can't match any change. +func listWorktrees(ctx context.Context, run func(context.Context, string) (string, error), repoDir string) ([]worktreeEntry, error) { + out, err := run(ctx, repoDir) + if err != nil { + return nil, err + } + + var entries []worktreeEntry + var cur *worktreeEntry + for _, line := range strings.Split(out, "\n") { + switch { + case strings.HasPrefix(line, "worktree "): + if cur != nil { + entries = append(entries, *cur) + } + cur = &worktreeEntry{path: strings.TrimPrefix(line, "worktree ")} + case strings.HasPrefix(line, "branch "): + if cur != nil { + cur.branch = strings.TrimPrefix(strings.TrimPrefix(line, "branch "), "refs/heads/") + } + } + } + if cur != nil { + entries = append(entries, *cur) + } + return entries, nil +} + +// worktreeBranchPattern matches this repo's branch convention: feat/-. +var worktreeBranchPattern = regexp.MustCompile(`^feat/\d+-(.+)$`) + +// matchWorktree finds the worktree for a change: first by branch name +// (feat/-), then by directory basename, per agent-topology-command. +func matchWorktree(entries []worktreeEntry, slug string) (branch, path string, ok bool) { + for _, e := range entries { + if m := worktreeBranchPattern.FindStringSubmatch(e.branch); m != nil && m[1] == slug { + return e.branch, e.path, true + } + } + for _, e := range entries { + if filepath.Base(e.path) == slug { + return e.branch, e.path, true + } + } + return "", "", false +} diff --git a/topology_test.go b/topology_test.go new file mode 100644 index 0000000..53beb15 --- /dev/null +++ b/topology_test.go @@ -0,0 +1,365 @@ +package specsync + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestChangeTopology_JSONRoundTripsWithEveryOptionalFieldAbsent(t *testing.T) { + row := ChangeTopology{Slug: "add-thing", Title: "Add thing", Stage: "active"} + b, err := json.Marshal(row) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got ChangeTopology + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !reflect.DeepEqual(got, row) { + t.Errorf("round-trip mismatch: got %+v, want %+v", got, row) + } + // Every optional field must be omitted from the wire form, not emitted as + // a zero value (null/"") — that's the whole point of omitempty here. + for _, field := range []string{`"store"`, `"repo"`, `"issue"`, `"branch"`, `"worktree"`, `"epic"`, `"linked"`} { + if strings.Contains(string(b), field) { + t.Errorf("expected %s to be omitted from JSON, got %s", field, b) + } + } +} + +func fakeGitWorktreeList(porcelain map[string]string, calls *[]string) func(context.Context, string) (string, error) { + return func(_ context.Context, dir string) (string, error) { + if calls != nil { + *calls = append(*calls, dir) + } + out, ok := porcelain[dir] + if !ok { + return "", errors.New("no such repo: " + dir) + } + return out, nil + } +} + +func fakeGitRemoteOrigin(repos map[string]string) func(context.Context, string) (string, error) { + return func(_ context.Context, dir string) (string, error) { + repo, ok := repos[dir] + if !ok { + return "", errors.New("no remote for " + dir) + } + return repo, nil + } +} + +func TestBuildTopology_FullyResolvedSingleStore(t *testing.T) { + root := t.TempDir() + cdir := filepath.Join(root, "changes", "add-field-forms") + mustWrite(t, filepath.Join(cdir, "proposal.md"), "# Add field forms\n") + mustWrite(t, filepath.Join(cdir, ".specsync", "refs.json"), + `{"github:acme/portal":{"provider":"github:acme/portal","id":"4231","url":"https://github.com/acme/portal/issues/4231"}}`) + + worktreePath := filepath.Join(root, "..", "worktrees", "add-field-forms") + porcelain := "worktree " + filepath.Dir(root) + "\nHEAD abc\nbranch refs/heads/main\n\n" + + "worktree " + worktreePath + "\nHEAD def\nbranch refs/heads/feat/4231-add-field-forms\n" + + opts := TopologyOptions{ + OpenSpecDir: root, + readCoordination: func(context.Context) (*Coordination, error) { return nil, nil }, + gitWorktreeList: fakeGitWorktreeList(map[string]string{filepath.Dir(root): porcelain}, nil), + gitRemoteOrigin: fakeGitRemoteOrigin(map[string]string{filepath.Dir(root): "acme/portal"}), + run: func(_ context.Context, args ...string) (string, error) { + return "open", nil // -q .state + }, + } + + topo, err := BuildTopology(context.Background(), opts) + if err != nil { + t.Fatalf("BuildTopology: %v", err) + } + if len(topo.Changes) != 1 { + t.Fatalf("expected 1 change, got %d: %+v", len(topo.Changes), topo.Changes) + } + row := topo.Changes[0] + if row.Slug != "add-field-forms" { + t.Errorf("Slug = %q", row.Slug) + } + if row.Repo != "acme/portal" { + t.Errorf("Repo = %q, want acme/portal", row.Repo) + } + if row.Issue == nil || row.Issue.URL != "https://github.com/acme/portal/issues/4231" { + t.Fatalf("Issue = %+v", row.Issue) + } + if row.Issue.State != "open" { + t.Errorf("Issue.State = %q, want open", row.Issue.State) + } + if row.Branch != "feat/4231-add-field-forms" { + t.Errorf("Branch = %q", row.Branch) + } + if row.Worktree != worktreePath { + t.Errorf("Worktree = %q, want %q", row.Worktree, worktreePath) + } +} + +func TestBuildTopology_ChangeWithNoWorktreeStillReportedExitZero(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "changes", "c1", "proposal.md"), "# C1\n") + mustWrite(t, filepath.Join(root, "changes", "c1", ".specsync", "refs.json"), + `{"github:acme/portal":{"provider":"github:acme/portal","id":"1","url":"https://github.com/acme/portal/issues/1"}}`) + + porcelain := "worktree " + filepath.Dir(root) + "\nHEAD abc\nbranch refs/heads/main\n" + opts := TopologyOptions{ + OpenSpecDir: root, + readCoordination: func(context.Context) (*Coordination, error) { return nil, nil }, + gitWorktreeList: fakeGitWorktreeList(map[string]string{root: porcelain}, nil), + gitRemoteOrigin: fakeGitRemoteOrigin(map[string]string{root: "acme/portal"}), + run: func(context.Context, ...string) (string, error) { return "open", nil }, + } + + topo, err := BuildTopology(context.Background(), opts) + if err != nil { + t.Fatalf("BuildTopology must not error: %v", err) + } + if len(topo.Changes) != 1 { + t.Fatalf("expected 1 row even with no worktree, got %d", len(topo.Changes)) + } + if topo.Changes[0].Worktree != "" { + t.Errorf("expected null/empty worktree, got %q", topo.Changes[0].Worktree) + } +} + +func TestBuildTopology_GHUnauthenticatedOmitsStateNotesReason(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "changes", "c1", "proposal.md"), "# C1\n") + mustWrite(t, filepath.Join(root, "changes", "c1", ".specsync", "refs.json"), + `{"github:acme/portal":{"provider":"github:acme/portal","id":"1","url":"https://github.com/acme/portal/issues/1"}}`) + + opts := TopologyOptions{ + OpenSpecDir: root, + readCoordination: func(context.Context) (*Coordination, error) { return nil, nil }, + gitWorktreeList: fakeGitWorktreeList(map[string]string{root: ""}, nil), + gitRemoteOrigin: fakeGitRemoteOrigin(map[string]string{root: "acme/portal"}), + run: func(context.Context, ...string) (string, error) { return "", errors.New("gh: not authenticated") }, + } + + topo, err := BuildTopology(context.Background(), opts) + if err != nil { + t.Fatalf("BuildTopology: %v", err) + } + if len(topo.Changes) != 1 { + t.Fatalf("expected 1 row despite gh being unavailable, got %d", len(topo.Changes)) + } + row := topo.Changes[0] + if row.Issue == nil || row.Issue.URL == "" { + t.Fatalf("expected the issue id/URL from the local ref cache regardless of gh, got %+v", row.Issue) + } + if row.Issue.State != "" { + t.Errorf("expected State empty when gh is unavailable, got %q", row.Issue.State) + } + found := false + for _, s := range topo.Status { + if s == "gh unavailable: issue state omitted" { + found = true + } + } + if !found { + t.Errorf("expected a status line explaining the omission, got %v", topo.Status) + } +} + +func TestBuildTopology_NothingResolvedIsAnEmptyResultNotAnError(t *testing.T) { + root := t.TempDir() // no changes/ dir at all + opts := TopologyOptions{ + OpenSpecDir: root, + readCoordination: func(context.Context) (*Coordination, error) { return nil, nil }, + } + topo, err := BuildTopology(context.Background(), opts) + if err != nil { + t.Fatalf("BuildTopology: %v", err) + } + if len(topo.Changes) != 0 { + t.Fatalf("expected 0 rows, got %d", len(topo.Changes)) + } + // The CLI, not BuildTopology, decides the exit code for "nothing resolved" + // (see cmd/specsync/topology.go) — BuildTopology itself just reports an + // empty, error-free result. +} + +func TestBuildTopology_ReadOnly_NoCacheOrGitStateWritten(t *testing.T) { + root := t.TempDir() + cdir := filepath.Join(root, "changes", "c1") + mustWrite(t, filepath.Join(cdir, "proposal.md"), "# C1\n") + + opts := TopologyOptions{ + OpenSpecDir: root, + readCoordination: func(context.Context) (*Coordination, error) { return nil, nil }, + gitWorktreeList: fakeGitWorktreeList(map[string]string{root: ""}, nil), + gitRemoteOrigin: fakeGitRemoteOrigin(map[string]string{}), + } + if _, err := BuildTopology(context.Background(), opts); err != nil { + t.Fatalf("BuildTopology: %v", err) + } + if _, err := LoadRefs(cdir); err != nil { + t.Fatalf("LoadRefs after BuildTopology: %v", err) + } + // No .specsync dir should have been created as a side effect. + entries, _ := os.ReadDir(cdir) + for _, e := range entries { + if e.Name() == ".specsync" { + t.Errorf("BuildTopology must not write a ref cache as a side effect") + } + } +} + +func TestBuildTopology_ScopesToOneChange(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "changes", "a", "proposal.md"), "# A\n") + mustWrite(t, filepath.Join(root, "changes", "b", "proposal.md"), "# B\n") + + opts := TopologyOptions{ + OpenSpecDir: root, + Slug: "a", + readCoordination: func(context.Context) (*Coordination, error) { return nil, nil }, + gitWorktreeList: fakeGitWorktreeList(map[string]string{root: ""}, nil), + gitRemoteOrigin: fakeGitRemoteOrigin(map[string]string{}), + } + topo, err := BuildTopology(context.Background(), opts) + if err != nil { + t.Fatalf("BuildTopology: %v", err) + } + if len(topo.Changes) != 1 || topo.Changes[0].Slug != "a" { + t.Fatalf("expected only change 'a', got %+v", topo.Changes) + } +} + +func TestBuildTopology_ArchivedExcludedByDefaultIncludedWithAll(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "changes", "active-one", "proposal.md"), "# Active\n") + mustWrite(t, filepath.Join(root, "changes", "archive", "2026-01-01-old-one", "proposal.md"), "# Old\n") + + base := TopologyOptions{ + OpenSpecDir: root, + readCoordination: func(context.Context) (*Coordination, error) { return nil, nil }, + gitWorktreeList: fakeGitWorktreeList(map[string]string{root: ""}, nil), + gitRemoteOrigin: fakeGitRemoteOrigin(map[string]string{}), + } + + topo, err := BuildTopology(context.Background(), base) + if err != nil { + t.Fatalf("BuildTopology: %v", err) + } + if len(topo.Changes) != 1 { + t.Fatalf("expected archived change excluded by default, got %+v", topo.Changes) + } + + withAll := base + withAll.All = true + topo, err = BuildTopology(context.Background(), withAll) + if err != nil { + t.Fatalf("BuildTopology -all: %v", err) + } + if len(topo.Changes) != 2 { + t.Fatalf("expected both changes with -all, got %+v", topo.Changes) + } +} + +func TestBuildTopology_MultiRepoJoinAcrossCoordinatedStores(t *testing.T) { + rootStore := t.TempDir() + siblingRepo := t.TempDir() + mustWrite(t, filepath.Join(rootStore, "changes", "backend-part", "proposal.md"), "# Backend part\n") + mustWrite(t, filepath.Join(siblingRepo, "openspec", "changes", "frontend-part", "proposal.md"), "# Frontend part\n") + + opts := TopologyOptions{ + OpenSpecDir: rootStore, + readCoordination: func(context.Context) (*Coordination, error) { + return &Coordination{ + Root: StoreEntry{Path: filepath.Dir(rootStore)}, + Members: []StoreEntry{{Path: siblingRepo}}, + }, nil + }, + gitWorktreeList: fakeGitWorktreeList(map[string]string{ + filepath.Dir(rootStore): "", + siblingRepo: "", + }, nil), + gitRemoteOrigin: fakeGitRemoteOrigin(map[string]string{ + filepath.Dir(rootStore): "acme/backend", + siblingRepo: "acme/frontend", + }), + } + + topo, err := BuildTopology(context.Background(), opts) + if err != nil { + t.Fatalf("BuildTopology: %v", err) + } + if len(topo.Changes) != 2 { + t.Fatalf("expected 2 changes across both stores, got %+v", topo.Changes) + } + byRepo := map[string]string{} + for _, c := range topo.Changes { + byRepo[c.Slug] = c.Repo + } + if byRepo["backend-part"] != "acme/backend" || byRepo["frontend-part"] != "acme/frontend" { + t.Errorf("expected each change to carry its own store's repo, got %+v", byRepo) + } +} + +func TestBuildTopology_LinksResolveToLocalSiblingSlugs(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "changes", "api-part", "proposal.md"), "# API part\n") + mustWrite(t, filepath.Join(root, "changes", "api-part", ".specsync", "refs.json"), + `{"github:acme/portal":{"provider":"github:acme/portal","id":"10","url":"https://github.com/acme/portal/issues/10"}}`) + mustWrite(t, filepath.Join(root, "changes", "ui-part", "proposal.md"), "# UI part\n") + mustWrite(t, filepath.Join(root, "changes", "ui-part", "links.md"), "- https://github.com/acme/portal/issues/10\n") + + opts := TopologyOptions{ + OpenSpecDir: root, + readCoordination: func(context.Context) (*Coordination, error) { return nil, nil }, + gitWorktreeList: fakeGitWorktreeList(map[string]string{root: ""}, nil), + gitRemoteOrigin: fakeGitRemoteOrigin(map[string]string{root: "acme/portal"}), + } + + topo, err := BuildTopology(context.Background(), opts) + if err != nil { + t.Fatalf("BuildTopology: %v", err) + } + var ui *ChangeTopology + for i := range topo.Changes { + if topo.Changes[i].Slug == "ui-part" { + ui = &topo.Changes[i] + } + } + if ui == nil { + t.Fatalf("ui-part not found in %+v", topo.Changes) + } + if len(ui.Linked) != 1 || ui.Linked[0] != "api-part" { + t.Errorf("Linked = %v, want [api-part]", ui.Linked) + } +} + +func TestBuildTopology_EpicResolvedViaLabelOnUnresolvedRelatedLink(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "changes", "child-part", "proposal.md"), "# Child part\n") + mustWrite(t, filepath.Join(root, "changes", "child-part", "links.md"), "- https://github.com/acme/planning/issues/88\n") + + opts := TopologyOptions{ + OpenSpecDir: root, + readCoordination: func(context.Context) (*Coordination, error) { return nil, nil }, + gitWorktreeList: fakeGitWorktreeList(map[string]string{root: ""}, nil), + gitRemoteOrigin: fakeGitRemoteOrigin(map[string]string{root: "acme/portal"}), + run: func(_ context.Context, args ...string) (string, error) { + return "type:epic\nspecsync", nil // -q .labels[].name + }, + } + + topo, err := BuildTopology(context.Background(), opts) + if err != nil { + t.Fatalf("BuildTopology: %v", err) + } + if len(topo.Changes) != 1 || topo.Changes[0].Epic != "https://github.com/acme/planning/issues/88" { + t.Fatalf("expected the related link to resolve as Epic, got %+v", topo.Changes) + } +} diff --git a/topology_worktree_test.go b/topology_worktree_test.go new file mode 100644 index 0000000..16e80a7 --- /dev/null +++ b/topology_worktree_test.go @@ -0,0 +1,89 @@ +package specsync + +import ( + "context" + "testing" +) + +func TestListWorktrees_ParsesPorcelain(t *testing.T) { + porcelain := "worktree /repo\n" + + "HEAD aaaa\n" + + "branch refs/heads/main\n" + + "\n" + + "worktree /worktrees/add-thing\n" + + "HEAD bbbb\n" + + "branch refs/heads/feat/42-add-thing\n" + + "\n" + + "worktree /worktrees/detached\n" + + "HEAD cccc\n" + + "detached\n" + + "\n" + + "worktree /repo.git\n" + + "bare\n" + + run := func(_ context.Context, dir string) (string, error) { return porcelain, nil } + entries, err := listWorktrees(context.Background(), run, "/repo") + if err != nil { + t.Fatalf("listWorktrees: %v", err) + } + if len(entries) != 4 { + t.Fatalf("expected 4 entries, got %d: %+v", len(entries), entries) + } + if entries[1].path != "/worktrees/add-thing" || entries[1].branch != "feat/42-add-thing" { + t.Errorf("entry[1] = %+v", entries[1]) + } + if entries[2].branch != "" { + t.Errorf("detached-HEAD entry should have empty branch, got %q", entries[2].branch) + } + if entries[3].branch != "" { + t.Errorf("bare-repo entry should have empty branch, got %q", entries[3].branch) + } +} + +func TestMatchWorktree(t *testing.T) { + entries := []worktreeEntry{ + {path: "/repo", branch: "main"}, + {path: "/worktrees/add-thing", branch: "feat/42-add-thing"}, + {path: "/worktrees/detached", branch: ""}, + {path: "/some/path/basename-only-match", branch: ""}, + } + + t.Run("matched by branch pattern", func(t *testing.T) { + branch, path, ok := matchWorktree(entries, "add-thing") + if !ok || branch != "feat/42-add-thing" || path != "/worktrees/add-thing" { + t.Errorf("got branch=%q path=%q ok=%v", branch, path, ok) + } + }) + + t.Run("matched by directory basename fallback", func(t *testing.T) { + branch, path, ok := matchWorktree(entries, "basename-only-match") + if !ok || path != "/some/path/basename-only-match" || branch != "" { + t.Errorf("got branch=%q path=%q ok=%v", branch, path, ok) + } + }) + + t.Run("unmatched", func(t *testing.T) { + _, _, ok := matchWorktree(entries, "no-such-change") + if ok { + t.Error("expected no match") + } + }) + + t.Run("detached HEAD with no branch falls through to basename match", func(t *testing.T) { + // entries[2].branch == "" (detached), but its directory basename + // happens to equal the slug — the documented basename fallback still + // applies; a detached worktree isn't unmatchable, just unmatchable + // by branch. + branch, path, ok := matchWorktree(entries, "detached") + if !ok || branch != "" || path != "/worktrees/detached" { + t.Errorf("got branch=%q path=%q ok=%v", branch, path, ok) + } + }) + + t.Run("bare repo (no entries at all) yields no match, not an error", func(t *testing.T) { + _, _, ok := matchWorktree(nil, "anything") + if ok { + t.Error("expected no match against an empty entry list") + } + }) +}