From 0214ca1ab5e9868787195e4e7ce611181ff1cfc9 Mon Sep 17 00:00:00 2001 From: cruldra Date: Tue, 1 Sep 2026 20:01:55 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(vscode):=20=E6=91=98?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8C=89=E6=96=87=E4=BB=B6=E6=94=B9=E5=8A=A8?= =?UTF-8?q?=E8=AE=A4=E5=B7=B2=E6=91=98=E8=BF=87=EF=BC=8C=E4=B8=8D=E9=9D=A0?= =?UTF-8?q?=E6=A0=87=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/package.json | 2 +- vscode/src/git/release.ts | 89 ++++++++++++++++++++++++++++++--- vscode/test/git/release.test.ts | 42 ++++++++++++++++ 3 files changed, 125 insertions(+), 8 deletions(-) diff --git a/vscode/package.json b/vscode/package.json index c947426..6229d15 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -2,7 +2,7 @@ "publisher": "clurdra", "name": "superpowers-vscode-clurdra", "displayName": "Superpowers-clurdra", - "version": "0.2.112", + "version": "0.2.113", "packageManager": "pnpm@10.27.0", "description": "Superpowers specs and plans Kanban explorer", "author": "clurdra", diff --git a/vscode/src/git/release.ts b/vscode/src/git/release.ts index 1fac4ca..9660490 100644 --- a/vscode/src/git/release.ts +++ b/vscode/src/git/release.ts @@ -54,6 +54,15 @@ function runGit(cwd: string, args: string[], timeoutMs = 30_000): Promise { }) } +function runGitStdin(args: string[], input: string, timeoutMs = 30_000): Promise { + return new Promise((resolve) => { + const child = execFile('git', args, { timeout: timeoutMs, encoding: 'utf8' }, (err, stdout, stderr) => { + resolve({ ok: !err, stdout: stdout ?? '', stderr: stderr ?? '' }) + }) + child.stdin?.end(input) + }) +} + const tail = (s: string, n = 400): string => s.trim().slice(-n) async function mustGit(cwd: string, args: string[], failLabel: string): Promise { @@ -145,12 +154,8 @@ export async function getReleaseStatus(o: { cwd: string, devBranch: string, pref const current = pickCurrentRelease(o.prefix, await remoteBranchNames(o.cwd, o.prefix)) if (!current) return base - const count = await mustGit( - o.cwd, - ['rev-list', '--count', '--right-only', '--cherry-pick', '--no-merges', unreleasedRange(current, o.devBranch)], - '统计未发布提交失败', - ) - return { devBranch: o.devBranch, current, unreleased: Number(count) } + const commits = await listUnreleasedCommits({ cwd: o.cwd, devBranch: o.devBranch, current }) + return { devBranch: o.devBranch, current, unreleased: commits.length } } /** 新→旧;不 fetch,调用方自己决定何时刷新远端 */ @@ -209,6 +214,63 @@ export function attachPulls( }) } +/** 发布分支上已有的提交标题。拆开摘过的会留下同标题多条,整包再摘必冲突。 */ +async function commitFiles(cwd: string, sha: string): Promise { + const out = await mustGit(cwd, ['diff-tree', '--no-commit-id', '--name-only', '-r', sha], '读取提交文件列表失败') + return out ? out.split('\n').filter(Boolean) : [] +} + +async function fileChangeId(cwd: string, sha: string, file: string): Promise { + const shown = await runGit(cwd, ['show', '--pretty=format:', '--full-index', sha, '--', file]) + if (!shown.ok || !shown.stdout.trim()) + return null + const id = await runGitStdin(['patch-id', '--stable'], shown.stdout) + const hash = id.stdout.trim().split(/\s+/)[0] + return hash || null +} + +async function commitFileChangeKeys(cwd: string, sha: string): Promise { + const files = await commitFiles(cwd, sha) + const keys: string[] = [] + for (const file of files) { + const id = await fileChangeId(cwd, sha, file) + if (id) + keys.push(`${file}\0${id}`) + } + return keys +} + +/** 发布分支上每个文件已有的改动指纹。用来认出「一次提交被拆成多次摘过去」的情况。 */ +async function releaseFileChangeKeys(cwd: string, current: string, files: Set): Promise> { + const keys = new Set() + if (files.size === 0) + return keys + const log = await mustGit( + cwd, + ['log', '--no-merges', '--name-only', '--format=%n%H', `origin/${current}`], + '读取发布分支改动失败', + ) + let sha = '' + const hits: Array<{ sha: string, files: string[] }> = [] + for (const line of log.split('\n')) { + if (/^[0-9a-f]{40}$/.test(line)) { + sha = line + hits.push({ sha, files: [] }) + continue + } + if (sha && files.has(line)) + hits[hits.length - 1]?.files.push(line) + } + for (const hit of hits) { + for (const file of hit.files) { + const id = await fileChangeId(cwd, hit.sha, file) + if (id) + keys.add(`${file}\0${id}`) + } + } + return keys +} + export async function listUnreleasedCommits(o: { cwd: string, devBranch: string, current: string }): Promise { const out = await mustGit( o.cwd, @@ -217,13 +279,26 @@ export async function listUnreleasedCommits(o: { cwd: string, devBranch: string, ) if (!out) return [] - return out.split('\x1e').flatMap((chunk) => { + const commits = out.split('\x1e').flatMap((chunk) => { const trimmed = chunk.replace(/^\n+/, '').trimEnd() if (!trimmed) return [] const [sha, subject, author, date, body = ''] = trimmed.split('\0') return [{ sha, subject, author, date, body: body.trim() }] }) + const perCommitKeys = new Map() + const files = new Set() + for (const c of commits) { + const keys = await commitFileChangeKeys(o.cwd, c.sha) + perCommitKeys.set(c.sha, keys) + for (const key of keys) + files.add(key.slice(0, key.indexOf('\0'))) + } + const released = await releaseFileChangeKeys(o.cwd, o.current, files) + return commits.filter((c) => { + const keys = perCommitKeys.get(c.sha) ?? [] + return keys.length === 0 || keys.some(k => !released.has(k)) + }) } /** 纯远端切分支;返回该分支相对上一个发布点的提交数(无上一个则为 0) */ diff --git a/vscode/test/git/release.test.ts b/vscode/test/git/release.test.ts index 60f7d40..1d766fd 100644 --- a/vscode/test/git/release.test.ts +++ b/vscode/test/git/release.test.ts @@ -147,4 +147,46 @@ describe('集成', () => { expect(map.get(a)).toEqual({ number: 260, title: '连续包月会员订阅体系' }) expect(map.get(b)).toEqual({ number: 260, title: '连续包月会员订阅体系' }) }) + it('发布分支把同一改动拆成两条,即使标题不同也不再列出', async () => { + fx = await setup() + await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-31' }) + writeFileSync(path.join(fx.peer, 'a.txt'), 'A\n') + writeFileSync(path.join(fx.peer, 'b.txt'), 'B\n') + git(fx.peer, ['add', 'a.txt', 'b.txt']) + git(fx.peer, ['commit', '-m', 'fix(jobs): 一次性命令收口']) + commit(fx.peer, 'c.txt', 'c', 'feat: other') + git(fx.peer, ['push', 'origin', 'main']) + git(fx.peer, ['fetch']) + git(fx.peer, ['checkout', '-b', 'release/2026-08-31', 'origin/release/2026-08-31']) + writeFileSync(path.join(fx.peer, 'a.txt'), 'A\n') + git(fx.peer, ['add', 'a.txt']) + git(fx.peer, ['commit', '-m', 'part a']) + writeFileSync(path.join(fx.peer, 'b.txt'), 'B\n') + git(fx.peer, ['add', 'b.txt']) + git(fx.peer, ['commit', '-m', 'part b']) + git(fx.peer, ['push', 'origin', 'release/2026-08-31']) + git(fx.repo, ['fetch', 'origin']) + const list = await listUnreleasedCommits({ cwd: fx.repo, devBranch: 'main', current: 'release/2026-08-31' }) + expect(list.map(c => c.subject)).toEqual(['feat: other']) + const s = await getReleaseStatus({ cwd: fx.repo, devBranch: 'main', prefix: 'release/' }) + expect(s.unreleased).toBe(1) + }) + it('标题相同但改的文件不同,仍算未发布', async () => { + fx = await setup() + await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-31' }) + git(fx.peer, ['fetch']) + git(fx.peer, ['checkout', '-b', 'release/2026-08-31', 'origin/release/2026-08-31']) + writeFileSync(path.join(fx.peer, 'a.txt'), 'release-a\n') + git(fx.peer, ['add', 'a.txt']) + git(fx.peer, ['commit', '-m', 'fix(jobs): 一次性命令收口']) + git(fx.peer, ['push', 'origin', 'release/2026-08-31']) + git(fx.peer, ['checkout', 'main']) + writeFileSync(path.join(fx.peer, 'b.txt'), 'main-b\n') + git(fx.peer, ['add', 'b.txt']) + git(fx.peer, ['commit', '-m', 'fix(jobs): 一次性命令收口']) + git(fx.peer, ['push', 'origin', 'main']) + git(fx.repo, ['fetch', 'origin']) + const list = await listUnreleasedCommits({ cwd: fx.repo, devBranch: 'main', current: 'release/2026-08-31' }) + expect(list.map(c => c.subject)).toEqual(['fix(jobs): 一次性命令收口']) + }) })