Merge pull request #1267 from gsd-build/feat/multi-project-workspaces-1241

feat: add multi-project workspace commands
This commit is contained in:
Tom Boucher
2026-03-20 21:57:13 -04:00
committed by GitHub
13 changed files with 1301 additions and 3 deletions

View File

@@ -0,0 +1,19 @@
---
name: gsd:list-workspaces
description: List active GSD workspaces and their status
allowed-tools:
- Bash
- Read
---
<objective>
Scan `~/gsd-workspaces/` for workspace directories containing `WORKSPACE.md` manifests. Display a summary table with name, path, repo count, strategy, and GSD project status.
</objective>
<execution_context>
@~/.claude/get-shit-done/workflows/list-workspaces.md
@~/.claude/get-shit-done/references/ui-brand.md
</execution_context>
<process>
Execute the list-workspaces workflow from @~/.claude/get-shit-done/workflows/list-workspaces.md end-to-end.
</process>

View File

@@ -0,0 +1,44 @@
---
name: gsd:new-workspace
description: Create an isolated workspace with repo copies and independent .planning/
argument-hint: "--name <name> [--repos repo1,repo2] [--path /target] [--strategy worktree|clone] [--branch name] [--auto]"
allowed-tools:
- Read
- Bash
- Write
- AskUserQuestion
---
<context>
**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/<name>`
- `--strategy` — `worktree` (default, lightweight) or `clone` (fully independent)
- `--branch` — Branch to checkout. Defaults to `workspace/<name>`
- `--auto` — Skip interactive questions, use defaults
</context>
<objective>
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:**
- `<path>/WORKSPACE.md` — workspace manifest
- `<path>/.planning/` — independent planning directory
- `<path>/<repo>/` — git worktree or clone for each specified repo
**After this command:** `cd` into the workspace and run `/gsd:new-project` to initialize GSD.
</objective>
<execution_context>
@~/.claude/get-shit-done/workflows/new-workspace.md
@~/.claude/get-shit-done/references/ui-brand.md
</execution_context>
<process>
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).
</process>

View File

@@ -0,0 +1,26 @@
---
name: gsd:remove-workspace
description: Remove a GSD workspace and clean up worktrees
argument-hint: "<workspace-name>"
allowed-tools:
- Bash
- Read
- AskUserQuestion
---
<context>
**Arguments:**
- `<workspace-name>` (required) — Name of the workspace to remove
</context>
<objective>
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.
</objective>
<execution_context>
@~/.claude/get-shit-done/workflows/remove-workspace.md
@~/.claude/get-shit-done/references/ui-brand.md
</execution_context>
<process>
Execute the remove-workspace workflow from @~/.claude/get-shit-done/workflows/remove-workspace.md end-to-end.
</process>

View File

@@ -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 <name>` | Workspace name (required) |
| `--repos repo1,repo2` | Comma-separated repo paths or names |
| `--path /target` | Target directory (default: `~/gsd-workspaces/<name>`) |
| `--strategy worktree\|clone` | Copy strategy (default: `worktree`) |
| `--branch <name>` | Branch to checkout (default: `workspace/<name>`) |
| `--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 |
|----------|----------|-------------|
| `<name>` | 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.

View File

@@ -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

View File

@@ -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/<name>` | Target directory |
| `--strategy` | No | `worktree` | `worktree` (lightweight, shared .git) or `clone` (fully independent) |
| `--branch` | No | `workspace/<name>` | 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 <path>`
5. **Copy repos** — For each repo:
- Worktree: `git worktree add <workspace>/<repo-name> -b workspace/<name>`
- Clone: `git clone <source> <workspace>/<repo-name>`
6. **Write WORKSPACE.md** — Manifest with source paths, strategy, branch
7. **Initialize .planning/** — `mkdir -p <workspace>/.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/<name>-<timestamp>` 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/<name>` | Predictable location for `list-workspaces` to scan, keeps workspaces out of source repos |

View File

@@ -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;
}

View File

@@ -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,
};

View File

@@ -0,0 +1,56 @@
<purpose>
List all GSD workspaces found in ~/gsd-workspaces/ with their status.
</purpose>
<required_reading>
Read all files referenced by the invoking prompt's execution_context before starting.
</required_reading>
<process>
## 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/<name> # Enter a workspace
/gsd:remove-workspace <name> # 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)
</process>

View File

@@ -0,0 +1,237 @@
<purpose>
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.
</purpose>
<required_reading>
Read all files referenced by the invoking prompt's execution_context before starting.
</required_reading>
<process>
## 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
</process>
<success_criteria>
- [ ] 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
</success_criteria>

View File

@@ -0,0 +1,90 @@
<purpose>
Remove a GSD workspace, cleaning up git worktrees and deleting the workspace directory.
</purpose>
<required_reading>
Read all files referenced by the invoking prompt's execution_context before starting.
</required_reading>
<process>
## 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
```
</process>

View File

@@ -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) {

388
tests/workspace.test.cjs Normal file
View File

@@ -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'));
});
});