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

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