@aiti-ai/aiti-git-trace 0.0.0-stage → 0.2.11
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/AGENTS.md +28 -0
- package/LICENSE +21 -0
- package/api.ts +424 -0
- package/check-client.mjs +273 -0
- package/client/src/common.ts +179 -0
- package/client/src/entry.tsx +81 -0
- package/client/src/host-shim.ts +118 -0
- package/client/src/jsx-runtime.ts +2 -0
- package/client/src/panels.tsx +1377 -0
- package/client/src/trace-app.tsx +275 -0
- package/client/src/trace-files-row.pure.ts +88 -0
- package/client/src/trace-files-row.tsx +110 -0
- package/client/src/version-mention.ts +90 -0
- package/deploy-commit.mjs +52 -0
- package/dev-pack.mjs +246 -0
- package/dist/aiti-git-trace-client.mjs +4 -0
- package/dist/entry-C_le59dp.js +1594 -0
- package/dist/panels-BpC4XLni.js +1522 -0
- package/ext-icons-deploy.mjs +24 -0
- package/icon.svg +1 -0
- package/icons/git-branch.svg +1 -0
- package/icons/git-commit-horizontal.svg +1 -0
- package/index.ts +705 -0
- package/package.json +33 -4
- package/reconcile-deps.mjs +41 -0
- package/reload-instances.mjs +60 -0
- package/scripts/aiti-trace.mjs +117 -0
- package/skills/index.json +1 -0
- package/skills/rollback-session/SKILL.md +34 -0
- package/skills/trace-ops/SKILL.md +31 -0
- package/skills/watch-path/SKILL.md +34 -0
- package/test/fixtures/ops-sample.jsonl +3 -0
- package/tools/deploy.mjs +103 -0
- package/tools/trace-compact.mjs +58 -0
- package/trace-core.test.mjs +1120 -0
- package/trace-core.ts +1346 -0
- package/trace-files-row.test.mjs +66 -0
- package/tsconfig.check.json +21 -0
- package/version-mention.test.mjs +76 -0
- package/vite.config.ts +43 -0
- package/README.md +0 -3
package/trace-core.ts
ADDED
|
@@ -0,0 +1,1346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiti-git-trace · 核心逻辑(纯 ESM,可被 index.ts 与 node:test 直接导入)
|
|
3
|
+
*
|
|
4
|
+
* 职责(方案 docs/aiti-git-trace-plan.md §3/§5/§6.3/§7.2):
|
|
5
|
+
* - hermetic git 封装:显式 --git-dir/--work-tree/GIT_INDEX_FILE 三件套 +
|
|
6
|
+
* GIT_CONFIG_GLOBAL=/dev/null + 关 hooks/gpgsign —— 物理上不可能碰用户仓
|
|
7
|
+
* - store 初始化(.aiti-trace,§13.2 顶层)与安全校验
|
|
8
|
+
* - 回撤流:safety commit → read-tree -u --reset → phase=rollback 记账
|
|
9
|
+
* - 部分回滚流:diff 过滤 hunk → apply -R --recount(失败 -3 三方兜底)
|
|
10
|
+
* - 台账(ops ledger)原子追加 / 读取;workspace 关注路径配置(原子写)
|
|
11
|
+
*
|
|
12
|
+
* 红线:绝不写用户仓 .git;所有写路径只允许 trace store(显式三件套)与 configDir。
|
|
13
|
+
*
|
|
14
|
+
* ⚠ 性能红线(2026-09-12 execSync→async 改造):全部 git 调用走异步(_shared/exec-async),
|
|
15
|
+
* 同仓写序列(safety/record/rollback 的 commit 链)经 queueRepo 按 gitDir 串行——
|
|
16
|
+
* 异步化后并发事件可能交错「读-改-写」。任何人不得在本文件重新引入 execFileSync/spawnSync。
|
|
17
|
+
*/
|
|
18
|
+
import { execFileAsync, queueRepo } from "@aiti-ai/ext-shared/exec-async";
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync, copyFileSync, renameSync, appendFileSync, openSync, writeSync, closeSync, statSync, unlinkSync } from "node:fs";
|
|
20
|
+
import { join as pathJoin, resolve as pathResolve, dirname as pathDirname, sep as pathSep } from "node:path";
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import { homedir as osHomedir } from "node:os";
|
|
23
|
+
|
|
24
|
+
// ---------- 通用 ----------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 数据分层(三目录对称 2026-09-13):入参 configDir = <extRoot>/config;
|
|
28
|
+
* cache/state 一律在 <extRoot> 下(configDir 的上一级),扩展内部统一经本函数推导。
|
|
29
|
+
*/
|
|
30
|
+
function extRootOf(configDir: string): string {
|
|
31
|
+
return pathDirname(configDir);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function jsonHash(obj) {
|
|
35
|
+
return createHash("sha1").update(JSON.stringify(obj)).digest("hex").slice(0, 12);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** workspace key:主仓根(无 git 则 cwd)规范路径哈希前 16 位(§7.2;与 pi-git-trace/src/paths.ts 严格一致) */
|
|
39
|
+
export function workspaceKey(workspaceRoot) {
|
|
40
|
+
return createHash("sha256").update(pathResolve(workspaceRoot)).digest("hex").slice(0, 16);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function atomicWrite(file, text) {
|
|
44
|
+
mkdirSync(pathDirname(file), { recursive: true });
|
|
45
|
+
const tmp = file + ".tmp-" + process.pid + "-" + Math.random().toString(36).slice(2, 8);
|
|
46
|
+
writeFileSync(tmp, text);
|
|
47
|
+
renameSync(tmp, file);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------- hermetic git 封装 ----------
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 组装 hermetic git 参数前缀。store = { gitDir, workTree, sessionId }。
|
|
54
|
+
* 红线(§3.2):三件套缺一不可;全局/系统配置一律不进追踪仓;用户 hooks 不生效。
|
|
55
|
+
*/
|
|
56
|
+
export function gitPrefix(store) {
|
|
57
|
+
const args = ["--git-dir", store.gitDir, "--work-tree", store.workTree,
|
|
58
|
+
"-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false"];
|
|
59
|
+
const env = {
|
|
60
|
+
...process.env,
|
|
61
|
+
GIT_INDEX_FILE: pathJoin(store.gitDir, "index-" + (store.sessionId || "default")),
|
|
62
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
63
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
64
|
+
};
|
|
65
|
+
return { args, env };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 执行追踪仓 git(cwd=workTree 仓根;异步,不阻塞事件循环)。抛错时带 stderr 首行。 */
|
|
69
|
+
export async function runGit(store, args, opts = {}) {
|
|
70
|
+
const { args: prefix, env } = gitPrefix(store);
|
|
71
|
+
try {
|
|
72
|
+
const stdout = await execFileAsync("git", [...prefix, ...args], {
|
|
73
|
+
cwd: store.workTree,
|
|
74
|
+
env,
|
|
75
|
+
// stdin 仅在需要 input 时接管道(update-ref --stdin 等);否则 ignore——
|
|
76
|
+
// 'ignore' 会把 input 吞掉(曾致批量删 tag 静默无效)
|
|
77
|
+
input: opts.input,
|
|
78
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
79
|
+
});
|
|
80
|
+
return { code: 0, stdout };
|
|
81
|
+
} catch (e) {
|
|
82
|
+
if (opts.allowFail) return { code: e.status ?? 1, stdout: String(e.stdout ?? ""), stderr: String(e.stderr ?? "") };
|
|
83
|
+
const msg = String(e.stderr ?? e.message ?? "").trim().split("\n")[0] || "git error";
|
|
84
|
+
throw new Error("git " + args[0] + " 失败: " + msg);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------- Windows 保留设备名防御(add 毒丸) ----------
|
|
89
|
+
|
|
90
|
+
/** Windows 保留设备名(大小写不敏感):git 无法 stat/index,混入会让整批 add fatal */
|
|
91
|
+
const WIN_RESERVED_NAMES = new Set([
|
|
92
|
+
"CON", "PRN", "AUX", "NUL",
|
|
93
|
+
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
|
94
|
+
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Windows 保留设备名判定(含 <name>.<ext> 形态,nul.txt 同为保留)。
|
|
99
|
+
* 典型来源:bash 里误用 cmd 风格 `> nul` 重定向 → 创建真实文件 nul。
|
|
100
|
+
* 同步实现:pi-git-trace/src/engine.ts isWindowsReservedPath(info/exclude v2 模式段同源)。
|
|
101
|
+
*/
|
|
102
|
+
export function isWindowsReservedPath(p) {
|
|
103
|
+
const base = p.split(/[\\/]/).pop() ?? "";
|
|
104
|
+
const stem = base.split(".", 1)[0].toUpperCase();
|
|
105
|
+
return WIN_RESERVED_NAMES.has(stem);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* add -A 安全版:批次毒丸降级。
|
|
110
|
+
* `git add -A` 遇 Windows 保留设备名(如 bash 误 `> nul` 建出的真实 nul 文件)
|
|
111
|
+
* 会 `fatal: adding files failed` 整批报废 → 枚举变更文件、过滤保留名后分批重加;
|
|
112
|
+
* 批次仍失败再降逐文件(坏文件单独失败,其余照常入库)。store 的 info/exclude
|
|
113
|
+
* v2 已含保留名模式(pi 扩展 ensureStore 幂等升级同仓共享),此处为末道防线。
|
|
114
|
+
*/
|
|
115
|
+
export async function addAllSafe(store) {
|
|
116
|
+
const r = await runGit(store, ["add", "-A"], { allowFail: true });
|
|
117
|
+
if (r.code === 0) return;
|
|
118
|
+
const un = await runGit(store, ["ls-files", "--others", "--exclude-standard"], { allowFail: true });
|
|
119
|
+
const md = await runGit(store, ["ls-files", "-m", "-d"], { allowFail: true });
|
|
120
|
+
const paths = [...new Set(
|
|
121
|
+
((un.code === 0 ? un.stdout : "") + "\n" + (md.code === 0 ? md.stdout : ""))
|
|
122
|
+
.split("\n").map((s) => s.trim()).filter(Boolean),
|
|
123
|
+
)].filter((p) => !isWindowsReservedPath(p));
|
|
124
|
+
for (let i = 0; i < paths.length; i += 500) {
|
|
125
|
+
const batch = paths.slice(i, i + 500);
|
|
126
|
+
const b = await runGit(store, ["add", "--", ...batch], { allowFail: true });
|
|
127
|
+
if (b.code !== 0) {
|
|
128
|
+
for (const p of batch) await runGit(store, ["add", "--", p], { allowFail: true });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------- store 定位与初始化(§3.1) ----------
|
|
134
|
+
|
|
135
|
+
/** cwd 是否处于某个合法 git 仓内:向上走查含 HEAD 的 .git 目录 */
|
|
136
|
+
export function repoRootOf(cwd) {
|
|
137
|
+
let dir = pathResolve(cwd);
|
|
138
|
+
for (; ; dir = pathDirname(dir)) {
|
|
139
|
+
const g = pathJoin(dir, ".git");
|
|
140
|
+
if (existsSync(g) && existsSync(pathJoin(g, "HEAD"))) return dir;
|
|
141
|
+
const parent = pathDirname(dir);
|
|
142
|
+
if (parent === dir) return null; // 走到根仍无 → 无 git(不建仓不追踪,§3.1)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** trace store 是否已初始化(<repoRoot>/.aiti-trace/HEAD 存在) */
|
|
147
|
+
export function storeOk(repoRoot) {
|
|
148
|
+
return !!repoRoot && existsSync(pathJoin(repoRoot, ".aiti-trace", "HEAD"));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function storeOf(repoRoot, sessionId) {
|
|
152
|
+
return { gitDir: pathJoin(repoRoot, ".aiti-trace"), workTree: repoRoot, sessionId };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** store 初始化:git init + hermetic config 写死(幂等;已初始化则跳过 config 外的自排除补写) */
|
|
156
|
+
export async function initStore(repoRoot) {
|
|
157
|
+
if (!repoRoot || !existsSync(pathJoin(repoRoot, ".git", "HEAD"))) {
|
|
158
|
+
throw new Error("initStore:目标不是合法 git 仓(无 .git/HEAD)");
|
|
159
|
+
}
|
|
160
|
+
const store = storeOf(repoRoot, "default");
|
|
161
|
+
if (storeOk(repoRoot)) return store;
|
|
162
|
+
await queueRepo(pathResolve(store.gitDir), async () => {
|
|
163
|
+
mkdirSync(store.gitDir, { recursive: true });
|
|
164
|
+
await runGit(store, ["init", "--quiet"]);
|
|
165
|
+
for (const [k, v] of [
|
|
166
|
+
["user.name", "aiti-trace"], ["user.email", "aiti@local"],
|
|
167
|
+
["core.autocrlf", "false"], ["core.fsmonitor", "off"],
|
|
168
|
+
["gc.auto", "0"], ["status.showuntrackedfiles", "all"], ["core.quotepath", "false"],
|
|
169
|
+
]) await runGit(store, ["config", k, v]);
|
|
170
|
+
});
|
|
171
|
+
// §13.3①:store 自身 info/exclude 加 .aiti-trace/(实证 git 不自动排除非 .git 名 gitdir;
|
|
172
|
+
// 缺它则任何 add -A 都会把 store 内部文件吸进 index,rollback read-tree 时炸对象缺失)
|
|
173
|
+
try {
|
|
174
|
+
mkdirSync(pathJoin(store.gitDir, "info"), { recursive: true });
|
|
175
|
+
const excl = pathJoin(store.gitDir, "info", "exclude");
|
|
176
|
+
let prev = "";
|
|
177
|
+
try { prev = readFileSync(excl, "utf8"); } catch { /* new */ }
|
|
178
|
+
if (!prev.includes(".aiti-trace/")) writeFileSync(excl, prev + "# aiti store self-exclude\n.aiti-trace/\n");
|
|
179
|
+
} catch { /* 不阻塞 */ }
|
|
180
|
+
// store 自携带免打扰声明(AI grep/搜索自动跳过 store 内部):.aiti-trace/.gitignore + .ignore = *(幂等,含 `*` 行则跳过)
|
|
181
|
+
try {
|
|
182
|
+
for (const name of [".gitignore", ".ignore"]) {
|
|
183
|
+
const f = pathJoin(store.gitDir, name);
|
|
184
|
+
let c = "";
|
|
185
|
+
try { c = readFileSync(f, "utf8"); } catch { /* new */ }
|
|
186
|
+
if (c.split("\n").map((l) => l.trim()).includes("*")) continue;
|
|
187
|
+
const h = name === ".gitignore" ? "# aiti-trace store self-ignore (managed)\n" : "";
|
|
188
|
+
writeFileSync(f, c + (c && !c.endsWith("\n") ? "\n" : "") + h + "*\n");
|
|
189
|
+
}
|
|
190
|
+
} catch { /* 不阻塞 */ }
|
|
191
|
+
// §13.3②:项目仓 .git/info/exclude 可见性托管块(用户 git status 干净)
|
|
192
|
+
try {
|
|
193
|
+
mkdirSync(pathJoin(repoRoot, ".git", "info"), { recursive: true });
|
|
194
|
+
const pexcl = pathJoin(repoRoot, ".git", "info", "exclude");
|
|
195
|
+
let pprev = "";
|
|
196
|
+
try { pprev = readFileSync(pexcl, "utf8"); } catch { /* new */ }
|
|
197
|
+
if (!pprev.includes(".aiti-trace/")) {
|
|
198
|
+
const re = /# --- aiti store visibility ---[\s\S]*?# --- end aiti store visibility ---\n?/;
|
|
199
|
+
const block = ["# --- aiti store visibility ---", ".aiti-trace/", "# --- end aiti store visibility ---", ""].join("\n");
|
|
200
|
+
writeFileSync(pexcl, re.test(pprev) ? pprev.replace(re, block) : pprev + (pprev && !pprev.endsWith("\n") ? "\n" : "") + block);
|
|
201
|
+
}
|
|
202
|
+
} catch { /* 不阻塞 */ }
|
|
203
|
+
return store;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------- 提交原语(§3.4/§5.3) ----------
|
|
207
|
+
|
|
208
|
+
export async function resolveCommit(store, ref) {
|
|
209
|
+
const r = await runGit(store, ["cat-file", "-e", ref + "^{commit}"], { allowFail: true });
|
|
210
|
+
return r.code === 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** 会话分支:refs/heads/aiti/<sid 短 16>(与 pi-git-trace/src/paths.ts sessionBranch 严格一致) */
|
|
214
|
+
export function sessionBranch(store) {
|
|
215
|
+
const short = String(store.sessionId || "s").replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 16) || "s";
|
|
216
|
+
return "refs/heads/aiti/" + short;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* plumbing 提交到会话分支(红线修正:绝不用 git commit——那会写到 HEAD 指向的
|
|
221
|
+
* master/main,脱离会话分支导致时间线看不见回滚记账)。
|
|
222
|
+
*/
|
|
223
|
+
/** compact 排它锁探测:写入侧在 commit 前等待其释放(防 compact gc 剪掉 commit-tree 已产、update-ref 未指的对象)。
|
|
224
|
+
* 异步轮询等待——严禁改回 Atomics.wait/while-sleep 同步阻塞(会冻结事件循环)。 */
|
|
225
|
+
export async function waitForCompactLock(repoRoot, maxMs = 5000) {
|
|
226
|
+
const lock = pathJoin(repoRoot, ".aiti-trace", "compact.lock");
|
|
227
|
+
const t0 = Date.now();
|
|
228
|
+
while (existsSync(lock)) {
|
|
229
|
+
if (Date.now() - t0 > maxMs) break; // 超时放行(compact 全程持锁,正常早已释放)
|
|
230
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
231
|
+
}
|
|
232
|
+
return Date.now() - t0;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 提交原语(未串行化内核,仅供下方 queued 公共 API 在 queueRepo 块内调用;
|
|
237
|
+
* 直接调用会失去同仓写序列原子性)。 */
|
|
238
|
+
async function commitToSessionBranch(store, message) {
|
|
239
|
+
const branch = sessionBranch(store);
|
|
240
|
+
try { await waitForCompactLock(store.workTree); } catch { /* 等待失败不阻塞写入 */ }
|
|
241
|
+
await addAllSafe(store);
|
|
242
|
+
const tree = (await runGit(store, ["write-tree"])).stdout.trim();
|
|
243
|
+
const parentRes = await runGit(store, ["rev-parse", "--verify", "--quiet", branch], { allowFail: true });
|
|
244
|
+
const parent = parentRes.code === 0 && parentRes.stdout.trim() ? parentRes.stdout.trim() : null;
|
|
245
|
+
const args = ["commit-tree", tree];
|
|
246
|
+
if (parent) args.push("-p", parent);
|
|
247
|
+
args.push("-m", message);
|
|
248
|
+
const sha = (await runGit(store, args)).stdout.trim();
|
|
249
|
+
await runGit(store, ["update-ref", branch, sha]);
|
|
250
|
+
return sha;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 公共 API:同仓写序列经 queueRepo(gitDir) 串行(异步化后防并发交错)。 */
|
|
254
|
+
export function safetyCommit(store, label = "pre-rollback") {
|
|
255
|
+
return queueRepo(pathResolve(store.gitDir), () => safetyCommitUnq(store, label));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 未串行化内核:safety 记账(仅供 queued 流程内部使用)。 */
|
|
259
|
+
function safetyCommitUnq(store, label = "pre-rollback") {
|
|
260
|
+
return commitToSessionBranch(store, `aiti phase=safety ${label} @${new Date().toISOString()}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function lastCommit(store) {
|
|
264
|
+
const branch = sessionBranch(store);
|
|
265
|
+
const r = await runGit(store, ["rev-parse", "--verify", "--quiet", branch], { allowFail: true });
|
|
266
|
+
return r.code === 0 ? r.stdout.trim() : ""; // 分支未建立(尚无任何记账)→ 空串
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function readTreeReset(store, commit) {
|
|
270
|
+
// 未入队:仅由下方已整体入队的 rollback* 流程调用(嵌套 queueRepo 同 key 会死锁)
|
|
271
|
+
return runGit(store, ["read-tree", "-u", "--reset", commit]);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** 记账 commit(当前 index 状态;落到会话分支,追加式历史;同仓串行) */
|
|
275
|
+
export function recordCommit(store, message) {
|
|
276
|
+
return queueRepo(pathResolve(store.gitDir), () => commitToSessionBranch(store, message));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------- 回撤流(§5.3) ----------
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* 全量回撤:safety → read-tree -u --reset target → phase=rollback 记账。
|
|
283
|
+
* 返回 {safety, rollback};store 只写 trace store(三件套隔离)。
|
|
284
|
+
* 整段入队(queueRepo per gitDir):safety/read-tree/记账三步原子,防并发交错。
|
|
285
|
+
*/
|
|
286
|
+
export function rollbackFull({ repoRoot, sessionId, targetCommit }) {
|
|
287
|
+
const store = storeOf(repoRoot, sessionId);
|
|
288
|
+
return queueRepo(pathResolve(store.gitDir), () => rollbackFullUnq(store, targetCommit));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function rollbackFullUnq(store, targetCommit) {
|
|
292
|
+
const { repoRoot, sessionId } = { repoRoot: store.workTree, sessionId: store.sessionId };
|
|
293
|
+
if (!storeOk(repoRoot)) throw new Error("该仓没有初始化 trace store(.aiti-trace 不存在)");
|
|
294
|
+
if (!(await resolveCommit(store, targetCommit))) throw new Error("targetCommit 不存在于追踪仓: " + targetCommit);
|
|
295
|
+
const safety = await safetyCommitUnq(store, "pre-rollback");
|
|
296
|
+
await readTreeReset(store, targetCommit);
|
|
297
|
+
const rollback = await commitToSessionBranch(store, `aiti phase=rollback → ${targetCommit} @${new Date().toISOString()}`);
|
|
298
|
+
return { safety, rollback };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---------- 部分回滚(§6.3) ----------
|
|
302
|
+
|
|
303
|
+
/** diff base..target(限 files),返回 { text, hunks:[{file, idx, header}] }(只读,不入队) */
|
|
304
|
+
export async function diffPatch(store, base, target, files) {
|
|
305
|
+
const args = ["diff", "--no-color", base, target, "--"];
|
|
306
|
+
for (const f of files) args.push(f);
|
|
307
|
+
const text = (await runGit(store, args)).stdout;
|
|
308
|
+
const hunks = [];
|
|
309
|
+
let file = null, idx = 0;
|
|
310
|
+
for (const line of text.split("\n")) {
|
|
311
|
+
const m = line.startsWith("diff --git ") ? line.match(/^diff --git a\/(.+?) b\/(.+)$/) : null;
|
|
312
|
+
if (m) file = m[2];
|
|
313
|
+
if (line.startsWith("@@")) hunks.push({ file, idx: idx++, header: line });
|
|
314
|
+
}
|
|
315
|
+
return { text, hunks };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** 过滤 patch:只保留 hunkIdx 集合命中的 hunk(连同其所在文件头),供 apply -R */
|
|
319
|
+
export function filterPatchHunks(patchText, hunkIdxSet) {
|
|
320
|
+
const keep = new Set(hunkIdxSet);
|
|
321
|
+
const out = [];
|
|
322
|
+
let cur = null, curHas = false, inHunk = false, hunkKeep = false, n = 0;
|
|
323
|
+
const flush = () => { if (cur && curHas) out.push(...cur); };
|
|
324
|
+
const lines = patchText.replace(/\n$/, "").split("\n");
|
|
325
|
+
for (const line of lines) {
|
|
326
|
+
if (line.startsWith("diff --git ")) {
|
|
327
|
+
flush();
|
|
328
|
+
cur = [line]; curHas = false; inHunk = false; hunkKeep = false;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (cur === null) { out.push(line); continue; } // 罕见:无文件头的前置行原样保留
|
|
332
|
+
if (line.startsWith("@@")) { // 新 hunk 开始(先于 !inHunk 兑底判断)
|
|
333
|
+
inHunk = true;
|
|
334
|
+
hunkKeep = keep.has(n);
|
|
335
|
+
n++;
|
|
336
|
+
if (hunkKeep) { cur.push(line); curHas = true; }
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (!inHunk) { cur.push(line); continue; } // 文件头元数据行(index/---/+++/mode…)
|
|
340
|
+
if (hunkKeep) cur.push(line); // hunk 内容行
|
|
341
|
+
}
|
|
342
|
+
flush();
|
|
343
|
+
return out.join("\n") + "\n";
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* 部分回滚:safety → 过滤逆 patch → apply -R --recount(失败 -3 兜底)→ 记账。
|
|
348
|
+
* hunkIdx 为「files 过滤后 patch 内的全局 hunk 序号」(0 起,按 diff 输出顺序)。
|
|
349
|
+
*/
|
|
350
|
+
export function rollbackPartial({ repoRoot, sessionId, base, target, files, hunkIdx }) {
|
|
351
|
+
const store = storeOf(repoRoot, sessionId);
|
|
352
|
+
return queueRepo(pathResolve(store.gitDir), () => rollbackPartialUnq(store, { base, target, files, hunkIdx }));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function rollbackPartialUnq(store, { base, target, files, hunkIdx }) {
|
|
356
|
+
const { repoRoot, sessionId } = { repoRoot: store.workTree, sessionId: store.sessionId };
|
|
357
|
+
if (!storeOk(repoRoot)) throw new Error("该仓没有初始化 trace store(.aiti-trace 不存在)");
|
|
358
|
+
for (const ref of [base, target]) if (!(await resolveCommit(store, ref))) throw new Error("commit 不存在于追踪仓: " + ref);
|
|
359
|
+
const { text } = await diffPatch(store, base, target, files);
|
|
360
|
+
// [P0-1 契约] 空 hunkIdx = 该文件全部 hunk(不做过滤)——逐 op 回滚前端只圈文件级,
|
|
361
|
+
// hunk 级细选留给未来的部分回滚 UI;契约见 docs/aiti-lineage-closure-plan.md P0-1。
|
|
362
|
+
const patch = hunkIdx.length ? filterPatchHunks(text, new Set(hunkIdx)) : text;
|
|
363
|
+
if (!patch.trim()) throw new Error("过滤后 patch 为空(hunkIdx 与 diff 不匹配)");
|
|
364
|
+
const safety = await safetyCommitUnq(store, "pre-partial-rollback");
|
|
365
|
+
const patchFile = pathJoin(store.gitDir, "partial-rollback.patch");
|
|
366
|
+
writeFileSync(patchFile, patch);
|
|
367
|
+
let r = await runGit(store, ["apply", "-R", "--whitespace=nowarn", patchFile], { allowFail: true });
|
|
368
|
+
let usedThreeway = false;
|
|
369
|
+
if (r.code !== 0) { // worktree 已漂移 → 三方兜底(store 内有全部 blob)
|
|
370
|
+
r = await runGit(store, ["apply", "-R", "--3way", "--whitespace=nowarn", patchFile], { allowFail: true });
|
|
371
|
+
usedThreeway = true;
|
|
372
|
+
}
|
|
373
|
+
try { rmSync(patchFile, { force: true }); } catch {}
|
|
374
|
+
if (r.code !== 0) {
|
|
375
|
+
throw new Error("部分回滚 apply 失败" + (usedThreeway ? "(-3 亦失败)" : "") + ": " + (r.stderr || "").trim().split("\n")[0]);
|
|
376
|
+
}
|
|
377
|
+
await addAllSafe(store);
|
|
378
|
+
const rollback = await commitToSessionBranch(store, `aiti phase=rollback partial files=[${files.join(",")}] hunks=[${hunkIdx.join(",")}] base=${base} target=${target} @${new Date().toISOString()}`);
|
|
379
|
+
return { safety, rollback, usedThreeway };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ---------- 台账(§3.6,state/ops/<sid>.jsonl;不可再生 → state 层) ----------
|
|
383
|
+
|
|
384
|
+
/** 台账目录(state 层:不可再生数据,不随 cache 清理)。写==读同源:统一经 opsDirFor/opsFile。 */
|
|
385
|
+
export function opsDirFor(configDir) {
|
|
386
|
+
return pathJoin(extRootOf(configDir), "state", "ops");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function opsFile(configDir, sessionId) {
|
|
390
|
+
return pathJoin(opsDirFor(configDir), sanitizeSession(sessionId) + ".jsonl");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function sanitizeSession(sid) {
|
|
394
|
+
return String(sid || "default").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || "default";
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function readOps(configDir, sessionId) {
|
|
398
|
+
return readOpsWithMeta(configDir, sessionId).rows.slice(); // 浅拷贝:防调用方就地改写污染解析缓存
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** /ops 解析缓存(mtime+size 键,LRU 上限):live 轮次 SSE 批次重取免去整读整 parse
|
|
402
|
+
* (方案 §11.3 性能审计——1.1MB 台账每批省 ~12ms 同步阻塞;台账 append 均保前缀,
|
|
403
|
+
* size/mtime 变化即失效,清理/截断缩小亦然,无需手动失效)。 */
|
|
404
|
+
const OPS_PARSE_CACHE_MAX = 8;
|
|
405
|
+
const opsParseCache = new Map<string, { mtimeMs: number; size: number; rows: any[] }>();
|
|
406
|
+
|
|
407
|
+
/** 读台账并附带 ETag 元数据(`W/"size-mtimeMs"`;api /ops 据此做 If-None-Match 304 再校验)。 */
|
|
408
|
+
export function readOpsWithMeta(configDir, sessionId): { rows: any[]; etag: string | null } {
|
|
409
|
+
const f = opsFile(configDir, sessionId);
|
|
410
|
+
let st;
|
|
411
|
+
try { st = statSync(f); } catch { return { rows: [], etag: null }; }
|
|
412
|
+
const etag = `W/"${st.size}-${Math.round(st.mtimeMs)}"`;
|
|
413
|
+
const hit = opsParseCache.get(f);
|
|
414
|
+
if (hit && hit.size === st.size && hit.mtimeMs === st.mtimeMs) {
|
|
415
|
+
opsParseCache.delete(f);
|
|
416
|
+
opsParseCache.set(f, hit); // LRU touch
|
|
417
|
+
return { rows: hit.rows, etag };
|
|
418
|
+
}
|
|
419
|
+
const rows: any[] = [];
|
|
420
|
+
for (const line of readFileSync(f, "utf8").split("\n")) {
|
|
421
|
+
const t = line.trim();
|
|
422
|
+
if (!t) continue;
|
|
423
|
+
try { rows.push(JSON.parse(t)); } catch { /* 跳过坏行(台账容错) */ }
|
|
424
|
+
}
|
|
425
|
+
opsParseCache.delete(f);
|
|
426
|
+
opsParseCache.set(f, { mtimeMs: st.mtimeMs, size: st.size, rows });
|
|
427
|
+
while (opsParseCache.size > OPS_PARSE_CACHE_MAX) {
|
|
428
|
+
opsParseCache.delete(opsParseCache.keys().next().value);
|
|
429
|
+
}
|
|
430
|
+
return { rows, etag };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// web 侧追加通知(api.ts 的 plan-record/回撤记账走 appendOps 直写文件,不经 pi 引擎的
|
|
434
|
+
// __aitiTraceEmit 广播——index.ts 装此钩子把同形事件转 traceBus/SSE,否则浏览器实时化
|
|
435
|
+
// 对 web 侧写入不生效(2026-09-09 SSE 推送实测发现))
|
|
436
|
+
let appendListener: ((sessionId: string, entry: any) => void) | null = null;
|
|
437
|
+
export function setAppendListener(fn: ((sessionId: string, entry: any) => void) | null): void {
|
|
438
|
+
appendListener = fn;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export function appendOps(configDir, sessionId, entry) {
|
|
442
|
+
const f = opsFile(configDir, sessionId);
|
|
443
|
+
mkdirSync(pathDirname(f), { recursive: true });
|
|
444
|
+
appendFileSync(f, JSON.stringify(entry) + "\n", "utf8");
|
|
445
|
+
try { appendListener?.(sessionId, entry); } catch { /* 通知失败不影响记账 */ }
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* 会话台账里出现过的全部仓根(stores[].root 去重,按首次出现序)。
|
|
450
|
+
* 用途:跨仓文件分辨(aiti-msg-smart 协作)与 watch 弹窗「本会话识别的仓」补全——
|
|
451
|
+
* state/<sid>.json 只是运行时快照,引擎重启会丢之前的副仓;台账才是持久记录。
|
|
452
|
+
*/
|
|
453
|
+
export function sessionRepoRoots(configDir, sessionId) {
|
|
454
|
+
const roots = [];
|
|
455
|
+
for (const op of readOps(configDir, sessionId)) {
|
|
456
|
+
for (const st of Array.isArray(op.stores) ? op.stores : []) {
|
|
457
|
+
const r = typeof st?.root === "string" ? st.root : "";
|
|
458
|
+
if (r && !roots.includes(r)) roots.push(r);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return roots;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** fork 预览:返回 fork 点之后的 ops。fork 点=用户消息 entry → 优先匹配 userEntryId
|
|
465
|
+
* (ledger entryId 是 assistant/toolCall entry,永不等于 fork 点);两者都找不到时保守返回全部。 */
|
|
466
|
+
export function forkPreviewOps(configDir, sessionId, entryId) {
|
|
467
|
+
const ops = readOps(configDir, sessionId);
|
|
468
|
+
if (!entryId) return ops;
|
|
469
|
+
let last = -1;
|
|
470
|
+
ops.forEach((o, i) => {
|
|
471
|
+
if (o.userEntryId === entryId || o.entryId === entryId) last = i;
|
|
472
|
+
});
|
|
473
|
+
return last >= 0 ? ops.slice(last + 1) : ops;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ---------- workspace 关注路径配置(§7.2,configDir/workspaces/<key>.json) ----------
|
|
477
|
+
|
|
478
|
+
export const DEFAULT_WORKSPACE = { enabled: true, autoDiscover: true, paths: [] };
|
|
479
|
+
|
|
480
|
+
export function workspaceFile(configDir, key) {
|
|
481
|
+
const safe = String(key || "").replace(/[^A-Za-z0-9_-]/g, "");
|
|
482
|
+
return pathJoin(extRootOf(configDir), "cache", "workspaces", safe + ".json");
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export function loadWorkspace(configDir, key) {
|
|
486
|
+
const f = workspaceFile(configDir, key);
|
|
487
|
+
if (!existsSync(f)) return { ...DEFAULT_WORKSPACE, paths: [] };
|
|
488
|
+
try {
|
|
489
|
+
const cfg = JSON.parse(readFileSync(f, "utf8"));
|
|
490
|
+
return {
|
|
491
|
+
enabled: cfg.enabled !== false,
|
|
492
|
+
autoDiscover: cfg.autoDiscover !== false,
|
|
493
|
+
paths: Array.isArray(cfg.paths) ? cfg.paths : [],
|
|
494
|
+
};
|
|
495
|
+
} catch { return { ...DEFAULT_WORKSPACE, paths: [] }; }
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export function saveWorkspace(configDir, key, cfg) {
|
|
499
|
+
const clean = {
|
|
500
|
+
enabled: cfg?.enabled !== false,
|
|
501
|
+
autoDiscover: cfg?.autoDiscover !== false,
|
|
502
|
+
paths: (Array.isArray(cfg?.paths) ? cfg.paths : []).map((p) => ({
|
|
503
|
+
path: String(p.path || ""),
|
|
504
|
+
origin: p.origin === "user" ? "user" : "user",
|
|
505
|
+
repoRoot: p.repoRoot ? String(p.repoRoot) : null,
|
|
506
|
+
addedAt: Number(p.addedAt) || Date.now(),
|
|
507
|
+
})).filter((p) => p.path),
|
|
508
|
+
};
|
|
509
|
+
atomicWrite(workspaceFile(configDir, key), JSON.stringify(clean, null, 2));
|
|
510
|
+
return clean;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export function deleteWorkspace(configDir, key) {
|
|
514
|
+
const f = workspaceFile(configDir, key);
|
|
515
|
+
if (existsSync(f)) rmSync(f, { force: true });
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** 归属解析:路径向上走查最近 .git(§3.7);目录无归属时向下扫一层找嵌套仓 */
|
|
520
|
+
export function resolveWatchPath(p) {
|
|
521
|
+
const abs = pathResolve(p);
|
|
522
|
+
const up = repoRootOf(abs);
|
|
523
|
+
if (up) return { path: abs, repoRoot: up, nested: [] };
|
|
524
|
+
const nested = [];
|
|
525
|
+
try {
|
|
526
|
+
for (const name of readdirSync(abs)) {
|
|
527
|
+
const child = pathJoin(abs, name);
|
|
528
|
+
try {
|
|
529
|
+
if (existsSync(pathJoin(child, ".git", "HEAD"))) nested.push(child);
|
|
530
|
+
} catch {}
|
|
531
|
+
}
|
|
532
|
+
} catch {}
|
|
533
|
+
return { path: abs, repoRoot: null, nested };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ---------- 内置排除清单(§3.5 v1;供 store init 写 aiti/excludes) ----------
|
|
537
|
+
|
|
538
|
+
export const BUILTIN_EXCLUDES_V1 = `# aiti-excludes v1 —— 构建产物 / 依赖 / 缓存 / 临时物;锁文件与 .env 不在列
|
|
539
|
+
# —— Node / 前端
|
|
540
|
+
node_modules/
|
|
541
|
+
.next/ .nuxt/ .output/ .svelte-kit/ .astro/ .vite/ .vitest/ .parcel-cache/ .turbo/ .vercel/
|
|
542
|
+
dist/ build/ out/ coverage/ .nyc_output/ .cache/
|
|
543
|
+
.eslintcache *.tsbuildinfo
|
|
544
|
+
.yarn/cache .yarn/install-state.gz .pnpm-store/
|
|
545
|
+
# —— Python
|
|
546
|
+
__pycache__/ *.py[cod] .ipynb_checkpoints/
|
|
547
|
+
.venv/ venv/ .tox/ .nox/ .hypothesis/
|
|
548
|
+
.pytest_cache/ .mypy_cache/ .ruff_cache/ *.egg-info/
|
|
549
|
+
# —— JVM / .NET
|
|
550
|
+
target/ build/ .gradle/ *.class
|
|
551
|
+
bin/ obj/ .vs/
|
|
552
|
+
# —— Ruby
|
|
553
|
+
.bundle/ vendor/bundle/
|
|
554
|
+
# —— 日志 / 临时 / 崩溃物
|
|
555
|
+
*.log logs/ tmp/ .tmp/ *.swp *.swo core core.*
|
|
556
|
+
# —— OS / IDE
|
|
557
|
+
.DS_Store Thumbs.db desktop.ini
|
|
558
|
+
.idea/ .vscode/
|
|
559
|
+
`;
|
|
560
|
+
|
|
561
|
+
/** 写内置排除托管文件(幂等;版本戳变更时重写) */
|
|
562
|
+
export function ensureExcludesFile(configDir) {
|
|
563
|
+
const dir = pathJoin(extRootOf(configDir), "cache", "excludes");
|
|
564
|
+
mkdirSync(dir, { recursive: true });
|
|
565
|
+
const f = pathJoin(dir, "aiti-excludes-v1.txt");
|
|
566
|
+
if (!existsSync(f)) writeFileSync(f, BUILTIN_EXCLUDES_V1);
|
|
567
|
+
return f;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export { pathJoin, pathResolve, existsSync, copyFileSync };
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
/* ---------------- loose 模式判定(§13.4;镜像 pi-git-trace/src/loose.ts) ---------------- */
|
|
574
|
+
|
|
575
|
+
const DENY_EXACT = new Set(["/", "/tmp", "/proc", "/sys", "/dev", "/run", "/etc", "/var", "/boot", "/usr", "/lib", "/opt", "/snap"]);
|
|
576
|
+
const DENY_PREFIX = ["/tmp/", "/proc/", "/sys/", "/dev/", "/run/", "/etc/", "/var/", "/boot/", "/usr/", "/lib/", "/opt/", "/snap/"];
|
|
577
|
+
|
|
578
|
+
export function looseDenied(dir) {
|
|
579
|
+
try {
|
|
580
|
+
const p = pathResolve(dir);
|
|
581
|
+
if (p === osHomedir()) return true;
|
|
582
|
+
if (DENY_EXACT.has(p)) return true;
|
|
583
|
+
return DENY_PREFIX.some((pre) => p.startsWith(pre));
|
|
584
|
+
} catch { return true; }
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export function countFilesWithBudget(dir, budget) {
|
|
588
|
+
let count = 0;
|
|
589
|
+
const visit = (d) => {
|
|
590
|
+
let entries;
|
|
591
|
+
try { entries = readdirSync(d, { withFileTypes: true }); } catch { return true; }
|
|
592
|
+
for (const e of entries) {
|
|
593
|
+
if (e.name === ".aiti-trace") continue;
|
|
594
|
+
if (e.isSymbolicLink()) continue;
|
|
595
|
+
if (e.isDirectory()) { if (!visit(pathJoin(d, e.name))) return false; }
|
|
596
|
+
else { count += 1; if (count > budget) return false; }
|
|
597
|
+
}
|
|
598
|
+
return true;
|
|
599
|
+
};
|
|
600
|
+
visit(dir);
|
|
601
|
+
return count;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** loose 决策(与引擎同链):返回 { allowed, reason } */
|
|
605
|
+
export function looseDecision(cwd, opts = {}) {
|
|
606
|
+
if (opts.autoInit === false) return { allowed: false, reason: "no-git-disabled" };
|
|
607
|
+
if (looseDenied(cwd)) return { allowed: false, reason: "no-git-denylist" };
|
|
608
|
+
const budget = Number(opts.maxFiles) || 2000;
|
|
609
|
+
if (countFilesWithBudget(cwd, budget) > budget) return { allowed: false, reason: "no-git-oversize" };
|
|
610
|
+
return { allowed: true, reason: "" };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
/* ---------------- 清理(§15):会话台账 / 工作区深清理 ---------------- */
|
|
615
|
+
|
|
616
|
+
/** 会话台账清理:beforeOp 缺省 = 删除整文件;否则保留 op >= beforeOp 的行。返回删除行数。 */
|
|
617
|
+
export function cleanupSessionOps(configDir, sessionId, beforeOp) {
|
|
618
|
+
const f = opsFile(configDir, sessionId);
|
|
619
|
+
if (!existsSync(f)) return { removed: 0, deleted: true };
|
|
620
|
+
if (!beforeOp) { rmSync(f); return { removed: -1, deleted: true }; }
|
|
621
|
+
const rows = readOps(configDir, sessionId);
|
|
622
|
+
const keep = rows.filter((r) => r.op >= beforeOp);
|
|
623
|
+
const removed = rows.length - keep.length;
|
|
624
|
+
if (keep.length === 0) { rmSync(f); return { removed, deleted: true }; }
|
|
625
|
+
writeFileSync(f, keep.map((r) => JSON.stringify(r)).join("\n") + "\n");
|
|
626
|
+
return { removed, deleted: false };
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** 工作区深清理:删除 cwd 位于 root 之下(含 root 本身)的全部会话台账 + 本工作区追踪仓。
|
|
630
|
+
* 追踪仓仅在确认是我们创建的(HEAD 存在)时删除。运行中会话由引擎自愈重建空仓。 */
|
|
631
|
+
export function cleanupWorkspace(configDir, root) {
|
|
632
|
+
if (!root) return { sessions: 0, storeRemoved: false };
|
|
633
|
+
const r = String(root);
|
|
634
|
+
const prefix = r.endsWith(pathSep) ? r : r + pathSep;
|
|
635
|
+
const dir = opsDirFor(configDir);
|
|
636
|
+
let sessions = 0;
|
|
637
|
+
try {
|
|
638
|
+
for (const f of readdirSync(dir)) {
|
|
639
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
640
|
+
const fp = pathJoin(dir, f);
|
|
641
|
+
try {
|
|
642
|
+
const first = readFileSync(fp, "utf8").split("\n").find((l) => l.trim());
|
|
643
|
+
if (!first) { rmSync(fp); sessions++; continue; }
|
|
644
|
+
const cwd = String(JSON.parse(first).cwd || "");
|
|
645
|
+
if (cwd === r || cwd.startsWith(prefix)) { rmSync(fp); sessions++; }
|
|
646
|
+
} catch { /* 坏行文件:保守跳过 */ }
|
|
647
|
+
}
|
|
648
|
+
} catch { /* 目录缺失 */ }
|
|
649
|
+
let storeRemoved = false;
|
|
650
|
+
const store = pathJoin(r, ".aiti-trace");
|
|
651
|
+
if (existsSync(pathJoin(store, "HEAD"))) {
|
|
652
|
+
rmSync(store, { recursive: true, force: true });
|
|
653
|
+
storeRemoved = true;
|
|
654
|
+
}
|
|
655
|
+
return { sessions, storeRemoved };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
/** 家谱(§3.3):沿台账 parent 链聚合——祖先链(根优先)+ 当前会话 + 全部后代(BFS 序) */
|
|
660
|
+
export function lineage(configDir, sessionId) {
|
|
661
|
+
const nodes = new Map();
|
|
662
|
+
const dir = opsDirFor(configDir);
|
|
663
|
+
try {
|
|
664
|
+
for (const f of readdirSync(dir)) {
|
|
665
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
666
|
+
const sid = f.replace(/\.jsonl$/, "");
|
|
667
|
+
let parent = null; let opCount = 0; let t0 = Infinity; let t1 = 0;
|
|
668
|
+
try {
|
|
669
|
+
for (const line of readFileSync(pathJoin(dir, f), "utf8").split("\n")) {
|
|
670
|
+
if (!line.trim()) continue;
|
|
671
|
+
try {
|
|
672
|
+
const r = JSON.parse(line);
|
|
673
|
+
opCount++;
|
|
674
|
+
if (r.t0) t0 = Math.min(t0, r.t0);
|
|
675
|
+
if (r.t1) t1 = Math.max(t1, r.t1);
|
|
676
|
+
if (!parent && r.parent) parent = String(r.parent);
|
|
677
|
+
} catch { /* 坏行跳过 */ }
|
|
678
|
+
}
|
|
679
|
+
} catch { continue; }
|
|
680
|
+
if (opCount > 0) nodes.set(sid, { parent, opCount, t0: t0 === Infinity ? 0 : t0, t1 });
|
|
681
|
+
}
|
|
682
|
+
} catch { /* 目录缺失 */ }
|
|
683
|
+
const seen = new Set([sessionId]);
|
|
684
|
+
const up = []; let cur = sessionId;
|
|
685
|
+
while (true) {
|
|
686
|
+
const n = nodes.get(cur);
|
|
687
|
+
const p = n && n.parent;
|
|
688
|
+
if (!p || seen.has(p)) break;
|
|
689
|
+
seen.add(p); up.unshift(p); cur = p;
|
|
690
|
+
if (!nodes.has(p)) break; // 父会话台账缺失:止步
|
|
691
|
+
}
|
|
692
|
+
const down = [];
|
|
693
|
+
const frontier = [sessionId];
|
|
694
|
+
while (frontier.length) {
|
|
695
|
+
const x = frontier.shift();
|
|
696
|
+
for (const [sid, n] of nodes) {
|
|
697
|
+
if (n.parent === x && !seen.has(sid)) { seen.add(sid); down.push(sid); frontier.push(sid); }
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
const genOf = new Map();
|
|
701
|
+
up.forEach((sid, i) => genOf.set(sid, i));
|
|
702
|
+
genOf.set(sessionId, up.length);
|
|
703
|
+
for (const sid of down) {
|
|
704
|
+
const n = nodes.get(sid);
|
|
705
|
+
genOf.set(sid, n && n.parent && genOf.has(n.parent) ? genOf.get(n.parent) + 1 : up.length + 1);
|
|
706
|
+
}
|
|
707
|
+
const node = (sid) => { const n = nodes.get(sid) || {}; return { id: sid, parent: n.parent || null, gen: genOf.get(sid) ?? 0, opCount: n.opCount || 0, t0: n.t0 || 0, t1: n.t1 || 0 }; };
|
|
708
|
+
return { ancestors: up.map(node), self: node(sessionId), descendants: down.map(node) };
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ---------- 「从这里编辑」区间 + 逐 op 逆序反向回撤(docs/aiti-git-trace-edit-here-reverse-rollback-plan.md) ----------
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* 按被裁轮次集合精确圈定 ops(方案 §4.1):`userEntryId ∈ ids`,旧台账无 userEntryId 时
|
|
715
|
+
* 兜底 `entryId ∈ ids`;`ids` 为空/无不匹配 → 返回 [](调用方直通不弹窗,安全默认——
|
|
716
|
+
* 漏提示可后续手工处理,误撤销才是灾难)。
|
|
717
|
+
* 区别于 forkPreviewOps(单 entryId + slice(last+1) 漏掉被编辑轮)。
|
|
718
|
+
*/
|
|
719
|
+
export function forkPreviewOpsByUserEntries(configDir, sessionId, ids) {
|
|
720
|
+
const set = new Set((ids || []).filter(Boolean));
|
|
721
|
+
if (set.size === 0) return [];
|
|
722
|
+
return readOps(configDir, sessionId).filter((o) => set.has(o.userEntryId) || set.has(o.entryId));
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* 逐 op 逆序反向回撤(方案 §3.2/§4.2):对台账 before→after 快照对,从后往前逐个
|
|
727
|
+
* `git diff before after` → `git apply -R`(失败 -3 三方兜底;仍失败则停止并返回 partial)。
|
|
728
|
+
* 只移除被撤 op 引入的变更,保留其他来源(用户手改/并行改动)。
|
|
729
|
+
*
|
|
730
|
+
* gap(before=null,首触无基线)op 无法逆撤 → 计入 skipped 跳过。
|
|
731
|
+
* 回撤动作本身作为可逆记录:`before = safety commit`(回撤前现状)、`after = 记账 commit`(回撤后现状),
|
|
732
|
+
* 双双落会话分支(方案 §7:回撤自身也进 trace、可再回滚)。
|
|
733
|
+
*
|
|
734
|
+
* @param refs 升序 [{op, before, after}](before=null 表示 gap,跳过)
|
|
735
|
+
* @returns { safety, rollback, applied, skipped, usedThreeway, failed: {op,before,after,stderr}|null }
|
|
736
|
+
*/
|
|
737
|
+
export function rollbackOpsReversed({ repoRoot, sessionId, refs }) {
|
|
738
|
+
const store = storeOf(repoRoot, sessionId);
|
|
739
|
+
return queueRepo(pathResolve(store.gitDir), () => rollbackOpsReversedUnq(store, refs));
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async function rollbackOpsReversedUnq(store, refs) {
|
|
743
|
+
const { repoRoot, sessionId } = { repoRoot: store.workTree, sessionId: store.sessionId };
|
|
744
|
+
if (!storeOk(repoRoot)) throw new Error("该仓没有初始化 trace store(.aiti-trace 不存在)");
|
|
745
|
+
const list = (refs || []).filter((r) => r && typeof r === "object");
|
|
746
|
+
// 只校验有 before/after 的 ref(gap 无 before,不校验)
|
|
747
|
+
for (const r of list) {
|
|
748
|
+
if (r.before && r.after) {
|
|
749
|
+
for (const ref of [r.before, r.after]) {
|
|
750
|
+
if (!(await resolveCommit(store, ref))) throw new Error("commit 不存在于追踪仓: " + ref);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
const safety = await safetyCommitUnq(store, "pre-reverse-rollback");
|
|
755
|
+
let applied = 0; let usedThreeway = false; let failed = null;
|
|
756
|
+
const skipped = [];
|
|
757
|
+
// 逆序:后发生的先撤,patch 上下文 = 前一 op 的 after 快照,冲突面最小
|
|
758
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
759
|
+
const r = list[i];
|
|
760
|
+
if (!r.before || !r.after) { skipOperation(skipped, r); continue; }
|
|
761
|
+
const { text } = await diffPatch(store, r.before, r.after, []); // files=[] → 全仓 diff
|
|
762
|
+
if (!text.trim()) continue; // 无差异(理论上不应发生)
|
|
763
|
+
const patchFile = pathJoin(store.gitDir, "reverse-rollback.patch");
|
|
764
|
+
writeFileSync(patchFile, text);
|
|
765
|
+
let ok = await runGit(store, ["apply", "-R", "--whitespace=nowarn", patchFile], { allowFail: true });
|
|
766
|
+
let tw = false;
|
|
767
|
+
if (ok.code !== 0) { // worktree 已漂移 → 三方兜底(store 有全部 blob)
|
|
768
|
+
ok = await runGit(store, ["apply", "-R", "--3way", "--whitespace=nowarn", patchFile], { allowFail: true });
|
|
769
|
+
tw = true;
|
|
770
|
+
}
|
|
771
|
+
try { rmSync(patchFile, { force: true }); } catch {}
|
|
772
|
+
if (ok.code !== 0) {
|
|
773
|
+
failed = { op: r.op, before: r.before, after: r.after, stderr: String(ok.stderr || "").trim().split("\n")[0] };
|
|
774
|
+
break;
|
|
775
|
+
}
|
|
776
|
+
applied += 1;
|
|
777
|
+
if (tw) usedThreeway = true;
|
|
778
|
+
}
|
|
779
|
+
await addAllSafe(store);
|
|
780
|
+
const doneOps = list.map((r) => r.op).join(",");
|
|
781
|
+
const rollback = await commitToSessionBranch(store, `aiti phase=rollback scope=reverse-ops ops=[${doneOps}] applied=${applied}${failed ? " failed@" + failed.op : ""} ${skipped.length ? "skipped=" + skipped.join(",") : ""} @${new Date().toISOString()}`);
|
|
782
|
+
return { safety, rollback, applied, skipped, usedThreeway, failed };
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** 增补:gap op 记录进 skipped(含 op 号与原因),不阻塞后续 ref */
|
|
786
|
+
function skipOperation(skipped, r) {
|
|
787
|
+
skipped.push({ op: r.op, reason: "gap" });
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
// ---------- 变更总览(docs/aiti-git-trace-overview-view-plan.md §3;只读聚合,零写路径) ----------
|
|
792
|
+
|
|
793
|
+
/** 台账行圈定(R3):plan-accept/plan-discard 无 stores 不参与统计;rollback 单独归一 */
|
|
794
|
+
const SUMMARY_OP_EXCLUDED_KINDS = new Set(["rollback", "plan-accept", "plan-discard"]);
|
|
795
|
+
|
|
796
|
+
/** 变更总览文件列表返回上限(§8 渲染保护;超出置 filesTruncated,totals.files 仍为全量计数) */
|
|
797
|
+
const SUMMARY_FILES_CAP = 2000;
|
|
798
|
+
/** 触达史每文件上限(§3.1) */
|
|
799
|
+
const SUMMARY_TOUCH_CAP = 50;
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* 回撤行三 shape 归一(R2,§3.1):
|
|
803
|
+
* - rollback-reversed:LedgerRow 同构(stores[].before/after,时间键 t1)
|
|
804
|
+
* - rollback-partial / rollback full:扁平(storeRoot/base/target/safety/commit,只有 t)
|
|
805
|
+
* 归一为 { t, stores:[{root, before, after, paths}] };latest commit 取 stores[].after ∨ commit。
|
|
806
|
+
* flat 的 before=safety 是会话中点——只参与 target,不参与 base(调用方约定)。
|
|
807
|
+
*/
|
|
808
|
+
function normalizeRollbackRow(row) {
|
|
809
|
+
if (!row || row.kind !== "rollback") return null;
|
|
810
|
+
const t = Number(row.t1 ?? row.t ?? 0) || 0;
|
|
811
|
+
let stores;
|
|
812
|
+
if (Array.isArray(row.stores)) {
|
|
813
|
+
stores = row.stores
|
|
814
|
+
.map((st) => ({ root: st?.root, before: st?.before || null, after: st?.after || null, paths: Array.isArray(st?.paths) ? st.paths : [] }))
|
|
815
|
+
.filter((st) => st.root);
|
|
816
|
+
} else if (typeof row.storeRoot === "string" && row.storeRoot) {
|
|
817
|
+
stores = [{ root: row.storeRoot, before: row.safety || null, after: row.commit || null, paths: Array.isArray(row.files) ? row.files : [] }];
|
|
818
|
+
} else stores = [];
|
|
819
|
+
return { t, stores };
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** `git diff --name-status -z` 解析:普通 `X\0path\0`;rename/copy `X<score>\0old\0new\0`(三段)。
|
|
823
|
+
* 位置解析:记录首段必为状态 token(rename 双形态 N1:未检测到的改名以 D+A 两行出现,无需合并)。 */
|
|
824
|
+
function parseNameStatusZ(stdout) {
|
|
825
|
+
const parts = String(stdout || "").split("\0");
|
|
826
|
+
const out = [];
|
|
827
|
+
for (let i = 0; i < parts.length;) {
|
|
828
|
+
const st = parts[i];
|
|
829
|
+
if (!st) { i++; continue; }
|
|
830
|
+
if (/^[RC]/.test(st)) {
|
|
831
|
+
const from = parts[i + 1] || null, to = parts[i + 2] || null;
|
|
832
|
+
i += 3;
|
|
833
|
+
if (to) out.push({ status: st[0], from, path: to });
|
|
834
|
+
} else {
|
|
835
|
+
const to = parts[i + 1] || null;
|
|
836
|
+
i += 2;
|
|
837
|
+
if (to) out.push({ status: st[0], from: null, path: to });
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
return out;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** `git diff --numstat -z` 解析(口径镜像 git-review changeSet):普通 `a\td\tpath\0`;
|
|
844
|
+
* rename `a\td\t\0old\0new\0`(第三段空串判定,防 binary `-\t-` 误判);binary = `-\t-`。 */
|
|
845
|
+
function parseNumstatZ(stdout) {
|
|
846
|
+
const parts = String(stdout || "").split("\0");
|
|
847
|
+
const out = [];
|
|
848
|
+
for (let i = 0; i < parts.length;) {
|
|
849
|
+
const m = /^(\d+|-)\t(\d+|-)\t(.*)$/s.exec(parts[i] || "");
|
|
850
|
+
if (!m) { i++; continue; }
|
|
851
|
+
const binary = m[1] === "-" || m[2] === "-";
|
|
852
|
+
const adds = m[1] === "-" ? 0 : parseInt(m[1], 10) || 0;
|
|
853
|
+
const dels = m[2] === "-" ? 0 : parseInt(m[2], 10) || 0;
|
|
854
|
+
if (m[3] === "") {
|
|
855
|
+
const from = parts[i + 1] || null, to = parts[i + 2] || null;
|
|
856
|
+
i += 3;
|
|
857
|
+
if (to) out.push({ path: to, from, adds, dels, binary });
|
|
858
|
+
} else {
|
|
859
|
+
i += 1;
|
|
860
|
+
out.push({ path: m[3], from: null, adds, dels, binary });
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
return out;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* 会话(或指定轮次集合)文件变更总览(§3.1,只读)。
|
|
868
|
+
*
|
|
869
|
+
* 口径:
|
|
870
|
+
* - 行圈定(R2/R3):op 行 = 有 stores[] 且 kind ∉ {rollback, plan-accept, plan-discard};
|
|
871
|
+
* 回撤行(kind=rollback)三 shape 归一后参与 target 与 rollbacks 计数,不参与 base。
|
|
872
|
+
* - 区间(每 store):base = op 行最早(t0)非空 before,全空(首触)→ 最早 after;
|
|
873
|
+
* target = 所有行最新(t1 ?? t)非空 after/commit。entryIds 给定时仅用命中轮 op 行(轮次 = 净增量)。
|
|
874
|
+
* - 交集口径(R1):文件集合 = 区间 diff ∩ 台账窗口增量路径并集(before→after 窗口 op 实测 paths)。
|
|
875
|
+
* status 以 name-status 为权威;numstat 只供行数;+A/−B = 区间净变更(非逐 op 累加)。
|
|
876
|
+
* - storeMissing(storeOk 假)/ rejected(allowRoot 拒,Y5)标注部分返回,非静默丢。
|
|
877
|
+
*
|
|
878
|
+
* @param entryIds 用户消息 entry 集合(轮次过滤;空/null = 全部)
|
|
879
|
+
* @param opts.allowRoot (root) => Promise<boolean>,缺省放行
|
|
880
|
+
* @param opts.opSet op 号 Set(GET fork-preview 圈定范围用;与 entryIds 二选一场景)
|
|
881
|
+
*/
|
|
882
|
+
export async function sessionFileSummary(configDir, sessionId, entryIds, opts = {}) {
|
|
883
|
+
const rows = readOps(configDir, sessionId);
|
|
884
|
+
const idSet = Array.isArray(entryIds) && entryIds.length ? new Set(entryIds.map(String)) : null;
|
|
885
|
+
const opSet = opts.opSet instanceof Set ? opts.opSet : null;
|
|
886
|
+
const matchEntry = (row) => !idSet
|
|
887
|
+
|| (row.userEntryId != null && idSet.has(String(row.userEntryId)))
|
|
888
|
+
|| (row.entryId != null && idSet.has(String(row.entryId)));
|
|
889
|
+
const matchOp = (row) => !opSet || (typeof row.op === "number" && opSet.has(row.op));
|
|
890
|
+
const rowT = (row) => Number(row?.t1 ?? row?.t ?? row?.t0 ?? 0) || 0;
|
|
891
|
+
const rowT0 = (row) => Number(row?.t0 ?? row?.t1 ?? row?.t ?? 0) || 0;
|
|
892
|
+
|
|
893
|
+
const opRows = [];
|
|
894
|
+
const rbRows = []; // { row: 原台账行, n: 归一结果 }
|
|
895
|
+
for (const row of rows) {
|
|
896
|
+
if (row?.kind === "rollback") {
|
|
897
|
+
if (idSet) continue; // 轮次过滤口径 = 该轮净增量:回撤行不掺入
|
|
898
|
+
const n = normalizeRollbackRow(row);
|
|
899
|
+
if (n && matchOp(row)) rbRows.push({ row, n });
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
if (SUMMARY_OP_EXCLUDED_KINDS.has(row?.kind)) continue;
|
|
903
|
+
if (!Array.isArray(row?.stores) || row.stores.length === 0) continue;
|
|
904
|
+
if (!matchEntry(row) || !matchOp(row)) continue;
|
|
905
|
+
opRows.push(row);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// 每 store 聚合桶(按首现序)
|
|
909
|
+
const storesOrder = [];
|
|
910
|
+
const buckets = new Map(); // root → { bases, baseAfters, targets, window:Set, touches:Map<path,[]> }
|
|
911
|
+
const bucketOf = (root) => {
|
|
912
|
+
let b = buckets.get(root);
|
|
913
|
+
if (!b) {
|
|
914
|
+
b = { bases: [], baseAfters: [], targets: [], window: new Set(), touches: new Map() };
|
|
915
|
+
buckets.set(root, b);
|
|
916
|
+
storesOrder.push(root);
|
|
917
|
+
}
|
|
918
|
+
return b;
|
|
919
|
+
};
|
|
920
|
+
const pushTouch = (b, path, touch) => {
|
|
921
|
+
let list = b.touches.get(path);
|
|
922
|
+
if (!list) { list = []; b.touches.set(path, list); }
|
|
923
|
+
list.push(touch);
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
for (const row of opRows) {
|
|
927
|
+
const t0 = rowT0(row), t1 = rowT(row);
|
|
928
|
+
const kind = row.kind || "op";
|
|
929
|
+
for (const st of row.stores) {
|
|
930
|
+
if (!st?.root) continue;
|
|
931
|
+
const b = bucketOf(st.root);
|
|
932
|
+
const paths = Array.isArray(st.paths) ? st.paths : [];
|
|
933
|
+
if (st.before) {
|
|
934
|
+
b.bases.push({ t: t0, c: st.before });
|
|
935
|
+
if (st.after) b.targets.push({ t: t1, c: st.after });
|
|
936
|
+
for (const p of paths) {
|
|
937
|
+
b.window.add(p);
|
|
938
|
+
// 触达行附 before/after/partial/truncated(§2 触达行 看diff/回滚 按钮需要该 op 快照对)
|
|
939
|
+
pushTouch(b, p, { op: row.op, msgSeq: row.msgSeq, tool: row.tool, summary: row.summary, t1: t1 || t0, kind,
|
|
940
|
+
before: st.before || null, after: st.after || null, partial: !!st.partial, truncated: !!st.pathsTruncated });
|
|
941
|
+
}
|
|
942
|
+
} else if (st.after) {
|
|
943
|
+
// 首触基线(before=null)不产生文件行;after 仍可作 base 兜底与 target
|
|
944
|
+
b.baseAfters.push({ t: t0, c: st.after });
|
|
945
|
+
b.targets.push({ t: t1, c: st.after });
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
for (const { row, n } of rbRows) {
|
|
950
|
+
for (const st of n.stores) {
|
|
951
|
+
const b = bucketOf(st.root);
|
|
952
|
+
if (st.after) b.targets.push({ t: n.t, c: st.after }); // 回撤后 worktree 实际状态
|
|
953
|
+
if (st.before && st.after) {
|
|
954
|
+
for (const p of st.paths) {
|
|
955
|
+
pushTouch(b, p, { op: row.op ?? null, msgSeq: null, tool: "rollback", summary: "↩", t1: n.t, kind: "rollback",
|
|
956
|
+
before: st.before, after: st.after, partial: false, truncated: false });
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const storesOut = [];
|
|
963
|
+
const filesMap = new Map(); // root\0path → file row
|
|
964
|
+
const totals = { ops: 0, files: 0, adds: 0, dels: 0, rollbacks: rbRows.length, leaks: 0, binary: 0 };
|
|
965
|
+
const opHasDelta = (row) => (row.leaks || []).length > 0
|
|
966
|
+
|| (row.stores || []).some((st) => st.before && st.files
|
|
967
|
+
&& (st.files.M || 0) + (st.files.A || 0) + (st.files.D || 0) > 0);
|
|
968
|
+
totals.ops = opRows.filter(opHasDelta).length;
|
|
969
|
+
const leakPaths = new Set();
|
|
970
|
+
for (const row of opRows) for (const l of row.leaks || []) if (l?.path) leakPaths.add(l.path);
|
|
971
|
+
totals.leaks = leakPaths.size;
|
|
972
|
+
|
|
973
|
+
const allowRoot = typeof opts.allowRoot === "function" ? opts.allowRoot : null;
|
|
974
|
+
for (const root of storesOrder) {
|
|
975
|
+
const b = buckets.get(root);
|
|
976
|
+
const entry = {
|
|
977
|
+
root,
|
|
978
|
+
// base = op 行最早(t0)非空 before;全空(首触)→ 最早 after(回撤行不参与 base)
|
|
979
|
+
base: (b.bases.slice().sort((x, y) => x.t - y.t)[0]?.c ?? null)
|
|
980
|
+
?? (b.baseAfters.slice().sort((x, y) => x.t - y.t)[0]?.c ?? null),
|
|
981
|
+
// target = 所有行(含归一回撤行)最新(t1 ?? t)非空 after/commit
|
|
982
|
+
target: (b.targets.slice().sort((x, y) => x.t - y.t).filter((x) => x.c).pop()?.c ?? null),
|
|
983
|
+
};
|
|
984
|
+
storesOut.push(entry);
|
|
985
|
+
if (!entry.base || !entry.target || entry.base === entry.target) continue;
|
|
986
|
+
if (!storeOk(root)) { entry.storeMissing = true; continue; }
|
|
987
|
+
if (allowRoot) {
|
|
988
|
+
let ok = false;
|
|
989
|
+
try { ok = await allowRoot(root); } catch { ok = false; }
|
|
990
|
+
if (!ok) { entry.rejected = true; continue; }
|
|
991
|
+
}
|
|
992
|
+
let store;
|
|
993
|
+
try {
|
|
994
|
+
store = storeOf(root, sessionId);
|
|
995
|
+
if (!(await resolveCommit(store, entry.base)) || !(await resolveCommit(store, entry.target))) continue;
|
|
996
|
+
} catch { continue; }
|
|
997
|
+
let ns = "", num = "";
|
|
998
|
+
try {
|
|
999
|
+
ns = (await runGit(store, ["diff", "--no-color", "--no-ext-diff", "--no-textconv", "--name-status", "-z", entry.base, entry.target])).stdout;
|
|
1000
|
+
num = (await runGit(store, ["diff", "--no-color", "--no-ext-diff", "--no-textconv", "--numstat", "-z", entry.base, entry.target])).stdout;
|
|
1001
|
+
} catch { continue; }
|
|
1002
|
+
const numByPath = new Map();
|
|
1003
|
+
const numByPair = new Map();
|
|
1004
|
+
for (const n of parseNumstatZ(num)) {
|
|
1005
|
+
if (n.from) numByPair.set(n.from + "\0" + n.path, n);
|
|
1006
|
+
else numByPath.set(n.path, n);
|
|
1007
|
+
}
|
|
1008
|
+
for (const r of parseNameStatusZ(ns)) {
|
|
1009
|
+
// 交集口径(R1):区间 diff ∩ 台账窗口增量路径(rename 新旧路径任一命中即可)
|
|
1010
|
+
if (!b.window.has(r.path) && !(r.from && b.window.has(r.from))) continue;
|
|
1011
|
+
const n = r.from ? numByPair.get(r.from + "\0" + r.path) : numByPath.get(r.path);
|
|
1012
|
+
const touchList = [...(b.touches.get(r.path) || []), ...(r.from ? b.touches.get(r.from) || [] : [])]
|
|
1013
|
+
.sort((x, y) => (x.t1 || 0) - (y.t1 || 0))
|
|
1014
|
+
.slice(0, SUMMARY_TOUCH_CAP);
|
|
1015
|
+
filesMap.set(root + "\u0000" + r.path, {
|
|
1016
|
+
root, path: r.path, status: r.status,
|
|
1017
|
+
adds: n ? n.adds : 0, dels: n ? n.dels : 0, binary: n ? !!n.binary : false,
|
|
1018
|
+
renamedFrom: r.from || null,
|
|
1019
|
+
touches: touchList,
|
|
1020
|
+
lastT: touchList.length ? Math.max(...touchList.map((x) => x.t1 || 0)) : 0,
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const files = [...filesMap.values()]
|
|
1026
|
+
.sort((a, b2) => (b2.lastT || 0) - (a.lastT || 0) || (a.path < b2.path ? -1 : 1))
|
|
1027
|
+
.slice(0, SUMMARY_FILES_CAP);
|
|
1028
|
+
totals.files = filesMap.size;
|
|
1029
|
+
for (const f of filesMap.values()) {
|
|
1030
|
+
totals.adds += f.adds;
|
|
1031
|
+
totals.dels += f.dels;
|
|
1032
|
+
if (f.binary) totals.binary += 1;
|
|
1033
|
+
}
|
|
1034
|
+
return { stores: storesOut, totals, files, filesTruncated: filesMap.size > files.length };
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const FILE_DIFF_MAX_LINES = 2000;
|
|
1038
|
+
const FILE_DIFF_MAX_BYTES = 256 * 1024;
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* 单文件(或全树)区间 diff 文本(§3.2,hermetic 只读 + 截断保护 ≤2000 行 / 256KB)。
|
|
1042
|
+
* base/target 必须能 resolveCommit(防任意 ref 读——纵深防御)。
|
|
1043
|
+
*/
|
|
1044
|
+
export async function fileDiffText(repoRoot, sessionId, base, target, path, maxLines = FILE_DIFF_MAX_LINES) {
|
|
1045
|
+
if (!storeOk(repoRoot)) throw new Error("该仓没有初始化 trace store(.aiti-trace 不存在)");
|
|
1046
|
+
const store = storeOf(repoRoot, sessionId);
|
|
1047
|
+
for (const ref of [base, target]) if (!(await resolveCommit(store, ref))) throw new Error("commit 不存在于追踪仓: " + ref);
|
|
1048
|
+
const args = ["diff", "--no-color", "--no-ext-diff", "--no-textconv", base, target];
|
|
1049
|
+
if (path) args.push("--", path);
|
|
1050
|
+
const full = (await runGit(store, args)).stdout;
|
|
1051
|
+
const lines = full.split("\n");
|
|
1052
|
+
let text = full;
|
|
1053
|
+
let truncated = false;
|
|
1054
|
+
if (lines.length > maxLines) {
|
|
1055
|
+
text = lines.slice(0, maxLines).join("\n");
|
|
1056
|
+
truncated = true;
|
|
1057
|
+
}
|
|
1058
|
+
if (Buffer.byteLength(text, "utf8") > FILE_DIFF_MAX_BYTES) {
|
|
1059
|
+
text = Buffer.from(text, "utf8").subarray(0, FILE_DIFF_MAX_BYTES).toString("utf8");
|
|
1060
|
+
truncated = true;
|
|
1061
|
+
}
|
|
1062
|
+
return { text, truncated, total: lines.length };
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
// ---------- 影子仓按时间瘦身(方案四 §5.2:compact) ----------
|
|
1067
|
+
// 膨胀机理:每 AI 会话一个分支 refs/heads/aiti/<sid>,plumbing 提交永不删除;无 reflog → 删 ref 即断可达性,
|
|
1068
|
+
// gc --prune=now 可完全回收该会话对象(分支间不合并,无共享对象顾虑)。
|
|
1069
|
+
|
|
1070
|
+
export interface CompactOptions {
|
|
1071
|
+
days?: number; // 保留窗(tip committer date 早于 now-days → 候删;默认 30)
|
|
1072
|
+
keepActiveHours?: number; // 活跃会话保护:分支 ref 文件 mtime 近 N 小时内 → 跳过(默认 24)
|
|
1073
|
+
maxObjectsMB?: number; // 水位:gc 后 objects 仍超 → days 减半重跑(默认 300;最多 3 轮)
|
|
1074
|
+
dryRun?: boolean; // 只出报告不动任何数据
|
|
1075
|
+
extConfigDir?: string; // 本扩展 configDir(ops/state 无主残留同窗清理用)
|
|
1076
|
+
log?: (m: string) => void;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
export interface CompactResult {
|
|
1080
|
+
ok: boolean;
|
|
1081
|
+
repoRoot: string;
|
|
1082
|
+
dryRun?: boolean;
|
|
1083
|
+
lockHeld?: boolean; // 已有 compact 在跑 → 本轮直接放弃
|
|
1084
|
+
rounds?: number;
|
|
1085
|
+
daysUsed?: number;
|
|
1086
|
+
branchesCandidate?: number;
|
|
1087
|
+
branchesRemoved?: number;
|
|
1088
|
+
removedBranches?: string[];
|
|
1089
|
+
opsStateCleaned?: number;
|
|
1090
|
+
/** 同批删除的 tag 数(删分支的会话;tag 是可达性根,不清则 gc 回收不了对象) */
|
|
1091
|
+
tagsRemoved?: number;
|
|
1092
|
+
/** 同批删除的 index-<sid> 文件数(会话死后的磁盘残留,~232KB/会话) */
|
|
1093
|
+
indexesRemoved?: number;
|
|
1094
|
+
/** dry-run:候选会话的 tag 总数 */
|
|
1095
|
+
tagsCandidate?: number;
|
|
1096
|
+
/** dry-run:候选会话的 index 文件总数 */
|
|
1097
|
+
indexesCandidate?: number;
|
|
1098
|
+
objectsMBBefore?: number;
|
|
1099
|
+
objectsMBAfter?: number;
|
|
1100
|
+
reclaimedMB?: number;
|
|
1101
|
+
error?: string;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const TRACE_OBJECTS_LIMIT_FILES = 20000;
|
|
1105
|
+
|
|
1106
|
+
/** objects 体积(count-objects -v 的 size+size-pack,KB→MB)。 */
|
|
1107
|
+
async function traceObjectsMB(store): Promise<number> {
|
|
1108
|
+
const r = await runGit(store, ["count-objects", "-v"], { allowFail: true });
|
|
1109
|
+
let kb = 0;
|
|
1110
|
+
for (const line of String(r.stdout || "").split("\n")) {
|
|
1111
|
+
const m = line.match(/^(size|size-pack):\s+(\d+)$/);
|
|
1112
|
+
if (m) kb += Number(m[2]);
|
|
1113
|
+
}
|
|
1114
|
+
return kb / 1024;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/** 列 refs/heads/aiti/* 分支:[{ ref, short, tipUnix, mtimeMs }]。 */
|
|
1118
|
+
async function listSessionBranches(store): Promise<Array<{ ref: string; short: string; tipUnix: number; mtimeMs: number }>> {
|
|
1119
|
+
const r = await runGit(store, ["for-each-ref", "refs/heads/aiti/", "--format=%(refname)%09%(committerdate:unix)"], { allowFail: true });
|
|
1120
|
+
const out = [];
|
|
1121
|
+
for (const line of String(r.stdout || "").split("\n")) {
|
|
1122
|
+
const [ref, unix] = line.split("\t");
|
|
1123
|
+
if (!ref || !unix) continue;
|
|
1124
|
+
const short = ref.replace("refs/heads/aiti/", "");
|
|
1125
|
+
let mtimeMs = 0;
|
|
1126
|
+
try { mtimeMs = statSync(pathJoin(store.gitDir, "refs", "heads", "aiti", short)).mtimeMs; } catch (e) { /* packed-refs:视为不活跃 */ }
|
|
1127
|
+
out.push({ ref, short, tipUnix: Number(unix) * 1000, mtimeMs });
|
|
1128
|
+
}
|
|
1129
|
+
return out;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/** 同批删该会话的台账与 state(三者同生共死:只删影子仓分支会留孤儿索引)。短 sid 前缀匹配长 sid 文件。 */
|
|
1133
|
+
function removeSessionSidecarFiles(extConfigDir: string, short: string): number {
|
|
1134
|
+
if (!extConfigDir) return 0;
|
|
1135
|
+
let n = 0;
|
|
1136
|
+
for (const [dirName, suffix] of [["state/ops", ".jsonl"], ["cache/state", ".json"]]) {
|
|
1137
|
+
const dir = pathJoin(extRootOf(extConfigDir), dirName);
|
|
1138
|
+
if (!existsSync(dir)) continue;
|
|
1139
|
+
let ents: any[] = [];
|
|
1140
|
+
try { ents = readdirSync(dir); } catch (e) { continue; }
|
|
1141
|
+
for (const f of ents) {
|
|
1142
|
+
if (f.startsWith(short) && f.endsWith(suffix)) {
|
|
1143
|
+
try { rmSync(pathJoin(dir, f)); n++; } catch (e) { /* 尽力删 */ }
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
return n;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/** 该会话的全部 tag 引用(refs/tags/aiti/<short>/...)。
|
|
1151
|
+
* 缺口背景(2026-09-13 审计):compact 原只删分支不删 tag——tag 是 git 可达性根,
|
|
1152
|
+
* 被引用的 commit/tree/blob 永远 gc 不掉(对象回收形同虚设),且 packed-refs 随 op
|
|
1153
|
+
* 无限膨胀(pi-work 实测 13 天 11.3 万 tag / packed-refs 8.3MB / 10 万行)。
|
|
1154
|
+
* 须与分支同批清(保留窗一致:都只动过期已删会话)。 */
|
|
1155
|
+
async function listSessionTags(store, short: string): Promise<string[]> {
|
|
1156
|
+
const prefix = "refs/tags/aiti/" + short;
|
|
1157
|
+
const r = await runGit(store, ["for-each-ref", "--format=%(refname)", prefix], { allowFail: true });
|
|
1158
|
+
return String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean);
|
|
1159
|
+
}
|
|
1160
|
+
/** 批量删该会话全部 tag(update-ref --stdin 单进程;逐个 -d 会 fork N 次,单会话可达数千 tag)。 */
|
|
1161
|
+
async function removeSessionTags(store, short: string): Promise<number> {
|
|
1162
|
+
const refs = await listSessionTags(store, short);
|
|
1163
|
+
if (!refs.length) return 0;
|
|
1164
|
+
const input = refs.map((ref) => "delete " + ref).join("\n") + "\n";
|
|
1165
|
+
await runGit(store, ["update-ref", "--stdin"], { allowFail: true, input });
|
|
1166
|
+
return refs.length;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/** 该会话的 index 文件(<gitDir>/index-<完整 sid>;与 sidecar 同款短 sid 前缀匹配)。
|
|
1170
|
+
* index 命名用完整 sessionId(pi 侧 sanitize,本侧直拼;UUID 无特殊字符实际同形),
|
|
1171
|
+
* 而分支名用短 sid(16 字符)——前缀匹配。 */
|
|
1172
|
+
function listSessionIndexFiles(store, short: string): string[] {
|
|
1173
|
+
const out: string[] = [];
|
|
1174
|
+
try {
|
|
1175
|
+
for (const f of readdirSync(store.gitDir)) {
|
|
1176
|
+
if (!f.startsWith("index-")) continue;
|
|
1177
|
+
if (f.slice("index-".length, "index-".length + short.length) !== short) continue;
|
|
1178
|
+
out.push(f);
|
|
1179
|
+
}
|
|
1180
|
+
} catch (e) { /* 目录不可读 → 无候选 */ }
|
|
1181
|
+
return out;
|
|
1182
|
+
}
|
|
1183
|
+
function removeSessionIndexFiles(store, short: string): number {
|
|
1184
|
+
let n = 0;
|
|
1185
|
+
for (const f of listSessionIndexFiles(store, short)) {
|
|
1186
|
+
try { rmSync(pathJoin(store.gitDir, f)); n++; } catch (e) { /* 尽力删 */ }
|
|
1187
|
+
}
|
|
1188
|
+
return n;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/** 无主残留清理:ops/*.jsonl、state/*.json 按 mtime 早于保留窗直接删(与影子仓同一保留窗)。 */
|
|
1192
|
+
export function cleanupOrphanSidecars(extConfigDir: string, days: number, dryRun = false): number {
|
|
1193
|
+
if (!extConfigDir) return 0;
|
|
1194
|
+
const cutoff = Date.now() - days * 86400 * 1000;
|
|
1195
|
+
let n = 0;
|
|
1196
|
+
for (const dirName of ["state/ops", "cache/state"]) {
|
|
1197
|
+
const dir = pathJoin(extRootOf(extConfigDir), dirName);
|
|
1198
|
+
if (!existsSync(dir)) continue;
|
|
1199
|
+
let ents: any[] = [];
|
|
1200
|
+
try { ents = readdirSync(dir); } catch (e) { continue; }
|
|
1201
|
+
for (const f of ents) {
|
|
1202
|
+
const fp = pathJoin(dir, f);
|
|
1203
|
+
try {
|
|
1204
|
+
if (statSync(fp).mtimeMs < cutoff) { if (!dryRun) rmSync(fp); n++; }
|
|
1205
|
+
} catch (e) { /* 尽力 */ }
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
return n;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* 影子仓瘦身主流程(§5.2 八步)。
|
|
1213
|
+
* 不可逆提示:裁剪丢的是 N 天前的 AI 操作追溯(时间线/回滚能力);默认 30 天审计窗。
|
|
1214
|
+
*/
|
|
1215
|
+
export async function compact(repoRoot: string, opts: CompactOptions = {}): Promise<CompactResult> {
|
|
1216
|
+
const days0 = Math.max(1, opts.days ?? 30);
|
|
1217
|
+
const keepActiveHours = opts.keepActiveHours ?? 24;
|
|
1218
|
+
const maxObjectsMB = opts.maxObjectsMB ?? 300;
|
|
1219
|
+
const dryRun = !!opts.dryRun;
|
|
1220
|
+
const log = opts.log || (() => {});
|
|
1221
|
+
const store = storeOf(repoRoot, "compact");
|
|
1222
|
+
const out: CompactResult = { ok: false, repoRoot, dryRun };
|
|
1223
|
+
|
|
1224
|
+
if (!storeOk(repoRoot)) return { ...out, error: "目标不是追踪仓(无 .aiti-trace/HEAD)" };
|
|
1225
|
+
|
|
1226
|
+
// 1. 排它锁(O_EXCL 原子创建;写入侧 commit 前探测等待——防撕裂窗口)
|
|
1227
|
+
const lock = pathJoin(store.gitDir, "compact.lock");
|
|
1228
|
+
let fd: number | null = null;
|
|
1229
|
+
if (dryRun) {
|
|
1230
|
+
if (existsSync(lock)) return { ...out, lockHeld: true, error: "已有 compact 在跑(锁存在);dry-run 只报告不持锁" };
|
|
1231
|
+
} else {
|
|
1232
|
+
try {
|
|
1233
|
+
fd = openSync(lock, "wx");
|
|
1234
|
+
writeSync(fd, String(process.pid));
|
|
1235
|
+
} catch (e) {
|
|
1236
|
+
return { ...out, lockHeld: true, error: "已有 compact 在跑(compact.lock O_EXCL 失败)" };
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
try {
|
|
1241
|
+
const mbBefore = await traceObjectsMB(store);
|
|
1242
|
+
let days = days0;
|
|
1243
|
+
let rounds = 0;
|
|
1244
|
+
const removedBranches: string[] = [];
|
|
1245
|
+
let opsStateCleaned = 0;
|
|
1246
|
+
let tagsRemoved = 0;
|
|
1247
|
+
let indexesRemoved = 0;
|
|
1248
|
+
|
|
1249
|
+
while (rounds < 3) {
|
|
1250
|
+
rounds++;
|
|
1251
|
+
const now = Date.now();
|
|
1252
|
+
const branches = await listSessionBranches(store);
|
|
1253
|
+
const cands = branches.filter((b) =>
|
|
1254
|
+
b.tipUnix < now - days * 86400 * 1000 && // tip 早于保留窗
|
|
1255
|
+
(b.mtimeMs === 0 || b.mtimeMs < now - keepActiveHours * 3600 * 1000)); // 且非活跃(mtime 保护)
|
|
1256
|
+
if (dryRun) {
|
|
1257
|
+
let tagsCandidate = 0;
|
|
1258
|
+
let indexesCandidate = 0;
|
|
1259
|
+
for (const b of cands) {
|
|
1260
|
+
tagsCandidate += (await listSessionTags(store, b.short)).length;
|
|
1261
|
+
indexesCandidate += listSessionIndexFiles(store, b.short).length;
|
|
1262
|
+
}
|
|
1263
|
+
return {
|
|
1264
|
+
...out, ok: true, rounds, daysUsed: days, branchesCandidate: cands.length,
|
|
1265
|
+
removedBranches: cands.map((b) => b.short), objectsMBBefore: mbBefore,
|
|
1266
|
+
opsStateCleaned: dryRun ? cleanupOrphanSidecars(opts.extConfigDir || "", days, true) : 0,
|
|
1267
|
+
tagsCandidate, indexesCandidate,
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
// 4. 候删分支逐个原子删除 + 同批删台账/state/tag/index(后二者:审计缺口补全)
|
|
1271
|
+
for (const b of cands) {
|
|
1272
|
+
const r = await runGit(store, ["update-ref", "-d", b.ref], { allowFail: true });
|
|
1273
|
+
if (r.code !== 0) { log(`[compact] 删 ${b.short} 失败:` + (r.stderr || "").split("\n")[0]); continue; }
|
|
1274
|
+
removedBranches.push(b.short);
|
|
1275
|
+
opsStateCleaned += removeSessionSidecarFiles(opts.extConfigDir || "", b.short);
|
|
1276
|
+
tagsRemoved += await removeSessionTags(store, b.short);
|
|
1277
|
+
indexesRemoved += removeSessionIndexFiles(store, b.short);
|
|
1278
|
+
}
|
|
1279
|
+
// 5. gc 回收不可达对象(hermetic env 同写入侧)
|
|
1280
|
+
await runGit(store, ["gc", "--prune=now", "--quiet"], { allowFail: true });
|
|
1281
|
+
// 6. 水位复核
|
|
1282
|
+
const mbNow = await traceObjectsMB(store);
|
|
1283
|
+
if (mbNow <= maxObjectsMB) {
|
|
1284
|
+
return {
|
|
1285
|
+
...out, ok: true, rounds, daysUsed: days, branchesCandidate: cands.length,
|
|
1286
|
+
branchesRemoved: removedBranches.length, removedBranches, opsStateCleaned, tagsRemoved, indexesRemoved,
|
|
1287
|
+
objectsMBBefore: mbBefore, objectsMBAfter: mbNow, reclaimedMB: Math.max(0, mbBefore - mbNow),
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
days = Math.floor(days / 2);
|
|
1291
|
+
if (days < 1) {
|
|
1292
|
+
return { ...out, ok: true, rounds, daysUsed: days0, branchesRemoved: removedBranches.length,
|
|
1293
|
+
removedBranches, opsStateCleaned, tagsRemoved, indexesRemoved, objectsMBBefore: mbBefore, objectsMBAfter: mbNow,
|
|
1294
|
+
reclaimedMB: Math.max(0, mbBefore - mbNow) };
|
|
1295
|
+
}
|
|
1296
|
+
log(`[compact] 水位超 ${maxObjectsMB}MB(现 ${mbNow.toFixed(0)}MB)→ days 减半为 ${days} 重跑`);
|
|
1297
|
+
}
|
|
1298
|
+
const mbFinal = await traceObjectsMB(store);
|
|
1299
|
+
return { ...out, ok: true, rounds, daysUsed: days, branchesRemoved: removedBranches.length,
|
|
1300
|
+
removedBranches, opsStateCleaned, tagsRemoved, indexesRemoved, objectsMBBefore: mbBefore, objectsMBAfter: mbFinal,
|
|
1301
|
+
reclaimedMB: Math.max(0, mbBefore - mbFinal) };
|
|
1302
|
+
} finally {
|
|
1303
|
+
// 7. 释放锁
|
|
1304
|
+
if (fd !== null) { try { closeSync(fd); } catch (e) {} }
|
|
1305
|
+
if (!dryRun) { try { unlinkSync(lock); } catch (e) {} }
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/** 收集已知追踪仓列表:state 快照的 cwd + workspace 配置的 paths(去重、过滤非追踪仓)。 */
|
|
1310
|
+
export function collectTraceRepos(extConfigDir: string): string[] {
|
|
1311
|
+
if (!extConfigDir) return [];
|
|
1312
|
+
const roots = new Set<string>();
|
|
1313
|
+
const pushIfStore = (p: string) => {
|
|
1314
|
+
try {
|
|
1315
|
+
const rp = pathResolve(p);
|
|
1316
|
+
if (storeOk(rp)) roots.add(rp);
|
|
1317
|
+
} catch (e) { /* 忽略坏路径 */ }
|
|
1318
|
+
};
|
|
1319
|
+
for (const dirName of ["cache/state", "state"]) {
|
|
1320
|
+
const dir = pathJoin(extRootOf(extConfigDir), dirName);
|
|
1321
|
+
if (!existsSync(dir)) continue;
|
|
1322
|
+
let ents: any[] = [];
|
|
1323
|
+
try { ents = readdirSync(dir).filter((f) => f.endsWith(".json")); } catch (e) { continue; }
|
|
1324
|
+
for (const f of ents) {
|
|
1325
|
+
try {
|
|
1326
|
+
const j = JSON.parse(readFileSync(pathJoin(dir, f), "utf8"));
|
|
1327
|
+
if (j && typeof j.cwd === "string") pushIfStore(j.cwd);
|
|
1328
|
+
} catch (e) { /* 坏 json 跳过 */ }
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
for (const dirName of ["cache/workspaces", "workspaces"]) {
|
|
1332
|
+
const dir = pathJoin(extRootOf(extConfigDir), dirName);
|
|
1333
|
+
if (!existsSync(dir)) continue;
|
|
1334
|
+
let ents: any[] = [];
|
|
1335
|
+
try { ents = readdirSync(dir).filter((f) => f.endsWith(".json")); } catch (e) { continue; }
|
|
1336
|
+
for (const f of ents) {
|
|
1337
|
+
try {
|
|
1338
|
+
const j = JSON.parse(readFileSync(pathJoin(dir, f), "utf8"));
|
|
1339
|
+
for (const p of (j && Array.isArray(j.paths) ? j.paths : [])) {
|
|
1340
|
+
if (p && typeof p.path === "string") pushIfStore(p.path);
|
|
1341
|
+
}
|
|
1342
|
+
} catch (e) { /* 坏 json 跳过 */ }
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
return [...roots];
|
|
1346
|
+
}
|