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/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 | 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..5b8d5b8f2 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 = process.env.HOME || 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 = process.env.HOME || 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 = process.env.HOME || 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')); + }); +});