diff --git a/vscode/package.json b/vscode/package.json index 3a5e772..1bcd78b 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.115", + "version": "0.2.116", "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 fa5b96c..54027d0 100644 --- a/vscode/src/git/release.ts +++ b/vscode/src/git/release.ts @@ -48,7 +48,7 @@ interface Run { ok: boolean, stdout: string, stderr: string } function runGit(cwd: string, args: string[], timeoutMs = 30_000): Promise { return new Promise((resolve) => { - execFile('git', ['-C', cwd, ...args], { timeout: timeoutMs, encoding: 'utf8' }, (err, stdout, stderr) => { + execFile('git', ['-C', cwd, ...args], { timeout: timeoutMs, encoding: 'utf8', maxBuffer: 512 * 1024 * 1024 }, (err, stdout, stderr) => { resolve({ ok: !err, stdout: stdout ?? '', stderr: stderr ?? '' }) }) }) @@ -56,7 +56,7 @@ 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) => { + const child = execFile('git', args, { timeout: timeoutMs, encoding: 'utf8', maxBuffer: 512 * 1024 * 1024 }, (err, stdout, stderr) => { resolve({ ok: !err, stdout: stdout ?? '', stderr: stderr ?? '' }) }) child.stdin?.end(input) @@ -218,61 +218,46 @@ 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) : [] -} +type FileChangeKeys = Map -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, devBranch: 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/${devBranch}..origin/${current}`], - '读取发布分支改动失败', - ) +/** 一次 `git log -p` 切出每个提交每个文件的改动,再一次 `git patch-id` 算完指纹。 */ +async function fileChangeKeysByCommit(cwd: string, logArgs: string[], onlyFiles?: Set): Promise { + const out = new Map() + const log = await mustGit(cwd, ['log', '-p', '--no-merges', '--format=commit %H', ...logArgs], '读取提交改动失败') + if (!log) + return out + const blocks: Array<{ sha: string, file: string, text: string[] }> = [] 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: [] }) + const commit = /^commit ([0-9a-f]{40})$/.exec(line) + if (commit) { + sha = commit[1] + out.set(sha, []) 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}`) + const header = /^diff --git "?a\/.*? "?b\/(.+?)"?$/.exec(line) + if (header) { + blocks.push({ sha, file: header[1], text: [line] }) + continue } + blocks[blocks.length - 1]?.text.push(line) } - return keys + const wanted = blocks.filter(b => !onlyFiles || onlyFiles.has(b.file)) + if (wanted.length === 0) + return out + const stream = wanted + .map((b, i) => `commit ${i.toString(16).padStart(40, '0')}\n${b.text.join('\n')}\n`) + .join('') + const ids = await runGitStdin(['patch-id', '--stable'], stream) + for (const line of ids.stdout.split('\n')) { + const [id, marker] = line.trim().split(/\s+/) + if (!id || !marker) + continue + const block = wanted[Number.parseInt(marker, 16)] + if (block) + out.get(block.sha)?.push(`${block.file}\0${id}`) + } + return out } export async function listUnreleasedCommits(o: { cwd: string, devBranch: string, current: string }): Promise { @@ -290,17 +275,20 @@ export async function listUnreleasedCommits(o: { cwd: string, devBranch: string, const [sha, subject, author, date, body = ''] = trimmed.split('\0') return [{ sha, subject, author, date, body: body.trim() }] }) - const perCommitKeys = new Map() + const perCommit = await fileChangeKeysByCommit(o.cwd, ['--right-only', '--cherry-pick', unreleasedRange(o.current, o.devBranch)]) const files = new Set() - for (const c of commits) { - const keys = await commitFileChangeKeys(o.cwd, c.sha) - perCommitKeys.set(c.sha, keys) + for (const keys of perCommit.values()) { for (const key of keys) files.add(key.slice(0, key.indexOf('\0'))) } - const released = await releaseFileChangeKeys(o.cwd, o.current, o.devBranch, files) + const onRelease = await fileChangeKeysByCommit(o.cwd, [`origin/${o.devBranch}..origin/${o.current}`], files) + const released = new Set() + for (const keys of onRelease.values()) { + for (const key of keys) + released.add(key) + } return commits.filter((c) => { - const keys = perCommitKeys.get(c.sha) ?? [] + const keys = perCommit.get(c.sha) ?? [] return keys.length === 0 || keys.some(k => !released.has(k)) }) }