✨ feat(vscode): 摘修复识别 alembic 迁移,接不上发布分支链的高亮禁用不可摘

按 writing-alembic-migrations 的规矩判断:迁移的前任必须已在发布分支上,
且发布分支上该前任不能已有别的后继;根迁移接不上已有链、删迁移会断链也不可摘。
列表里带迁移的提交打「迁移」角标,不可摘的高亮警告色、禁用勾选并写明原因;
确认接口再校验一遍,绕过前端也摘不进去。

Claude-Session: https://claude.ai/code/session_01QWxLb6X8Vd2G5vHnp6R7qm
This commit is contained in:
cruldra committed 2026-09-08 11:44:24 +08:00
1 parent df0660cff7
commit 028195ab2b
8 files changed
+261 -26

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.121", "version": "0.2.123",
"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",
+144 -1
View File
@@ -2,6 +2,7 @@
* 发布分支管理的纯 git 逻辑:切发布点、统计未发布提交、cherry-pick 修复到发布分支。 * 发布分支管理的纯 git 逻辑:切发布点、统计未发布提交、cherry-pick 修复到发布分支。
* 不依赖 VS Code API;所有写操作只碰远端引用和发布 worktree,不改用户工作区。 * 不依赖 VS Code API;所有写操作只碰远端引用和发布 worktree,不改用户工作区。
*/ */
import type { Buffer } from 'node:buffer'
import { execFile } from 'node:child_process' import { execFile } from 'node:child_process'
import { access, mkdir, realpath } from 'node:fs/promises' import { access, mkdir, realpath } from 'node:fs/promises'
import * as path from 'node:path' import * as path from 'node:path'
@@ -28,6 +29,20 @@ export interface UnreleasedCommit {
/** Gitea 查到的所属 PR;没有就不分组。 */ /** Gitea 查到的所属 PR;没有就不分组。 */
pr?: number pr?: number
prTitle?: string prTitle?: string
/** 带 alembic 迁移文件时才有;blocked 有值就不能摘。 */
migration?: MigrationVerdict
}
/** 提交带的 alembic 迁移文件,以及能否跟着摘到发布分支。 */
export interface MigrationVerdict {
files: string[]
/** 不能摘的原因;没有就可以摘 */
blocked?: string
}
export interface AlembicRevision {
revision: string
downRevisions: string[]
} }
export interface ReleaseCommitGroup { export interface ReleaseCommitGroup {
@@ -310,10 +325,138 @@ export async function listUnreleasedCommits(o: { cwd: string, devBranch: string,
for (const key of keys) for (const key of keys)
released.add(key) released.add(key)
} }
return commits.filter((c) => { const kept = commits.filter((c) => {
const keys = perCommit.get(c.sha) ?? [] const keys = perCommit.get(c.sha) ?? []
return keys.length === 0 || keys.some(k => !released.has(k)) return keys.length === 0 || keys.some(k => !released.has(k))
}) })
const filesOf = new Map(kept.map(c => [c.sha, (perCommit.get(c.sha) ?? []).map(k => k.slice(0, k.indexOf('\0')))]))
return attachMigrationVerdicts(o.cwd, o.current, kept, filesOf)
}
export function parseAlembicRevision(text: string): AlembicRevision | undefined {
const rev = /^revision[^=\n]*=\s*(['"])([^'"]+)\1/m.exec(text)
if (!rev)
return undefined
const down = /^down_revision[^=\n]*=(.*)$/m.exec(text)
const downRevisions = down ? Array.from(down[1].matchAll(/['"]([^'"]+)['"]/g), m => m[1]) : []
return { revision: rev[2], downRevisions }
}
function isMigrationPath(file: string): boolean {
return /(?:^|\/)versions\/[^/]+\.py$/.test(file) && !file.endsWith('__init__.py')
}
/** 一次 cat-file --batch 读多个 `<ref>:<path>`;对象不存在时值为 undefined。按字节切内容,避免多字节字符错位。 */
async function catFileBatch(cwd: string, specs: string[]): Promise<Map<string, string | undefined>> {
const out = new Map<string, string | undefined>()
if (specs.length === 0)
return out
const buf = await new Promise<Buffer>((resolve, reject) => {
const child = execFile('git', ['-C', cwd, 'cat-file', '--batch'], { encoding: 'buffer', maxBuffer: 512 * 1024 * 1024 }, (err, stdout) => {
if (err)
reject(new ReleaseError('读取迁移文件失败', String(err)))
else
resolve(stdout)
})
child.stdin?.end(`${specs.join('\n')}\n`)
})
let pos = 0
for (const spec of specs) {
const eol = buf.indexOf(0x0A, pos)
const header = buf.toString('utf8', pos, eol).split(' ')
pos = eol + 1
if (header[1] === 'missing') {
out.set(spec, undefined)
continue
}
const size = Number(header[2])
out.set(spec, buf.toString('utf8', pos, pos + size))
pos += size + 1
}
return out
}
interface ReleaseMigrations {
byPath: Map<string, AlembicRevision>
revisions: Set<string>
children: Map<string, Set<string>>
dirs: Set<string>
}
/** 按 writing-alembic-migrations 的规矩:迁移只能接在发布分支已有的链尾上,前任缺失或已有别的后继都不能摘。 */
function judgeMigration(branch: string, own: Map<string, AlembicRevision | undefined>, rel: ReleaseMigrations): string | undefined {
const added = new Set(Array.from(own.values()).flatMap(r => r ? [r.revision] : []))
for (const [file, r] of own) {
if (!r) {
const gone = rel.byPath.get(file)!
if ((rel.children.get(gone.revision)?.size ?? 0) > 0)
return `删掉 ${gone.revision} 后 ${branch} 上断链`
continue
}
if (r.downRevisions.length === 0 && !rel.revisions.has(r.revision) && rel.dirs.has(path.posix.dirname(file)))
return `根迁移接不上 ${branch} 已有的迁移链`
for (const d of r.downRevisions) {
if (added.has(d))
continue
if (!rel.revisions.has(d))
return `上游迁移 ${d} 不在 ${branch}`
if (Array.from(rel.children.get(d) ?? []).some(x => x !== r.revision))
return `${branch} 上 ${d} 已有后继,会出现多个 head`
}
}
return undefined
}
async function attachMigrationVerdicts(cwd: string, current: string, commits: UnreleasedCommit[], filesOf: Map<string, string[]>): Promise<UnreleasedCommit[]> {
const candidates = new Map<string, string[]>()
for (const c of commits) {
const files = (filesOf.get(c.sha) ?? []).filter(isMigrationPath)
if (files.length > 0)
candidates.set(c.sha, files)
}
if (candidates.size === 0)
return commits
const ref = `origin/${current}`
const dirs = new Set(Array.from(candidates.values()).flat().map(f => path.posix.dirname(f)))
const listed = await mustGit(cwd, ['ls-tree', '-r', '--name-only', ref, '--', ...dirs], '读取发布分支迁移目录失败')
const releasePaths = listed.split('\n').filter(isMigrationPath)
const specs = Array.from(candidates, ([sha, files]) => files.map(f => `${sha}:${f}`)).flat()
const blobs = await catFileBatch(cwd, [...specs, ...releasePaths.map(p => `${ref}:${p}`)])
const rel: ReleaseMigrations = { byPath: new Map(), revisions: new Set(), children: new Map(), dirs: new Set() }
for (const p of releasePaths) {
const parsed = parseAlembicRevision(blobs.get(`${ref}:${p}`) ?? '')
if (!parsed)
continue
rel.byPath.set(p, parsed)
rel.revisions.add(parsed.revision)
rel.dirs.add(path.posix.dirname(p))
for (const d of parsed.downRevisions) {
if (!rel.children.has(d))
rel.children.set(d, new Set())
rel.children.get(d)!.add(parsed.revision)
}
}
return commits.map((c) => {
const files = candidates.get(c.sha)
if (!files)
return c
const own = new Map<string, AlembicRevision | undefined>()
for (const f of files) {
const text = blobs.get(`${c.sha}:${f}`)
if (text === undefined) {
if (rel.byPath.has(f))
own.set(f, undefined)
continue
}
const parsed = parseAlembicRevision(text)
if (parsed)
own.set(f, parsed)
}
if (own.size === 0)
return c
const blocked = judgeMigration(current, own, rel)
return { ...c, migration: blocked ? { files: [...own.keys()], blocked } : { files: [...own.keys()] } }
})
} }
/** 纯远端切分支;返回该分支相对上一个发布点的提交数(无上一个则为 0) */ /** 纯远端切分支;返回该分支相对上一个发布点的提交数(无上一个则为 0) */
+4
View File
@@ -145,6 +145,10 @@ export function handleReleaseCherryPickConfirm(panel: KanbanWebviewPanel, shas:
if (!current) if (!current)
throw new Error('尚无发布分支,先切新发布') throw new Error('尚无发布分支,先切新发布')
const commits = await listUnreleasedCommits({ cwd: root, devBranch: s.devBranch, current }) const commits = await listUnreleasedCommits({ cwd: root, devBranch: s.devBranch, current })
const wanted = new Set(shas)
const blocked = commits.find(c => wanted.has(c.sha) && c.migration?.blocked)
if (blocked)
throw new Error(`${blocked.sha.slice(0, 7)} ${blocked.subject}:${blocked.migration!.blocked}`)
const order = new Map(commits.map((c, i) => [c.sha, i])) const order = new Map(commits.map((c, i) => [c.sha, i]))
const ordered = [...shas].sort((a, b) => (order.get(b) ?? 0) - (order.get(a) ?? 0)) const ordered = [...shas].sort((a, b) => (order.get(b) ?? 0) - (order.get(a) ?? 0))
// 发布 worktree 走和实施 worktree 同一套目录模板,分支名里的 / 换成 - 当目录名。 // 发布 worktree 走和实施 worktree 同一套目录模板,分支名里的 / 换成 - 当目录名。
+46 -1
View File
@@ -1,5 +1,5 @@
import { execFileSync } from 'node:child_process' import { execFileSync } from 'node:child_process'
import { existsSync, promises as fsp, rmSync, writeFileSync } from 'node:fs' import { existsSync, promises as fsp, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import * as os from 'node:os' import * as os from 'node:os'
import * as path from 'node:path' import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it } from 'vitest'
@@ -12,6 +12,7 @@ import {
listReleaseBranches, listReleaseBranches,
listUnreleasedCommits, listUnreleasedCommits,
normalizeReleasePrefix, normalizeReleasePrefix,
parseAlembicRevision,
parseGiteaMergeSubject, parseGiteaMergeSubject,
pickCurrentRelease, pickCurrentRelease,
todayReleaseBranch, todayReleaseBranch,
@@ -39,6 +40,10 @@ function commit(cwd: string, file: string, content: string, msg: string): string
git(cwd, ['commit', '-m', msg]) git(cwd, ['commit', '-m', msg])
return git(cwd, ['rev-parse', 'HEAD']) return git(cwd, ['rev-parse', 'HEAD'])
} }
function migration(cwd: string, file: string, rev: string, down: string | null, msg: string): string {
mkdirSync(path.dirname(path.join(cwd, file)), { recursive: true })
return commit(cwd, file, `revision = '${rev}'\ndown_revision = ${down === null ? 'None' : `'${down}'`}\n`, msg)
}
let fx: Fx | undefined let fx: Fx | undefined
afterEach(() => { afterEach(() => {
if (fx) if (fx)
@@ -66,6 +71,13 @@ describe('纯函数', () => {
}) })
expect(parseGiteaMergeSubject('fix: no merge')).toBeUndefined() expect(parseGiteaMergeSubject('fix: no merge')).toBeUndefined()
}) })
it('parseAlembicRevision 认裸赋值、带类型标注、元组和 None', () => {
expect(parseAlembicRevision('revision = \'a1\'\ndown_revision = \'a0\'\n')).toEqual({ revision: 'a1', downRevisions: ['a0'] })
expect(parseAlembicRevision('revision: str = "a1"\ndown_revision: Union[str, None] = "a0"\nbranch_labels = None\n')).toEqual({ revision: 'a1', downRevisions: ['a0'] })
expect(parseAlembicRevision('revision = \'m\'\ndown_revision = (\'a\', \'b\')\n')).toEqual({ revision: 'm', downRevisions: ['a', 'b'] })
expect(parseAlembicRevision('revision: str = \'root\'\ndown_revision: Union[str, None] = None\n')).toEqual({ revision: 'root', downRevisions: [] })
expect(parseAlembicRevision('print(1)\n')).toBeUndefined()
})
it('groupCommitsByPr 按 Gitea 给的 pr 号分组,说明里没有 Closes 也能归组', () => { it('groupCommitsByPr 按 Gitea 给的 pr 号分组,说明里没有 Closes 也能归组', () => {
const groups = groupCommitsByPr([ const groups = groupCommitsByPr([
{ sha: '3e519ea', subject: 'fix(admin): 会员档位隐藏', author: 'chehongweo', date: '2026-08-31', body: '', pr: 260, prTitle: '连续包月会员订阅体系' }, { sha: '3e519ea', subject: 'fix(admin): 会员档位隐藏', author: 'chehongweo', date: '2026-08-31', body: '', pr: 260, prTitle: '连续包月会员订阅体系' },
@@ -237,4 +249,37 @@ describe('集成', () => {
const list = await listUnreleasedCommits({ cwd: fx.repo, devBranch: 'main', current: 'release/2026-08-31' }) const list = await listUnreleasedCommits({ cwd: fx.repo, devBranch: 'main', current: 'release/2026-08-31' })
expect(list.map(c => c.subject)).toEqual(['fix(jobs): 一次性命令收口']) expect(list.map(c => c.subject)).toEqual(['fix(jobs): 一次性命令收口'])
}) })
it('带迁移的提交:前任在发布分支上才能摘,前任缺失的标不可摘,不带迁移的不打标', async () => {
fx = await setup()
migration(fx.peer, 'alembic/versions/0001_init.py', '0001', null, 'feat: init schema')
git(fx.peer, ['push', 'origin', 'main'])
await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-31' })
migration(fx.peer, 'alembic/versions/0002_x.py', '0002', '0001', 'feat: m2')
migration(fx.peer, 'alembic/versions/0003_y.py', '0003', '0002', 'feat: m3')
commit(fx.peer, 'b.txt', 'b', 'fix: plain')
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: plain', 'feat: m3', 'feat: m2'])
expect(list[0].migration).toBeUndefined()
expect(list[1].migration?.files).toEqual(['alembic/versions/0003_y.py'])
expect(list[1].migration?.blocked).toContain('0002')
expect(list[2].migration).toEqual({ files: ['alembic/versions/0002_x.py'] })
})
it('发布分支上同一前任已有后继 → 多 head,不可摘', async () => {
fx = await setup()
migration(fx.peer, 'alembic/versions/0001_init.py', '0001', null, 'feat: init schema')
git(fx.peer, ['push', 'origin', 'main'])
await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-31' })
migration(fx.peer, 'alembic/versions/0002_x.py', '0002', '0001', 'feat: m2')
git(fx.peer, ['push', 'origin', 'main'])
git(fx.peer, ['fetch'])
git(fx.peer, ['checkout', '-b', 'release/2026-08-31', 'origin/release/2026-08-31'])
migration(fx.peer, 'alembic/versions/0002_hotfix.py', '0002b', '0001', 'fix: hotfix schema')
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: m2'])
expect(list[0].migration?.blocked).toContain('多个 head')
})
}) })
@@ -1,7 +1,7 @@
import type { ReleaseCommitGroup } from '../lib/messages' import type { ReleaseCommitGroup } from '../lib/messages'
import { ChevronRight, X } from 'lucide-react' import { ChevronRight, X } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { filterCherryPickGroups, groupContainingSha, uniqueAuthors } from '../lib/cherryPickView' import { filterCherryPickGroups, groupContainingSha, pickableShas, uniqueAuthors } from '../lib/cherryPickView'
interface Props { interface Props {
open: boolean open: boolean
@@ -78,7 +78,7 @@ export function CherryPickModal({ open, branch, groups, firstAfterReleaseSha, on
} }
function toggleGroupSelected(g: ReleaseCommitGroup): void { function toggleGroupSelected(g: ReleaseCommitGroup): void {
const shas = g.commits.map(c => c.sha) const shas = pickableShas(g.commits)
const allOn = shas.every(s => selected.has(s)) const allOn = shas.every(s => selected.has(s))
setSelected((prev) => { setSelected((prev) => {
const next = new Set(prev) const next = new Set(prev)
@@ -150,7 +150,8 @@ export function CherryPickModal({ open, branch, groups, firstAfterReleaseSha, on
: visible.map((g) => { : visible.map((g) => {
const key = groupKey(g) const key = groupKey(g)
const openGroup = query.trim() !== '' || expanded.has(key) const openGroup = query.trim() !== '' || expanded.has(key)
const shas = g.commits.map(c => c.sha) const shas = pickableShas(g.commits)
const blockedCount = g.commits.length - shas.length
const allOn = shas.length > 0 && shas.every(s => selected.has(s)) const allOn = shas.length > 0 && shas.every(s => selected.has(s))
const someOn = shas.some(s => selected.has(s)) const someOn = shas.some(s => selected.has(s))
return ( return (
@@ -171,11 +172,13 @@ export function CherryPickModal({ open, branch, groups, firstAfterReleaseSha, on
</span> </span>
<span className="shrink-0 text-[11px] opacity-60"> <span className="shrink-0 text-[11px] opacity-60">
{`${g.authors.join(' · ')} · ${g.commits.length} 条`} {`${g.authors.join(' · ')} · ${g.commits.length} 条`}
{blockedCount > 0 ? ` · ${blockedCount} 条带迁移不可摘` : ''}
</span> </span>
</button> </button>
<input <input
type="checkbox" type="checkbox"
checked={allOn} checked={allOn}
disabled={shas.length === 0}
ref={(el) => { ref={(el) => {
if (el) if (el)
el.indeterminate = someOn && !allOn el.indeterminate = someOn && !allOn
@@ -187,25 +190,47 @@ export function CherryPickModal({ open, branch, groups, firstAfterReleaseSha, on
</div> </div>
{openGroup && ( {openGroup && (
<ul className="border-t border-[var(--vscode-panel-border)] py-1"> <ul className="border-t border-[var(--vscode-panel-border)] py-1">
{g.commits.map(c => ( {g.commits.map((c) => {
<li key={c.sha}> const blocked = c.migration?.blocked
<label className={`flex cursor-pointer items-start gap-2 px-3 py-1.5 hover:bg-white/5 ${c.sha === firstAfterReleaseSha ? 'bg-[var(--vscode-editor-findMatchHighlightBackground)]' : ''}`}> const rowBg = blocked
<input ? 'bg-[var(--vscode-inputValidation-warningBackground)]'
type="checkbox" : c.sha === firstAfterReleaseSha ? 'bg-[var(--vscode-editor-findMatchHighlightBackground)]' : ''
checked={selected.has(c.sha)} return (
onChange={() => toggleSha(c.sha)} <li key={c.sha}>
className="mt-0.5" <label className={`flex items-start gap-2 px-3 py-1.5 ${blocked ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-white/5'} ${rowBg}`}>
/> <input
<span className="min-w-0 flex-1"> type="checkbox"
<span className="block truncate">{c.subject}</span> checked={selected.has(c.sha)}
<span className="block text-[11px] opacity-60"> disabled={!!blocked}
{`${c.sha.slice(0, 7)} · ${c.author} · ${c.date}`} onChange={() => toggleSha(c.sha)}
{c.sha === firstAfterReleaseSha ? ' · 发布点后第一条' : ''} className="mt-0.5"
/>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-1.5">
{c.migration && (
<span
title={c.migration.files.join('\n')}
className="shrink-0 rounded border border-current px-1 text-[10px] leading-4 opacity-80"
>
迁移
</span>
)}
<span className="min-w-0 flex-1 truncate">{c.subject}</span>
</span>
<span className="block text-[11px] opacity-60">
{`${c.sha.slice(0, 7)} · ${c.author} · ${c.date}`}
{c.sha === firstAfterReleaseSha ? ' · 发布点后第一条' : ''}
</span>
{blocked && (
<span className="block text-[11px] text-[var(--vscode-editorWarning-foreground)]">
{`不可摘:${blocked}`}
</span>
)}
</span> </span>
</span> </label>
</label> </li>
</li> )
))} })}
</ul> </ul>
)} )}
</section> </section>
@@ -1,6 +1,6 @@
import type { ReleaseCommitGroup } from './messages' import type { ReleaseCommitGroup } from './messages'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { filterCherryPickGroups, uniqueAuthors } from './cherryPickView' import { filterCherryPickGroups, pickableShas, uniqueAuthors } from './cherryPickView'
const groups: ReleaseCommitGroup[] = [ const groups: ReleaseCommitGroup[] = [
{ {
@@ -35,3 +35,14 @@ describe('uniqueAuthors', () => {
expect(uniqueAuthors(groups)).toEqual(['cruldra', 'chehongweo']) expect(uniqueAuthors(groups)).toEqual(['cruldra', 'chehongweo'])
}) })
}) })
describe('pickableShas', () => {
it('带不兼容迁移的提交不能勾;兼容的迁移照常', () => {
const commits = [
{ sha: 'a', subject: 'ok', author: 'x', date: '', body: '' },
{ sha: 'b', subject: 'mig ok', author: 'x', date: '', body: '', migration: { files: ['alembic/versions/0002.py'] } },
{ sha: 'c', subject: 'mig bad', author: 'x', date: '', body: '', migration: { files: ['alembic/versions/0003.py'], blocked: '上游迁移 0002 不在 release/x' } },
]
expect(pickableShas(commits)).toEqual(['a', 'b'])
})
})
+6 -1
View File
@@ -1,4 +1,9 @@
import type { ReleaseCommitGroup } from './messages' import type { ReleaseCommit, ReleaseCommitGroup } from './messages'
/** 能勾选的提交:带不兼容迁移的不算 */
export function pickableShas(commits: ReleaseCommit[]): string[] {
return commits.filter(c => !c.migration?.blocked).map(c => c.sha)
}
export function uniqueAuthors(groups: ReleaseCommitGroup[]): string[] { export function uniqueAuthors(groups: ReleaseCommitGroup[]): string[] {
const out: string[] = [] const out: string[] = []
+2
View File
@@ -29,6 +29,8 @@ export interface ReleaseCommit {
author: string author: string
date: string date: string
body: string body: string
/** 带 alembic 迁移文件时才有;blocked 有值就不能摘 */
migration?: { files: string[], blocked?: string }
} }
export interface ReleaseCommitGroup { export interface ReleaseCommitGroup {