diff --git a/README.md b/README.md index 5dfbbc9..ca22e3d 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,9 @@ Design principles: - `wspace check` — read-only baseline check. Reports `CLEAN`, `DIRTY`, `FEATURE_CLEAN`, `DIVERGED`, `UNKNOWN`, `MISSING`, and `UNMANAGED` states. -- `wspace init` — clone missing repositories from the manifest. Prints a warning - that fresh clones lack gitignored files and repo-specific setup. +- `wspace init []` — clone missing repositories from the manifest (or + only a specified subset of repos). Prints a warning that fresh clones lack + gitignored files and repo-specific setup. - `wspace update` — fetch remotes and fast-forward only clean default branches. - `wspace worktree add []` — create a git worktree under `worktrees///`, branching from the repo's default-branch diff --git a/deno.json b/deno.json index c71da3d..96c083d 100644 --- a/deno.json +++ b/deno.json @@ -20,6 +20,6 @@ "test": "deno test --allow-read --allow-write --allow-run" }, "publish": { - "include": ["src/", "schema/", "LICENSE", "README.md"] + "include": ["src/", "schema/", "skills/", "LICENSE", "README.md"] } } diff --git a/skills/wspace/SKILL.md b/skills/wspace/SKILL.md new file mode 100644 index 0000000..e1cefcb --- /dev/null +++ b/skills/wspace/SKILL.md @@ -0,0 +1,99 @@ +--- +name: wspace +description: Manage multi-repo Wazoo workspaces, feature worktrees, conservative default-branch updates, and secret sync using wspace CLI. Use when managing multi-repo workflows, creating feature worktrees, checking baseline health, syncing secrets, or running wspace commands. +--- + +# `wspace` workspace skill + +Orchestrate multi-repo development, isolate tasks in Git worktrees, refresh +default branch baselines safely, and synchronize environment secrets using +`wspace`. + +## Quick start + +Execute all workspace commands from the workspace root containing `repos.json`: + +```sh +# Inspect workspace baseline health +wspace check + +# Clone missing repositories (or a specific subset of repos) +wspace init [repo1 repo2 ...] + +# Fast-forward clean default branches safely +wspace update + +# Create isolated feature worktree anchored to origin/ +wspace worktree add +# (Or using raw git): git -C repos/ worktree add "$PWD/worktrees//" -b + +# Propagate local secrets to checkouts and worktrees +wspace env sync + +# Push branch and open PR from inside worktrees// +git push -u origin +env GITHUB_TOKEN="" gh pr create + +# List fully merged stale worktrees and clean up +wspace worktree list --stale +wspace worktree remove +``` + +## Worktree lifecycle and workflows + +### Feature worktree creation and isolation + +To start work on a feature, bug fix, or agent task: + +1. **Baseline health check**: Run `wspace check` to verify the repository is + clean or on a default branch. +2. **Refresh upstreams**: Run `wspace update` to fetch remotes and fast-forward + clean default branches (`merge --ff-only`). +3. **Provision worktree**: Run `wspace worktree add ` or + `git -C repos/ worktree add "$PWD/worktrees//" -b `. + - **Baseline rule**: `wspace worktree add` automatically branches from + `origin/` (via `origin/HEAD`) using `--no-track`, preventing + accidental forks from dirty local `HEAD` references. + - **Path rule**: Always use `$PWD` when running `git -C repos/` so + worktrees resolve to `worktrees//` at the workspace root + rather than nested under `repos//`. +4. **Sync secrets**: Run `wspace env sync` (or `--dry-run` to preview) to copy + secrets from `secrets//` into the new worktree. +5. **Develop and commit**: Perform changes strictly inside + `worktrees///`. Keep `repos//` clean. + +### Push, PR, and worktree cleanup + +1. **Push and create PR**: Inside `worktrees///`, run: + ```sh + git push -u origin + env GITHUB_TOKEN="" gh pr create + ``` +2. **Identify stale worktrees**: After PR merge, run + `wspace worktree list --stale`. + - **Staleness criteria**: Identifies worktrees whose branch has no unique + commits beyond `origin/` (fully merged) or whose branch ref is + deleted. +3. **Teardown worktree**: Run `wspace worktree remove `. + - **Cleanup**: Removes the worktree, prunes stale git references, and cleans + up empty parent directories (`worktrees//`). + +### Machine inspection for agents + +To verify workspace integrity before cross-repo edits: + +- Call `wspace check --json` to receive structured state per repo (`CLEAN`, + `DIRTY`, `FEATURE_CLEAN`, `DIVERGED`, `UNKNOWN`, `MISSING`, `INVALID`, + `WORKTREE_DIRTY`, `ERROR`). +- If `wspace check` exits with `1` or returns any state other than `CLEAN` or + `FEATURE_CLEAN`, halt or request user resolution before applying multi-repo + edits. + +## Guiding principles + +- **Root anchor**: The workspace root is the single source of truth; all paths + resolve relative to the directory containing `repos.json`. +- **Conservative mutation**: `wspace update` never resets, rebases, stashes, or + rewrites history. It skips dirty or feature branches. +- **Central secret vault**: Never write `.env` files directly in `repos/` or + `worktrees/`. Always edit `secrets//` and run `wspace env sync`. diff --git a/src/cli.ts b/src/cli.ts index 2dd42f5..f88f478 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -52,8 +52,8 @@ function usage(): void { Usage: wspace check [--json] - wspace init [--json] - wspace sync [--json] + wspace init [] [--json] + wspace sync [] [--json] wspace update [--json] wspace worktree add [] wspace worktree list [--stale] [--json] @@ -243,10 +243,26 @@ async function cloneMissing( g: GitRunner, manifest: WorkspaceManifest, paths: ManifestPaths, + targets: string[] = [], ): Promise<{ name: string; state: string; detail?: string }[]> { await Deno.mkdir(paths.repositoriesDirectory, { recursive: true }); + let repositories = manifest.repositories; + if (targets.length > 0) { + const validNames = new Set(manifest.repositories.map((r) => r.name)); + for (const name of targets) { + if (!validNames.has(name)) { + return [{ + name, + state: "UNKNOWN_REPO", + detail: `Repository "${name}" not found in manifest`, + }]; + } + } + const targetSet = new Set(targets); + repositories = repositories.filter((r) => targetSet.has(r.name)); + } const rows: { name: string; state: string; detail?: string }[] = []; - for (const repository of manifest.repositories) { + for (const repository of repositories) { const repoPath = resolveRepositoryPath(repository, paths); if (await exists(repoPath)) { if (await exists(join(repoPath, ".git"))) { @@ -290,7 +306,10 @@ async function runCommand( } case "sync": case "init": { - const rows = await cloneMissing(g, manifest, paths); + const targets = opts.subcommand + ? [opts.subcommand, ...opts.positional] + : []; + const rows = await cloneMissing(g, manifest, paths, targets); if (opts.json) { console.log(JSON.stringify(rows, null, 2)); } else { @@ -300,7 +319,8 @@ async function runCommand( (r) => r.state === "CLONE_FAILED" || r.state === "PATH_BLOCKED" || - r.state === "INVALID", + r.state === "INVALID" || + r.state === "UNKNOWN_REPO", ); if (opts.command === "init" && !failed) { console.error( diff --git a/tests/integration_test.ts b/tests/integration_test.ts index aac1010..1562892 100644 --- a/tests/integration_test.ts +++ b/tests/integration_test.ts @@ -362,6 +362,42 @@ Deno.test("wspace init clones missing repositories", async () => { } }); +Deno.test("wspace init clones only specified subset of repositories", async () => { + const dir = await Deno.makeTempDir(); + try { + await makeRepoWithMain(dir, "a"); + await makeRepoWithMain(dir, "b"); + await makeRepoWithMain(dir, "c"); + await Deno.remove(join(dir, "b"), { recursive: true }); + await Deno.remove(join(dir, "c"), { recursive: true }); + const manifestPath = join(dir, "repos.json"); + await Deno.writeTextFile( + manifestPath, + JSON.stringify({ + repositories: [ + { name: "a", url: join(dir, "a.git"), path: join(dir, "a") }, + { name: "b", url: join(dir, "b.git"), path: join(dir, "b") }, + { name: "c", url: join(dir, "c.git"), path: join(dir, "c") }, + ], + }), + ); + const code = await run(["init", "b", "--manifest", manifestPath]); + assertEquals(code, 0); + assertEquals( + await exists(join(dir, "b", ".git")), + true, + "specified repo b should be cloned", + ); + assertEquals( + await exists(join(dir, "c", ".git")), + false, + "unspecified repo c should not be cloned", + ); + } finally { + await Deno.remove(dir, { recursive: true }); + } +}); + Deno.test("wspace check reports CLEAN via CLI", async () => { const dir = await Deno.makeTempDir(); try {