sillyspec 3.25.3 → 3.25.5

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/src/worktree.js CHANGED
@@ -138,10 +138,11 @@ function printSyncReport(diag, baseHash, changeName) {
138
138
  const ch = changeName || '<change>';
139
139
  if (diag.status === 'behind') {
140
140
  console.log(`⚠️ base 同步检测:落后 origin/${b} ${diag.behind} 个 commit(base ${short})`);
141
- console.log(` execute 期间若主仓库对齐 main,apply 回来可能冲突。建议先对齐:`);
141
+ console.log(` apply 时 git --3way 会自动三路合并主干已提交推进;只有同文件同区域重叠才冲突。`);
142
+ console.log(` 建议先对齐以减小冲突面:`);
142
143
  console.log(` git merge --ff-only origin/${b}`);
143
144
  console.log(` sillyspec worktree cleanup ${ch} && sillyspec run execute --change ${ch}`);
144
- console.log(` 或继续,apply 时用 --merge 降级(会引入合并提交)。`);
145
+ console.log(` 或继续,重叠冲突时用 --merge 兜底(会引入合并提交)。`);
145
146
  } else if (diag.status === 'diverged') {
146
147
  console.log(`⚠️ base 同步检测:与 origin/${b} 分叉(领先 ${diag.ahead} / 落后 ${diag.behind},base ${short})`);
147
148
  console.log(` apply 回 main 大概率冲突。建议先在主仓库对齐(rebase 或 merge origin/${b}),`);
@@ -170,11 +171,12 @@ function sleepMs(ms) {
170
171
  export function computeBaselineHash(cwd) {
171
172
  // 排除非交付物的元数据/文档 churn,避免多操作者仓库里别人改这些 → 整树 hash 变 → apply 误阻断
172
173
  // (execute 自身也会改 .sillyspec/docs.sillyspec,否则每个 execute 收尾都因自身改动 BLOCKED)。
173
- // 安全:coarse hash 放过这些不丢真冲突——applyWorktree step5a(未提交∩changedFiles)+step5b(HEAD blob)
174
- // 对交付文件做精确 per-file 校验,不读这里的 exclude。
174
+ // 安全:coarse hash 只挡「未提交」dirty(git apply --3way 危险区,实测必拦);真冲突由
175
+ // applyWorktree step5a(未提交∩changedFiles 精确点名)+step7(--3way 冲突回滚)兜底,
176
+ // 「已提交」推进不再拦(step5b 已放宽交 --3way 自动三路合并),不读这里的 exclude。
175
177
  // - .sillyspec/:brainstorm/plan 蓝图 + runtime 产物
176
178
  // - .claude/:agent 配置/skills/CLAUDE.md(多操作者 agent 指引 churn)
177
- // - docs/:文档(非代码交付物;deliverable 文档冲突由 step5a/5b 兜底)
179
+ // - docs/:文档(非代码交付物)
178
180
  // - CLAUDE.md:根 agent 指引(多操作者常改)
179
181
  // 必须和 applyWorktree step 4.5 (worktree-apply.js) 使用相同的排除规则。
180
182
  const exclude = '-- . ":(exclude).sillyspec/" ":(exclude).claude/" ":(exclude)docs/" ":(exclude)CLAUDE.md"';
@@ -247,15 +249,16 @@ export class WorktreeManager {
247
249
 
248
250
  let root = this.cwd;
249
251
  try {
250
- // git-common-dir 在主仓库内 = <main>/.git
251
- // 在 linked worktree 内 = <main>/.git(git 共享 .git 目录)
252
+ // git-common-dir 在主仓库内 = <main>/.git,在 linked worktree 内 = <main>/.git(git 共享 .git 目录)。
253
+ // 但 git 可能返回**相对路径**(如 `.git`)。必须相对 this.cwd 绝对化,否则下面的
254
+ // existsSync/statSync 会相对 process.cwd() 解析——当 process.cwd 是另一个 git 仓库
255
+ // (未 chdir 的脚本、或 CLI 在别处跑)时,worktreeBase 会错解析到 process.cwd 仓库,
256
+ // getMeta 读错位置返回 null。resolve() 对绝对参数原样返回、对相对参数相对 this.cwd 解析,两者皆稳。
252
257
  const commonDir = gitQuiet(this.cwd, 'rev-parse --git-common-dir');
253
- // commonDir 应该是 <main-repo>/.git
254
- // dirname(commonDir) = <main-repo>
255
- if (commonDir && existsSync(commonDir)) {
256
- const st = statSync(commonDir);
257
- if (st.isDirectory()) {
258
- root = dirname(commonDir);
258
+ if (commonDir) {
259
+ const absCommonDir = resolve(this.cwd, commonDir);
260
+ if (existsSync(absCommonDir) && statSync(absCommonDir).isDirectory()) {
261
+ root = dirname(absCommonDir);
259
262
  }
260
263
  }
261
264
  } catch (e) {
@@ -989,7 +992,12 @@ export class WorktreeManager {
989
992
  }
990
993
 
991
994
  /**
992
- * 检查 worktree 是否有未 apply 到主工作区的变更
995
+ * 检查 worktree 是否有尚未落到主工作区的交付变更。
996
+ *
997
+ * 语义:worktree 相对 baseline 的交付变更(tracked + untracked,排除 .sillyspec//meta.json)里,
998
+ * 哪些还没 byte-identical 出现在主工作区 HEAD。全部已在 main HEAD(含 cherry-pick/rebase/merge/apply
999
+ * 落地)→ hasChanges:false(调用方可安全 cleanup);否则 hasChanges:true(保留 worktree)。
1000
+ * 检测失败/拿不准 → 保守 hasChanges:true(防误删未落代码)。
993
1001
  * @param {string} changeName
994
1002
  * @returns {{ hasChanges: boolean, changedFiles: string[], reason?: string }}
995
1003
  */
@@ -1007,36 +1015,102 @@ export class WorktreeManager {
1007
1015
  if (meta.mode === 'in-place-fallback') {
1008
1016
  return { hasChanges: false, changedFiles: [], reason: 'in-place mode' };
1009
1017
  }
1018
+ // native-worktree 是用户外部隔离环境,不纳入"未应用"判定(与 execute 完成路径一致)
1019
+ if (meta.mode === 'native-worktree') {
1020
+ return { hasChanges: false, changedFiles: [], reason: 'native-worktree (external)' };
1021
+ }
1010
1022
 
1011
1023
  const diffBase = meta.baselineCommit || meta.baseHash;
1012
1024
  if (!diffBase) {
1013
1025
  return { hasChanges: false, changedFiles: [], reason: 'no diff base' };
1014
1026
  }
1015
1027
 
1028
+ const isDeliverable = f => f && !f.startsWith('.sillyspec/') && f !== 'meta.json';
1016
1029
  try {
1017
- // tracked 文件变更
1018
- const statusRaw = gitQuiet(worktreePath, `diff --name-status ${diffBase}`) || '';
1019
- const statusFiles = new Set();
1020
- if (statusRaw) {
1021
- for (const line of statusRaw.split('\n').filter(Boolean)) {
1022
- const parts = line.split('\t');
1023
- if (parts.length >= 2) statusFiles.add(parts[parts.length - 1]);
1024
- if (parts.length >= 3) statusFiles.add(parts[parts.length - 2]);
1030
+ // 1) 候选交付变更(worktree 工作区相对 diffBase)。--no-renames:rename 退化成 D+A,两侧文件都进集
1031
+ const tracked = (gitQuiet(worktreePath, `diff --no-renames --name-only ${diffBase}`) || '')
1032
+ .split('\n').filter(Boolean).filter(isDeliverable);
1033
+ const untracked = (gitQuiet(worktreePath, `ls-files --others --exclude-standard`) || '')
1034
+ .split('\n').filter(Boolean).filter(isDeliverable);
1035
+
1036
+ if (tracked.length === 0 && untracked.length === 0) {
1037
+ return { hasChanges: false, changedFiles: [], reason: 'no changes in worktree' };
1038
+ }
1039
+
1040
+ // 2) 候选集中尚未落到主工作区 HEAD 的子集
1041
+ const pending = this._changesAlreadyOnMain(worktreePath, tracked, untracked);
1042
+ if (pending.length === 0) {
1043
+ return { hasChanges: false, changedFiles: [], reason: 'all changes already on main HEAD' };
1044
+ }
1045
+ return { hasChanges: true, changedFiles: pending };
1046
+ } catch (e) {
1047
+ // 检测失败时保守处理:视为有变更,保留 worktree
1048
+ return { hasChanges: true, changedFiles: [], reason: `check failed: ${e.message}` };
1049
+ }
1050
+ }
1051
+
1052
+ /**
1053
+ * 判定候选变更里哪些还没到主工作区 HEAD。
1054
+ * - tracked:`git -C worktree diff --no-renames --name-only <mainHead> -- <files>`,
1055
+ * 比较 worktree 工作区(含未提交)vs main HEAD,限定到候选集;空 = 已在 main HEAD。
1056
+ * - untracked:worktree `hash-object`(默认带 filter,与 tree blob 同口径)vs main `ls-tree HEAD` blob;
1057
+ * 不等 = 该新文件未在 main HEAD。HEAD-only,不查 main 工作区未提交副本(防误删)。
1058
+ * 三种 mode 共享主 repo 对象库,mainHead 可在 worktree cwd 解析。
1059
+ * @private
1060
+ * @param {string} worktreePath
1061
+ * @param {string[]} trackedFiles
1062
+ * @param {string[]} untrackedFiles
1063
+ * @returns {string[]} 尚未落到 main HEAD 的文件
1064
+ */
1065
+ _changesAlreadyOnMain(worktreePath, trackedFiles, untrackedFiles) {
1066
+ const mainHead = git(this.cwd, 'rev-parse HEAD'); // 失败即抛 → 外层 catch fail-safe
1067
+ const pending = [];
1068
+
1069
+ if (trackedFiles.length > 0) {
1070
+ const diverged = (gitQuiet(worktreePath,
1071
+ `diff --no-renames --name-only ${mainHead} -- ${trackedFiles.join(' ')}`) || '')
1072
+ .split('\n').filter(Boolean);
1073
+ pending.push(...diverged);
1074
+ }
1075
+
1076
+ if (untrackedFiles.length > 0) {
1077
+ // hash-object 按 argv 顺序逐行输出 blob hash;某文件不存在则整命令失败 → gitQuiet 返回 null
1078
+ const wtHashes = (gitQuiet(worktreePath, `hash-object -- ${untrackedFiles.join(' ')}`) || '')
1079
+ .split('\n');
1080
+ const mainBlobs = this._lsTreeBlobs(this.cwd, 'HEAD', untrackedFiles);
1081
+ for (let i = 0; i < untrackedFiles.length; i++) {
1082
+ // wtHashes[i] 缺失(命令失败/行数不齐)→ 视为未应用,保守保留
1083
+ if (wtHashes[i] !== (mainBlobs.get(untrackedFiles[i]) ?? null)) {
1084
+ pending.push(untrackedFiles[i]);
1025
1085
  }
1026
1086
  }
1087
+ }
1027
1088
 
1028
- // untracked 文件
1029
- const untrackedRaw = gitQuiet(worktreePath, `ls-files --others --exclude-standard`) || '';
1030
- const untrackedFiles = untrackedRaw
1031
- ? untrackedRaw.split('\n').filter(Boolean).filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json')
1032
- : [];
1089
+ return [...new Set(pending)];
1090
+ }
1033
1091
 
1034
- const changedFiles = [...new Set([...statusFiles, ...untrackedFiles])];
1035
- return { hasChanges: changedFiles.length > 0, changedFiles };
1036
- } catch (e) {
1037
- // 检测失败时保守处理:视为有变更
1038
- return { hasChanges: true, changedFiles: [], reason: `diff failed: ${e.message}` };
1092
+ /**
1093
+ * 一次 `git ls-tree <treeish> -- <files>` → Map<path, blobHash>(仅 tree 中存在者)。
1094
+ * 与 worktree-apply.js 的 getBlobHashMap 同逻辑;此处内联私有,避免反向 import 循环依赖。
1095
+ * @private
1096
+ * @param {string} cwd
1097
+ * @param {string} treeish
1098
+ * @param {string[]} files
1099
+ * @returns {Map<string, string>}
1100
+ */
1101
+ _lsTreeBlobs(cwd, treeish, files) {
1102
+ const map = new Map();
1103
+ if (!files || files.length === 0) return map; // 空 pathspec 会列整棵树,必须拦
1104
+ const raw = gitQuiet(cwd, `ls-tree ${treeish} -- ${files.join(' ')}`);
1105
+ if (!raw) return map;
1106
+ for (const line of raw.split('\n')) {
1107
+ if (!line) continue;
1108
+ const tab = line.indexOf('\t');
1109
+ if (tab === -1) continue;
1110
+ const hash = line.slice(0, tab).split(' ')[2]; // "<mode> <type> <hash>"
1111
+ if (hash) map.set(line.slice(tab + 1), hash);
1039
1112
  }
1113
+ return map;
1040
1114
  }
1041
1115
 
1042
1116
  /**