diff --git a/lib/domains/archive.mjs b/lib/domains/archive.mjs index c544e24..bc7468a 100644 --- a/lib/domains/archive.mjs +++ b/lib/domains/archive.mjs @@ -22,10 +22,16 @@ export function execute(r, o, x, b) { write(b) const dest = dirname(xp(r, n)) mkdirSync(dirname(dest), { recursive: true }) - renameSync(dirname(b.path), dest) + const source = dirname(b.path) + // 旧目录仅在被 git 追踪时才需要暂存删除:change 从未入 commit(如 release 文件清单遗漏)时 + // 移动后旧路径无物可加,硬加会 pathspec fatal 并把归档卡在半落状态 + const tracked = git(r, ['ls-files', '--', source]).trim().length > 0 + renameSync(source, dest) writeFileSync(join(r, 'shadow-docs', 'INDEX.md'), buildIndex(r)) // 归档落本地 commit:半落状态(INDEX 改 + 旧目录删 + 新目录未跟踪)对 AI 与人都易漏提交 - git(r, ['add', '--', 'shadow-docs/INDEX.md', `shadow-docs/changes/${n}`, `shadow-docs/changes/archive/${n}`]) + const staged = ['shadow-docs/INDEX.md', `shadow-docs/changes/archive/${n}`] + if (tracked) staged.unshift(`shadow-docs/changes/${n}`) + git(r, ['add', '--', ...staged]) git(r, ['commit', '-m', `docs(shadow): 归档 ${n}——PR #${x.pullRequest} 已合入 main,brief 移入 archive 并重建 INDEX`]) return { path: xp(r, n) } } diff --git a/test/cli.test.mjs b/test/cli.test.mjs index f2c8cfb..1437dae 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -890,6 +890,33 @@ test('archive execute lands the archive move as a local commit', () => { } }) +test('archive execute survives an untracked change dir and lands the local commit', () => { + const root = fixture() + const head = () => execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root }).toString().trim() + // brief 保持未追踪(如 release 文件清单遗漏了 change 目录),旧路径在移动后无物可加 + updateBrief(root, (d) => { + d.status = 'published' + d.github = { repository: 'owner/repo', issue: null, issueUrl: null, pullRequest: 9, pullRequestUrl: 'https://github.test/pull/9' } + d.review = { conclusion: 'passed', verifiedCommit: head(), verifiedAt: '2026-01-01T00:00:00.000Z' } + }) + const api = apiStub([{ method: 'GET', path: '/repos/owner/repo/pulls/9', body: { number: 9, merged: true, state: 'closed' } }]) + try { + const planned = run(['archive', 'plan', '--name', 'sample', '--json'], root, { GITHUB_TOKEN: 'token', SHADOW_GITHUB_API_URL: api.url }) + assert.equal(planned.status, 0, planned.stderr) + const before = head() + const result = run(['archive', 'execute', '--name', 'sample', '--plan-hash', JSON.parse(planned.stdout).planHash, '--confirm', '--json'], root, { GITHUB_TOKEN: 'token', SHADOW_GITHUB_API_URL: api.url }) + assert.equal(result.status, 0, result.stderr) + const after = head() + assert.notEqual(after, before, 'archive must land a local commit') + const inspect = run(['repo', 'inspect', '--json'], root) + assert.deepEqual(JSON.parse(inspect.stdout).data.changedFiles, [], 'archive commit must leave the tree clean') + assert.ok(!existsSync(join(root, 'shadow-docs', 'changes', 'sample')), 'source dir must be gone') + assert.ok(existsSync(join(root, 'shadow-docs', 'changes', 'archive', 'sample', 'brief.md')), 'brief must land in archive') + } finally { + api.close() + } +}) + test('commit execute reuses persisted files and message without re-passing them', () => { const root = fixture() writeFileSync(join(root, 'a.js'), 'a\n')