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