From ff3bf6622cf770c73c24efbcb07467cdd793047f Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 20 Mar 2026 16:49:33 -0400 Subject: [PATCH 1/3] docs: add multi-project workspaces design spec (#1241) Physical workspace model with three commands: new-workspace, list-workspaces, remove-workspace. Supports both multi-repo orchestration and same-repo feature branch isolation. Co-Authored-By: Claude Opus 4.6 --- ...6-03-20-multi-project-workspaces-design.md | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-20-multi-project-workspaces-design.md diff --git a/docs/superpowers/specs/2026-03-20-multi-project-workspaces-design.md b/docs/superpowers/specs/2026-03-20-multi-project-workspaces-design.md new file mode 100644 index 000000000..1953dd5c2 --- /dev/null +++ b/docs/superpowers/specs/2026-03-20-multi-project-workspaces-design.md @@ -0,0 +1,185 @@ +# Multi-Project Workspaces (`/gsd:new-workspace`) + +**Issue:** #1241 +**Date:** 2026-03-20 +**Status:** Approved + +## Problem + +GSD is tied to one `.planning/` directory per working directory. Users with multiple independent projects (monorepo-style setups with 20+ child repos) or users needing feature branch isolation in the same repo cannot run parallel GSD sessions without manual cloning and state management. + +## Solution + +Three new commands that create, list, and remove **physical workspace directories** — each containing repo copies (git worktrees or clones) and an independent `.planning/` directory. + +This covers two use cases: +- **Multi-repo orchestration (A):** Workspace spanning multiple repos from a parent directory +- **Feature branch isolation (B):** Workspace containing a worktree of the current repo (special case of A where `--repos .`) + +## Commands + +### `/gsd:new-workspace` + +Creates a workspace directory with repo copies and its own `.planning/`. + +``` +/gsd:new-workspace --name feature-b --repos hr-ui,ZeymoAPI --path ~/workspaces/feature-b +/gsd:new-workspace --name feature-b --repos . --strategy worktree # same-repo isolation +``` + +**Arguments:** + +| Flag | Required | Default | Description | +|------|----------|---------|-------------| +| `--name` | Yes | — | Workspace name | +| `--repos` | No | Interactive selection | Comma-separated repo paths or names | +| `--path` | No | `~/gsd-workspaces/` | Target directory | +| `--strategy` | No | `worktree` | `worktree` (lightweight, shared .git) or `clone` (fully independent) | +| `--branch` | No | `workspace/` | Branch to checkout | +| `--auto` | No | false | Skip interactive questions, use defaults | + +### `/gsd:list-workspaces` + +Scans `~/gsd-workspaces/*/WORKSPACE.md` for workspace manifests. Displays table with name, path, repo count, GSD status (has PROJECT.md, current phase). + +### `/gsd:remove-workspace` + +Removes a workspace directory after confirmation. For worktree strategy, runs `git worktree remove` for each member repo first. Refuses if any repo has uncommitted changes. + +## Directory Structure + +``` +~/gsd-workspaces/feature-b/ # workspace root +├── WORKSPACE.md # manifest +├── .planning/ # independent GSD planning directory +│ ├── PROJECT.md # (if user ran /gsd:new-project) +│ ├── STATE.md +│ └── config.json +├── hr-ui/ # git worktree of source repo +│ └── (repo contents on workspace/feature-b branch) +└── ZeymoAPI/ # git worktree of source repo + └── (repo contents on workspace/feature-b branch) +``` + +Key properties: +- `.planning/` is at the workspace root, not inside any individual repo +- Each repo is a peer directory under the workspace root +- `WORKSPACE.md` is the only GSD-specific file at the root (besides `.planning/`) +- For `--strategy clone`, same structure but repos are full clones + +## WORKSPACE.md Format + +```markdown +# Workspace: feature-b + +Created: 2026-03-20 +Strategy: worktree + +## Member Repos + +| Repo | Source | Branch | Strategy | +|------|--------|--------|----------| +| hr-ui | /root/source/repos/hr-ui | workspace/feature-b | worktree | +| ZeymoAPI | /root/source/repos/ZeymoAPI | workspace/feature-b | worktree | + +## Notes + +[User can add context about what this workspace is for] +``` + +## Workflow + +### `/gsd:new-workspace` Workflow Steps + +1. **Setup** — Call `init new-workspace`, parse JSON context +2. **Gather inputs** — If `--name`/`--repos`/`--path` not provided, ask interactively. For repos, show child `.git` directories in cwd as options +3. **Validate** — Target path doesn't exist (or is empty). Source repos exist and are git repos +4. **Create workspace directory** — `mkdir -p ` +5. **Copy repos** — For each repo: + - Worktree: `git worktree add / -b workspace/` + - Clone: `git clone /` +6. **Write WORKSPACE.md** — Manifest with source paths, strategy, branch +7. **Initialize .planning/** — `mkdir -p /.planning` +8. **Offer /gsd:new-project** — Ask if user wants to run project initialization in the new workspace +9. **Commit** — If commit_docs enabled, atomic commit of WORKSPACE.md +10. **Done** — Print workspace path and next steps + +### Init Function (`cmdInitNewWorkspace`) + +Detects: +- Child git repos in cwd (for interactive repo selection) +- Whether target path already exists +- Whether source repos have uncommitted changes +- Whether `git worktree` is available +- Default workspace base dir (`~/gsd-workspaces/`) + +Returns JSON with flags for workflow gating. + +## Error Handling + +### Validation Errors (Block Creation) + +- **Target path exists and is non-empty** — Error with suggestion to pick a different name/path +- **Source repo path doesn't exist or isn't a git repo** — Error listing which repos failed +- **`git worktree add` fails** (e.g., branch exists) — Fall back to `workspace/-` branch, or error if that also fails + +### Graceful Handling + +- **Source repo has uncommitted changes** — Warn but allow (worktrees checkout the branch fresh, don't copy working directory state) +- **Partial failure in multi-repo workspace** — Create workspace with repos that succeeded, report failures, write partial WORKSPACE.md +- **`--repos .` (current repo, case B)** — Detect repo name from directory name or git remote, use as subdirectory name + +### Remove-Workspace Safety + +- **Uncommitted changes in workspace repos** — Refuse removal, print which repos have changes +- **Worktree removal fails** (e.g., source repo deleted) — Warn and continue with directory cleanup +- **Confirmation** — Require explicit confirmation with workspace name typed out + +### List-Workspaces Edge Cases + +- **`~/gsd-workspaces/` doesn't exist** — "No workspaces found" +- **WORKSPACE.md exists but repos inside are gone** — Show workspace, mark repos as missing + +## Testing + +### Unit Tests (`tests/workspace.test.cjs`) + +1. `cmdInitNewWorkspace` returns correct JSON — detects child git repos, validates target path, detects git worktree availability +2. WORKSPACE.md generation — correct format with repo table, strategy, date +3. Repo discovery — identifies `.git` directories in cwd children, skips non-git directories and files +4. Validation — rejects existing non-empty target paths, rejects non-git source paths + +### Integration Tests (same file) + +5. Worktree creation — creates workspace, verifies repo directories are valid git worktrees +6. Clone creation — creates workspace, verifies repos are independent clones +7. List workspaces — creates two workspaces, verifies list output includes both +8. Remove workspace — creates workspace with worktrees, removes it, verifies cleanup +9. Partial failure — one valid repo + one invalid path, workspace created with valid repo only + +All tests use temp directories and clean up after themselves. Follow existing `node:test` + `node:assert` patterns. + +## Implementation Files + +| Component | Path | +|-----------|------| +| Command: new-workspace | `commands/gsd/new-workspace.md` | +| Command: list-workspaces | `commands/gsd/list-workspaces.md` | +| Command: remove-workspace | `commands/gsd/remove-workspace.md` | +| Workflow: new-workspace | `get-shit-done/workflows/new-workspace.md` | +| Workflow: list-workspaces | `get-shit-done/workflows/list-workspaces.md` | +| Workflow: remove-workspace | `get-shit-done/workflows/remove-workspace.md` | +| Init function | `get-shit-done/bin/lib/init.cjs` (add `cmdInitNewWorkspace`, `cmdInitListWorkspaces`, `cmdInitRemoveWorkspace`) | +| Routing | `get-shit-done/bin/gsd-tools.cjs` (add cases to init switch) | +| Tests | `tests/workspace.test.cjs` | + +## Design Decisions + +| Decision | Rationale | +|----------|-----------| +| Physical directories over logical registry | Filesystem is source of truth — matches GSD's existing cwd-based detection pattern | +| Worktree as default strategy | Lightweight (shared .git objects), fast to create, easy to clean up | +| `.planning/` at workspace root | Gives full isolation from individual repo planning. Each workspace is an independent GSD project | +| No central registry | Avoids state drift. `list-workspaces` scans the filesystem directly | +| Case B as special case of A | `--repos .` reuses the same machinery, no special feature-branch code needed | +| Default path `~/gsd-workspaces/` | Predictable location for `list-workspaces` to scan, keeps workspaces out of source repos | From 5c4d5e5f47089aeef67d6fe32bc854685f527a11 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 20 Mar 2026 17:02:48 -0400 Subject: [PATCH 2/3] feat: add multi-project workspace commands (#1241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new commands for managing isolated GSD workspaces: - /gsd:new-workspace — create workspace with repo worktrees/clones - /gsd:list-workspaces — scan ~/gsd-workspaces/ for active workspaces - /gsd:remove-workspace — clean up workspace and git worktrees Supports both multi-repo orchestration (subset of repos from a parent directory) and feature branch isolation (worktree of current repo with independent .planning/). Includes init functions, command routing, workflows, 24 tests, and user documentation. Closes #1241 Co-Authored-By: Claude Opus 4.6 --- commands/gsd/list-workspaces.md | 19 + commands/gsd/new-workspace.md | 44 +++ commands/gsd/remove-workspace.md | 26 ++ docs/COMMANDS.md | 56 +++ docs/USER-GUIDE.md | 25 ++ get-shit-done/bin/gsd-tools.cjs | 11 +- get-shit-done/bin/lib/init.cjs | 163 ++++++++ get-shit-done/workflows/list-workspaces.md | 56 +++ get-shit-done/workflows/new-workspace.md | 237 ++++++++++++ get-shit-done/workflows/remove-workspace.md | 90 +++++ tests/copilot-install.test.cjs | 4 +- tests/workspace.test.cjs | 388 ++++++++++++++++++++ 12 files changed, 1116 insertions(+), 3 deletions(-) create mode 100644 commands/gsd/list-workspaces.md create mode 100644 commands/gsd/new-workspace.md create mode 100644 commands/gsd/remove-workspace.md create mode 100644 get-shit-done/workflows/list-workspaces.md create mode 100644 get-shit-done/workflows/new-workspace.md create mode 100644 get-shit-done/workflows/remove-workspace.md create mode 100644 tests/workspace.test.cjs diff --git a/commands/gsd/list-workspaces.md b/commands/gsd/list-workspaces.md new file mode 100644 index 000000000..932e46dd1 --- /dev/null +++ b/commands/gsd/list-workspaces.md @@ -0,0 +1,19 @@ +--- +name: gsd:list-workspaces +description: List active GSD workspaces and their status +allowed-tools: + - Bash + - Read +--- + +Scan `~/gsd-workspaces/` for workspace directories containing `WORKSPACE.md` manifests. Display a summary table with name, path, repo count, strategy, and GSD project status. + + + +@~/.claude/get-shit-done/workflows/list-workspaces.md +@~/.claude/get-shit-done/references/ui-brand.md + + + +Execute the list-workspaces workflow from @~/.claude/get-shit-done/workflows/list-workspaces.md end-to-end. + diff --git a/commands/gsd/new-workspace.md b/commands/gsd/new-workspace.md new file mode 100644 index 000000000..e340b8037 --- /dev/null +++ b/commands/gsd/new-workspace.md @@ -0,0 +1,44 @@ +--- +name: gsd:new-workspace +description: Create an isolated workspace with repo copies and independent .planning/ +argument-hint: "--name [--repos repo1,repo2] [--path /target] [--strategy worktree|clone] [--branch name] [--auto]" +allowed-tools: + - Read + - Bash + - Write + - AskUserQuestion +--- + +**Flags:** +- `--name` (required) — Workspace name +- `--repos` — Comma-separated repo paths or names. If omitted, interactive selection from child git repos in cwd +- `--path` — Target directory. Defaults to `~/gsd-workspaces/` +- `--strategy` — `worktree` (default, lightweight) or `clone` (fully independent) +- `--branch` — Branch to checkout. Defaults to `workspace/` +- `--auto` — Skip interactive questions, use defaults + + + +Create a physical workspace directory containing copies of specified git repos (as worktrees or clones) with an independent `.planning/` directory for isolated GSD sessions. + +**Use cases:** +- Multi-repo orchestration: work on a subset of repos in parallel with isolated GSD state +- Feature branch isolation: create a worktree of the current repo with its own `.planning/` + +**Creates:** +- `/WORKSPACE.md` — workspace manifest +- `/.planning/` — independent planning directory +- `//` — git worktree or clone for each specified repo + +**After this command:** `cd` into the workspace and run `/gsd:new-project` to initialize GSD. + + + +@~/.claude/get-shit-done/workflows/new-workspace.md +@~/.claude/get-shit-done/references/ui-brand.md + + + +Execute the new-workspace workflow from @~/.claude/get-shit-done/workflows/new-workspace.md end-to-end. +Preserve all workflow gates (validation, approvals, commits, routing). + diff --git a/commands/gsd/remove-workspace.md b/commands/gsd/remove-workspace.md new file mode 100644 index 000000000..2b9855ef3 --- /dev/null +++ b/commands/gsd/remove-workspace.md @@ -0,0 +1,26 @@ +--- +name: gsd:remove-workspace +description: Remove a GSD workspace and clean up worktrees +argument-hint: "" +allowed-tools: + - Bash + - Read + - AskUserQuestion +--- + +**Arguments:** +- `` (required) — Name of the workspace to remove + + + +Remove a workspace directory after confirmation. For worktree strategy, runs `git worktree remove` for each member repo first. Refuses if any repo has uncommitted changes. + + + +@~/.claude/get-shit-done/workflows/remove-workspace.md +@~/.claude/get-shit-done/references/ui-brand.md + + + +Execute the remove-workspace workflow from @~/.claude/get-shit-done/workflows/remove-workspace.md end-to-end. + diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index a9756c29a..a55619ff3 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -32,6 +32,62 @@ Initialize a new project with deep context gathering. --- +### `/gsd:new-workspace` + +Create an isolated workspace with repo copies and independent `.planning/` directory. + +| Flag | Description | +|------|-------------| +| `--name ` | Workspace name (required) | +| `--repos repo1,repo2` | Comma-separated repo paths or names | +| `--path /target` | Target directory (default: `~/gsd-workspaces/`) | +| `--strategy worktree\|clone` | Copy strategy (default: `worktree`) | +| `--branch ` | Branch to checkout (default: `workspace/`) | +| `--auto` | Skip interactive questions | + +**Use cases:** +- Multi-repo: work on a subset of repos with isolated GSD state +- Feature isolation: `--repos .` creates a worktree of the current repo + +**Produces:** `WORKSPACE.md`, `.planning/`, repo copies (worktrees or clones) + +```bash +/gsd:new-workspace --name feature-b --repos hr-ui,ZeymoAPI +/gsd:new-workspace --name feature-b --repos . --strategy worktree # Same-repo isolation +/gsd:new-workspace --name spike --repos api,web --strategy clone # Full clones +``` + +--- + +### `/gsd:list-workspaces` + +List active GSD workspaces and their status. + +**Scans:** `~/gsd-workspaces/` for `WORKSPACE.md` manifests +**Shows:** Name, repo count, strategy, GSD project status + +```bash +/gsd:list-workspaces +``` + +--- + +### `/gsd:remove-workspace` + +Remove a workspace and clean up git worktrees. + +| Argument | Required | Description | +|----------|----------|-------------| +| `` | Yes | Workspace name to remove | + +**Safety:** Refuses removal if any repo has uncommitted changes. Requires name confirmation. + +```bash +/gsd:remove-workspace feature-b +``` + +--- + ### `/gsd:discuss-phase` Capture implementation decisions before planning. diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index d5948897f..6d0bd5b5f 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -625,6 +625,31 @@ claude --dangerously-skip-permissions /gsd:remove-phase 7 # Descope phase 7 and renumber ``` +### Multi-Project Workspaces + +Work on multiple repos or features in parallel with isolated GSD state. + +```bash +# Create a workspace with repos from your monorepo +/gsd:new-workspace --name feature-b --repos hr-ui,ZeymoAPI + +# Feature branch isolation — worktree of current repo with its own .planning/ +/gsd:new-workspace --name feature-b --repos . + +# Then cd into the workspace and initialize GSD +cd ~/gsd-workspaces/feature-b +/gsd:new-project + +# List and manage workspaces +/gsd:list-workspaces +/gsd:remove-workspace feature-b +``` + +Each workspace gets: +- Its own `.planning/` directory (fully independent from source repos) +- Git worktrees (default) or clones of specified repos +- A `WORKSPACE.md` manifest tracking member repos + --- ## Troubleshooting diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index c15104f0b..18fc219df 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -651,8 +651,17 @@ async function main() { case 'progress': init.cmdInitProgress(cwd, raw); break; + case 'new-workspace': + init.cmdInitNewWorkspace(cwd, raw); + break; + case 'list-workspaces': + init.cmdInitListWorkspaces(cwd, raw); + break; + case 'remove-workspace': + init.cmdInitRemoveWorkspace(cwd, args[2], raw); + break; default: - error(`Unknown init workflow: ${workflow}\nAvailable: execute-phase, plan-phase, new-project, new-milestone, quick, resume, verify-work, phase-op, todos, milestone-op, map-codebase, progress`); + error(`Unknown init workflow: ${workflow}\nAvailable: execute-phase, plan-phase, new-project, new-milestone, quick, resume, verify-work, phase-op, todos, milestone-op, map-codebase, progress, new-workspace, list-workspaces, remove-workspace`); } break; } diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index d87302b51..6871fd5b1 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -896,6 +896,165 @@ function cmdInitProgress(cwd, raw) { output(withProjectRoot(cwd, result), raw); } +/** + * Detect child git repos in a directory (one level deep). + * Returns array of { name, path, has_uncommitted } objects. + */ +function detectChildRepos(dir) { + const repos = []; + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return repos; } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('.')) continue; + const fullPath = path.join(dir, entry.name); + const gitDir = path.join(fullPath, '.git'); + if (fs.existsSync(gitDir)) { + let hasUncommitted = false; + try { + const status = execSync('git status --porcelain', { cwd: fullPath, encoding: 'utf8', timeout: 5000 }); + hasUncommitted = status.trim().length > 0; + } catch { /* best-effort */ } + repos.push({ name: entry.name, path: fullPath, has_uncommitted: hasUncommitted }); + } + } + return repos; +} + +function cmdInitNewWorkspace(cwd, raw) { + const homedir = require('os').homedir(); + const defaultBase = path.join(homedir, 'gsd-workspaces'); + + // Detect child git repos for interactive selection + const childRepos = detectChildRepos(cwd); + + // Check if git worktree is available + let worktreeAvailable = false; + try { + execSync('git --version', { encoding: 'utf8', timeout: 5000, stdio: 'pipe' }); + worktreeAvailable = true; + } catch { /* no git at all */ } + + const result = { + default_workspace_base: defaultBase, + child_repos: childRepos, + child_repo_count: childRepos.length, + worktree_available: worktreeAvailable, + is_git_repo: pathExistsInternal(cwd, '.git'), + cwd_repo_name: path.basename(cwd), + }; + + output(withProjectRoot(cwd, result), raw); +} + +function cmdInitListWorkspaces(cwd, raw) { + const homedir = require('os').homedir(); + const defaultBase = path.join(homedir, 'gsd-workspaces'); + + const workspaces = []; + if (fs.existsSync(defaultBase)) { + let entries; + try { entries = fs.readdirSync(defaultBase, { withFileTypes: true }); } catch { entries = []; } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const wsPath = path.join(defaultBase, entry.name); + const manifestPath = path.join(wsPath, 'WORKSPACE.md'); + if (!fs.existsSync(manifestPath)) continue; + + let repoCount = 0; + let hasProject = false; + let strategy = 'unknown'; + try { + const manifest = fs.readFileSync(manifestPath, 'utf8'); + const strategyMatch = manifest.match(/^Strategy:\s*(.+)$/m); + if (strategyMatch) strategy = strategyMatch[1].trim(); + // Count table rows (lines starting with |, excluding header and separator) + const tableRows = manifest.split('\n').filter(l => l.match(/^\|\s*\w/) && !l.includes('Repo') && !l.includes('---')); + repoCount = tableRows.length; + } catch { /* best-effort */ } + hasProject = fs.existsSync(path.join(wsPath, '.planning', 'PROJECT.md')); + + workspaces.push({ + name: entry.name, + path: wsPath, + repo_count: repoCount, + strategy, + has_project: hasProject, + }); + } + } + + const result = { + workspace_base: defaultBase, + workspaces, + workspace_count: workspaces.length, + }; + + output(result, raw); +} + +function cmdInitRemoveWorkspace(cwd, name, raw) { + const homedir = require('os').homedir(); + const defaultBase = path.join(homedir, 'gsd-workspaces'); + + if (!name) { + error('workspace name required for init remove-workspace'); + } + + const wsPath = path.join(defaultBase, name); + const manifestPath = path.join(wsPath, 'WORKSPACE.md'); + + if (!fs.existsSync(wsPath)) { + error(`Workspace not found: ${wsPath}`); + } + + // Parse manifest for repo info + const repos = []; + let strategy = 'unknown'; + if (fs.existsSync(manifestPath)) { + try { + const manifest = fs.readFileSync(manifestPath, 'utf8'); + const strategyMatch = manifest.match(/^Strategy:\s*(.+)$/m); + if (strategyMatch) strategy = strategyMatch[1].trim(); + + // Parse table rows for repo names and source paths + const lines = manifest.split('\n'); + for (const line of lines) { + const match = line.match(/^\|\s*(\S+)\s*\|\s*(\S+)\s*\|\s*(\S+)\s*\|\s*(\S+)\s*\|$/); + if (match && match[1] !== 'Repo' && !match[1].includes('---')) { + repos.push({ name: match[1], source: match[2], branch: match[3], strategy: match[4] }); + } + } + } catch { /* best-effort */ } + } + + // Check for uncommitted changes in workspace repos + const dirtyRepos = []; + for (const repo of repos) { + const repoPath = path.join(wsPath, repo.name); + if (!fs.existsSync(repoPath)) continue; + try { + const status = execSync('git status --porcelain', { cwd: repoPath, encoding: 'utf8', timeout: 5000, stdio: 'pipe' }); + if (status.trim().length > 0) { + dirtyRepos.push(repo.name); + } + } catch { /* best-effort */ } + } + + const result = { + workspace_name: name, + workspace_path: wsPath, + has_manifest: fs.existsSync(manifestPath), + strategy, + repos, + repo_count: repos.length, + dirty_repos: dirtyRepos, + has_dirty_repos: dirtyRepos.length > 0, + }; + + output(result, raw); +} + module.exports = { cmdInitExecutePhase, cmdInitPlanPhase, @@ -909,4 +1068,8 @@ module.exports = { cmdInitMilestoneOp, cmdInitMapCodebase, cmdInitProgress, + cmdInitNewWorkspace, + cmdInitListWorkspaces, + cmdInitRemoveWorkspace, + detectChildRepos, }; diff --git a/get-shit-done/workflows/list-workspaces.md b/get-shit-done/workflows/list-workspaces.md new file mode 100644 index 000000000..9a3cbd6a0 --- /dev/null +++ b/get-shit-done/workflows/list-workspaces.md @@ -0,0 +1,56 @@ + +List all GSD workspaces found in ~/gsd-workspaces/ with their status. + + + +Read all files referenced by the invoking prompt's execution_context before starting. + + + + +## 1. Setup + +```bash +INIT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init list-workspaces) +if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi +``` + +Parse JSON for: `workspace_base`, `workspaces`, `workspace_count`. + +## 2. Display + +**If `workspace_count` is 0:** + +``` +No workspaces found in ~/gsd-workspaces/ + +Create one with: + /gsd:new-workspace --name my-workspace --repos repo1,repo2 +``` + +Done. + +**If workspaces exist:** + +Display a table: + +``` +GSD Workspaces (~/gsd-workspaces/) + +| Name | Repos | Strategy | GSD Project | +|------|-------|----------|-------------| +| feature-a | 3 | worktree | Yes | +| feature-b | 2 | clone | No | + +Manage: + cd ~/gsd-workspaces/ # Enter a workspace + /gsd:remove-workspace # Remove a workspace +``` + +For each workspace, show: +- **Name** — directory name +- **Repos** — count from init data +- **Strategy** — from WORKSPACE.md +- **GSD Project** — whether `.planning/PROJECT.md` exists (Yes/No) + + diff --git a/get-shit-done/workflows/new-workspace.md b/get-shit-done/workflows/new-workspace.md new file mode 100644 index 000000000..35eb3692b --- /dev/null +++ b/get-shit-done/workflows/new-workspace.md @@ -0,0 +1,237 @@ + +Create an isolated workspace directory with git repo copies (worktrees or clones) and an independent `.planning/` directory. Supports multi-repo orchestration and single-repo feature branch isolation. + + + +Read all files referenced by the invoking prompt's execution_context before starting. + + + + +## 1. Setup + +**MANDATORY FIRST STEP — Execute init command:** + +```bash +INIT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init new-workspace) +if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi +``` + +Parse JSON for: `default_workspace_base`, `child_repos`, `child_repo_count`, `worktree_available`, `is_git_repo`, `cwd_repo_name`, `project_root`. + +## 2. Parse Arguments + +Extract from $ARGUMENTS: +- `--name` → `WORKSPACE_NAME` (required) +- `--repos` → `REPO_LIST` (comma-separated paths or names) +- `--path` → `TARGET_PATH` (defaults to `$default_workspace_base/$WORKSPACE_NAME`) +- `--strategy` → `STRATEGY` (defaults to `worktree`) +- `--branch` → `BRANCH_NAME` (defaults to `workspace/$WORKSPACE_NAME`) +- `--auto` → skip interactive questions + +**If `--name` is missing and not `--auto`:** + +Use AskUserQuestion: +- header: "Workspace Name" +- question: "What should this workspace be called?" +- requireAnswer: true + +## 3. Select Repos + +**If `--repos` is provided:** Parse comma-separated values. For each value: +- If it's an absolute path, use it directly +- If it's a relative path or name, resolve against `$project_root` +- Special case: `.` means current repo (use `$project_root`, name it `$cwd_repo_name`) + +**If `--repos` is NOT provided and not `--auto`:** + +**If `child_repo_count` > 0:** + +Present child repos for selection: + +Use AskUserQuestion: +- header: "Select Repos" +- question: "Which repos should be included in the workspace?" +- options: List each child repo from `child_repos` array by name +- multiSelect: true + +**If `child_repo_count` is 0 and `is_git_repo` is true:** + +Use AskUserQuestion: +- header: "Current Repo" +- question: "No child repos found. Create a workspace with the current repo?" +- options: + - "Yes — create workspace with current repo" → use current repo + - "Cancel" → exit + +**If `child_repo_count` is 0 and `is_git_repo` is false:** + +Error: +``` +No git repos found in the current directory and this is not a git repo. + +Run this command from a directory containing git repos, or specify repos explicitly: + /gsd:new-workspace --name my-workspace --repos /path/to/repo1,/path/to/repo2 +``` +Exit. + +**If `--auto` and `--repos` is NOT provided:** + +Error: +``` +Error: --auto requires --repos to specify which repos to include. + +Usage: + /gsd:new-workspace --name my-workspace --repos repo1,repo2 --auto +``` +Exit. + +## 4. Select Strategy + +**If `--strategy` is provided:** Use it (validate: must be `worktree` or `clone`). + +**If `--strategy` is NOT provided and not `--auto`:** + +Use AskUserQuestion: +- header: "Strategy" +- question: "How should repos be copied into the workspace?" +- options: + - "Worktree (recommended) — lightweight, shares .git objects with source repo" → `worktree` + - "Clone — fully independent copy, no connection to source repo" → `clone` + +**If `--auto`:** Default to `worktree`. + +## 5. Validate + +Before creating anything, validate: + +1. **Target path** — must not exist or must be empty: +```bash +if [ -d "$TARGET_PATH" ] && [ "$(ls -A "$TARGET_PATH" 2>/dev/null)" ]; then + echo "Error: Target path already exists and is not empty: $TARGET_PATH" + echo "Choose a different --name or --path." + exit 1 +fi +``` + +2. **Source repos exist and are git repos** — for each repo path: +```bash +if [ ! -d "$REPO_PATH/.git" ]; then + echo "Error: Not a git repo: $REPO_PATH" + exit 1 +fi +``` + +3. **Worktree availability** — if strategy is `worktree` and `worktree_available` is false: +``` +Error: git is not available. Install git or use --strategy clone. +``` + +Report all validation errors at once, not one at a time. + +## 6. Create Workspace + +```bash +mkdir -p "$TARGET_PATH" +``` + +### For each repo: + +**Worktree strategy:** +```bash +cd "$SOURCE_REPO_PATH" +git worktree add "$TARGET_PATH/$REPO_NAME" -b "$BRANCH_NAME" 2>&1 +``` + +If `git worktree add` fails because the branch already exists, try with a timestamped branch: +```bash +TIMESTAMP=$(date +%Y%m%d%H%M%S) +git worktree add "$TARGET_PATH/$REPO_NAME" -b "${BRANCH_NAME}-${TIMESTAMP}" 2>&1 +``` + +If that also fails, report the error and continue with remaining repos. + +**Clone strategy:** +```bash +git clone "$SOURCE_REPO_PATH" "$TARGET_PATH/$REPO_NAME" 2>&1 +cd "$TARGET_PATH/$REPO_NAME" +git checkout -b "$BRANCH_NAME" 2>&1 +``` + +Track results: which repos succeeded, which failed, what branch was used. + +## 7. Write WORKSPACE.md + +Write the workspace manifest at `$TARGET_PATH/WORKSPACE.md`: + +```markdown +# Workspace: $WORKSPACE_NAME + +Created: $DATE +Strategy: $STRATEGY + +## Member Repos + +| Repo | Source | Branch | Strategy | +|------|--------|--------|----------| +| $REPO_NAME | $SOURCE_PATH | $BRANCH | $STRATEGY | +...for each repo... + +## Notes + +[Add context about what this workspace is for] +``` + +## 8. Initialize .planning/ + +```bash +mkdir -p "$TARGET_PATH/.planning" +``` + +## 9. Report and Next Steps + +**If all repos succeeded:** + +``` +Workspace created: $TARGET_PATH + + Repos: $REPO_COUNT + Strategy: $STRATEGY + Branch: $BRANCH_NAME + +Next steps: + cd $TARGET_PATH + /gsd:new-project # Initialize GSD in the workspace +``` + +**If some repos failed:** + +``` +Workspace created with $SUCCESS_COUNT of $TOTAL_COUNT repos: $TARGET_PATH + + Succeeded: repo1, repo2 + Failed: repo3 (branch already exists), repo4 (not a git repo) + +Next steps: + cd $TARGET_PATH + /gsd:new-project # Initialize GSD in the workspace +``` + +**Offer to initialize GSD (if not `--auto`):** + +Use AskUserQuestion: +- header: "Initialize GSD" +- question: "Would you like to initialize a GSD project in the new workspace?" +- options: + - "Yes — run /gsd:new-project" → tell user to `cd $TARGET_PATH` first, then run `/gsd:new-project` + - "No — I'll set it up later" → done + + + + +- [ ] Workspace directory created at target path +- [ ] All specified repos copied (worktree or clone) into workspace +- [ ] WORKSPACE.md manifest written with correct repo table +- [ ] `.planning/` directory initialized at workspace root +- [ ] User informed of workspace path and next steps + diff --git a/get-shit-done/workflows/remove-workspace.md b/get-shit-done/workflows/remove-workspace.md new file mode 100644 index 000000000..321986744 --- /dev/null +++ b/get-shit-done/workflows/remove-workspace.md @@ -0,0 +1,90 @@ + +Remove a GSD workspace, cleaning up git worktrees and deleting the workspace directory. + + + +Read all files referenced by the invoking prompt's execution_context before starting. + + + + +## 1. Setup + +Extract workspace name from $ARGUMENTS. + +```bash +INIT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init remove-workspace "$WORKSPACE_NAME") +if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi +``` + +Parse JSON for: `workspace_name`, `workspace_path`, `has_manifest`, `strategy`, `repos`, `repo_count`, `dirty_repos`, `has_dirty_repos`. + +**If no workspace name provided:** + +First run `/gsd:list-workspaces` to show available workspaces, then ask: + +Use AskUserQuestion: +- header: "Remove Workspace" +- question: "Which workspace do you want to remove?" +- requireAnswer: true + +Re-run init with the provided name. + +## 2. Safety Checks + +**If `has_dirty_repos` is true:** + +``` +Cannot remove workspace "$WORKSPACE_NAME" — the following repos have uncommitted changes: + + - repo1 + - repo2 + +Commit or stash changes in these repos before removing the workspace: + cd $WORKSPACE_PATH/repo1 + git stash # or git commit +``` + +Exit. Do NOT proceed. + +## 3. Confirm Removal + +Use AskUserQuestion: +- header: "Confirm Removal" +- question: "Remove workspace '$WORKSPACE_NAME' at $WORKSPACE_PATH? This will delete all files in the workspace directory. Type the workspace name to confirm:" +- requireAnswer: true + +**If answer does not match `$WORKSPACE_NAME`:** Exit with "Removal cancelled." + +## 4. Clean Up Worktrees + +**If strategy is `worktree`:** + +For each repo in the workspace: + +```bash +cd "$SOURCE_REPO_PATH" +git worktree remove "$WORKSPACE_PATH/$REPO_NAME" 2>&1 || true +``` + +If `git worktree remove` fails, warn but continue: +``` +Warning: Could not remove worktree for $REPO_NAME — source repo may have been moved or deleted. +``` + +## 5. Delete Workspace Directory + +```bash +rm -rf "$WORKSPACE_PATH" +``` + +## 6. Report + +``` +Workspace "$WORKSPACE_NAME" removed. + + Path: $WORKSPACE_PATH (deleted) + Repos: $REPO_COUNT worktrees cleaned up +``` + + diff --git a/tests/copilot-install.test.cjs b/tests/copilot-install.test.cjs index 06061caf7..297a94047 100644 --- a/tests/copilot-install.test.cjs +++ b/tests/copilot-install.test.cjs @@ -625,7 +625,7 @@ describe('copyCommandsAsCopilotSkills', () => { // Count gsd-* directories — should be 31 const dirs = fs.readdirSync(tempDir, { withFileTypes: true }) .filter(e => e.isDirectory() && e.name.startsWith('gsd-')); - assert.strictEqual(dirs.length, 50, `expected 50 skill folders, got ${dirs.length}`); + assert.strictEqual(dirs.length, 53, `expected 53 skill folders, got ${dirs.length}`); } finally { fs.rmSync(tempDir, { recursive: true }); } @@ -1119,7 +1119,7 @@ const { execFileSync } = require('child_process'); const crypto = require('crypto'); const INSTALL_PATH = path.join(__dirname, '..', 'bin', 'install.js'); -const EXPECTED_SKILLS = 50; +const EXPECTED_SKILLS = 53; const EXPECTED_AGENTS = 17; function runCopilotInstall(cwd) { diff --git a/tests/workspace.test.cjs b/tests/workspace.test.cjs new file mode 100644 index 000000000..48ccfe572 --- /dev/null +++ b/tests/workspace.test.cjs @@ -0,0 +1,388 @@ +/** + * GSD Workspace Tests + * + * Tests for /gsd:new-workspace, /gsd:list-workspaces, /gsd:remove-workspace + * init functions and integration with gsd-tools routing. + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execSync } = require('child_process'); +const { runGsdTools, cleanup } = require('./helpers.cjs'); +const { detectChildRepos } = require('../get-shit-done/bin/lib/init.cjs'); + +// ─── detectChildRepos ──────────────────────────────────────────────────────── + +describe('detectChildRepos', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ws-test-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('detects child git repos', () => { + // Create two child git repos + const repo1 = path.join(tmpDir, 'repo-a'); + const repo2 = path.join(tmpDir, 'repo-b'); + fs.mkdirSync(repo1); + fs.mkdirSync(repo2); + execSync('git init', { cwd: repo1, stdio: 'pipe' }); + execSync('git init', { cwd: repo2, stdio: 'pipe' }); + + const repos = detectChildRepos(tmpDir); + assert.strictEqual(repos.length, 2); + const names = repos.map(r => r.name).sort(); + assert.deepStrictEqual(names, ['repo-a', 'repo-b']); + }); + + test('skips non-git directories', () => { + const gitRepo = path.join(tmpDir, 'real-repo'); + const notRepo = path.join(tmpDir, 'just-a-dir'); + fs.mkdirSync(gitRepo); + fs.mkdirSync(notRepo); + execSync('git init', { cwd: gitRepo, stdio: 'pipe' }); + + const repos = detectChildRepos(tmpDir); + assert.strictEqual(repos.length, 1); + assert.strictEqual(repos[0].name, 'real-repo'); + }); + + test('skips hidden directories', () => { + const hiddenRepo = path.join(tmpDir, '.hidden-repo'); + fs.mkdirSync(hiddenRepo); + execSync('git init', { cwd: hiddenRepo, stdio: 'pipe' }); + + const repos = detectChildRepos(tmpDir); + assert.strictEqual(repos.length, 0); + }); + + test('skips files', () => { + fs.writeFileSync(path.join(tmpDir, 'some-file.txt'), 'hello'); + const repos = detectChildRepos(tmpDir); + assert.strictEqual(repos.length, 0); + }); + + test('returns empty array for non-existent directory', () => { + const repos = detectChildRepos(path.join(tmpDir, 'does-not-exist')); + assert.strictEqual(repos.length, 0); + }); +}); + +// ─── cmdInitNewWorkspace via gsd-tools ────────────────────────────────────── + +describe('init new-workspace', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ws-test-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('returns expected JSON fields', () => { + const result = runGsdTools('init new-workspace', tmpDir); + assert.ok(result.success, `init failed: ${result.error}`); + const data = JSON.parse(result.output); + assert.ok('default_workspace_base' in data); + assert.ok('child_repos' in data); + assert.ok('child_repo_count' in data); + assert.ok('worktree_available' in data); + assert.ok('is_git_repo' in data); + assert.ok('cwd_repo_name' in data); + assert.ok('project_root' in data); + }); + + test('detects child git repos in cwd', () => { + const repo = path.join(tmpDir, 'my-repo'); + fs.mkdirSync(repo); + execSync('git init', { cwd: repo, stdio: 'pipe' }); + + const result = runGsdTools('init new-workspace', tmpDir); + const data = JSON.parse(result.output); + assert.strictEqual(data.child_repo_count, 1); + assert.strictEqual(data.child_repos[0].name, 'my-repo'); + }); + + test('reports no git repo when cwd is not a git repo', () => { + const result = runGsdTools('init new-workspace', tmpDir); + const data = JSON.parse(result.output); + assert.strictEqual(data.is_git_repo, false); + }); +}); + +// ─── cmdInitListWorkspaces via gsd-tools ──────────────────────────────────── + +describe('init list-workspaces', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ws-test-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('returns empty list when no workspaces exist', () => { + const result = runGsdTools('init list-workspaces', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init failed: ${result.error}`); + const data = JSON.parse(result.output); + assert.strictEqual(data.workspace_count, 0); + assert.deepStrictEqual(data.workspaces, []); + }); + + test('finds workspaces with WORKSPACE.md', () => { + const wsBase = path.join(tmpDir, 'gsd-workspaces'); + const ws1 = path.join(wsBase, 'feature-a'); + fs.mkdirSync(path.join(ws1, '.planning'), { recursive: true }); + fs.writeFileSync(path.join(ws1, 'WORKSPACE.md'), [ + '# Workspace: feature-a', + '', + 'Created: 2026-03-20', + 'Strategy: worktree', + '', + '## Member Repos', + '', + '| Repo | Source | Branch | Strategy |', + '|------|--------|--------|----------|', + '| hr-ui | /tmp/hr-ui | workspace/feature-a | worktree |', + ].join('\n')); + + const result = runGsdTools('init list-workspaces', tmpDir, { HOME: tmpDir }); + const data = JSON.parse(result.output); + assert.strictEqual(data.workspace_count, 1); + assert.strictEqual(data.workspaces[0].name, 'feature-a'); + assert.strictEqual(data.workspaces[0].strategy, 'worktree'); + assert.strictEqual(data.workspaces[0].repo_count, 1); + }); +}); + +// ─── cmdInitRemoveWorkspace via gsd-tools ─────────────────────────────────── + +describe('init remove-workspace', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ws-test-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('errors when no name provided', () => { + const result = runGsdTools('init remove-workspace', tmpDir); + assert.strictEqual(result.success, false); + assert.ok(result.error.includes('workspace name required')); + }); + + test('errors when workspace not found', () => { + const result = runGsdTools('init remove-workspace nonexistent', tmpDir, { HOME: tmpDir }); + assert.strictEqual(result.success, false); + assert.ok(result.error.includes('Workspace not found')); + }); + + test('returns workspace info for existing workspace', () => { + const wsBase = path.join(tmpDir, 'gsd-workspaces'); + const ws = path.join(wsBase, 'test-ws'); + fs.mkdirSync(ws, { recursive: true }); + fs.writeFileSync(path.join(ws, 'WORKSPACE.md'), [ + '# Workspace: test-ws', + '', + 'Created: 2026-03-20', + 'Strategy: clone', + '', + '## Member Repos', + '', + '| Repo | Source | Branch | Strategy |', + '|------|--------|--------|----------|', + '| api | /tmp/api | workspace/test-ws | clone |', + ].join('\n')); + + const result = runGsdTools('init remove-workspace test-ws', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init failed: ${result.error}`); + const data = JSON.parse(result.output); + assert.strictEqual(data.workspace_name, 'test-ws'); + assert.strictEqual(data.strategy, 'clone'); + assert.strictEqual(data.has_dirty_repos, false); + }); +}); + +// ─── Integration: worktree creation and removal ───────────────────────────── + +describe('workspace worktree integration', () => { + let tmpDir; + let sourceRepo; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ws-integ-')); + // Create a source git repo with a commit + sourceRepo = path.join(tmpDir, 'source-repo'); + fs.mkdirSync(sourceRepo); + execSync('git init', { cwd: sourceRepo, stdio: 'pipe' }); + execSync('git config user.email "test@test.com"', { cwd: sourceRepo, stdio: 'pipe' }); + execSync('git config user.name "Test"', { cwd: sourceRepo, stdio: 'pipe' }); + fs.writeFileSync(path.join(sourceRepo, 'README.md'), '# Test Repo\n'); + execSync('git add -A', { cwd: sourceRepo, stdio: 'pipe' }); + execSync('git commit -m "initial"', { cwd: sourceRepo, stdio: 'pipe' }); + }); + + afterEach(() => { + // Clean up worktrees before removing tmp dir + try { + execSync('git worktree prune', { cwd: sourceRepo, stdio: 'pipe' }); + } catch { /* best-effort */ } + cleanup(tmpDir); + }); + + test('creates workspace with git worktree', () => { + const wsPath = path.join(tmpDir, 'my-workspace'); + fs.mkdirSync(wsPath); + fs.mkdirSync(path.join(wsPath, '.planning')); + + // Create worktree + execSync(`git worktree add "${path.join(wsPath, 'source-repo')}" -b workspace/test`, { + cwd: sourceRepo, + stdio: 'pipe', + }); + + // Verify worktree was created + assert.ok(fs.existsSync(path.join(wsPath, 'source-repo', 'README.md'))); + + // Verify it's a worktree (has .git file, not .git directory) + const gitPath = path.join(wsPath, 'source-repo', '.git'); + assert.ok(fs.existsSync(gitPath)); + const stat = fs.statSync(gitPath); + assert.ok(stat.isFile(), '.git should be a file (worktree link), not a directory'); + }); + + test('creates workspace with git clone', () => { + const wsPath = path.join(tmpDir, 'cloned-workspace'); + fs.mkdirSync(wsPath); + + // Clone repo + execSync(`git clone "${sourceRepo}" "${path.join(wsPath, 'source-repo')}"`, { + stdio: 'pipe', + }); + + // Verify clone + assert.ok(fs.existsSync(path.join(wsPath, 'source-repo', 'README.md'))); + + // Verify it's a full clone (has .git directory) + const gitPath = path.join(wsPath, 'source-repo', '.git'); + const stat = fs.statSync(gitPath); + assert.ok(stat.isDirectory(), '.git should be a directory (full clone)'); + }); + + test('worktree removal cleans up properly', () => { + const wsPath = path.join(tmpDir, 'removable-ws'); + fs.mkdirSync(wsPath); + + // Create worktree + execSync(`git worktree add "${path.join(wsPath, 'source-repo')}" -b workspace/removable`, { + cwd: sourceRepo, + stdio: 'pipe', + }); + + assert.ok(fs.existsSync(path.join(wsPath, 'source-repo', 'README.md'))); + + // Remove worktree + execSync(`git worktree remove "${path.join(wsPath, 'source-repo')}"`, { + cwd: sourceRepo, + stdio: 'pipe', + }); + + // Verify worktree is gone + assert.ok(!fs.existsSync(path.join(wsPath, 'source-repo'))); + + // Verify worktree list doesn't include it + const worktrees = execSync('git worktree list', { cwd: sourceRepo, encoding: 'utf8' }); + assert.ok(!worktrees.includes('removable-ws')); + }); +}); + +// ─── Command and workflow file existence ──────────────────────────────────── + +describe('workspace command files', () => { + const baseDir = path.join(__dirname, '..'); + + test('new-workspace command exists with correct frontmatter', () => { + const content = fs.readFileSync(path.join(baseDir, 'commands/gsd/new-workspace.md'), 'utf8'); + assert.ok(content.includes('name: gsd:new-workspace')); + assert.ok(content.includes('--name')); + assert.ok(content.includes('--repos')); + assert.ok(content.includes('--strategy')); + assert.ok(content.includes('workflows/new-workspace.md')); + }); + + test('list-workspaces command exists with correct frontmatter', () => { + const content = fs.readFileSync(path.join(baseDir, 'commands/gsd/list-workspaces.md'), 'utf8'); + assert.ok(content.includes('name: gsd:list-workspaces')); + assert.ok(content.includes('workflows/list-workspaces.md')); + }); + + test('remove-workspace command exists with correct frontmatter', () => { + const content = fs.readFileSync(path.join(baseDir, 'commands/gsd/remove-workspace.md'), 'utf8'); + assert.ok(content.includes('name: gsd:remove-workspace')); + assert.ok(content.includes('workflows/remove-workspace.md')); + }); + + test('new-workspace workflow exists', () => { + const content = fs.readFileSync(path.join(baseDir, 'get-shit-done/workflows/new-workspace.md'), 'utf8'); + assert.ok(content.includes('init new-workspace')); + assert.ok(content.includes('WORKSPACE.md')); + assert.ok(content.includes('git worktree add')); + assert.ok(content.includes('git clone')); + }); + + test('list-workspaces workflow exists', () => { + const content = fs.readFileSync(path.join(baseDir, 'get-shit-done/workflows/list-workspaces.md'), 'utf8'); + assert.ok(content.includes('init list-workspaces')); + }); + + test('remove-workspace workflow exists', () => { + const content = fs.readFileSync(path.join(baseDir, 'get-shit-done/workflows/remove-workspace.md'), 'utf8'); + assert.ok(content.includes('init remove-workspace')); + assert.ok(content.includes('git worktree remove')); + }); +}); + +// ─── Routing in gsd-tools ─────────────────────────────────────────────────── + +describe('workspace routing in gsd-tools', () => { + test('init new-workspace is routed correctly', () => { + const toolsContent = fs.readFileSync( + path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs'), + 'utf8' + ); + assert.ok(toolsContent.includes("case 'new-workspace'")); + assert.ok(toolsContent.includes('cmdInitNewWorkspace')); + }); + + test('init list-workspaces is routed correctly', () => { + const toolsContent = fs.readFileSync( + path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs'), + 'utf8' + ); + assert.ok(toolsContent.includes("case 'list-workspaces'")); + assert.ok(toolsContent.includes('cmdInitListWorkspaces')); + }); + + test('init remove-workspace is routed correctly', () => { + const toolsContent = fs.readFileSync( + path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs'), + 'utf8' + ); + assert.ok(toolsContent.includes("case 'remove-workspace'")); + assert.ok(toolsContent.includes('cmdInitRemoveWorkspace')); + }); +}); From ef4453ebf5b394f461c006b825d57f779587311e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 20 Mar 2026 17:08:50 -0400 Subject: [PATCH 3/3] fix: respect HOME env var in workspace init functions for Windows compat On Windows, os.homedir() reads USERPROFILE, not HOME. Tests pass HOME override for sandboxing. Use process.env.HOME with os.homedir() fallback so tests work cross-platform. Co-Authored-By: Claude Opus 4.6 --- get-shit-done/bin/lib/init.cjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index 6871fd5b1..5b8d5b8f2 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -922,7 +922,7 @@ function detectChildRepos(dir) { } function cmdInitNewWorkspace(cwd, raw) { - const homedir = require('os').homedir(); + const homedir = process.env.HOME || require('os').homedir(); const defaultBase = path.join(homedir, 'gsd-workspaces'); // Detect child git repos for interactive selection @@ -948,7 +948,7 @@ function cmdInitNewWorkspace(cwd, raw) { } function cmdInitListWorkspaces(cwd, raw) { - const homedir = require('os').homedir(); + const homedir = process.env.HOME || require('os').homedir(); const defaultBase = path.join(homedir, 'gsd-workspaces'); const workspaces = []; @@ -994,7 +994,7 @@ function cmdInitListWorkspaces(cwd, raw) { } function cmdInitRemoveWorkspace(cwd, name, raw) { - const homedir = require('os').homedir(); + const homedir = process.env.HOME || require('os').homedir(); const defaultBase = path.join(homedir, 'gsd-workspaces'); if (!name) {