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>
This commit is contained in:
@@ -14,7 +14,7 @@ import planningWorkspace = require('./planning-workspace.cjs');
|
||||
import frontmatterMod = require('./frontmatter.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- state.cjs is an export= CommonJS module
|
||||
import stateMod = require('./state.cjs');
|
||||
import { platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs';
|
||||
import { platformWriteSync, platformEnsureDir, execGit } from './shell-command-projection.cjs';
|
||||
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import ioMod = require('./io.cjs');
|
||||
@@ -390,6 +390,9 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
|
||||
function cmdPhasesClear(cwd: string, raw: boolean, args: string[]): void {
|
||||
const phasesDir = planningPaths(cwd).phases;
|
||||
const confirm = Array.isArray(args) && args.includes('--confirm');
|
||||
// --force bypasses the uncommitted-changes guard. Only use when the caller
|
||||
// has already archived or explicitly accepts loss of uncommitted work. (#1447)
|
||||
const force = Array.isArray(args) && args.includes('--force');
|
||||
let cleared = 0;
|
||||
|
||||
if (fs.existsSync(phasesDir)) {
|
||||
@@ -403,6 +406,45 @@ function cmdPhasesClear(cwd: string, raw: boolean, args: string[]): void {
|
||||
);
|
||||
}
|
||||
|
||||
// Guard (#1447): refuse to hard-delete phase directories that contain
|
||||
// uncommitted changes. This prevents data loss when `new-milestone` runs
|
||||
// `phases.clear --confirm` before the operator has archived or committed
|
||||
// phase work from the outgoing milestone.
|
||||
// Use `--force` to bypass this guard only when you have verified that
|
||||
// archive or commit of the outgoing phases is already done.
|
||||
if (dirs.length > 0 && !force) {
|
||||
// Compute the path relative to cwd for git status
|
||||
let relPhasesDir: string;
|
||||
try {
|
||||
relPhasesDir = path.relative(cwd, phasesDir);
|
||||
} catch {
|
||||
relPhasesDir = phasesDir;
|
||||
}
|
||||
|
||||
let gitStatusOutput = '';
|
||||
try {
|
||||
const gitResult = execGit(['status', '--porcelain', relPhasesDir], { cwd, timeout: 10_000 });
|
||||
if (gitResult.exitCode === 0) {
|
||||
gitStatusOutput = gitResult.stdout ?? '';
|
||||
}
|
||||
// If git is not available or this is not a git repo, skip the guard
|
||||
// (gitResult.exitCode non-zero → not a git repo → no uncommitted changes to protect).
|
||||
} catch {
|
||||
// git unavailable — skip guard
|
||||
}
|
||||
|
||||
const uncommittedLines = gitStatusOutput
|
||||
.split('\n')
|
||||
.filter((line) => line.trim().length > 0);
|
||||
if (uncommittedLines.length > 0) {
|
||||
error(
|
||||
`phases clear aborted: ${uncommittedLines.length} uncommitted change${uncommittedLines.length === 1 ? '' : 's'} detected in phase directories. ` +
|
||||
`Archive or commit outgoing phase work before running this command, ` +
|
||||
`or pass --force to skip this check and permanently delete the phase directories. (#1447)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
for (const entry of dirs) {
|
||||
fs.rmSync(path.join(phasesDir, entry.name), { recursive: true, force: true });
|
||||
|
||||
@@ -102,8 +102,43 @@ export function findProjectRoot(startDir: string): string {
|
||||
// config.json missing or unparseable — fall through to .git heuristic.
|
||||
}
|
||||
if (matched) return parent;
|
||||
// Heuristic: parent has .planning/ and we're inside a git repo.
|
||||
// Heuristic (3): parent has .planning/ and we're inside a git repo.
|
||||
// Before returning, check if any further ancestor has sub_repos that explicitly
|
||||
// claims our startDir — explicit sub_repos config takes precedence over the
|
||||
// implicit .git signal. (#1422)
|
||||
if (isInsideGitRepo(parent)) {
|
||||
// Lookahead: walk ancestors above `parent` to find a sub_repos claim.
|
||||
let ancestor = path.dirname(parent);
|
||||
let ancestorDepth = 0;
|
||||
while (ancestor !== fsRoot && ancestor !== home && ancestorDepth < FIND_PROJECT_ROOT_MAX_DEPTH) {
|
||||
const ancestorPlanning = ancestor + path.sep + '.planning';
|
||||
try {
|
||||
if (fs.existsSync(ancestorPlanning) && fs.statSync(ancestorPlanning).isDirectory()) {
|
||||
const ancestorConfig = ancestor + path.sep + '.planning' + path.sep + 'config.json';
|
||||
const rawA = fs.readFileSync(ancestorConfig, 'utf-8');
|
||||
const cfgA = JSON.parse(rawA) as Record<string, unknown>;
|
||||
const subReposValueA =
|
||||
cfgA['sub_repos'] ??
|
||||
(cfgA['planning'] && typeof cfgA['planning'] === 'object'
|
||||
? (cfgA['planning'] as Record<string, unknown>)['sub_repos']
|
||||
: undefined);
|
||||
const subReposA = Array.isArray(subReposValueA) ? (subReposValueA as unknown[]) : [];
|
||||
if (subReposA.length > 0) {
|
||||
const relPathA = path.relative(ancestor, resolvedStart);
|
||||
const topSegmentA = relPathA.split(path.sep)[0];
|
||||
if (subReposA.includes(topSegmentA)) {
|
||||
return ancestor;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore — config missing or unparseable, keep walking
|
||||
}
|
||||
const nextAncestor = path.dirname(ancestor);
|
||||
if (nextAncestor === ancestor) break;
|
||||
ancestor = nextAncestor;
|
||||
ancestorDepth += 1;
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user