Merge pull request #1484 from NedMalki-Chief/feature/multi-project-workspace

feat: GSD_PROJECT env var for multi-project workspaces
This commit is contained in:
Tom Boucher
2026-04-02 18:03:20 -04:00
committed by GitHub
2 changed files with 107 additions and 8 deletions

View File

@@ -195,7 +195,7 @@ function safeReadFile(filePath) {
}
function loadConfig(cwd) {
const configPath = path.join(cwd, '.planning', 'config.json');
const configPath = path.join(planningDir(cwd), 'config.json');
const defaults = {
model_profile: 'balanced',
commit_docs: true,
@@ -548,20 +548,43 @@ function withPlanningLock(cwd, fn) {
}
/**
* Get the .planning directory path, workstream-aware.
* When a workstream is active (via explicit ws arg or GSD_WORKSTREAM env var),
* returns `.planning/workstreams/{ws}/`. Otherwise returns `.planning/`.
* Get the .planning directory path, project- and workstream-aware.
*
* Resolution order:
* 1. If GSD_PROJECT is set (env var or explicit `project` arg), routes to
* `.planning/{project}/` — supports multi-project workspaces where several
* independent projects share a single `.planning/` root directory (e.g.,
* an Obsidian vault or monorepo knowledge base used as a command center).
* 2. If GSD_WORKSTREAM is set, routes to `.planning/workstreams/{ws}/`.
* 3. Otherwise returns `.planning/`.
*
* GSD_PROJECT and GSD_WORKSTREAM can be combined:
* `.planning/{project}/workstreams/{ws}/`
*
* @param {string} cwd - project root
* @param {string} [ws] - explicit workstream name; if omitted, checks GSD_WORKSTREAM env var
* @param {string} [project] - explicit project name; if omitted, checks GSD_PROJECT env var
*/
function planningDir(cwd, ws) {
function planningDir(cwd, ws, project) {
if (project === undefined) project = process.env.GSD_PROJECT || null;
if (ws === undefined) ws = process.env.GSD_WORKSTREAM || null;
if (!ws) return path.join(cwd, '.planning');
return path.join(cwd, '.planning', 'workstreams', ws);
// Reject path separators and traversal components in project/workstream names
const BAD_SEGMENT = /[/\\]|\.\./;
if (project && BAD_SEGMENT.test(project)) {
throw new Error(`GSD_PROJECT contains invalid path characters: ${project}`);
}
if (ws && BAD_SEGMENT.test(ws)) {
throw new Error(`GSD_WORKSTREAM contains invalid path characters: ${ws}`);
}
let base = path.join(cwd, '.planning');
if (project) base = path.join(base, project);
if (ws) base = path.join(base, 'workstreams', ws);
return base;
}
/** Always returns the root .planning/ path, ignoring workstreams. For shared resources. */
/** Always returns the root .planning/ path, ignoring workstreams and projects. For shared resources. */
function planningRoot(cwd) {
return path.join(cwd, '.planning');
}

View File

@@ -30,6 +30,7 @@ const {
findPhaseInternal,
findProjectRoot,
detectSubRepos,
planningDir,
} = require('../get-shit-done/bin/lib/core.cjs');
// ─── loadConfig ────────────────────────────────────────────────────────────────
@@ -1611,3 +1612,78 @@ describe('reapStaleTempFiles', () => {
});
});
});
// ─── planningDir ──────────────────────────────────────────────────────────────
describe('planningDir', () => {
const cwd = '/fake/repo';
let savedProject, savedWorkstream;
beforeEach(() => {
savedProject = process.env.GSD_PROJECT;
savedWorkstream = process.env.GSD_WORKSTREAM;
delete process.env.GSD_PROJECT;
delete process.env.GSD_WORKSTREAM;
});
afterEach(() => {
if (savedProject !== undefined) process.env.GSD_PROJECT = savedProject;
else delete process.env.GSD_PROJECT;
if (savedWorkstream !== undefined) process.env.GSD_WORKSTREAM = savedWorkstream;
else delete process.env.GSD_WORKSTREAM;
});
test('returns .planning/ when neither project nor workstream is set', () => {
const result = planningDir(cwd, null, null);
assert.strictEqual(result, path.join(cwd, '.planning'));
});
test('returns .planning/{project}/ when project is set', () => {
const result = planningDir(cwd, null, 'my-app');
assert.strictEqual(result, path.join(cwd, '.planning', 'my-app'));
});
test('returns .planning/workstreams/{ws}/ when workstream is set', () => {
const result = planningDir(cwd, 'feature-x', null);
assert.strictEqual(result, path.join(cwd, '.planning', 'workstreams', 'feature-x'));
});
test('returns .planning/{project}/workstreams/{ws}/ when both are set', () => {
const result = planningDir(cwd, 'feature-x', 'my-app');
assert.strictEqual(result, path.join(cwd, '.planning', 'my-app', 'workstreams', 'feature-x'));
});
test('reads GSD_PROJECT from env when project param is undefined', () => {
process.env.GSD_PROJECT = 'env-project';
const result = planningDir(cwd);
assert.strictEqual(result, path.join(cwd, '.planning', 'env-project'));
});
test('rejects path traversal in project name', () => {
assert.throws(
() => planningDir(cwd, null, '../../etc'),
/invalid path characters/
);
});
test('rejects forward slash in project name', () => {
assert.throws(
() => planningDir(cwd, null, 'foo/bar'),
/invalid path characters/
);
});
test('rejects backslash in project name', () => {
assert.throws(
() => planningDir(cwd, null, 'foo\\bar'),
/invalid path characters/
);
});
test('rejects path traversal in workstream name', () => {
assert.throws(
() => planningDir(cwd, '../../../tmp', null),
/invalid path characters/
);
});
});