Follow-up to #1919 (archive-then-remove core). Closes the remaining #1871 acceptance criteria so phase history is preserved across the full milestone lifecycle, not just at phases.clear: - #2 src/milestone.cts + src/phases-command-router.cts: extract shared archivePhaseDirectories() helper; add cmdPhasesArchive (the previously half-wired phases.archive alias now routes instead of erroring Unknown). - #4 gsd-tools.cjs + src/milestone.cts: milestone complete archives phase dirs by default (--no-archive-phases opts out; --archive-phases is now a harmless no-op). complete-milestone.md updated to drop the redundant manual Yes/Skip archive prompt. - #3 gsd-core/workflows/new-milestone.md: §6 stages the archive move + source removal (git add .planning/milestones/ .planning/phases/) in the same commit as the milestone start, so the archive lands atomically — no orphaned uncommitted deletions, no un-archived dirs inherited. - docs/CLI-TOOLS.md (+ ja/zh/ko/pt) + help/modes/full.md: flag accuracy. - tests: phases archive command (#2) + milestone complete default archive / --no-archive-phases opt-out (#4). Goldens + workflow size baseline refreshed. Closes #1871
This commit is contained in:
@@ -341,7 +341,8 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
|
||||
|
||||
// Archive phase directories if requested
|
||||
let phasesArchived = false;
|
||||
if (options.archivePhases) {
|
||||
// #1871: archive phase dirs by default on milestone complete (opt out via --no-archive-phases).
|
||||
if (options.archivePhases !== false) {
|
||||
try {
|
||||
const phaseArchiveDir = path.join(archiveDir, `${version}-phases`);
|
||||
platformEnsureDir(phaseArchiveDir);
|
||||
@@ -440,32 +441,8 @@ function cmdPhasesClear(cwd: string, raw: boolean, args: string[]): void {
|
||||
}
|
||||
|
||||
try {
|
||||
// #1871: archive phase directories instead of destroying them. Move each
|
||||
// non-999 dir to milestones/<version>-phases/ so browsable phase history
|
||||
// survives the milestone switch (previously rmSync hard-deleted committed
|
||||
// dirs, leaving orphaned uncommitted deletions and no archive).
|
||||
let archiveVersion: string | null = null;
|
||||
try {
|
||||
archiveVersion = getMilestoneInfo(cwd).version ?? null;
|
||||
} catch {
|
||||
/* ROADMAP/STATE unreadable — fall back to a dated label */
|
||||
}
|
||||
if (!archiveVersion) {
|
||||
archiveVersion = `archived-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 8)}`;
|
||||
}
|
||||
const archivePhasesDir = path.join(planningPaths(cwd).planning, 'milestones', `${archiveVersion}-phases`);
|
||||
platformEnsureDir(archivePhasesDir);
|
||||
for (const entry of dirs) {
|
||||
const src = path.join(phasesDir, entry.name);
|
||||
// Collision-safe: if a same-named archive entry exists (re-run), suffix it.
|
||||
let dest = path.join(archivePhasesDir, entry.name);
|
||||
let n = 1;
|
||||
while (fs.existsSync(dest)) {
|
||||
dest = path.join(archivePhasesDir, `${entry.name}.${n++}`);
|
||||
}
|
||||
retryRenameSync(src, dest);
|
||||
cleared++;
|
||||
}
|
||||
// #1871: archive phase directories instead of destroying them (shared helper).
|
||||
cleared = archivePhaseDirectories(cwd, phasesDir, dirs).archived;
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
error('Failed to clear phases directory: ' + message);
|
||||
@@ -475,6 +452,40 @@ function cmdPhasesClear(cwd: string, raw: boolean, args: string[]): void {
|
||||
output({ cleared }, raw, `${cleared} phase director${cleared === 1 ? 'y' : 'ies'} cleared`);
|
||||
}
|
||||
|
||||
/**
|
||||
* #1871: move each non-999 phase directory under `phasesDir` into
|
||||
* `milestones/<version>-phases/` (collision-safe; version from getMilestoneInfo,
|
||||
* timestamp fallback). Shared by `phases clear` (archive-then-remove) and the
|
||||
* internal milestone.complete phase archival so phase history survives a
|
||||
* milestone switch instead of being hard-deleted.
|
||||
*/
|
||||
function archivePhaseDirectories(cwd: string, phasesDir: string, dirs: ReadonlyArray<{ name: string }>): { archiveDir: string; archived: number } {
|
||||
let archiveVersion: string | null = null;
|
||||
try {
|
||||
archiveVersion = getMilestoneInfo(cwd).version ?? null;
|
||||
} catch {
|
||||
/* ROADMAP/STATE unreadable — fall back to a dated label */
|
||||
}
|
||||
if (!archiveVersion) {
|
||||
archiveVersion = `archived-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 8)}`;
|
||||
}
|
||||
const archivePhasesDir = path.join(planningPaths(cwd).planning, 'milestones', `${archiveVersion}-phases`);
|
||||
platformEnsureDir(archivePhasesDir);
|
||||
let archived = 0;
|
||||
for (const entry of dirs) {
|
||||
const src = path.join(phasesDir, entry.name);
|
||||
// Collision-safe: if a same-named archive entry exists (re-run), suffix it.
|
||||
let dest = path.join(archivePhasesDir, entry.name);
|
||||
let n = 1;
|
||||
while (fs.existsSync(dest)) {
|
||||
dest = path.join(archivePhasesDir, `${entry.name}.${n++}`);
|
||||
}
|
||||
retryRenameSync(src, dest);
|
||||
archived++;
|
||||
}
|
||||
return { archiveDir: archivePhasesDir, archived };
|
||||
}
|
||||
|
||||
export = {
|
||||
cmdRequirementsMarkComplete,
|
||||
cmdMilestoneComplete,
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* Keeps gsd-tools.cjs thin while preserving current CJS semantics.
|
||||
*
|
||||
* Unsupported in this router (treated as unknown):
|
||||
* - archive: `phases archive` is excluded from the subcommands list so it
|
||||
* falls through to the unknown-subcommand error path.
|
||||
* - archive: `phases archive` is excluded (#2684) — it is an internal
|
||||
* subcommand that milestone.complete forwards to, not a public surface.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/phases-command-router.cjs
|
||||
* collapsed to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
||||
@@ -46,7 +46,8 @@ interface RoutePhasesCommandOptions {
|
||||
function routePhasesCommand({ phase, milestone, args, cwd, raw, error }: RoutePhasesCommandOptions): void {
|
||||
routeCjsCommandFamily({
|
||||
args,
|
||||
// Exclude 'archive' so it hits the unknownMessage path.
|
||||
// #2684: `phases archive` is deliberately excluded — it is an internal
|
||||
// subcommand that milestone.complete forwards to, not a public surface.
|
||||
subcommands: PHASES_SUBCOMMANDS.filter((s) => s !== 'archive'),
|
||||
error,
|
||||
unknownMessage: (_subcommand: string, available: string[]) => `Unknown phases subcommand. Available: ${available.join(', ')}`,
|
||||
|
||||
Reference in New Issue
Block a user