sillyspec 3.33.5 → 3.33.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/sillyspec-config/SKILL.md +22 -0
- package/.claude/skills/sillyspec-push/SKILL.md +79 -0
- package/AGENTS.md +1 -0
- package/README.md +2 -0
- package/SKILL.md +2 -0
- package/assets/command-cards/push.md +38 -0
- package/package.json +2 -2
- package/src/command-cards.js +1 -1
- package/src/config-schema.js +22 -0
- package/src/deleted-ledger.js +3 -1
- package/src/flow-parity.js +40 -0
- package/src/flow-review.js +19 -3
- package/src/flow.js +70 -19
- package/src/git-helper.js +22 -6
- package/src/index.js +34 -3
- package/src/knowledge-scope.js +82 -0
- package/src/progress/change-registry.js +15 -1
- package/src/push.js +934 -0
- package/src/run/complete-handlers.js +30 -29
- package/src/run/shared.js +1 -1
- package/src/stages/knowledge.js +44 -1
- package/src/verify-postcheck.js +97 -3
- package/templates/agents-instruction.md +2 -1
package/src/push.js
ADDED
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sillyspec push — 推送前压扁本地未推送提交(2026-10-23-push-squash)
|
|
3
|
+
*
|
|
4
|
+
* 定位:客户反馈每 task 一笔、归档两笔、merge 兜底带入 worktree 全量历史,远端提交过密。
|
|
5
|
+
* 本模块提供显式命令:以目标远端分支为 base,只处理 `base..HEAD` 的本地未推送段;默认按
|
|
6
|
+
* “连续且归属同一变更”的提交段分组,压成按变更一笔;`--mode one` 合并为一个大提交;
|
|
7
|
+
* 然后普通快进 push。禁止 pre-push hook 静默改写、禁止改写已推送历史、禁止 force-push。
|
|
8
|
+
*
|
|
9
|
+
* 核心安全不变量:
|
|
10
|
+
* ① 新 tip 树 == 旧 HEAD 树(commit-tree 取段末树),index/工作区/暂存区零变化;
|
|
11
|
+
* ② base 必须是 HEAD 祖先,且区间内不得含任何 remote-tracking ref 可达的提交;
|
|
12
|
+
* ③ pre/post 备份 ref + CAS update-ref + 文件锁,并发/中断可恢复;
|
|
13
|
+
* ④ SillySpec 活锚点(scan source_commit / active baseline / change-patch / worktree meta)
|
|
14
|
+
* 抬升 effectiveBase,保证压扁后旧锚点仍是新 HEAD 祖先;
|
|
15
|
+
* ⑤ by-change 合成 subject 带 `(<变更名>)`,body 保留全部原始消息与 task-NN 证据 token。
|
|
16
|
+
*
|
|
17
|
+
* 本模块不直接 console;人类输出由调用方(index.js)渲染,便于 --json 保持 stdout 纯净。
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
20
|
+
import { join, resolve } from 'node:path'
|
|
21
|
+
import { git, safeGit, gitQuiet } from './git-helper.js'
|
|
22
|
+
|
|
23
|
+
const PUSH_MODES = ['by-change', 'one', 'none']
|
|
24
|
+
const GIT_TIMEOUT = 30000
|
|
25
|
+
const PUSH_TIMEOUT = 600000
|
|
26
|
+
const MAX_MESSAGE_BYTES = 200000
|
|
27
|
+
const PLUMBING_RE = /^sillyspec:/
|
|
28
|
+
|
|
29
|
+
export const PUSH_USAGE = `用法: sillyspec push [--mode by-change|one|none] [--change <名>] [--dry-run] [--no-push]
|
|
30
|
+
[--undo] [--base <ref>] [--remote <名>] [--branch <名>] [--json]
|
|
31
|
+
|
|
32
|
+
推送前压扁当前分支相对目标远端的本地未推送提交,然后快进推送。
|
|
33
|
+
--mode by-change 默认:连续且归属同一变更的提交压成一笔;无归属提交原样保留
|
|
34
|
+
--mode one 全部未推送提交合并为一个大提交(需 --change;有活跃变更时拒绝)
|
|
35
|
+
--mode none 只推送,不压扁
|
|
36
|
+
--dry-run 只打印计划,不建备份 ref、不改写、不推送
|
|
37
|
+
--no-push 只压扁,不推送
|
|
38
|
+
--undo 恢复最近一次压扁前的本地分支(远端已更新时拒绝)
|
|
39
|
+
--base <ref> 显式指定压扁基线(默认取目标远端分支)
|
|
40
|
+
--remote <名> 目标远端(默认 branch.<分支>.pushRemote/remote,再退 origin)
|
|
41
|
+
--branch <名> 只作断言:必须等于当前 checkout 分支
|
|
42
|
+
--json 机器可读输出`
|
|
43
|
+
|
|
44
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
// 纯函数:参数解析 / 归属识别 / 分组 / 消息 / 计划渲染
|
|
46
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
function escapeRegExp(s) {
|
|
49
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeText(s) {
|
|
53
|
+
return String(s == null ? '' : s).replace(/\r\n/g, '\n')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function splitLines(s) {
|
|
57
|
+
return String(s == null ? '' : s).split('\n').map((x) => x.trim()).filter(Boolean)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 解析 push 参数(纯函数)。未知 flag/位置参数/互斥组合一律 fail-closed。
|
|
62
|
+
* @param {string[]} argv 命令后的参数(不含 `push` 本身;--json 已由全局层剥离)
|
|
63
|
+
* @returns {{ok:true,options:object}|{ok:false,errors:string[]}}
|
|
64
|
+
*/
|
|
65
|
+
export function parsePushArgs(argv = []) {
|
|
66
|
+
const options = {
|
|
67
|
+
mode: 'by-change', change: null, dryRun: false, noPush: false, undo: false,
|
|
68
|
+
base: null, remote: null, branch: null, help: false, json: false,
|
|
69
|
+
}
|
|
70
|
+
const errors = []
|
|
71
|
+
const valueFlags = new Set(['--mode', '--change', '--base', '--remote', '--branch'])
|
|
72
|
+
const boolFlags = new Set(['--dry-run', '--no-push', '--undo', '--help', '-h', '--json'])
|
|
73
|
+
for (let i = 0; i < argv.length; i++) {
|
|
74
|
+
const a = String(argv[i])
|
|
75
|
+
if (a === '--') { errors.push('push 不接受位置参数或 pathspec(只操作当前分支的未推送提交)'); break }
|
|
76
|
+
let key = a
|
|
77
|
+
let inlineValue = null
|
|
78
|
+
if (a.startsWith('--') && a.includes('=')) {
|
|
79
|
+
key = a.slice(0, a.indexOf('='))
|
|
80
|
+
inlineValue = a.slice(a.indexOf('=') + 1)
|
|
81
|
+
}
|
|
82
|
+
if (boolFlags.has(key)) {
|
|
83
|
+
if (inlineValue !== null) { errors.push(`${key} 不接受值`); continue }
|
|
84
|
+
if (key === '--help' || key === '-h') options.help = true
|
|
85
|
+
else if (key === '--dry-run') options.dryRun = true
|
|
86
|
+
else if (key === '--no-push') options.noPush = true
|
|
87
|
+
else if (key === '--undo') options.undo = true
|
|
88
|
+
else if (key === '--json') options.json = true
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
if (valueFlags.has(key)) {
|
|
92
|
+
const v = inlineValue !== null ? inlineValue : argv[i + 1]
|
|
93
|
+
if (v == null || String(v).startsWith('--') || String(v).trim() === '') {
|
|
94
|
+
errors.push(`${key} 缺少值`)
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
if (inlineValue === null) i++
|
|
98
|
+
if (key === '--mode') options.mode = String(v)
|
|
99
|
+
else if (key === '--change') options.change = String(v)
|
|
100
|
+
else if (key === '--base') options.base = String(v)
|
|
101
|
+
else if (key === '--remote') options.remote = String(v)
|
|
102
|
+
else if (key === '--branch') options.branch = String(v)
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
if (a.startsWith('-')) { errors.push(`未知参数: ${a}`); continue }
|
|
106
|
+
errors.push(`不接受位置参数: ${a}`)
|
|
107
|
+
}
|
|
108
|
+
if (!PUSH_MODES.includes(options.mode)) errors.push(`--mode 非法:${options.mode}(应为 ${PUSH_MODES.join('|')})`)
|
|
109
|
+
if (options.mode === 'one' && !options.change) errors.push('--mode one 必须带 --change <名>(明确归属)')
|
|
110
|
+
if (options.change != null && options.mode !== 'one') errors.push('--change 仅用于 --mode one')
|
|
111
|
+
if (options.undo) {
|
|
112
|
+
for (const k of ['mode', 'change', 'base']) {
|
|
113
|
+
const def = k === 'mode' ? 'by-change' : null
|
|
114
|
+
if (options[k] !== def) errors.push(`--undo 不能与 --${k} 同用`)
|
|
115
|
+
}
|
|
116
|
+
if (options.dryRun) errors.push('--undo 不能与 --dry-run 同用')
|
|
117
|
+
if (options.noPush) errors.push('--undo 不能与 --no-push 同用')
|
|
118
|
+
}
|
|
119
|
+
if (options.mode === 'none' && options.noPush) errors.push('--mode none 已不压扁,不能与 --no-push 同用')
|
|
120
|
+
if (errors.length) return { ok: false, errors }
|
|
121
|
+
return { ok: true, options }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 从 subject 识别变更归属。只认已知变更名(附日期前缀)与括号锚,避免把正文/文件名误判。
|
|
126
|
+
* 多个候选 = 歧义 → null(按无归属处理,不猜)。
|
|
127
|
+
* @param {string} subject
|
|
128
|
+
* @param {string[]} knownChanges
|
|
129
|
+
* @returns {string|null}
|
|
130
|
+
*/
|
|
131
|
+
export function detectChangeOwner(subject, knownChanges = []) {
|
|
132
|
+
const s = normalizeText(subject)
|
|
133
|
+
const hits = new Set()
|
|
134
|
+
for (const m of s.matchAll(/(?:^|[^\w.-])(\d{4}-\d{2}-\d{2}-[A-Za-z0-9._-]+)(?=$|[^\w.-])/g)) hits.add(m[1])
|
|
135
|
+
const sorted = [...knownChanges].filter(Boolean).sort((a, b) => b.length - a.length)
|
|
136
|
+
for (const name of sorted) {
|
|
137
|
+
const re = new RegExp(`(^|[^A-Za-z0-9._-])${escapeRegExp(name)}([^A-Za-z0-9._-]|$)`)
|
|
138
|
+
if (re.test(s)) hits.add(name)
|
|
139
|
+
}
|
|
140
|
+
return hits.size === 1 ? [...hits][0] : null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** plumbing 提交(worktree baseline/WIP/align)无变更名时,贴到相邻归属段;不跨裸提交猜测。 */
|
|
144
|
+
function attachPlumbingOwners(commits) {
|
|
145
|
+
for (let i = 0; i < commits.length; i++) {
|
|
146
|
+
const c = commits[i]
|
|
147
|
+
if (c.owner || !PLUMBING_RE.test(c.subject)) continue
|
|
148
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
149
|
+
if (commits[j].owner) { c.owner = commits[j].owner; break }
|
|
150
|
+
}
|
|
151
|
+
if (c.owner) continue
|
|
152
|
+
for (let j = i + 1; j < commits.length; j++) {
|
|
153
|
+
if (commits[j].owner) { c.owner = commits[j].owner; break }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 合成压扁提交消息:subject 带 `(<owner>)`,body 保留原始 subject 清单 + 原始正文;
|
|
160
|
+
* 原始消息里的 task-NN token 必须全部保留(缺失时追加 Task-Evidence 行)。
|
|
161
|
+
*/
|
|
162
|
+
export function buildSquashMessage({ commits, owner, mode = 'by-change' }) {
|
|
163
|
+
const list = Array.isArray(commits) ? commits : []
|
|
164
|
+
if (list.length === 0) throw new Error('buildSquashMessage: commits 为空')
|
|
165
|
+
const last = list[list.length - 1]
|
|
166
|
+
const meaningful = list.filter((c) => !PLUMBING_RE.test(c.subject))
|
|
167
|
+
const baseSubject = normalizeText((meaningful[0] || last).subject).trim() || 'chore: squash local commits'
|
|
168
|
+
const name = owner || null
|
|
169
|
+
let subject = baseSubject
|
|
170
|
+
if (name) {
|
|
171
|
+
const anchored = new RegExp(`[((]\\s*${escapeRegExp(name)}\\s*[))]`).test(subject)
|
|
172
|
+
if (!anchored) {
|
|
173
|
+
if (/^chore\(archive\):/.test(subject) && subject.includes(name)) {
|
|
174
|
+
subject = `chore(archive): 归档留档 (${name})`
|
|
175
|
+
} else {
|
|
176
|
+
subject = `${subject} (${name})`
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const summary = [`Squashed ${list.length} commits (${list[0].sha.slice(0, 7)}..${last.sha.slice(0, 7)}):`]
|
|
181
|
+
for (const c of list) summary.push(`- ${c.sha.slice(0, 7)} ${normalizeText(c.subject).trim()}`)
|
|
182
|
+
const bodies = list.map((c) => normalizeText(c.body).trim()).filter(Boolean)
|
|
183
|
+
let msg = `${subject}\n\n${summary.join('\n')}\n`
|
|
184
|
+
if (bodies.length > 0) {
|
|
185
|
+
msg += '\nOriginal commit bodies:\n'
|
|
186
|
+
for (let i = 0; i < bodies.length; i++) {
|
|
187
|
+
if (i > 0) msg += '\n---\n'
|
|
188
|
+
msg += bodies[i] + '\n'
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const allText = list.map((c) => `${c.subject}\n${c.body}`).join('\n')
|
|
192
|
+
const tokens = [...new Set([...allText.matchAll(/\btask-\d{1,4}\b/g)].map((m) => m[0]))]
|
|
193
|
+
const missing = tokens.filter((t) => !msg.includes(t))
|
|
194
|
+
if (missing.length > 0) msg += `\nTask-Evidence: ${missing.join(' ')}\n`
|
|
195
|
+
if (Buffer.byteLength(msg, 'utf8') > MAX_MESSAGE_BYTES) {
|
|
196
|
+
// 超大段只保留 subject 清单 + 全部证据 token,避免提交信息无上限膨胀;
|
|
197
|
+
// 原始正文仍可通过备份 ref 的旧提交追溯。
|
|
198
|
+
const compact = [`${subject}`, '', ...summary, '', `Task-Evidence: ${tokens.join(' ')}`].join('\n') + '\n'
|
|
199
|
+
msg = Buffer.byteLength(compact, 'utf8') <= MAX_MESSAGE_BYTES ? compact : compact.slice(0, MAX_MESSAGE_BYTES)
|
|
200
|
+
}
|
|
201
|
+
return msg
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 提交分组(纯函数)。by-change 只合并连续同归属段;one 全段一笔;none 不压扁。
|
|
206
|
+
* @param {{commits:Array, mode:string, changeName?:string|null, knownChanges?:string[]}} p
|
|
207
|
+
* @returns {{ok:true,groups:Array,needsRewrite:boolean,warnings:string[]}|{ok:false,error:string}}
|
|
208
|
+
*/
|
|
209
|
+
export function classifyCommits({ commits, mode = 'by-change', changeName = null, knownChanges = [] }) {
|
|
210
|
+
const list = (Array.isArray(commits) ? commits : []).map((c) => ({ ...c }))
|
|
211
|
+
if (mode === 'none') return { ok: true, groups: [], needsRewrite: false, warnings: [] }
|
|
212
|
+
if (list.length === 0) return { ok: true, groups: [], needsRewrite: false, warnings: [] }
|
|
213
|
+
for (const c of list) {
|
|
214
|
+
if (c.owner === undefined) c.owner = detectChangeOwner(c.subject, knownChanges)
|
|
215
|
+
}
|
|
216
|
+
attachPlumbingOwners(list)
|
|
217
|
+
const warnings = []
|
|
218
|
+
if (mode === 'one') {
|
|
219
|
+
const foreign = list.filter((c) => c.owner && c.owner !== changeName)
|
|
220
|
+
if (foreign.length > 0) {
|
|
221
|
+
return { ok: false, error: `--mode one 区间含其他变更提交(${[...new Set(foreign.map((c) => c.owner))].join('、')})——请用默认 by-change 或先拆分提交` }
|
|
222
|
+
}
|
|
223
|
+
const group = { owner: changeName, commits: list, tree: list[list.length - 1].tree, action: 'squash' }
|
|
224
|
+
group.message = list.length > 1 ? buildSquashMessage({ commits: list, owner: changeName, mode }) : list[0].message
|
|
225
|
+
group.subject = list.length > 1 ? group.message.split('\n')[0] : list[0].subject
|
|
226
|
+
return { ok: true, groups: [group], needsRewrite: list.length > 1, warnings }
|
|
227
|
+
}
|
|
228
|
+
const groups = []
|
|
229
|
+
for (const c of list) {
|
|
230
|
+
const last = groups[groups.length - 1]
|
|
231
|
+
if (c.owner && last && last.owner === c.owner) last.commits.push(c)
|
|
232
|
+
else groups.push({ owner: c.owner, commits: [c] })
|
|
233
|
+
}
|
|
234
|
+
// 无归属的连续提交不合并(FR-02:原样保留为独立提交)
|
|
235
|
+
const expanded = []
|
|
236
|
+
for (const g of groups) {
|
|
237
|
+
if (!g.owner && g.commits.length > 1) {
|
|
238
|
+
for (const c of g.commits) expanded.push({ owner: null, commits: [c] })
|
|
239
|
+
} else expanded.push(g)
|
|
240
|
+
}
|
|
241
|
+
for (const g of expanded) {
|
|
242
|
+
g.tree = g.commits[g.commits.length - 1].tree
|
|
243
|
+
if (g.commits.length > 1) {
|
|
244
|
+
g.action = 'squash'
|
|
245
|
+
g.message = buildSquashMessage({ commits: g.commits, owner: g.owner, mode })
|
|
246
|
+
g.subject = g.message.split('\n')[0]
|
|
247
|
+
} else {
|
|
248
|
+
g.action = 'keep'
|
|
249
|
+
g.message = g.commits[0].message
|
|
250
|
+
g.subject = g.commits[0].subject
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (expanded.some((g) => g.action === 'squash' && !g.owner)) {
|
|
254
|
+
warnings.push('存在无变更归属的多提交段——已按单提交保留,未压扁')
|
|
255
|
+
}
|
|
256
|
+
return { ok: true, groups: expanded, needsRewrite: expanded.some((g) => g.action === 'squash'), warnings }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** 人类可读计划行。纯函数,不打印。 */
|
|
260
|
+
export function renderPushPlan({ plan, dryRun = false } = {}) {
|
|
261
|
+
const lines = []
|
|
262
|
+
const short = (s) => String(s || '').slice(0, 10)
|
|
263
|
+
lines.push(`📦 sillyspec push 计划(mode=${plan.mode},branch=${plan.ctx?.branch || '?'},remote=${plan.target?.remote || '?'}/${plan.target?.remoteBranch || '?'})`)
|
|
264
|
+
lines.push(` HEAD ${short(plan.ctx?.head)},base ${short(plan.effectiveBase)}${plan.baseRaised ? `(已从 ${short(plan.base)} 抬升)` : ''};未推送提交 ${plan.commits.length} 个,分 ${plan.groups.length} 段`)
|
|
265
|
+
for (const g of plan.groups) {
|
|
266
|
+
const old = g.commits.length === 1 ? short(g.commits[0].sha) : `${short(g.commits[0].sha)}..${short(g.commits[g.commits.length - 1].sha)}`
|
|
267
|
+
const owner = g.owner || '(无变更归属)'
|
|
268
|
+
const action = g.action === 'squash' ? `压扁 ${g.commits.length} 笔` : '保留'
|
|
269
|
+
lines.push(` - [${owner}] ${old} → ${action}:${g.subject}`)
|
|
270
|
+
}
|
|
271
|
+
if (!plan.needsRewrite) lines.push(' 无需压扁,直接推送当前历史')
|
|
272
|
+
for (const w of plan.warnings || []) lines.push(` ⚠️ ${w}`)
|
|
273
|
+
if (dryRun) lines.push(' 仅预览:未建备份 ref、未改写、未推送')
|
|
274
|
+
return lines
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
278
|
+
// IO:仓库/目标/锚点/提交读取
|
|
279
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
function resolveRepoContext({ cwd = process.cwd() } = {}) {
|
|
282
|
+
const top = safeGit(cwd, ['rev-parse', '--show-toplevel'])
|
|
283
|
+
if (top.error || !top.value) return { ok: false, error: `当前目录不是 git 仓库:${top.error || '无输出'}` }
|
|
284
|
+
const repoRoot = top.value.trim()
|
|
285
|
+
const gd = safeGit(repoRoot, ['rev-parse', '--git-dir'])
|
|
286
|
+
const gcd = safeGit(repoRoot, ['rev-parse', '--git-common-dir'])
|
|
287
|
+
if (gd.error || gcd.error) return { ok: false, error: 'git rev-parse --git-dir/--git-common-dir 失败' }
|
|
288
|
+
const gitDir = resolve(repoRoot, gd.value.trim())
|
|
289
|
+
const commonDir = resolve(repoRoot, gcd.value.trim())
|
|
290
|
+
const headRes = safeGit(repoRoot, ['rev-parse', 'HEAD'])
|
|
291
|
+
if (headRes.error || !headRes.value) return { ok: false, error: '仓库尚无 HEAD(空仓)——先提交再压扁' }
|
|
292
|
+
const head = headRes.value.trim()
|
|
293
|
+
const branchRes = safeGit(repoRoot, ['symbolic-ref', '--short', '-q', 'HEAD'])
|
|
294
|
+
const detached = !!branchRes.error || !branchRes.value
|
|
295
|
+
const branch = detached ? null : branchRes.value.trim()
|
|
296
|
+
const shallow = gitQuiet(repoRoot, ['rev-parse', '--is-shallow-repository']) === 'true'
|
|
297
|
+
const inProgress = []
|
|
298
|
+
for (const marker of ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'BISECT_LOG', 'rebase-merge', 'rebase-apply']) {
|
|
299
|
+
const p = gitQuiet(repoRoot, ['rev-parse', '--git-path', marker])
|
|
300
|
+
if (p && existsSync(resolve(repoRoot, p))) inProgress.push(marker)
|
|
301
|
+
}
|
|
302
|
+
return { ok: true, repoRoot, gitDir, commonDir, head, branch, detached, shallow, inProgress }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function resolvePushTarget({ repoRoot, branch, remote = null, remoteBranch = null }) {
|
|
306
|
+
if (!branch) return { ok: false, error: 'detached HEAD 无分支——先 git switch <分支>' }
|
|
307
|
+
const r = remote
|
|
308
|
+
|| gitQuiet(repoRoot, ['config', '--get', `branch.${branch}.pushRemote`])
|
|
309
|
+
|| gitQuiet(repoRoot, ['config', '--get', `branch.${branch}.remote`])
|
|
310
|
+
|| 'origin'
|
|
311
|
+
let rb = remoteBranch
|
|
312
|
+
if (!rb) {
|
|
313
|
+
const merge = gitQuiet(repoRoot, ['config', '--get', `branch.${branch}.merge`])
|
|
314
|
+
if (merge && merge.startsWith('refs/heads/')) rb = merge.slice('refs/heads/'.length)
|
|
315
|
+
}
|
|
316
|
+
rb = rb || branch
|
|
317
|
+
const url = gitQuiet(repoRoot, ['remote', 'get-url', r])
|
|
318
|
+
if (!url) return { ok: false, error: `远端不存在或不可达:${r}(git remote get-url 失败)` }
|
|
319
|
+
return { ok: true, remote: r, remoteBranch: rb, remoteRef: `refs/remotes/${r}/${rb}` }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function lsRemoteTip(repoRoot, remote, remoteBranch) {
|
|
323
|
+
const out = gitQuiet(repoRoot, ['ls-remote', '--heads', remote, `refs/heads/${remoteBranch}`], { timeout: 60000 })
|
|
324
|
+
const line = String(out == null ? '' : out).split('\n').map((x) => x.trim()).find(Boolean)
|
|
325
|
+
return line ? line.split(/\s+/)[0] : null
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function revParseCommit(repoRoot, ref) {
|
|
329
|
+
if (!ref) return null
|
|
330
|
+
const r = safeGit(repoRoot, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
|
|
331
|
+
return r.error || !r.value ? null : r.value.trim()
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function revParseTree(repoRoot, ref) {
|
|
335
|
+
if (!ref) return null
|
|
336
|
+
const r = safeGit(repoRoot, ['rev-parse', '--verify', '--quiet', `${ref}^{tree}`])
|
|
337
|
+
return r.error || !r.value ? null : r.value.trim()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* 批量解析锚点 commit(1 次 spawn 替代逐锚 rev-parse——43 锚 × 2 spawn 曾占 dry-run ~2.9s,
|
|
342
|
+
* 2026-10-23 实测)。`git cat-file --batch-check` 响应序=输入序(文档保证),按输入序对齐:
|
|
343
|
+
* 可解析回显 `<完整sha> commit <size>`(短哈希输入回显完整 sha,不能按回显内容反查短哈希
|
|
344
|
+
* 输入),缺失回显 `<输入> missing`。
|
|
345
|
+
* @returns {Map<string, string|null>} 输入 sha(原样)→ 解析后完整 sha;null=不可解析
|
|
346
|
+
*/
|
|
347
|
+
function batchCommitResolve(repoRoot, shas) {
|
|
348
|
+
const uniq = [...new Set(shas.map((s) => String(s || '').trim()).filter(Boolean))]
|
|
349
|
+
const map = new Map()
|
|
350
|
+
if (uniq.length === 0) return map
|
|
351
|
+
let out
|
|
352
|
+
try {
|
|
353
|
+
out = git(repoRoot, ['cat-file', '--batch-check'], {
|
|
354
|
+
input: uniq.map((s) => `${s}^{commit}`).join('\n') + '\n',
|
|
355
|
+
timeout: GIT_TIMEOUT, trim: false,
|
|
356
|
+
})
|
|
357
|
+
} catch { return map } // 整批失败:空 Map(调用方逐锚走不可解析路径,同向 fail 不洗白)
|
|
358
|
+
const lines = String(out).split('\n').map((l) => l.trim()).filter(Boolean)
|
|
359
|
+
for (let i = 0; i < uniq.length; i++) {
|
|
360
|
+
const line = lines[i]
|
|
361
|
+
if (!line || /\smissing$/.test(line)) { map.set(uniq[i], null); continue }
|
|
362
|
+
const full = line.split(/\s+/)[0]
|
|
363
|
+
map.set(uniq[i], /^[0-9a-f]{40,64}$/i.test(full) ? full : null)
|
|
364
|
+
}
|
|
365
|
+
return map
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function isAncestor(repoRoot, a, b) {
|
|
369
|
+
if (!a || !b) return false
|
|
370
|
+
return safeGit(repoRoot, ['merge-base', '--is-ancestor', a, b]).error === null
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function parseSourceCommit(content) {
|
|
374
|
+
const m = normalizeText(content).match(/^---\s*\n[\s\S]*?^source_commit:\s*([0-9a-f]{7,40})\s*$/m)
|
|
375
|
+
return m ? m[1] : null
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** 已知变更名(活跃 + 归档目录),供 subject 归属识别。 */
|
|
379
|
+
function collectKnownChanges(specBase) {
|
|
380
|
+
const out = new Set()
|
|
381
|
+
for (const root of [join(specBase, 'changes'), join(specBase, 'changes', 'archive')]) {
|
|
382
|
+
try {
|
|
383
|
+
for (const e of readdirSync(root, { withFileTypes: true })) {
|
|
384
|
+
if (e.isDirectory() && /^\d{4}-\d{2}-\d{2}-[A-Za-z0-9._-]+$/.test(e.name)) out.add(e.name)
|
|
385
|
+
}
|
|
386
|
+
} catch { /* 目录缺失/不可读 → 跳过 */ }
|
|
387
|
+
}
|
|
388
|
+
return [...out].sort((a, b) => b.length - a.length)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* 收集 SillySpec 活锚点(只读;缺失/坏文件 fail-soft 跳过)。
|
|
393
|
+
* @returns {Array<{kind:string,id:string,sha:string}>}
|
|
394
|
+
*/
|
|
395
|
+
function collectAnchorCandidates(specBase) {
|
|
396
|
+
const anchors = []
|
|
397
|
+
const push = (kind, id, sha) => { if (sha) anchors.push({ kind, id, sha: String(sha).trim() }) }
|
|
398
|
+
// scan 文档
|
|
399
|
+
try {
|
|
400
|
+
const docsRoot = join(specBase, 'docs')
|
|
401
|
+
for (const p of readdirSync(docsRoot, { withFileTypes: true })) {
|
|
402
|
+
if (!p.isDirectory() || p.name.startsWith('.')) continue
|
|
403
|
+
const scanDir = join(docsRoot, p.name, 'scan')
|
|
404
|
+
if (!existsSync(scanDir)) continue
|
|
405
|
+
for (const f of readdirSync(scanDir)) {
|
|
406
|
+
if (!f.endsWith('.md')) continue
|
|
407
|
+
try { push('scan', `${p.name}/scan/${f}`, parseSourceCommit(readFileSync(join(scanDir, f), 'utf8'))) } catch { /* 单文档失败跳过 */ }
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
} catch { /* 无 docs */ }
|
|
411
|
+
// 活跃变更 baseline + change-patch
|
|
412
|
+
try {
|
|
413
|
+
const changesRoot = join(specBase, 'changes')
|
|
414
|
+
for (const p of readdirSync(changesRoot, { withFileTypes: true })) {
|
|
415
|
+
if (!p.isDirectory() || p.name === 'archive') continue
|
|
416
|
+
const dir = join(changesRoot, p.name)
|
|
417
|
+
try {
|
|
418
|
+
const st = readFileSync(join(dir, 'flow-state.yaml'), 'utf8')
|
|
419
|
+
const m = st.match(/^baseline_commit:\s*'?([0-9a-f]{7,40})'?\s*$/m)
|
|
420
|
+
if (m) push('flow-baseline', p.name, m[1])
|
|
421
|
+
} catch { /* 无 flow-state */ }
|
|
422
|
+
try {
|
|
423
|
+
const cp = JSON.parse(readFileSync(join(dir, 'change-patch.json'), 'utf8'))
|
|
424
|
+
push('change-patch-baseline', p.name, cp.baseline)
|
|
425
|
+
push('change-patch-head', p.name, cp.head)
|
|
426
|
+
} catch { /* 无 change-patch/不可解析 */ }
|
|
427
|
+
}
|
|
428
|
+
} catch { /* 无 changes */ }
|
|
429
|
+
// worktree meta
|
|
430
|
+
try {
|
|
431
|
+
const wtRoot = join(specBase, '.runtime', 'worktrees')
|
|
432
|
+
for (const p of readdirSync(wtRoot, { withFileTypes: true })) {
|
|
433
|
+
if (!p.isDirectory()) continue
|
|
434
|
+
try {
|
|
435
|
+
const meta = JSON.parse(readFileSync(join(wtRoot, p.name, 'meta.json'), 'utf8'))
|
|
436
|
+
for (const key of ['baselineCommit', 'actualBaseHash', 'baseHash']) {
|
|
437
|
+
if (meta[key]) push('worktree-meta', `${p.name}:${key}`, meta[key])
|
|
438
|
+
}
|
|
439
|
+
} catch { /* 单 worktree meta 失败跳过 */ }
|
|
440
|
+
}
|
|
441
|
+
} catch { /* 无 worktrees */ }
|
|
442
|
+
return anchors
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* 计算 effectiveBase:所有活锚点必须仍是新 HEAD 祖先。
|
|
447
|
+
* 显式 --base 早于活锚点 → 拒绝;锚点分叉 → 取最低共同后代(通常是 merge commit)。
|
|
448
|
+
* @returns {{ok:true,base:string,anchors:Array,warnings:string[],raised:boolean}|{ok:false,error:string}}
|
|
449
|
+
*/
|
|
450
|
+
function computeEffectiveBase({ repoRoot, head, base, anchors = [], explicitBase = false }) {
|
|
451
|
+
const warnings = []
|
|
452
|
+
const original = base
|
|
453
|
+
// 批量解析:1 次 spawn 替代逐锚 rev-parse(43 锚 × 2 spawn 曾占 dry-run ~2.9s,实测);
|
|
454
|
+
// 祖先判定只对幸存 live 锚逐个 spawn(live 数通常 0-3,成本可忽略)。
|
|
455
|
+
const resolvedMap = batchCommitResolve(repoRoot, anchors.map((a) => a.sha))
|
|
456
|
+
const live = []
|
|
457
|
+
const seen = new Set()
|
|
458
|
+
const broken = { unresolvable: [], notAncestor: [] }
|
|
459
|
+
for (const a of anchors) {
|
|
460
|
+
const sha = resolvedMap.get(String(a.sha).trim()) || null
|
|
461
|
+
if (!sha) { broken.unresolvable.push(`${a.kind} ${a.id} ${String(a.sha).slice(0, 10)}`); continue }
|
|
462
|
+
if (!isAncestor(repoRoot, sha, head)) {
|
|
463
|
+
broken.notAncestor.push(`${a.kind} ${a.id} ${sha.slice(0, 10)}`)
|
|
464
|
+
continue
|
|
465
|
+
}
|
|
466
|
+
if (seen.has(sha)) continue
|
|
467
|
+
seen.add(sha)
|
|
468
|
+
live.push({ ...a, sha })
|
|
469
|
+
}
|
|
470
|
+
const brokenCount = broken.unresolvable.length + broken.notAncestor.length
|
|
471
|
+
if (brokenCount > 0) {
|
|
472
|
+
const first3 = [...broken.unresolvable, ...broken.notAncestor].slice(0, 3).join('、')
|
|
473
|
+
warnings.push(`预存锚点异常 ${brokenCount} 个已跳过(不可解析 ${broken.unresolvable.length} / 非 HEAD 祖先 ${broken.notAncestor.length};首 3:${first3})——预存脱锚压扁不修复,advisory 不阻断`)
|
|
474
|
+
}
|
|
475
|
+
const advance = (cur, target) => {
|
|
476
|
+
if (!cur) return target
|
|
477
|
+
if (isAncestor(repoRoot, cur, target)) return target
|
|
478
|
+
if (isAncestor(repoRoot, target, cur)) return cur
|
|
479
|
+
return lowestCommonDescendant(repoRoot, [cur, target], head)
|
|
480
|
+
}
|
|
481
|
+
if (explicitBase) {
|
|
482
|
+
for (const a of live) {
|
|
483
|
+
if (!isAncestor(repoRoot, a.sha, base)) {
|
|
484
|
+
return { ok: false, error: `--base ${String(base).slice(0, 10)} 早于活锚点 ${a.kind} ${a.id}(${a.sha.slice(0, 10)})——请把 base 抬到锚点之后,或先刷新/归档对应变更` }
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
const maximal = live.filter((a) => !live.some((b) => b.sha !== a.sha && isAncestor(repoRoot, a.sha, b.sha)))
|
|
489
|
+
let next = base
|
|
490
|
+
if (maximal.length === 1) {
|
|
491
|
+
next = advance(next, maximal[0].sha)
|
|
492
|
+
} else if (maximal.length > 1) {
|
|
493
|
+
const common = lowestCommonDescendant(repoRoot, maximal.map((a) => a.sha), head)
|
|
494
|
+
if (!common) return { ok: false, error: '活锚点分叉且找不到共同后代——先跑全量 scan/归档活跃变更,或显式 --base 到所有锚点之后' }
|
|
495
|
+
next = advance(next, common)
|
|
496
|
+
}
|
|
497
|
+
if (!next || !isAncestor(repoRoot, next, head)) return { ok: false, error: 'effectiveBase 不是 HEAD 祖先(内部算法错误)' }
|
|
498
|
+
for (const a of live) {
|
|
499
|
+
if (!isAncestor(repoRoot, a.sha, next)) {
|
|
500
|
+
return { ok: false, error: `effectiveBase ${next.slice(0, 10)} 未覆盖活锚点 ${a.kind} ${a.id}(${a.sha.slice(0, 10)})` }
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (next !== original) {
|
|
504
|
+
warnings.push(`effectiveBase 因活锚点抬升:${String(original).slice(0, 10)} → ${next.slice(0, 10)}(${live.filter((a) => !isAncestor(repoRoot, a.sha, original)).map((a) => `${a.kind}:${a.id}`).join('、')})`)
|
|
505
|
+
}
|
|
506
|
+
return { ok: true, base: next, anchors: live, warnings, raised: next !== original }
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function lowestCommonDescendant(repoRoot, shas, head) {
|
|
510
|
+
const uniq = [...new Set(shas.filter(Boolean))]
|
|
511
|
+
if (uniq.length === 0) return head
|
|
512
|
+
if (uniq.some((s) => s === head)) return head
|
|
513
|
+
const lists = []
|
|
514
|
+
for (const sha of uniq) {
|
|
515
|
+
const out = gitQuiet(repoRoot, ['rev-list', '--ancestry-path', '--reverse', `${sha}..${head}`], { timeout: GIT_TIMEOUT })
|
|
516
|
+
const arr = splitLines(out)
|
|
517
|
+
if (arr.length === 0) return head
|
|
518
|
+
lists.push(arr)
|
|
519
|
+
}
|
|
520
|
+
const sets = lists.map((l) => new Set(l))
|
|
521
|
+
const common = lists[0].find((sha) => sets.every((s) => s.has(sha)))
|
|
522
|
+
if (!common) return null
|
|
523
|
+
if (uniq.some((s) => !isAncestor(repoRoot, s, common))) return null
|
|
524
|
+
return common
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** 读取 `base..HEAD` 提交(拓扑正序)。 */
|
|
528
|
+
function collectCommits({ repoRoot, base, head }) {
|
|
529
|
+
const out = git(repoRoot, [
|
|
530
|
+
'log', '--reverse', '--no-color', '--no-decorate', '-z',
|
|
531
|
+
'--format=%H%x1f%T%x1f%P%x1f%an%x1f%ae%x1f%aI%x1f%B',
|
|
532
|
+
`${base}..${head}`,
|
|
533
|
+
], { timeout: GIT_TIMEOUT, trim: false })
|
|
534
|
+
return String(out).split('\x00').map((block) => block.replace(/^\n+/, '')).filter(Boolean).map((block) => {
|
|
535
|
+
const f = block.split('\x1f')
|
|
536
|
+
const message = normalizeText(f.slice(6).join('\x1f'))
|
|
537
|
+
const nl = message.indexOf('\n')
|
|
538
|
+
return {
|
|
539
|
+
sha: f[0].trim(),
|
|
540
|
+
tree: f[1].trim(),
|
|
541
|
+
parents: splitLines(f[2]).length > 0 ? f[2].trim().split(/\s+/).filter(Boolean) : [],
|
|
542
|
+
author: { name: f[3], email: f[4], date: f[5] },
|
|
543
|
+
message,
|
|
544
|
+
subject: nl === -1 ? message.trim() : message.slice(0, nl).trim(),
|
|
545
|
+
body: nl === -1 ? '' : message.slice(nl + 1),
|
|
546
|
+
}
|
|
547
|
+
})
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function readStatus(repoRoot) {
|
|
551
|
+
const staged = splitLines(gitQuiet(repoRoot, ['diff', '--cached', '--name-only']))
|
|
552
|
+
const dirty = splitLines(gitQuiet(repoRoot, ['diff', '--name-only']))
|
|
553
|
+
const untracked = splitLines(gitQuiet(repoRoot, ['ls-files', '--others', '--exclude-standard']))
|
|
554
|
+
const porcelain = gitQuiet(repoRoot, ['status', '--porcelain'], { trim: false }) || ''
|
|
555
|
+
return { staged, dirty, untracked, porcelain }
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function detectWorktree(cwd) {
|
|
559
|
+
try {
|
|
560
|
+
const { detectCwdInsideWorktree } = await import('./run/shared.js')
|
|
561
|
+
return detectCwdInsideWorktree(cwd)
|
|
562
|
+
} catch { return null }
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
566
|
+
// 计划层
|
|
567
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* 只读计划:解析仓库/目标/base/锚点/提交/分组;不写任何 ref/index/工作区/远端。
|
|
571
|
+
* @returns {{ok:true, ...}|{ok:false, exitCode:number, errors:string[], warnings:string[]}}
|
|
572
|
+
*/
|
|
573
|
+
export function planPush({
|
|
574
|
+
cwd = process.cwd(), specDir = null, mode = 'by-change', change = null,
|
|
575
|
+
base = null, remote = null, branch = null, remoteSha = null,
|
|
576
|
+
} = {}) {
|
|
577
|
+
const fail = (msg, exitCode = 1) => ({ ok: false, exitCode, errors: [msg], warnings: [] })
|
|
578
|
+
const ctx = resolveRepoContext({ cwd })
|
|
579
|
+
if (!ctx.ok) return fail(ctx.error)
|
|
580
|
+
if (ctx.detached) return fail('detached HEAD 无分支——先 git switch <分支>', 2)
|
|
581
|
+
if (branch && branch !== ctx.branch) return fail(`--branch ${branch} 与当前 checkout 分支 ${ctx.branch} 不一致(v1 只允许当前分支)`, 2)
|
|
582
|
+
if (ctx.shallow) return fail('shallow clone 的祖先/patch-id 不可靠——先 git fetch --unshallow', 2)
|
|
583
|
+
if (ctx.inProgress.length > 0) return fail(`仓库有进行中的 git 操作(${ctx.inProgress.join('、')})——先完成或中止后再 push`, 2)
|
|
584
|
+
const target = resolvePushTarget({ repoRoot: ctx.repoRoot, branch: ctx.branch, remote })
|
|
585
|
+
if (!target.ok) return fail(target.error, 2)
|
|
586
|
+
const specBase = specDir ? resolve(specDir) : join(ctx.repoRoot, '.sillyspec')
|
|
587
|
+
const warnings = []
|
|
588
|
+
if (change && !/^[A-Za-z0-9._-]+$/.test(change)) return fail(`--change 非法:${change}`, 2)
|
|
589
|
+
|
|
590
|
+
// 远端 tip:优先调用方刚 fetch 的结果;否则 ls-remote(只读,不改 remote-tracking ref)
|
|
591
|
+
const tip = remoteSha || lsRemoteTip(ctx.repoRoot, target.remote, target.remoteBranch)
|
|
592
|
+
let baseSha = null
|
|
593
|
+
if (base) {
|
|
594
|
+
baseSha = revParseCommit(ctx.repoRoot, base)
|
|
595
|
+
if (!baseSha) return fail(`--base 不可解析:${base}`, 2)
|
|
596
|
+
} else if (tip) {
|
|
597
|
+
baseSha = revParseCommit(ctx.repoRoot, tip)
|
|
598
|
+
if (!baseSha) return fail(`远端分支 ${target.remote}/${target.remoteBranch} 的 tip ${tip.slice(0, 10)} 本地不可达——先 git fetch ${target.remote} ${target.remoteBranch},或显式 --base`, 1)
|
|
599
|
+
} else {
|
|
600
|
+
return fail(`远端分支 ${target.remote}/${target.remoteBranch} 不存在——新分支请显式 --base <ref>`, 2)
|
|
601
|
+
}
|
|
602
|
+
if (!isAncestor(ctx.repoRoot, baseSha, ctx.head)) {
|
|
603
|
+
return fail(`base ${baseSha.slice(0, 10)} 不是 HEAD 祖先(远端领先或已分叉)——先 git fetch/rebase 后再 push;本命令不 force`, 1)
|
|
604
|
+
}
|
|
605
|
+
const knownChanges = collectKnownChanges(specBase)
|
|
606
|
+
const anchorCandidates = collectAnchorCandidates(specBase)
|
|
607
|
+
const eff = computeEffectiveBase({ repoRoot: ctx.repoRoot, head: ctx.head, base: baseSha, anchors: anchorCandidates, explicitBase: !!base })
|
|
608
|
+
if (!eff.ok) return fail(eff.error, 1)
|
|
609
|
+
warnings.push(...eff.warnings)
|
|
610
|
+
const effectiveBase = eff.base
|
|
611
|
+
if (effectiveBase === ctx.head) {
|
|
612
|
+
return { ok: true, ctx, target, specBase, mode, change, base: baseSha, effectiveBase, remoteSha: tip, baseRaised: eff.raised, anchors: eff.anchors, commits: [], groups: [], needsRewrite: false, warnings: [...warnings, 'base 已到 HEAD,无未推送提交'] }
|
|
613
|
+
}
|
|
614
|
+
// 区间含已推送提交 → 拒绝(仅压扁模式需要;--mode none 只推送,天然允许)
|
|
615
|
+
if (mode !== 'none') {
|
|
616
|
+
const allRange = splitLines(gitQuiet(ctx.repoRoot, ['rev-list', `${effectiveBase}..${ctx.head}`]))
|
|
617
|
+
const unpushed = new Set(splitLines(gitQuiet(ctx.repoRoot, ['rev-list', `${effectiveBase}..${ctx.head}`, '--not', '--remotes'])))
|
|
618
|
+
const pushedInRange = allRange.filter((sha) => !unpushed.has(sha))
|
|
619
|
+
if (pushedInRange.length > 0) {
|
|
620
|
+
return fail(`压扁区间含 ${pushedInRange.length} 个已被远端 ref 可达的提交——拒绝压扁(本命令不 force-push)`, 1)
|
|
621
|
+
}
|
|
622
|
+
const merges = splitLines(gitQuiet(ctx.repoRoot, ['rev-list', '--merges', `${effectiveBase}..${ctx.head}`]))
|
|
623
|
+
if (merges.length > 0) {
|
|
624
|
+
return fail(`压扁区间含 ${merges.length} 个 merge commit——默认拒绝(展平 merge 拓扑会丢语义);先人工整理为线性历史或改用 --mode none`, 1)
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (mode === 'one' && eff.anchors.some((a) => a.kind === 'flow-baseline' && a.id !== change)) {
|
|
628
|
+
return fail('--mode one 与活跃变更相交——单一大提交会让文件归属不可按变更切分;请等活跃变更 flow done,或用默认 by-change', 1)
|
|
629
|
+
}
|
|
630
|
+
const commits = collectCommits({ repoRoot: ctx.repoRoot, base: effectiveBase, head: ctx.head })
|
|
631
|
+
if (commits.length === 0) {
|
|
632
|
+
return fail('未收集到待处理提交(base..HEAD 为空)——检查 --base 或远端状态', 1)
|
|
633
|
+
}
|
|
634
|
+
const cls = classifyCommits({ commits, mode, changeName: change, knownChanges })
|
|
635
|
+
if (!cls.ok) return fail(cls.error, 1)
|
|
636
|
+
warnings.push(...cls.warnings)
|
|
637
|
+
if (mode === 'by-change' && knownChanges.length === 0) {
|
|
638
|
+
warnings.push('未发现 .sillyspec 变更元数据(changes/ 目录为空)——by-change 无归属可分组;需要合成一笔请用 --mode one --change <名>')
|
|
639
|
+
}
|
|
640
|
+
if (mode !== 'none') {
|
|
641
|
+
warnings.push(...buildRangeAdvisories({ repoRoot: ctx.repoRoot, base: effectiveBase, head: ctx.head, commits }))
|
|
642
|
+
}
|
|
643
|
+
return {
|
|
644
|
+
ok: true, ctx, target, specBase, mode, change, knownChanges,
|
|
645
|
+
base: baseSha, effectiveBase, remoteSha: tip, baseRaised: eff.raised,
|
|
646
|
+
anchors: eff.anchors, commits, groups: cls.groups, needsRewrite: cls.needsRewrite,
|
|
647
|
+
warnings,
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* 签名标记解析(纯函数,供单测直测):`git log --format=%G?` 的 flag 非 N 即带签名
|
|
653
|
+
* (G 有效/B 无效/E 过期/U 无法验证/X 签名过期)。压扁用 commit-tree 重写,签名不可迁移——
|
|
654
|
+
* 带签名提交必须告警,让用户在远端拒收/协作要求签名场景下先人工决策。
|
|
655
|
+
* @param {Array<{sha:string,flag:string}>} flags
|
|
656
|
+
* @returns {string|null} null=全无签名(零误报)
|
|
657
|
+
*/
|
|
658
|
+
export function signatureWarnFromFlags(flags) {
|
|
659
|
+
const signed = (Array.isArray(flags) ? flags : []).filter((f) => f && f.flag && f.flag !== 'N')
|
|
660
|
+
if (signed.length === 0) return null
|
|
661
|
+
const preview = signed.slice(0, 3).map((f) => String(f.sha).slice(0, 7)).join('、')
|
|
662
|
+
return `区间含 ${signed.length} 个带签名提交(${preview}${signed.length > 3 ? ' 等' : ''})——压扁会将其重写为无签名提交(签名不可迁移,pre-squash 备份 ref 保原提交可溯);如远端/协作方要求签名,请先人工处理`
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* 压扁区间 advisory(只读,不参与守卫判定):tag 落区间 + 带签名提交。
|
|
667
|
+
* 警示是披露面——计划后远端/本地变化的改写安全仍由守卫与 CAS 保证。
|
|
668
|
+
*/
|
|
669
|
+
function buildRangeAdvisories({ repoRoot, base, head, commits }) {
|
|
670
|
+
const out = []
|
|
671
|
+
// tag 落压扁区间:tip ∈ (base..head](tag 不随改写,仍指旧提交,备份 ref 保活可溯)
|
|
672
|
+
const rangeSet = new Set((commits || []).map((c) => c.sha))
|
|
673
|
+
const tagRefs = splitLines(gitQuiet(repoRoot, ['for-each-ref', 'refs/tags', '--format=%(refname:short)%09%(*objectname)%09%(objectname)'], { timeout: GIT_TIMEOUT }))
|
|
674
|
+
const inRangeTags = []
|
|
675
|
+
for (const line of tagRefs) {
|
|
676
|
+
const parts = line.split('\t')
|
|
677
|
+
const name = parts[0]
|
|
678
|
+
// annotated tag:*objectname=剥皮 commit;lightweight:*objectname 空,objectname 即 commit
|
|
679
|
+
const tip = parts[1] || parts[2]
|
|
680
|
+
if (!name || !tip || !rangeSet.has(tip)) continue
|
|
681
|
+
inRangeTags.push(name)
|
|
682
|
+
}
|
|
683
|
+
if (inRangeTags.length > 0) {
|
|
684
|
+
out.push(`${inRangeTags.length} 个 tag 指向压扁区间内提交(${inRangeTags.slice(0, 5).join('、')}${inRangeTags.length > 5 ? ' 等' : ''})——tag 不随改写(仍指旧提交,pre-squash 备份 ref 保原提交可溯);如需 tag 跟随压扁结果,推送后人工 git tag -f <tag> <新HEAD>`)
|
|
685
|
+
}
|
|
686
|
+
// 签名提交告警:commit-tree 重写后签名丢失
|
|
687
|
+
const signOut = gitQuiet(repoRoot, ['log', '--format=%H%x1f%G?', `${base}..${head}`], { timeout: GIT_TIMEOUT })
|
|
688
|
+
const flags = splitLines(signOut).map((line) => {
|
|
689
|
+
const i = line.indexOf('\x1f')
|
|
690
|
+
return i === -1 ? { sha: line, flag: 'N' } : { sha: line.slice(0, i), flag: line.slice(i + 1).trim() }
|
|
691
|
+
})
|
|
692
|
+
const sigWarn = signatureWarnFromFlags(flags)
|
|
693
|
+
if (sigWarn) out.push(sigWarn)
|
|
694
|
+
return out
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
698
|
+
// 执行层
|
|
699
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
700
|
+
|
|
701
|
+
function timestampToken() {
|
|
702
|
+
return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z')
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function backupRefs(branch, ts) {
|
|
706
|
+
const b = String(branch || 'detached').replace(/\s+/g, '-')
|
|
707
|
+
return {
|
|
708
|
+
pre: `refs/sillyspec/pre-squash/${b}/${ts}`,
|
|
709
|
+
post: `refs/sillyspec/post-squash/${b}/${ts}`,
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function commitWithMessage({ repoRoot, tree, parent, message, author }) {
|
|
714
|
+
const env = {
|
|
715
|
+
...process.env,
|
|
716
|
+
GIT_AUTHOR_NAME: author?.name || 'sillyspec',
|
|
717
|
+
GIT_AUTHOR_EMAIL: author?.email || 'sillyspec@push.local',
|
|
718
|
+
GIT_COMMITTER_NAME: author?.name || 'sillyspec',
|
|
719
|
+
GIT_COMMITTER_EMAIL: author?.email || 'sillyspec@push.local',
|
|
720
|
+
}
|
|
721
|
+
if (author?.date) env.GIT_AUTHOR_DATE = author.date
|
|
722
|
+
return git(repoRoot, ['commit-tree', tree, '-p', parent, '-F', '-'], {
|
|
723
|
+
input: message, timeout: GIT_TIMEOUT, env,
|
|
724
|
+
})
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function rewriteChain({ repoRoot, base, groups, oldHead }) {
|
|
728
|
+
let parent = base
|
|
729
|
+
let rewritten = false
|
|
730
|
+
const mapping = []
|
|
731
|
+
for (const g of groups) {
|
|
732
|
+
const last = g.commits[g.commits.length - 1]
|
|
733
|
+
const originalParent = g.commits[0].parents[0] || null
|
|
734
|
+
if (!rewritten && g.action === 'keep' && g.commits.length === 1 && parent === originalParent) {
|
|
735
|
+
// 前缀单提交且父链未变:复用原提交,避免无谓改写更多 hash
|
|
736
|
+
mapping.push({ from: g.commits[0].sha, to: g.commits[0].sha, action: 'keep' })
|
|
737
|
+
parent = g.commits[0].sha
|
|
738
|
+
continue
|
|
739
|
+
}
|
|
740
|
+
rewritten = true
|
|
741
|
+
const newSha = commitWithMessage({
|
|
742
|
+
repoRoot, tree: g.tree, parent, message: g.message, author: last.author,
|
|
743
|
+
})
|
|
744
|
+
if (!newSha) throw new Error('git commit-tree 未返回 commit hash')
|
|
745
|
+
const newTree = revParseTree(repoRoot, newSha)
|
|
746
|
+
if (newTree !== g.tree) throw new Error(`commit-tree 树不一致:期望 ${g.tree.slice(0, 10)},实际 ${String(newTree).slice(0, 10)}`)
|
|
747
|
+
mapping.push({ from: g.commits[0].sha, to: newSha, action: g.action })
|
|
748
|
+
parent = newSha
|
|
749
|
+
}
|
|
750
|
+
if (parent !== oldHead && revParseTree(repoRoot, parent) !== revParseTree(repoRoot, oldHead)) {
|
|
751
|
+
throw new Error('压扁后 HEAD 树与旧 HEAD 不一致——拒绝更新分支')
|
|
752
|
+
}
|
|
753
|
+
return { newHead: parent, mapping }
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function pushBranch({ repoRoot, remote, branch, remoteBranch, json = false }) {
|
|
757
|
+
try {
|
|
758
|
+
// human 模式 inherit:pre-push hook 输出实时直达终端——报错一次给全,不依赖二跑定位
|
|
759
|
+
// (cli-copy-selfsufficient 探针口径);--json 模式保持 pipe 捕获,stderr 并入错误信息供机器消费。
|
|
760
|
+
const out = git(repoRoot, ['push', remote, `refs/heads/${branch}:refs/heads/${remoteBranch}`], {
|
|
761
|
+
timeout: PUSH_TIMEOUT, trim: false,
|
|
762
|
+
stdio: json ? undefined : ['ignore', 'inherit', 'inherit'],
|
|
763
|
+
})
|
|
764
|
+
return { ok: true, output: String(out || '') }
|
|
765
|
+
} catch (e) {
|
|
766
|
+
// json(pipe)模式把 hook 的 stderr/stdout 并入错误信息——报错一次给全;
|
|
767
|
+
// human(inherit)模式下输出已实时直达终端,e.stderr 为空属预期。
|
|
768
|
+
const detail = [e && e.stderr, e && e.stdout].map((x) => String(x == null ? '' : x).trim()).filter(Boolean).join('\n')
|
|
769
|
+
const first = String(e && e.message ? e.message : e).split('\n')[0]
|
|
770
|
+
return { ok: false, error: detail ? `${first}\n${detail}` : first }
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* 执行 push(含锁/备份/CAS/推送)。dry-run 只调 planPush,不取锁不写 ref。
|
|
776
|
+
* @returns {Promise<{ok:boolean,exitCode:number,plan?:object,oldHead?:string,newHead?:string,pushed?:boolean,backupRefs?:object,errors:string[],warnings:string[],humanLines:string[],envelope:object}>}
|
|
777
|
+
*/
|
|
778
|
+
export async function runPush({
|
|
779
|
+
cwd = process.cwd(), specDir = null, mode = 'by-change', change = null,
|
|
780
|
+
base = null, remote = null, branch = null, dryRun = false, noPush = false,
|
|
781
|
+
undo = false, json = false, log = () => {}, warn = () => {},
|
|
782
|
+
} = {}) {
|
|
783
|
+
const warnings = []
|
|
784
|
+
const errors = []
|
|
785
|
+
if (undo) return undoPush({ cwd, remote, branch, json })
|
|
786
|
+
|
|
787
|
+
const ctx = resolveRepoContext({ cwd })
|
|
788
|
+
if (!ctx.ok) return { ok: false, exitCode: 1, errors: [ctx.error], warnings, humanLines: [], envelope: { command: 'push', ok: false, errors: [ctx.error] } }
|
|
789
|
+
const wt = await detectWorktree(cwd)
|
|
790
|
+
if (wt && !dryRun) {
|
|
791
|
+
const msg = `cwd 在 SillySpec worktree 内(${wt.changeName})——默认拒绝压扁(scanOrphanFace 会把旧提交误判为冻结面外);先 sillyspec worktree apply 回主仓,再从主仓 push`
|
|
792
|
+
return { ok: false, exitCode: 2, errors: [msg], warnings, humanLines: [], envelope: { command: 'push', ok: false, errors: [msg] } }
|
|
793
|
+
}
|
|
794
|
+
const status = readStatus(ctx.repoRoot)
|
|
795
|
+
if (status.staged.length > 0) {
|
|
796
|
+
const msg = `index 有 ${status.staged.length} 个已暂存文件——压扁不改变暂存区,但为避免误解先提交或 restore --staged;未暂存/未跟踪文件不阻止执行`
|
|
797
|
+
return { ok: false, exitCode: 2, errors: [msg], warnings, humanLines: [], envelope: { command: 'push', ok: false, errors: [msg] } }
|
|
798
|
+
}
|
|
799
|
+
if (status.dirty.length > 0) warnings.push(`工作区有 ${status.dirty.length} 个未暂存修改——不会进入本次提交/推送`)
|
|
800
|
+
if (status.untracked.length > 0) warnings.push(`工作区有 ${status.untracked.length} 个未跟踪文件——不会进入本次提交/推送`)
|
|
801
|
+
|
|
802
|
+
// 目标远端解析一次,fetch 用真实 push remote(不能硬编码 origin)
|
|
803
|
+
const targetForFetch = resolvePushTarget({ repoRoot: ctx.repoRoot, branch: ctx.branch, remote })
|
|
804
|
+
const exec = async () => {
|
|
805
|
+
// 执行前 fetch 目标分支(dry-run 不走这里);新分支 fetch 失败交给 planPush 报“缺 --base”
|
|
806
|
+
if (!dryRun && targetForFetch.ok) {
|
|
807
|
+
try {
|
|
808
|
+
git(ctx.repoRoot, ['fetch', '--no-tags', targetForFetch.remote, targetForFetch.remoteBranch], { timeout: 120000 })
|
|
809
|
+
} catch { /* 远端分支可能不存在/离线;planPush 会用 ls-remote 给出可执行错误 */ }
|
|
810
|
+
}
|
|
811
|
+
// fetch 后复用远端跟踪引用——非 dry-run 路径免第二次 ls-remote 网络探测(dry-run 不 fetch,
|
|
812
|
+
// 仍走 planPush 内单次 ls-remote;推送前的安全再探不变,见下方 tipNow)
|
|
813
|
+
const fetchedRemoteSha = (!dryRun && targetForFetch.ok)
|
|
814
|
+
? revParseCommit(ctx.repoRoot, targetForFetch.remoteRef)
|
|
815
|
+
: null
|
|
816
|
+
const plan = planPush({ cwd, specDir, mode, change, base, remote, branch, remoteSha: fetchedRemoteSha })
|
|
817
|
+
if (!plan.ok) return { ok: false, exitCode: plan.exitCode || 1, plan, errors: plan.errors, warnings: [...warnings, ...(plan.warnings || [])], humanLines: [], envelope: { command: 'push', ok: false, mode, branch: ctx.branch, errors: plan.errors, warnings: [...warnings, ...(plan.warnings || [])] } }
|
|
818
|
+
const humanLines = renderPushPlan({ plan, dryRun })
|
|
819
|
+
const envelope = {
|
|
820
|
+
schema_version: 1, command: 'push', ok: true, branch: plan.ctx.branch,
|
|
821
|
+
remote: plan.target.remote, remote_branch: plan.target.remoteBranch,
|
|
822
|
+
base: plan.effectiveBase, old_head: plan.ctx.head, new_head: plan.ctx.head,
|
|
823
|
+
mode: plan.mode, dry_run: dryRun, pushed: false,
|
|
824
|
+
groups: plan.groups.map((g) => ({ owner: g.owner, action: g.action, count: g.commits.length, from: g.commits[0].sha, to: g.commits[g.commits.length - 1].sha, subject: g.subject })),
|
|
825
|
+
backup_refs: [], errors: [], warnings: [...warnings, ...plan.warnings],
|
|
826
|
+
}
|
|
827
|
+
if (dryRun) return { ok: true, exitCode: 0, plan, errors: [], warnings: envelope.warnings, humanLines, envelope }
|
|
828
|
+
|
|
829
|
+
let newHead = plan.ctx.head
|
|
830
|
+
let refs = null
|
|
831
|
+
if (plan.needsRewrite) {
|
|
832
|
+
const ts = timestampToken()
|
|
833
|
+
refs = backupRefs(plan.ctx.branch, ts)
|
|
834
|
+
try {
|
|
835
|
+
git(ctx.repoRoot, ['update-ref', refs.pre, plan.ctx.head], { timeout: GIT_TIMEOUT })
|
|
836
|
+
const rewritten = rewriteChain({ repoRoot: ctx.repoRoot, base: plan.effectiveBase, groups: plan.groups, oldHead: plan.ctx.head })
|
|
837
|
+
newHead = rewritten.newHead
|
|
838
|
+
if (newHead === plan.ctx.head) throw new Error('压扁未产生新提交(内部状态异常)')
|
|
839
|
+
const oldTree = revParseTree(ctx.repoRoot, plan.ctx.head)
|
|
840
|
+
const newTree = revParseTree(ctx.repoRoot, newHead)
|
|
841
|
+
if (oldTree !== newTree) throw new Error('压扁后树与旧 HEAD 不一致——拒绝更新分支')
|
|
842
|
+
git(ctx.repoRoot, ['update-ref', refs.post, newHead], { timeout: GIT_TIMEOUT })
|
|
843
|
+
try {
|
|
844
|
+
git(ctx.repoRoot, ['update-ref', '-m', `sillyspec push: squash ${plan.commits.length} commits`, `refs/heads/${plan.ctx.branch}`, newHead, plan.ctx.head], { timeout: GIT_TIMEOUT })
|
|
845
|
+
} catch (e) {
|
|
846
|
+
try { gitQuiet(ctx.repoRoot, ['update-ref', '-d', refs.post]) } catch { /* 清理失败不掩盖主错误 */ }
|
|
847
|
+
throw new Error(`分支已被并发推进,CAS 更新失败(未改写远端):${String(e && e.message ? e.message : e).split('\n')[0]}——重跑 --dry-run 重新计划`)
|
|
848
|
+
}
|
|
849
|
+
} catch (e) {
|
|
850
|
+
const msg = String(e && e.message ? e.message : e)
|
|
851
|
+
return { ok: false, exitCode: 1, plan, oldHead: plan.ctx.head, newHead, backupRefs: refs, errors: [msg], warnings: envelope.warnings, humanLines, envelope: { ...envelope, ok: false, new_head: newHead, errors: [msg], backup_refs: refs ? [refs.pre, refs.post] : [] } }
|
|
852
|
+
}
|
|
853
|
+
envelope.old_head = plan.ctx.head
|
|
854
|
+
envelope.new_head = newHead
|
|
855
|
+
envelope.backup_refs = [refs.pre, refs.post]
|
|
856
|
+
humanLines.push(`✅ 已压扁:${plan.ctx.head.slice(0, 10)} → ${newHead.slice(0, 10)}(备份 ref: ${refs.pre} / ${refs.post})`)
|
|
857
|
+
}
|
|
858
|
+
if (!noPush) {
|
|
859
|
+
// 推送前再探一次远端:计划后远端被推进 → 拒绝,避免基于旧 base 的 non-FF
|
|
860
|
+
const tipNow = lsRemoteTip(ctx.repoRoot, plan.target.remote, plan.target.remoteBranch)
|
|
861
|
+
if (tipNow && plan.remoteSha && tipNow !== plan.remoteSha) {
|
|
862
|
+
const msg = `远端分支在计划后被更新(${plan.remoteSha.slice(0, 10)} → ${tipNow.slice(0, 10)})——本地压扁已保留,请重跑 --dry-run 重新计划或用 --undo 恢复`
|
|
863
|
+
return { ok: false, exitCode: 1, plan, oldHead: plan.ctx.head, newHead, pushed: false, backupRefs: refs, errors: [msg], warnings: envelope.warnings, humanLines, envelope: { ...envelope, ok: false, errors: [msg] } }
|
|
864
|
+
}
|
|
865
|
+
const pushed = pushBranch({ repoRoot: ctx.repoRoot, remote: plan.target.remote, branch: plan.ctx.branch, remoteBranch: plan.target.remoteBranch, json })
|
|
866
|
+
if (!pushed.ok) {
|
|
867
|
+
const msg = `git push 失败(本地压扁已保留):${pushed.error}`
|
|
868
|
+
return { ok: false, exitCode: 1, plan, oldHead: plan.ctx.head, newHead, pushed: false, backupRefs: refs, errors: [msg], warnings: envelope.warnings, humanLines, envelope: { ...envelope, ok: false, errors: [msg] } }
|
|
869
|
+
}
|
|
870
|
+
envelope.pushed = true
|
|
871
|
+
humanLines.push(`🚀 已推送 ${plan.target.remote}/${plan.target.remoteBranch}(快进,无 force)`)
|
|
872
|
+
if (pushed.output) humanLines.push(...String(pushed.output).trim().split('\n').filter(Boolean))
|
|
873
|
+
} else {
|
|
874
|
+
humanLines.push('ℹ️ --no-push:仅压扁,未推送')
|
|
875
|
+
}
|
|
876
|
+
return { ok: true, exitCode: 0, plan, oldHead: plan.ctx.head, newHead, pushed: envelope.pushed, backupRefs: refs, errors: [], warnings: envelope.warnings, humanLines, envelope }
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
if (dryRun) return exec()
|
|
880
|
+
const { withFileLock } = await import('./quicklog.js')
|
|
881
|
+
const lockPath = join(ctx.commonDir, 'sillyspec-push.lock')
|
|
882
|
+
try {
|
|
883
|
+
return await withFileLock(lockPath, exec, {
|
|
884
|
+
staleMs: 10 * 60 * 1000,
|
|
885
|
+
timeoutMs: 120000,
|
|
886
|
+
retryMs: 200,
|
|
887
|
+
content: JSON.stringify({ pid: process.pid, session: process.env.SILLYSPEC_SESSION_ID || 'anon', branch: ctx.branch, purpose: 'push', startedAt: new Date().toISOString() }),
|
|
888
|
+
})
|
|
889
|
+
} catch (e) {
|
|
890
|
+
const msg = `push 锁获取/执行失败:${String(e && e.message ? e.message : e).split('\n')[0]}`
|
|
891
|
+
return { ok: false, exitCode: 1, errors: [msg], warnings, humanLines: [], envelope: { command: 'push', ok: false, errors: [msg], warnings } }
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* 恢复最近一次压扁前的本地分支。只在当前 HEAD 精确等于 post-squash ref 且远端尚未
|
|
897
|
+
* 指向压扁结果时允许;不做 force,不推送。
|
|
898
|
+
*/
|
|
899
|
+
async function undoPush({ cwd = process.cwd(), remote = null, branch = null, json = false } = {}) {
|
|
900
|
+
const fail = (msg, exitCode = 1) => ({ ok: false, exitCode, errors: [msg], warnings: [], humanLines: [], envelope: { command: 'push', ok: false, undo: true, errors: [msg] } })
|
|
901
|
+
const ctx = resolveRepoContext({ cwd })
|
|
902
|
+
if (!ctx.ok) return fail(ctx.error)
|
|
903
|
+
if (ctx.detached) return fail('detached HEAD 无分支——不能 undo', 2)
|
|
904
|
+
if (branch && branch !== ctx.branch) return fail(`--branch ${branch} 与当前分支 ${ctx.branch} 不一致`, 2)
|
|
905
|
+
const target = resolvePushTarget({ repoRoot: ctx.repoRoot, branch: ctx.branch, remote })
|
|
906
|
+
if (!target.ok) return fail(target.error, 2)
|
|
907
|
+
const prefix = `refs/sillyspec/post-squash/${ctx.branch}/`
|
|
908
|
+
const refs = splitLines(gitQuiet(ctx.repoRoot, ['for-each-ref', '--sort=-creatordate', '--format=%(refname)', prefix]) || '')
|
|
909
|
+
if (refs.length === 0) return fail('没有可恢复的 post-squash 备份 ref——未在本分支执行过 sillyspec push --no-push/压扁', 2)
|
|
910
|
+
const postRef = refs[0]
|
|
911
|
+
const ts = postRef.slice(prefix.length)
|
|
912
|
+
const preRef = `refs/sillyspec/pre-squash/${ctx.branch}/${ts}`
|
|
913
|
+
const postSha = revParseCommit(ctx.repoRoot, postRef)
|
|
914
|
+
const preSha = revParseCommit(ctx.repoRoot, preRef)
|
|
915
|
+
if (!postSha || !preSha) return fail(`备份 ref 不可解析(${postRef} / ${preRef})`)
|
|
916
|
+
if (ctx.head !== postSha) return fail(`当前 HEAD ${ctx.head.slice(0, 10)} 不是最近一次压扁结果 ${postSha.slice(0, 10)}——已有新提交,拒绝 undo(避免抹掉后续工作)`)
|
|
917
|
+
const tip = lsRemoteTip(ctx.repoRoot, target.remote, target.remoteBranch)
|
|
918
|
+
if (tip && (tip === postSha || isAncestor(ctx.repoRoot, postSha, tip))) {
|
|
919
|
+
return fail(`远端 ${target.remote}/${target.remoteBranch} 已指向压扁结果——拒绝 undo(会让本地落后远端并制造 non-FF);如确需改写远端历史请人工处理`)
|
|
920
|
+
}
|
|
921
|
+
if (tip && tip !== preSha && !isAncestor(ctx.repoRoot, tip, preSha)) {
|
|
922
|
+
return fail(`远端已变化(${tip.slice(0, 10)}),既不等于压扁前 ${preSha.slice(0, 10)} 也不是其祖先——拒绝 undo,先人工核对`)
|
|
923
|
+
}
|
|
924
|
+
try {
|
|
925
|
+
git(ctx.repoRoot, ['update-ref', '-m', 'sillyspec push: undo squash', `refs/heads/${ctx.branch}`, preSha, postSha], { timeout: GIT_TIMEOUT })
|
|
926
|
+
} catch (e) {
|
|
927
|
+
return fail(`undo CAS 失败:${String(e && e.message ? e.message : e).split('\n')[0]}`)
|
|
928
|
+
}
|
|
929
|
+
const humanLines = [`↩️ 已恢复 ${ctx.branch}:${postSha.slice(0, 10)} → ${preSha.slice(0, 10)}(备份 ref 保留)`]
|
|
930
|
+
return {
|
|
931
|
+
ok: true, exitCode: 0, oldHead: postSha, newHead: preSha, errors: [], warnings: [], humanLines,
|
|
932
|
+
envelope: { schema_version: 1, command: 'push', ok: true, undo: true, branch: ctx.branch, remote: target.remote, remote_branch: target.remoteBranch, old_head: postSha, new_head: preSha, errors: [], warnings: [] },
|
|
933
|
+
}
|
|
934
|
+
}
|