diff --git a/vscode/package.json b/vscode/package.json index 24ec471..f3955e8 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.121", + "version": "0.2.123", "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 b361012..44bc3e6 100644 --- a/vscode/src/git/release.ts +++ b/vscode/src/git/release.ts @@ -2,6 +2,7 @@ * 发布分支管理的纯 git 逻辑:切发布点、统计未发布提交、cherry-pick 修复到发布分支。 * 不依赖 VS Code API;所有写操作只碰远端引用和发布 worktree,不改用户工作区。 */ +import type { Buffer } from 'node:buffer' import { execFile } from 'node:child_process' import { access, mkdir, realpath } from 'node:fs/promises' import * as path from 'node:path' @@ -28,6 +29,20 @@ export interface UnreleasedCommit { /** Gitea 查到的所属 PR;没有就不分组。 */ pr?: number prTitle?: string + /** 带 alembic 迁移文件时才有;blocked 有值就不能摘。 */ + migration?: MigrationVerdict +} + +/** 提交带的 alembic 迁移文件,以及能否跟着摘到发布分支。 */ +export interface MigrationVerdict { + files: string[] + /** 不能摘的原因;没有就可以摘 */ + blocked?: string +} + +export interface AlembicRevision { + revision: string + downRevisions: string[] } export interface ReleaseCommitGroup { @@ -310,10 +325,138 @@ export async function listUnreleasedCommits(o: { cwd: string, devBranch: string, for (const key of keys) released.add(key) } - return commits.filter((c) => { + const kept = commits.filter((c) => { const keys = perCommit.get(c.sha) ?? [] 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 读多个 `:`;对象不存在时值为 undefined。按字节切内容,避免多字节字符错位。 */ +async function catFileBatch(cwd: string, specs: string[]): Promise> { + const out = new Map() + if (specs.length === 0) + return out + const buf = await new Promise((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 + revisions: Set + children: Map> + dirs: Set +} + +/** 按 writing-alembic-migrations 的规矩:迁移只能接在发布分支已有的链尾上,前任缺失或已有别的后继都不能摘。 */ +function judgeMigration(branch: string, own: Map, 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): Promise { + const candidates = new Map() + 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() + 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) */ diff --git a/vscode/src/panel/handlers/release.ts b/vscode/src/panel/handlers/release.ts index e9e9f8f..ee763cb 100644 --- a/vscode/src/panel/handlers/release.ts +++ b/vscode/src/panel/handlers/release.ts @@ -145,6 +145,10 @@ export function handleReleaseCherryPickConfirm(panel: KanbanWebviewPanel, shas: if (!current) throw new Error('尚无发布分支,先切新发布') 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 ordered = [...shas].sort((a, b) => (order.get(b) ?? 0) - (order.get(a) ?? 0)) // 发布 worktree 走和实施 worktree 同一套目录模板,分支名里的 / 换成 - 当目录名。 diff --git a/vscode/test/git/release.test.ts b/vscode/test/git/release.test.ts index 52a7e8a..3ed67d2 100644 --- a/vscode/test/git/release.test.ts +++ b/vscode/test/git/release.test.ts @@ -1,5 +1,5 @@ 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 path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -12,6 +12,7 @@ import { listReleaseBranches, listUnreleasedCommits, normalizeReleasePrefix, + parseAlembicRevision, parseGiteaMergeSubject, pickCurrentRelease, todayReleaseBranch, @@ -39,6 +40,10 @@ function commit(cwd: string, file: string, content: string, msg: string): string git(cwd, ['commit', '-m', msg]) 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 afterEach(() => { if (fx) @@ -66,6 +71,13 @@ describe('纯函数', () => { }) 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 也能归组', () => { const groups = groupCommitsByPr([ { 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' }) 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') + }) }) diff --git a/vscode/webview-ui/src/components/CherryPickModal.tsx b/vscode/webview-ui/src/components/CherryPickModal.tsx index ac7a5b6..7a0debb 100644 --- a/vscode/webview-ui/src/components/CherryPickModal.tsx +++ b/vscode/webview-ui/src/components/CherryPickModal.tsx @@ -1,7 +1,7 @@ import type { ReleaseCommitGroup } from '../lib/messages' import { ChevronRight, X } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' -import { filterCherryPickGroups, groupContainingSha, uniqueAuthors } from '../lib/cherryPickView' +import { filterCherryPickGroups, groupContainingSha, pickableShas, uniqueAuthors } from '../lib/cherryPickView' interface Props { open: boolean @@ -78,7 +78,7 @@ export function CherryPickModal({ open, branch, groups, firstAfterReleaseSha, on } 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)) setSelected((prev) => { const next = new Set(prev) @@ -150,7 +150,8 @@ export function CherryPickModal({ open, branch, groups, firstAfterReleaseSha, on : visible.map((g) => { const key = groupKey(g) 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 someOn = shas.some(s => selected.has(s)) return ( @@ -171,11 +172,13 @@ export function CherryPickModal({ open, branch, groups, firstAfterReleaseSha, on {`${g.authors.join(' · ')} · ${g.commits.length} 条`} + {blockedCount > 0 ? ` · ${blockedCount} 条带迁移不可摘` : ''} { if (el) el.indeterminate = someOn && !allOn @@ -187,25 +190,47 @@ export function CherryPickModal({ open, branch, groups, firstAfterReleaseSha, on {openGroup && (
    - {g.commits.map(c => ( -
  • -
  • + -
  • - ))} + + + ) + })}
)} diff --git a/vscode/webview-ui/src/lib/cherryPickView.test.ts b/vscode/webview-ui/src/lib/cherryPickView.test.ts index 855f395..2bf0bd2 100644 --- a/vscode/webview-ui/src/lib/cherryPickView.test.ts +++ b/vscode/webview-ui/src/lib/cherryPickView.test.ts @@ -1,6 +1,6 @@ import type { ReleaseCommitGroup } from './messages' import { describe, expect, it } from 'vitest' -import { filterCherryPickGroups, uniqueAuthors } from './cherryPickView' +import { filterCherryPickGroups, pickableShas, uniqueAuthors } from './cherryPickView' const groups: ReleaseCommitGroup[] = [ { @@ -35,3 +35,14 @@ describe('uniqueAuthors', () => { 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']) + }) +}) diff --git a/vscode/webview-ui/src/lib/cherryPickView.ts b/vscode/webview-ui/src/lib/cherryPickView.ts index e7b1032..b4ba4c3 100644 --- a/vscode/webview-ui/src/lib/cherryPickView.ts +++ b/vscode/webview-ui/src/lib/cherryPickView.ts @@ -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[] { const out: string[] = [] diff --git a/vscode/webview-ui/src/lib/messages.ts b/vscode/webview-ui/src/lib/messages.ts index 7ad56c7..5feab03 100644 --- a/vscode/webview-ui/src/lib/messages.ts +++ b/vscode/webview-ui/src/lib/messages.ts @@ -29,6 +29,8 @@ export interface ReleaseCommit { author: string date: string body: string + /** 带 alembic 迁移文件时才有;blocked 有值就不能摘 */ + migration?: { files: string[], blocked?: string } } export interface ReleaseCommitGroup {