🐛 fix(vscode): 摘修复按文件改动认已摘过,不靠标题

This commit is contained in:
cruldra committed 2026-09-01 20:01:55 +08:00
1 parent beb7750eac
commit 0214ca1ab5
3 files changed
+125 -8

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
"publisher": "clurdra", "publisher": "clurdra",
"name": "superpowers-vscode-clurdra", "name": "superpowers-vscode-clurdra",
"displayName": "Superpowers-clurdra", "displayName": "Superpowers-clurdra",
"version": "0.2.112", "version": "0.2.113",
"packageManager": "pnpm@10.27.0", "packageManager": "pnpm@10.27.0",
"description": "Superpowers specs and plans Kanban explorer", "description": "Superpowers specs and plans Kanban explorer",
"author": "clurdra", "author": "clurdra",
+82 -7
View File
@@ -54,6 +54,15 @@ function runGit(cwd: string, args: string[], timeoutMs = 30_000): Promise<Run> {
}) })
} }
function runGitStdin(args: string[], input: string, timeoutMs = 30_000): Promise<Run> {
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) const tail = (s: string, n = 400): string => s.trim().slice(-n)
async function mustGit(cwd: string, args: string[], failLabel: string): Promise<string> { async function mustGit(cwd: string, args: string[], failLabel: string): Promise<string> {
@@ -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)) const current = pickCurrentRelease(o.prefix, await remoteBranchNames(o.cwd, o.prefix))
if (!current) if (!current)
return base return base
const count = await mustGit( const commits = await listUnreleasedCommits({ cwd: o.cwd, devBranch: o.devBranch, current })
o.cwd, return { devBranch: o.devBranch, current, unreleased: commits.length }
['rev-list', '--count', '--right-only', '--cherry-pick', '--no-merges', unreleasedRange(current, o.devBranch)],
'统计未发布提交失败',
)
return { devBranch: o.devBranch, current, unreleased: Number(count) }
} }
/** 新→旧;不 fetch,调用方自己决定何时刷新远端 */ /** 新→旧;不 fetch,调用方自己决定何时刷新远端 */
@@ -209,6 +214,63 @@ export function attachPulls<T extends { sha: string }>(
}) })
} }
/** 发布分支上已有的提交标题。拆开摘过的会留下同标题多条,整包再摘必冲突。 */
async function commitFiles(cwd: string, sha: string): Promise<string[]> {
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<string | null> {
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<string[]> {
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<string>): Promise<Set<string>> {
const keys = new Set<string>()
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<UnreleasedCommit[]> { export async function listUnreleasedCommits(o: { cwd: string, devBranch: string, current: string }): Promise<UnreleasedCommit[]> {
const out = await mustGit( const out = await mustGit(
o.cwd, o.cwd,
@@ -217,13 +279,26 @@ export async function listUnreleasedCommits(o: { cwd: string, devBranch: string,
) )
if (!out) if (!out)
return [] return []
return out.split('\x1e').flatMap((chunk) => { const commits = out.split('\x1e').flatMap((chunk) => {
const trimmed = chunk.replace(/^\n+/, '').trimEnd() const trimmed = chunk.replace(/^\n+/, '').trimEnd()
if (!trimmed) if (!trimmed)
return [] return []
const [sha, subject, author, date, body = ''] = trimmed.split('\0') const [sha, subject, author, date, body = ''] = trimmed.split('\0')
return [{ sha, subject, author, date, body: body.trim() }] return [{ sha, subject, author, date, body: body.trim() }]
}) })
const perCommitKeys = new Map<string, string[]>()
const files = new Set<string>()
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) */ /** 纯远端切分支;返回该分支相对上一个发布点的提交数(无上一个则为 0) */
+42
View File
@@ -147,4 +147,46 @@ describe('集成', () => {
expect(map.get(a)).toEqual({ number: 260, title: '连续包月会员订阅体系' }) expect(map.get(a)).toEqual({ number: 260, title: '连续包月会员订阅体系' })
expect(map.get(b)).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): 一次性命令收口'])
})
}) })