From 55030e30c6f4c9e093745aff6e639b5b52ec3065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E5=B0=92=E7=BA=A2?= <724132485@qq.com> Date: Tue, 29 Sep 2026 22:43:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(archive):=20=E6=9C=AA=E8=BF=BD=E8=B8=AA=20c?= =?UTF-8?q?hange=20=E7=9B=AE=E5=BD=95=E5=BD=92=E6=A1=A3=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=20pathspec=20fatal=E2=80=94=E2=80=94=E6=97=A7=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E4=BB=85=E5=9C=A8=E8=A2=AB=E8=BF=BD=E8=B8=AA=E6=97=B6?= =?UTF-8?q?=E6=9A=82=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit archive execute 在移动目录后无条件 git add 旧路径;当 change 目录从未入 commit(如 release 文件清单遗漏了 change 目录本身)时,旧路径无物可加, pathspec fatal 把归档卡在半落状态(目录已移走、INDEX 已重建、commit 未落)。 修复:rename 前用 git ls-files 探测旧目录是否被追踪,仅追踪时才把旧路径 加入暂存表;未追踪时只暂存 INDEX 与 archive 新路径。附未追踪场景回归测试。 --- lib/domains/archive.mjs | 10 ++++++++-- test/cli.test.mjs | 27 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) 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')