Files
msd-core/tests/project-root.test.cjs
Tom Boucher ba3181204b fix(#1422,#1447): fix sub_repos/.git precedence; guard uncommitted data in new-milestone (#1484)
* fix(#1422): sub_repos config takes precedence over .git in findProjectRoot

When heuristic-3 (.git + parent .planning/) fires, do a lookahead walk
over ancestors above the matching parent to check if any further ancestor
has a sub_repos entry that explicitly claims the starting directory. If
found, return that ancestor instead — explicit config wins over the
implicit .git signal.

Regression tests updated: the heuristic-3 precedence test now asserts the
correct new behavior (sub_repos wins), and two additional coverage cases
added (startDir directly in sub_repo child, and startDir nested 2+ levels
inside the child).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#1447): guard against uncommitted changes before deleting phase dirs in new-milestone

cmdPhasesClear now runs `git status --porcelain <phasesDir>` before
executing any rmSync. If uncommitted changes are detected it calls
error() and aborts, preventing silent data loss when new-milestone's
§6 "phases.clear --confirm" fires before the operator has archived
or committed outgoing phase work.

A new --force flag is added to bypass the guard for callers that have
already verified archival is done (or explicitly accept the loss).
When git is unavailable or the directory is not inside a git repo the
guard silently skips, preserving the existing behaviour for non-git
projects.

Five new regression tests cover: untracked files abort, staged-but-
uncommitted abort, --force bypasses, committed files pass, non-git
project passes without guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: add changeset for #1422 and #1447

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: correct changeset format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 13:37:16 -04:00

279 lines
13 KiB
JavaScript

/**
* Tests for findProjectRoot — Project-Root Resolution Module
* (#1414, part of Resolution Provenance epic #1411)
*
* Covers heuristic (4) (nearest-ancestor .planning/ walk-up) plus targeted
* regression cases for sub_repos and .git-precedence interactions. Does NOT
* exhaustively re-test every prior heuristic.
*/
'use strict';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { findProjectRoot } = require('../gsd-core/bin/lib/project-root.cjs');
const { cleanup } = require('./helpers.cjs');
// ─── helpers ────────────────────────────────────────────────────────────────
/** Create nested path under base (all segments), returns the leaf dir path. */
function mkDeep(base, ...segments) {
const full = path.join(base, ...segments);
fs.mkdirSync(full, { recursive: true });
return full;
}
// ─── describe block ──────────────────────────────────────────────────────────
describe('findProjectRoot nearest-.planning resolution (#1414)', () => {
let tmpDir;
// Saved HOME/USERPROFILE env vars for tests that override them.
let savedHome;
let savedUserProfile;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pr-test-'));
savedHome = process.env.HOME;
savedUserProfile = process.env.USERPROFILE;
});
afterEach(() => {
cleanup(tmpDir);
// Restore HOME/USERPROFILE unconditionally.
if (savedHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = savedHome;
}
if (savedUserProfile === undefined) {
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = savedUserProfile;
}
});
// HAPPY: invoked from a plain descendant (no .git/.planning in between)
test('resolves ancestor .planning/ when invoked from a descendant subdirectory', () => {
// Layout:
// tmpDir/
// .planning/ ← project root
// src/
// deep/
// nested/ ← startDir (no .planning/, no .git)
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
const nested = mkDeep(tmpDir, 'src', 'deep', 'nested');
const result = findProjectRoot(nested);
assert.strictEqual(result, tmpDir,
'findProjectRoot should walk up and return the ancestor dir that has .planning/');
});
// HAPPY (determinism): resolution from root and from descendant must be identical
test('resolution from project root and from descendant are byte-identical', () => {
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
const nested = mkDeep(tmpDir, 'lib', 'utils');
const fromRoot = findProjectRoot(tmpDir);
const fromDescendant = findProjectRoot(nested);
assert.strictEqual(fromRoot, fromDescendant,
'Resolution from project root and from descendant must produce the same path');
});
// BOUNDARY (exact): descendant exactly FIND_PROJECT_ROOT_MAX_DEPTH-1 levels below
// .planning/ ancestor (i.e. 9 hops when MAX_DEPTH=10) → must resolve.
// One level beyond (11 levels = 10 hops) → must return startDir.
test('resolves when descendant is exactly FIND_PROJECT_ROOT_MAX_DEPTH-1 levels below ancestor .planning/', () => {
// FIND_PROJECT_ROOT_MAX_DEPTH = 10.
// 9 levels of nesting = 9 parent hops = within bound → must resolve.
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
const deep = mkDeep(tmpDir, 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'); // 9 levels
const result = findProjectRoot(deep);
assert.strictEqual(result, tmpDir,
'Should resolve when exactly FIND_PROJECT_ROOT_MAX_DEPTH-1 levels deep (9 hops, bound=10)');
});
// BOUNDARY (exact): descendant exactly one level BEYOND FIND_PROJECT_ROOT_MAX_DEPTH
// (10 levels of nesting = 10 parent hops = at bound; 11 levels = 11 hops = beyond).
// The loop runs while depth2 < MAX_DEPTH (10), so depth2 reaches 9 after checking
// 10 parents; the 11th level parent is never checked → returns startDir.
test('returns startDir when descendant exceeds FIND_PROJECT_ROOT_MAX_DEPTH', () => {
// 11 levels deep — exceeds the depth=10 bound
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
const tooDeep = mkDeep(tmpDir, 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k'); // 11 levels
const result = findProjectRoot(tooDeep);
assert.strictEqual(result, tooDeep,
'Should return startDir when ancestor .planning/ is beyond FIND_PROJECT_ROOT_MAX_DEPTH');
});
// BOUNDARY: own .planning/ guard unchanged — startDir with .planning/ returns startDir
test('returns startDir when startDir itself has .planning/ (own-guard unchanged)', () => {
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
const result = findProjectRoot(tmpDir);
assert.strictEqual(result, tmpDir,
'When startDir has .planning/ it should be returned as-is (heuristic 0 guard)');
});
// HOME-ROOTED PROJECT: a project whose root is exactly $HOME must be resolvable
// from a descendant. Previously the `if (parent2 === home) break` fired BEFORE
// the .planning check, making $HOME-rooted projects unresolvable. After the
// reorder, $HOME itself is checked before the break fires.
test('resolves a project rooted at $HOME from a descendant (home checked before break)', () => {
// Make tmpDir act as $HOME by setting both HOME and USERPROFILE.
process.env.HOME = tmpDir;
process.env.USERPROFILE = tmpDir;
// Create a .planning/ directly inside "home" (tmpDir).
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
// Invoke from a subdirectory of "home".
const sub = mkDeep(tmpDir, 'sub', 'dir');
const result = findProjectRoot(sub);
assert.strictEqual(result, tmpDir,
'findProjectRoot must resolve a project rooted exactly at $HOME (home checked before break)');
});
// NEGATIVE/REGRESSION: sub_repos workspace — child sub-repo has its OWN .planning/
// but NO .git — invoked from inside the child → must still resolve to PARENT workspace.
// Why: heuristic (1) sub_repos claims the child (matched by name in sub_repos array)
// before heuristic (4) runs; because the child has no independent .git root, the
// sub_repos entry is the controlling signal. This test is the real guard that
// heuristic (4) does NOT hijack sub_repos resolution.
test('sub_repos workspace: child with own .planning/ (no .git) still resolves to parent workspace', () => {
// Layout:
// workspaceRoot/
// .planning/
// config.json ← sub_repos: ['child']
// child/
// .planning/ ← child has its own .planning/ but NO .git (the trap for heuristic 4)
// src/
// code.js ← startDir (descendant of child)
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pr-subrepos-'));
try {
fs.mkdirSync(path.join(workspaceRoot, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(workspaceRoot, '.planning', 'config.json'),
JSON.stringify({ sub_repos: ['child'] })
);
// Child repo with its own .planning/ but NO .git
fs.mkdirSync(path.join(workspaceRoot, 'child', '.planning'), { recursive: true });
const childSrc = mkDeep(workspaceRoot, 'child', 'src');
const result = findProjectRoot(childSrc);
assert.strictEqual(result, workspaceRoot,
'sub_repos heuristic must win over nearest-.planning/ walk-up: should resolve to workspace root, not child');
} finally {
cleanup(workspaceRoot);
}
});
// REGRESSION (#1422): sub_repos explicit config wins over .git implicit signal.
// A sub_repos workspace where the child has BOTH its own .planning/ AND its own
// .git/ — invoked from inside the child — MUST resolve to the PARENT workspace
// because the parent's config.json explicitly lists the child in sub_repos.
// The implicit .git heuristic (heuristic-3) must not override explicit sub_repos.
test('sub_repos child with BOTH .planning/ and .git/ resolves to PARENT workspace (sub_repos wins, #1422)', () => {
// Layout:
// workspaceRoot/
// .planning/
// config.json ← sub_repos: ['child']
// child/
// .planning/ ← child has own .planning/
// .git/ ← child ALSO has own .git/ → was triggering heuristic-3 prematurely
// src/ ← startDir
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pr-subrepos-git-'));
try {
fs.mkdirSync(path.join(workspaceRoot, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(workspaceRoot, '.planning', 'config.json'),
JSON.stringify({ sub_repos: ['child'] })
);
const childDir = path.join(workspaceRoot, 'child');
fs.mkdirSync(path.join(childDir, '.planning'), { recursive: true });
fs.mkdirSync(path.join(childDir, '.git'), { recursive: true });
const childSrc = mkDeep(childDir, 'src');
const result = findProjectRoot(childSrc);
// Explicit sub_repos config in the ancestor workspace must take precedence
// over the implicit .git heuristic — resolves to the workspace root.
assert.strictEqual(result, workspaceRoot,
'sub_repos config in parent workspace must win over child .git: should resolve to workspaceRoot (#1422)');
} finally {
cleanup(workspaceRoot);
}
});
// REGRESSION (#1422): sub_repos child with .git resolves to parent even when
// startDir is nested more than one level inside the child.
test('sub_repos child with .git: startDir nested 2+ levels inside child still resolves to parent (#1422)', () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pr-subrepos-nested-'));
try {
fs.mkdirSync(path.join(workspaceRoot, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(workspaceRoot, '.planning', 'config.json'),
JSON.stringify({ sub_repos: ['child'] })
);
const childDir = path.join(workspaceRoot, 'child');
fs.mkdirSync(path.join(childDir, '.planning'), { recursive: true });
fs.mkdirSync(path.join(childDir, '.git'), { recursive: true });
const deepChild = mkDeep(childDir, 'src', 'lib', 'utils');
const result = findProjectRoot(deepChild);
assert.strictEqual(result, workspaceRoot,
'sub_repos config must win over .git even when startDir is deeply nested inside the child (#1422)');
} finally {
cleanup(workspaceRoot);
}
});
// REGRESSION (multiRepo: true, no sub_repos): a workspace whose .planning/config.json
// has { "multiRepo": true } but no sub_repos array, with a child dir containing .git,
// invoked from inside the child — pins pre-existing heuristic-2 behavior.
test('multiRepo:true (no sub_repos) with child .git: pins pre-existing heuristic-2 behavior', () => {
// Layout:
// workspaceRoot/
// .planning/
// config.json ← { multiRepo: true } (no sub_repos)
// child/
// .git/ ← child has its own git repo
// src/ ← startDir
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pr-multirepo-'));
try {
fs.mkdirSync(path.join(workspaceRoot, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(workspaceRoot, '.planning', 'config.json'),
JSON.stringify({ multiRepo: true })
);
const childDir = path.join(workspaceRoot, 'child');
fs.mkdirSync(path.join(childDir, '.git'), { recursive: true });
const childSrc = mkDeep(childDir, 'src');
// Run once to observe actual behavior, then assert that value to lock it.
// Pre-existing heuristic-2: multiRepo:true + isInsideGitRepo → returns workspaceRoot.
const result = findProjectRoot(childSrc);
assert.strictEqual(result, workspaceRoot,
'multiRepo:true with a child .git returns the workspace root (pins pre-existing heuristic-2 behavior)');
} finally {
cleanup(workspaceRoot);
}
});
// NEGATIVE: no .planning/ anywhere in ancestry (within bound) → returns startDir
test('returns startDir when no .planning/ exists anywhere in ancestry within bound', () => {
// tmpDir has NO .planning/ — it's a plain directory
const nested = mkDeep(tmpDir, 'src', 'lib');
const result = findProjectRoot(nested);
assert.strictEqual(result, nested,
'Should return startDir unchanged when no ancestor has .planning/ within the depth bound');
});
});