Merge pull request #1267 from gsd-build/feat/multi-project-workspaces-1241
feat: add multi-project workspace commands
This commit is contained in:
19
commands/gsd/list-workspaces.md
Normal file
19
commands/gsd/list-workspaces.md
Normal 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>
|
||||
44
commands/gsd/new-workspace.md
Normal file
44
commands/gsd/new-workspace.md
Normal 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>
|
||||
26
commands/gsd/remove-workspace.md
Normal file
26
commands/gsd/remove-workspace.md
Normal 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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
56
get-shit-done/workflows/list-workspaces.md
Normal file
56
get-shit-done/workflows/list-workspaces.md
Normal 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>
|
||||
237
get-shit-done/workflows/new-workspace.md
Normal file
237
get-shit-done/workflows/new-workspace.md
Normal 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>
|
||||
90
get-shit-done/workflows/remove-workspace.md
Normal file
90
get-shit-done/workflows/remove-workspace.md
Normal 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>
|
||||
@@ -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
388
tests/workspace.test.cjs
Normal 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'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user