✨ feat(vscode): 摘修复识别 alembic 迁移,接不上发布分支链的高亮禁用不可摘
按 writing-alembic-migrations 的规矩判断:迁移的前任必须已在发布分支上, 且发布分支上该前任不能已有别的后继;根迁移接不上已有链、删迁移会断链也不可摘。 列表里带迁移的提交打「迁移」角标,不可摘的高亮警告色、禁用勾选并写明原因; 确认接口再校验一遍,绕过前端也摘不进去。 Claude-Session: https://claude.ai/code/session_01QWxLb6X8Vd2G5vHnp6R7qm
This commit is contained in:
1 parent
df0660cff7
commit
028195ab2b
8 files changed
+261
-26
No files matched your search
+144
-1
@@ -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 读多个 `<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) */
|
||||
|
||||
Reference in new issue
Block a user