sillyspec 3.28.7 → 3.28.8
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-execute/SKILL.md +224 -222
- package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
- package/.claude/skills/sillyspec-quick/SKILL.md +117 -116
- package/.husky/pre-push +3 -1
- package/.patch16-tmp.mjs +169 -0
- package/package.json +1 -1
- package/src/agent-session-log.js +446 -35
- package/src/config-cat.js +6 -3
- package/src/constants.js +3 -0
- package/src/db.js +12 -3
- package/src/docs-check.js +137 -12
- package/src/docs-gate.js +3 -0
- package/src/doctor-diagnostics.js +19 -7
- package/src/friction-tally.js +7 -2
- package/src/git-helper.js +6 -4
- package/src/hooks/worktree-guard.js +59 -29
- package/src/index.js +168 -64
- package/src/local-detect.js +30 -15
- package/src/local-register.js +13 -4
- package/src/mcp-server.js +38 -8
- package/src/progress/change-registry.js +41 -35
- package/src/progress/consistency-doctor.js +11 -4
- package/src/progress/stage-machine.js +3 -1
- package/src/progress/step-store.js +30 -17
- package/src/progress.js +23 -0
- package/src/quicklog.js +44 -18
- package/src/run/command.js +17 -3
- package/src/run/complete-handlers.js +3 -3
- package/src/run/complete.js +28 -9
- package/src/run/concurrent-detect.js +1 -1
- package/src/run/gate-snapshot.js +115 -5
- package/src/run/gates.js +35 -5
- package/src/run/prompt.js +9 -1
- package/src/run/quick-audit.js +25 -2
- package/src/run/shared.js +98 -10
- package/src/run/verify-quality-scan.js +14 -3
- package/src/spec-sync.js +19 -14
- package/src/stage-contract-spec.js +4 -3
- package/src/stage-review.js +6 -5
- package/src/stages/brainstorm.js +1 -1
- package/src/stages/execute.js +6 -1
- package/src/stages/plan-postcheck.js +57 -12
- package/src/sync.js +66 -4
- package/src/task-review.js +41 -6
- package/src/verify-facts-schema.js +10 -3
- package/src/verify-postcheck.js +21 -6
- package/src/verify-probes.js +8 -3
- package/src/worktree-apply.js +56 -18
- package/src/worktree-cross.js +12 -18
- package/src/worktree.js +138 -25
- package/src/wt-commit.js +128 -0
package/src/worktree.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { execFileSync } from 'child_process';
|
|
12
12
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, readlinkSync, unlinkSync, copyFileSync } from 'fs';
|
|
13
|
-
import { join, resolve, dirname, relative, isAbsolute } from 'path';
|
|
13
|
+
import { join, resolve, dirname, relative, isAbsolute, basename } from 'path';
|
|
14
14
|
import { createHash } from 'crypto';
|
|
15
15
|
import { provisionDeps, checkDepsFreshness, detectEditableInstallEscape } from './worktree-deps.js';
|
|
16
16
|
import { writeAtomicSync } from './fs-atomic.js';
|
|
@@ -25,6 +25,24 @@ const WORKTREES_REL = '.sillyspec/.runtime/worktrees';
|
|
|
25
25
|
const BRANCH_PREFIX = 'sillyspec/';
|
|
26
26
|
const META_FILE = 'meta.json';
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* argv 长度分批(审查遗留终批-①;与 worktree-apply.js chunkPaths 同逻辑内联私有版——
|
|
30
|
+
* 那边 import 本文件,反向 import 成环,两处须同步演化)。Windows CreateProcess 命令行
|
|
31
|
+
* 32767 字符上限:数百个长路径全量展开时 spawn 直接失败(hash-object 失败返 null 会让
|
|
32
|
+
* hasUnappliedChanges 全量误判 pending 卡死 cleanup)。保守 8000 字符/批。
|
|
33
|
+
*/
|
|
34
|
+
function _chunkPathsPrivate(paths, maxChars = 8000) {
|
|
35
|
+
const batches = [[]]
|
|
36
|
+
let len = 0
|
|
37
|
+
for (const p of paths) {
|
|
38
|
+
const l = String(p).length + 1
|
|
39
|
+
if (batches[batches.length - 1].length > 0 && len + l > maxChars) { batches.push([]); len = 0 }
|
|
40
|
+
batches[batches.length - 1].push(p)
|
|
41
|
+
len += l
|
|
42
|
+
}
|
|
43
|
+
return batches
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
/**
|
|
29
47
|
* 解除 worktree 内全部 node_modules junction/symlink(根 + meta.depsModules 各子模块)。
|
|
30
48
|
*
|
|
@@ -79,7 +97,65 @@ export function unlinkNodeModulesLinks(worktreePath, meta = null, details = null
|
|
|
79
97
|
* 安全删除 worktree 目录:先解链全部 node_modules junction 再 rmSync。
|
|
80
98
|
* 幽灵目录清理(create 强删 / doctor ghost 修复)的统一出口——不再裸 rmSync。
|
|
81
99
|
*/
|
|
82
|
-
|
|
100
|
+
/**
|
|
101
|
+
* 跨仓 worktree 目录判定(坑 cross-worktree-swept-by-main-cleanup,2026-09-12 驾驭第十三批①,
|
|
102
|
+
* 用户实证:跨仓 worktree 被 verify 期间的主仓侧清理面误删,apply 锚点校验连环失败、被迫
|
|
103
|
+
* cherry-pick 重建):目录内 meta.json 带 isCross:true,或目录名呈 <change>--<repoKey> 跨仓
|
|
104
|
+
* 形态(.runtime/worktrees/ 下主仓 worktree 恒为纯 <change>,双连字符后缀是跨仓专属命名)。
|
|
105
|
+
*/
|
|
106
|
+
export function isCrossWorktreeDir(worktreePath) {
|
|
107
|
+
try {
|
|
108
|
+
const metaPath = join(worktreePath, 'meta.json')
|
|
109
|
+
if (existsSync(metaPath)) {
|
|
110
|
+
try {
|
|
111
|
+
const m = JSON.parse(readFileSync(metaPath, 'utf8'))
|
|
112
|
+
if (m && m.isCross === true) return true
|
|
113
|
+
} catch { /* 损坏 meta → 落到命名形态判定 */ }
|
|
114
|
+
}
|
|
115
|
+
const base = basename(worktreePath)
|
|
116
|
+
// 主仓 changeName 可含连字符(2026-09-10-x),跨仓后缀是 '--<repoKey>'——要求双连字符
|
|
117
|
+
// 分隔且两侧非空、repoKey 段无路径分隔符
|
|
118
|
+
const m2 = base.match(/^(.+)--([A-Za-z0-9_.\-]+)$/)
|
|
119
|
+
return !!(m2 && m2[1].trim() && m2[2].trim() && !m2[2].includes('/') && !m2[2].includes('\\'))
|
|
120
|
+
} catch {
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* worktree 目录安全删除原语。opts.allowCross(默认 false):跨仓 worktree(isCross meta/
|
|
127
|
+
* 命名形态)拒绝删除——主仓清理面(cleanup force-remove / doctor ghost / 幽灵清理)不得
|
|
128
|
+
* 波及跨仓 worktree(其注册在跨仓仓 .git,删除权在 cleanupCrossWorktrees 显式路径)。
|
|
129
|
+
*/
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* baseline checkpoint 产物防御(坑 baseline-checkpoint-binary-sweep,2026-09-12 驾驭第十五
|
|
133
|
+
* 批②,用户实证:部署 tar.gz(289MB)被 checkpoint 带进分支——FF 合并被迫改 merge commit +
|
|
134
|
+
* 二进制永久入 git)。untracked 收入口对两类产物恒跳过:①已知归档/分发扩展名(tar.gz/zip/
|
|
135
|
+
* whl/exe 等——几乎不可能是源码 WIP);②单文件超体积帽(缺省 10MB,SILLYSPEC_BASELINE_
|
|
136
|
+
* MAX_FILE_MB 可调)。跳过清单返回调用方点名 + 补 .gitignore 指引(gitignore 缺口的实际代价
|
|
137
|
+
* 由工具兜底提示)。tracked-modified 不走本防御(已在 git 历史里,checkpoint diff 是 git 原生)。
|
|
138
|
+
*/
|
|
139
|
+
const ARTIFACT_EXT_RE = /\.(?:tar\.gz|tgz|tar|zip|7z|rar|gz|bz2|xz|whl|exe|dll|so|dylib|iso|dmg|pkg|jar|war|apk|ipa)$/i
|
|
140
|
+
|
|
141
|
+
export function isCheckpointSkippableArtifact(absPath) {
|
|
142
|
+
const rel = String(absPath).split('\\').join('/')
|
|
143
|
+
if (ARTIFACT_EXT_RE.test(rel)) return 'artifact-ext'
|
|
144
|
+
try {
|
|
145
|
+
const maxMB = Number(process.env.SILLYSPEC_BASELINE_MAX_FILE_MB) || 10
|
|
146
|
+
const st = statSync(absPath)
|
|
147
|
+
if (st.isFile() && st.size > maxMB * 1024 * 1024) return 'size-cap'
|
|
148
|
+
} catch { /* stat 失败按不跳过(后续 copy 自会报错) */ }
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function safeRemoveWorktreeDir(worktreePath, meta = null, opts = {}) {
|
|
153
|
+
if (!opts.allowCross && isCrossWorktreeDir(worktreePath)) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`跨仓 worktree 拒绝删除(主仓清理面守卫,坑 cross-worktree-swept-by-main-cleanup): ${worktreePath}` +
|
|
156
|
+
`——跨仓 worktree 由 sillyspec worktree cleanup <change>(跨仓一并清理)或 apply no-op 路径管理;主仓侧清理/doctor 扫描不碰它`
|
|
157
|
+
)
|
|
158
|
+
}
|
|
83
159
|
unlinkNodeModulesLinks(worktreePath, meta);
|
|
84
160
|
rmSync(worktreePath, { recursive: true, force: true });
|
|
85
161
|
}
|
|
@@ -873,11 +949,10 @@ export class WorktreeManager {
|
|
|
873
949
|
_branchReviewReferences(branch) {
|
|
874
950
|
const runsDir = join(this.cwd, '.sillyspec', '.runtime', 'execute-runs');
|
|
875
951
|
if (!existsSync(runsDir)) return [];
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
const refs = [];
|
|
952
|
+
// 终批-②:先收 review.json 里出现的候选 hash(少量),再逐个精确校验「是分支祖先或自身」
|
|
953
|
+
// ——旧实现整分支 rev-list 进 Set(大历史仓 10 万 commit 每次 cleanup/doctor 可观内存与
|
|
954
|
+
// 时间,而判定只用到 review 引用过的那几个 hash)。
|
|
955
|
+
const candidates = new Map(); // hash → 引用它的 review.json 路径数组
|
|
881
956
|
const visit = (dir) => {
|
|
882
957
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
883
958
|
const p = join(dir, entry.name);
|
|
@@ -887,14 +962,25 @@ export class WorktreeManager {
|
|
|
887
962
|
const r = JSON.parse(readFileSync(p, 'utf8'));
|
|
888
963
|
for (const key of ['base', 'head']) {
|
|
889
964
|
const h = typeof r[key] === 'string' ? r[key].trim() : '';
|
|
890
|
-
if (h
|
|
965
|
+
if (h) {
|
|
966
|
+
if (!candidates.has(h)) candidates.set(h, []);
|
|
967
|
+
candidates.get(h).push(p);
|
|
968
|
+
}
|
|
891
969
|
}
|
|
892
970
|
} catch { /* review.json 损坏不参与判定 */ }
|
|
893
971
|
}
|
|
894
972
|
}
|
|
895
973
|
};
|
|
896
974
|
visit(runsDir);
|
|
897
|
-
return
|
|
975
|
+
if (candidates.size === 0) return [];
|
|
976
|
+
const refs = [];
|
|
977
|
+
for (const [hash, paths] of candidates) {
|
|
978
|
+
// merge-base --is-ancestor:祖先或自身 → exit 0 输出空串(gitQuiet 返回 ''≠null);
|
|
979
|
+
// 非祖先 → exit 1 → null;未知/畸形 hash → 报错非零 → null(不在分支内,语义正确)
|
|
980
|
+
const probe = gitQuiet(this.cwd, ['merge-base', '--is-ancestor', hash, branch]);
|
|
981
|
+
if (probe !== null) refs.push(...paths);
|
|
982
|
+
}
|
|
983
|
+
return [...new Set(refs)];
|
|
898
984
|
}
|
|
899
985
|
|
|
900
986
|
/**
|
|
@@ -1468,7 +1554,12 @@ export class WorktreeManager {
|
|
|
1468
1554
|
hasUnappliedChanges(changeName) {
|
|
1469
1555
|
const name = validateChangeName(changeName);
|
|
1470
1556
|
const meta = this.getMeta(name);
|
|
1471
|
-
if (!meta)
|
|
1557
|
+
if (!meta) {
|
|
1558
|
+
// meta 缺失(写坏/误删)→ 无法定位与判定交付,与「目录不存在」同款保守保留(对齐 create()
|
|
1559
|
+
// 幽灵分支的 fail-closed)。旧版返回 hasChanges:false 使 cleanup 跳过未落仓护栏直接
|
|
1560
|
+
// worktree remove --force——可删未提交代码(2026-09-11 审查 P1)
|
|
1561
|
+
return { hasChanges: true, changedFiles: [], reason: 'no meta (conservative keep)' };
|
|
1562
|
+
}
|
|
1472
1563
|
|
|
1473
1564
|
const worktreePath = meta.worktreePath;
|
|
1474
1565
|
if (!worktreePath || !existsSync(worktreePath)) {
|
|
@@ -1596,9 +1687,14 @@ export class WorktreeManager {
|
|
|
1596
1687
|
}
|
|
1597
1688
|
|
|
1598
1689
|
if (untrackedFiles.length > 0) {
|
|
1599
|
-
// hash-object 按 argv 顺序逐行输出 blob hash
|
|
1600
|
-
|
|
1601
|
-
|
|
1690
|
+
// hash-object 按 argv 顺序逐行输出 blob hash;某文件不存在则整批失败 → 该批各行为空串
|
|
1691
|
+
// (视为未应用,保守保留——与旧整命令失败语义一致,只是失败面从全量缩小到单批)。
|
|
1692
|
+
// 终批-①:argv 分批(批次间行序拼接保持与 untrackedFiles 的索引对齐)。
|
|
1693
|
+
const wtHashes = []
|
|
1694
|
+
for (const batch of _chunkPathsPrivate(untrackedFiles)) {
|
|
1695
|
+
const out = gitQuiet(worktreePath, ['hash-object', '--', ...batch], { timeout: 30000 }) || ''
|
|
1696
|
+
wtHashes.push(...out.split('\n'))
|
|
1697
|
+
}
|
|
1602
1698
|
const mainBlobs = this._lsTreeBlobs(this.cwd, 'HEAD', untrackedFiles);
|
|
1603
1699
|
for (let i = 0; i < untrackedFiles.length; i++) {
|
|
1604
1700
|
// wtHashes[i] 缺失(命令失败/行数不齐)→ 视为未应用,保守保留
|
|
@@ -1623,14 +1719,18 @@ export class WorktreeManager {
|
|
|
1623
1719
|
_lsTreeBlobs(cwd, treeish, files) {
|
|
1624
1720
|
const map = new Map();
|
|
1625
1721
|
if (!files || files.length === 0) return map; // 空 pathspec 会列整棵树,必须拦
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
for (const
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1722
|
+
// 终批-①:argv 分批(worktree-apply.js 的 chunkPaths 同逻辑内联——那边 import 本文件,
|
|
1723
|
+
// 反向 import 会成环;两处口径须同步演化)
|
|
1724
|
+
for (const batch of _chunkPathsPrivate(files)) {
|
|
1725
|
+
const raw = gitQuiet(cwd, ['ls-tree', treeish, '--', ...batch]);
|
|
1726
|
+
if (!raw) continue;
|
|
1727
|
+
for (const line of raw.split('\n')) {
|
|
1728
|
+
if (!line) continue;
|
|
1729
|
+
const tab = line.indexOf('\t');
|
|
1730
|
+
if (tab === -1) continue;
|
|
1731
|
+
const hash = line.slice(0, tab).split(' ')[2]; // "<mode> <type> <hash>"
|
|
1732
|
+
if (hash) map.set(line.slice(tab + 1), hash);
|
|
1733
|
+
}
|
|
1634
1734
|
}
|
|
1635
1735
|
return map;
|
|
1636
1736
|
}
|
|
@@ -1758,10 +1858,15 @@ export class WorktreeManager {
|
|
|
1758
1858
|
// untracked 文件(排除 .sillyspec/.runtime 等);目录跳过避免 readFileSync EISDIR
|
|
1759
1859
|
const untracked = gitQuiet(mainCwd, ['ls-files', '--others', '--exclude-standard'], { timeout: 30000 }) || '';
|
|
1760
1860
|
const skippedDirs = [];
|
|
1861
|
+
const skippedArtifacts = [];
|
|
1761
1862
|
if (untracked) {
|
|
1762
1863
|
for (const f of untracked.split('\n').filter(Boolean)) {
|
|
1763
1864
|
const norm = f.replace(/\\/g, '/')
|
|
1764
1865
|
if (norm === '.sillyspec' || norm.startsWith('.sillyspec/')) { excludedSpecFiles.push(f); continue }
|
|
1866
|
+
// 产物防御(坑 baseline-checkpoint-binary-sweep):归档扩展名/超体积帽 untracked 恒不进
|
|
1867
|
+
// baseline checkpoint——289MB tar.gz 入分支(FF 破坏 + 二进制永久入 git)的实际代价兜底
|
|
1868
|
+
const artifactWhy = isCheckpointSkippableArtifact(join(mainCwd, f));
|
|
1869
|
+
if (artifactWhy) { skippedArtifacts.push(`${f}(${artifactWhy})`); continue }
|
|
1765
1870
|
const r = copyUntrackedEntry(join(mainCwd, f), join(worktreePath, f));
|
|
1766
1871
|
if (r.status === 'copied') files.push(f);
|
|
1767
1872
|
else if (r.status === 'skipped-dir') skippedDirs.push(f);
|
|
@@ -1771,6 +1876,10 @@ export class WorktreeManager {
|
|
|
1771
1876
|
if (skippedDirs.length > 0) {
|
|
1772
1877
|
console.log(`ℹ️ baseline overlay 跳过 ${skippedDirs.length} 个 untracked 目录(不读目录避免 EISDIR,坑 execute-worktree-overlay-untracked-dir-eisdir)`);
|
|
1773
1878
|
}
|
|
1879
|
+
if (skippedArtifacts.length > 0) {
|
|
1880
|
+
console.warn(`⚠️ baseline checkpoint 跳过 ${skippedArtifacts.length} 个产物/大文件(不进分支,留在主仓原位):${skippedArtifacts.slice(0, 5).join('、')}${skippedArtifacts.length > 5 ? ' 等' : ''}`);
|
|
1881
|
+
console.warn(` 归档/分发物与大文件不该入 git——补 .gitignore(如 *.tar.gz / dist/ / deploy/)或移出仓库;本跳过仅护 baseline checkpoint,git add -A 仍会把它们卷进提交`);
|
|
1882
|
+
}
|
|
1774
1883
|
// 跨变更 spec 文档隔离可见性:列出被排除的 .sillyspec/ 文件(去重,截断展示),
|
|
1775
1884
|
// 让「ROADMAP 被谁改动、为何不在本 baseline」在 create 时刻可查,不需事后考古
|
|
1776
1885
|
const excludedUnique = [...new Set(excludedSpecFiles)]
|
|
@@ -1819,8 +1928,12 @@ export class WorktreeManager {
|
|
|
1819
1928
|
* @returns {string} commit hash
|
|
1820
1929
|
*/
|
|
1821
1930
|
_createBaselineCheckpoint(worktreePath, changeName, baselineFiles = []) {
|
|
1822
|
-
//
|
|
1931
|
+
// 临时 git identity(用户未配置 user.name/email 时兜底)。终批-③:{ ...process.env } 展开
|
|
1932
|
+
// 而非裸替换——裸 env 丢 SystemRoot/USERPROFILE/TEMP(Windows 上 git 直接起不来);
|
|
1933
|
+
// 提交走统一 git() 入口(safe.directory/timeout/maxBuffer 与全仓其他调用同口径——
|
|
1934
|
+
// 旧裸 execFileSync 在 dubious-ownership 环境(CI/挂载盘)必败且无超时无 maxBuffer)。
|
|
1823
1935
|
const env = {
|
|
1936
|
+
...process.env,
|
|
1824
1937
|
GIT_AUTHOR_NAME: 'sillyspec',
|
|
1825
1938
|
GIT_AUTHOR_EMAIL: 'sillyspec@baseline',
|
|
1826
1939
|
GIT_COMMITTER_NAME: 'sillyspec',
|
|
@@ -1845,10 +1958,10 @@ export class WorktreeManager {
|
|
|
1845
1958
|
// 分支上以便区分「前置 baseline」与「子代理新增改动」。它不该触发项目 pre-commit
|
|
1846
1959
|
// hook(如 ruff format),否则主仓库 dirty 文件中任一不达标的会被 hook reformat
|
|
1847
1960
|
// 致 commit 失败 → worktree 创建失败 → execute 无法启动。
|
|
1848
|
-
|
|
1849
|
-
|
|
1961
|
+
git(
|
|
1962
|
+
worktreePath,
|
|
1850
1963
|
['commit', '--no-verify', '-m', `sillyspec: baseline checkpoint for ${changeName}${body}`],
|
|
1851
|
-
{
|
|
1964
|
+
{ timeout: 30000, env }
|
|
1852
1965
|
);
|
|
1853
1966
|
const hash = git(worktreePath, ['rev-parse', 'HEAD']);
|
|
1854
1967
|
console.log(`📌 baseline checkpoint: ${hash}`);
|
package/src/wt-commit.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sillyspec wt-commit —— worktree 内 per-task 串行化提交(提交队列)
|
|
3
|
+
*
|
|
4
|
+
* 坑 wt-parallel-commit-race(2026-08-29 用户实证×2):同 Wave 多个 task 子代理共享同一个
|
|
5
|
+
* 主仓 worktree,各自裸跑 git add/commit 有两条竞态:① `git add -A` 把并行兄弟的 WIP 文件卷进
|
|
6
|
+
* 自己的提交;② 并发写 index 撞 index.lock。两次都靠子代理自觉救回,属结构性风险——本命令把
|
|
7
|
+
* 「自觉」换成「机制」:
|
|
8
|
+
*
|
|
9
|
+
* ① 文件锁串行化:withFileLock 排他锁 <mainRepo>/.sillyspec/.runtime/wt-commit-<change>.lock,
|
|
10
|
+
* 同 change 的 wt-commit 全局排队(per-task 提交队列效果),锁内 add→commit 原子完成;
|
|
11
|
+
* ② 强制显式 pathspec:-- 后逐路径(或 --pathspec-from-file)——命令语义上杜绝 add -A 扫入
|
|
12
|
+
* 他人 WIP;commit 带同一 pathspec(--only 语义),不动其他并行者已暂存的内容。
|
|
13
|
+
*
|
|
14
|
+
* 边界:锁只约束 wt-commit 调用方;兄弟子代理若仍裸跑 git,靠 ② 的 pathspec 隔离兜底 + git 侧
|
|
15
|
+
* index.lock 冲突时退避重试。跨仓 task 不走本命令(跨仓有独立 workdir / 直写主干,execute 指引
|
|
16
|
+
* 要求显式 pathspec 提交)。锁文件在 .runtime(运行时目录,不入 git 历史)。
|
|
17
|
+
*
|
|
18
|
+
* 成功返回 HEAD hash(review.json 的 head 锚点直接可用);无变更 → ok=true + skipped。
|
|
19
|
+
*/
|
|
20
|
+
import { join, resolve } from 'node:path'
|
|
21
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
22
|
+
import { withFileLock } from './quicklog.js'
|
|
23
|
+
import { safeGit } from './git-helper.js'
|
|
24
|
+
import { WorktreeManager } from './worktree.js'
|
|
25
|
+
|
|
26
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* git 子操作退避重试:撞 index.lock / HEAD 锁(锁队列外裸跑 git 的兄弟进程短暂持锁)时等待重试。
|
|
30
|
+
* 非 lock 类错误(路径不存在 / pathspec 不匹配 / 权限)直接抛,不空转。
|
|
31
|
+
*/
|
|
32
|
+
async function gitWithLockRetry(cwd, args, label, { retries = 6, waitMs = 1000 } = {}) {
|
|
33
|
+
for (let i = 0; ; i++) {
|
|
34
|
+
const { value, error } = safeGit(cwd, args, { timeout: 30000 })
|
|
35
|
+
if (!error) return value
|
|
36
|
+
const msg = String((error && error.message) || error)
|
|
37
|
+
if (/index\.lock|HEAD\.lock|cannot lock ref/i.test(msg) && i < retries) {
|
|
38
|
+
await sleep(waitMs)
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`${label} 失败:${msg.split('\n')[0].trim()}`)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 执行 worktree 串行化提交。
|
|
47
|
+
*
|
|
48
|
+
* @param {object} opts
|
|
49
|
+
* @param {string} opts.changeName - 变更名(worktree 名)
|
|
50
|
+
* @param {string} opts.message - 提交信息(建议 "<task-NN 摘要>")
|
|
51
|
+
* @param {string[]} opts.pathspecs - 显式路径列表(必填,防 add -A 扫入并行兄弟 WIP)
|
|
52
|
+
* @param {string|null} [opts.pathspecFile] - --pathspec-from-file:每行一个路径(与 pathspecs 二选一或叠加)
|
|
53
|
+
* @param {string} [opts.cwd] - 调用方 cwd(主仓根或 worktree 内均可——WorktreeManager 经
|
|
54
|
+
* git-common-dir 反推主仓根,worktree 内调用同样定位正确)
|
|
55
|
+
* @returns {Promise<{ok:boolean,skipped:boolean,head:string,shortHead:string,worktreePath:string,changeName:string,files:string[]}>}
|
|
56
|
+
* ok=false 时抛错(调用方打错误 + exit 1),skipped=无变更可提交(HEAD 不动,不算失败)。
|
|
57
|
+
*/
|
|
58
|
+
export async function runWtCommit({ changeName, message, pathspecs = [], pathspecFile = null, cwd = process.cwd() }) {
|
|
59
|
+
if (!changeName) throw new Error('缺少 --change <变更名>(worktree 名;在 worktree 内运行时可省略,CLI 从 cwd 推断)')
|
|
60
|
+
if (!message || !message.trim()) throw new Error('缺少 -m/--message <提交信息>(建议 "<task-NN 摘要>")')
|
|
61
|
+
|
|
62
|
+
// pathspec 汇总:显式路径 + 文件逐行(# 注释与空行忽略,与 git pathspec-from-file 习惯一致)
|
|
63
|
+
const files = [...pathspecs.map(p => p.trim()).filter(Boolean)]
|
|
64
|
+
if (pathspecFile) {
|
|
65
|
+
if (!existsSync(pathspecFile)) throw new Error(`--pathspec-from-file 文件不存在: ${pathspecFile}`)
|
|
66
|
+
for (const line of readFileSync(pathspecFile, 'utf8').split(/\r?\n/)) {
|
|
67
|
+
const t = line.trim()
|
|
68
|
+
if (t && !t.startsWith('#')) files.push(t)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (files.length === 0) {
|
|
72
|
+
throw new Error('缺少提交路径:`--` 后逐个给出本 task 的精确路径(或 --pathspec-from-file <file>)。wt-commit 刻意不支持 add -A / 空 pathspec——同 Wave 兄弟子代理共享本 worktree,整树 add 会把对方 WIP 卷进你的提交')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const wm = new WorktreeManager({ cwd })
|
|
76
|
+
const worktreePath = wm.getWorktreePath(changeName)
|
|
77
|
+
// 隔离模式(worktree 目录存在)→ 提交目标 = worktree 本体;in-place-fallback(meta 声明无
|
|
78
|
+
// worktree 直改主仓)→ 主仓根(与 MultiRepoContext 主仓 entry 的 worktreePath 兜底口径一致)。
|
|
79
|
+
// 顺序先看目录再看 meta:worktree 目录存在即隔离态,meta 缺失/损坏不改变目标(fail-safe)。
|
|
80
|
+
let target = null
|
|
81
|
+
if (existsSync(worktreePath)) {
|
|
82
|
+
target = worktreePath
|
|
83
|
+
} else {
|
|
84
|
+
const meta = wm.getMeta(changeName)
|
|
85
|
+
if (meta && meta.mode === 'in-place-fallback') {
|
|
86
|
+
target = resolve(wm.worktreeBase, '..', '..', '..') // <main>/.sillyspec/.runtime/worktrees → <main>
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!target) {
|
|
90
|
+
throw new Error(`worktree 不存在: ${worktreePath}(确认 --change 变更名;execute 尚未创建 worktree 时先跑 sillyspec run execute)`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 锁:主仓 .sillyspec/.runtime/ 下按 change 一把(in-place 模式同目录可达,零分叉)
|
|
94
|
+
const lockPath = join(wm.worktreeBase, '..', `wt-commit-${changeName}.lock`)
|
|
95
|
+
const envTimeout = parseInt(process.env.SILLYSPEC_WT_COMMIT_LOCK_TIMEOUT_MS || '', 10)
|
|
96
|
+
const lockTimeoutMs = Number.isFinite(envTimeout) && envTimeout > 0 ? envTimeout : 120000
|
|
97
|
+
|
|
98
|
+
const result = await withFileLock(lockPath, async () => {
|
|
99
|
+
const headBefore = safeGit(target, ['rev-parse', 'HEAD'], { timeout: 10000 }).value || ''
|
|
100
|
+
await gitWithLockRetry(target, ['add', '--', ...files], 'git add')
|
|
101
|
+
// pathspec 范围内是否有 staged 变更(vs HEAD):空 = 无可提交(task 幂等重跑场景),
|
|
102
|
+
// 跳过 commit 保持 skipped 语义——不靠解析 git commit 的 "nothing to commit" stdout
|
|
103
|
+
// (safeGit 的 error 只保留命令首行,stdout 不可得)。
|
|
104
|
+
const staged = safeGit(target, ['diff', '--cached', '--name-only', '--', ...files], { timeout: 10000 }).value || ''
|
|
105
|
+
if (!staged.trim()) {
|
|
106
|
+
return { ok: true, skipped: true, head: headBefore }
|
|
107
|
+
}
|
|
108
|
+
await gitWithLockRetry(target, ['commit', '-m', message, '--', ...files], 'git commit')
|
|
109
|
+
const head = safeGit(target, ['rev-parse', 'HEAD'], { timeout: 10000 }).value || headBefore
|
|
110
|
+
return { ok: true, skipped: false, head }
|
|
111
|
+
}, {
|
|
112
|
+
// 持有者语义:超时报错时能看出是谁卡着队列
|
|
113
|
+
content: JSON.stringify({ pid: process.pid, changeName, purpose: 'wt-commit' }),
|
|
114
|
+
staleMs: 120000, // 持锁进程崩溃残留的回收阈值(commit 通常秒级)
|
|
115
|
+
timeoutMs: lockTimeoutMs, // 排队上限:同 Wave 并行 task 全走队列,给足余量
|
|
116
|
+
retryMs: 200,
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
ok: true,
|
|
121
|
+
skipped: !!result.skipped,
|
|
122
|
+
head: result.head,
|
|
123
|
+
shortHead: (result.head || '').slice(0, 8),
|
|
124
|
+
worktreePath: target,
|
|
125
|
+
changeName,
|
|
126
|
+
files,
|
|
127
|
+
}
|
|
128
|
+
}
|