@kienntpixon/dieu-phoi 0.7.4 → 0.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/wtgc.cjs ADDED
@@ -0,0 +1,756 @@
1
+ 'use strict';
2
+ // r1 s3 (core 0.7.6): `dieu-phoi wt gc [--repo R | --all-repos] [--yes] [--json] [--no-measure]`.
3
+ // Quét mọi worktree đăng ký trong git của mỗi repo (có dấu hay không), xếp vào lớp, báo dung lượng, và CHỈ khi --yes mới
4
+ // gỡ lớp `safe` (+ vỏ thư mục mồ côi, mục prunable, nhánh tạm đã merge không còn worktree). Dry-run mặc định.
5
+ //
6
+ // Lớp (class):
7
+ // safe wtSafety ok: nhánh đang checkout THẬT đã vào một ref bền bất kỳ (mergedAnywhere), sạch, không agent sống,
8
+ // không khoá thật; có dấu → tuổi ≥ 1 giờ và task đã có verdict pass/accept; không dấu (chỉ tên do wt add sinh /
9
+ // .claude/worktrees/agent-*) hoặc dấu thiếu verdict → nhàn rỗi > 24 giờ (phụ lục Q1).
10
+ // keep-slot slot giữ lại: tên tự đặt, dấu keep:true, có Library/ (Unity), nhánh lane/*, file .dieu-phoi-keep, hoặc khớp
11
+ // wt_keep trong config. KHÔNG BAO GIỜ tự gỡ (phụ lục Q3).
12
+ // abandoned chưa merge nhưng sạch, không agent sống, không khoá, và verdict reject cuối hoặc nhàn rỗi ≥ wt_abandon_days
13
+ // (14). CHỈ liệt kê ở bước này: lưu tag + gỡ thuộc s4 (`wt gc --abandoned --yes`).
14
+ // keep giữ kèm lý do: dirty, agent sống, khoá thật, detached, mới tạo, chưa nhàn rỗi đủ, chưa merge mà chưa bỏ dở...
15
+ // prunable thư mục đã mất (git worktree list: prunable) → `git worktree prune`.
16
+ // shell thư mục vỏ khớp mẫu tên tạm nhưng không đăng ký, chỉ còn junction/rỗng → removeShell; có file thật → keep.
17
+ // temp-branch nhánh tạm (dp/*, worktree-agent-*, <prefix>/<id8>) đã merge vào ref bền, không còn worktree → git branch -d.
18
+ // Dung lượng: ước lượng rẻ (lstat, không đi xuyên link/junction, trần thời gian mỗi mục và tổng); quá trần thì "≥ x".
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const core = require('../hook.cjs');
23
+ const agg = require('./aggregate.cjs');
24
+ const { ctxOf, repoOf } = require('./args.cjs');
25
+ const deps = require('./deps.cjs');
26
+ const repos = require('./repos.cjs');
27
+ const F = require('./wtfast.cjs');
28
+ const W = require('./worktree.cjs');
29
+
30
+ const HOUR_MS = 60 * 60 * 1000;
31
+ const DAY_MS = 24 * HOUR_MS;
32
+ const GC_TASK = 'wtgcauto'; // task_id đặt cho sự kiện gỡ worktree không dấu
33
+ const DEFAULT_MANUAL_BUDGET_MS = 55000; // chạy tay (không --auto): dư địa cho đĩa lạnh nhưng vẫn dưới 60 giây
34
+ const DEFAULT_BUDGET_MS = 25000; // ngân sách quét (seed + quét repo; hook cần cả lệnh dưới ~30 giây)
35
+ const DEFAULT_SEED_MS = 10000; // phần ngân sách dành cho seed sổ repo (--all-repos)
36
+ const DEFAULT_MEASURE_MS = 5000; // ngân sách đo dung lượng (toàn lệnh)
37
+ const ITEM_MEASURE_MS = 1500; // trần đo mỗi mục (quá thì báo "≥")
38
+ const ITEM_MEASURE_ENTRIES = 150000;
39
+ const REPO_GIT_PER_WT_MS = 1000; // --all-repos: cộng thêm cho mỗi worktree liên kết
40
+ const REPO_GIT_MAX_MS = 18000; // trần cứng mỗi repo (--all-repos): repo lớn như isle làm dần qua các lần chạy nhờ bộ nhớ đệm, không ăn hết ngân sách 30 giây
41
+ const REPO_GIT_MS = 6000; // trần thời gian cho MỖI repo (repo lạnh/chậm: phần còn lại của repo đó để lần sau)
42
+ const PREFETCH_CONC = 8; // số `git status` chạy song song trong tiền quét
43
+ const PREFETCH_CALL_MS = 30000; // trần mỗi `git status` của tiền quét (đĩa lạnh)
44
+ const GIT_CALL_MS = 4000; // trần mỗi lệnh git trong quét (repo git treo/chậm thì bỏ qua mục đó, không kéo cả lệnh)
45
+ const GC_SCANNED = 'gc-scanned.json'; // repo -> ts lần quét xong gần nhất: lần sau quét repo cũ nhất trước (ngân sách cắt thì lần sau làm tiếp)
46
+ const WT_CACHE = 'wt-gc-cache.json'; // bộ nhớ đệm bền: mỗi worktree -> { head, dirMs, dirty: {v,ts}, newest: {v,ts}, incomplete: {ts} }
47
+ // BẤT BIẾN AN TOÀN: đệm chỉ được rút ngắn kết quả ÂM (keep / idle-unknown). Không bao giờ dùng đệm để kết luận "sạch" hay "nhàn rỗi":
48
+ // một mục chỉ thành safe/abandoned sau git status + duyệt mtime + kiểm agent sống TƯƠI trong chính lượt quét (xem freshCheck).
49
+ const WT_CACHE_TTL_MS = 6 * 60 * 60 * 1000; // quá hạn thì tính lại (chặn đệm cũ ngầm quá lâu); đường gỡ (--yes) luôn kiểm lại, không dùng đệm
50
+ const SCAN_WALK_CAP = 20000; // quét: trần số mục duyệt mtime mỗi worktree (hết trần = nhàn rỗi không biết = giữ; duyệt xong thì được đệm nên chỉ tốn một lần)
51
+ const SCAN_WALK_MS = 8000;
52
+ const REMOVE_WALK_CAP = 60000; // đường gỡ --yes: duyệt kỹ hơn
53
+ const REMOVE_WALK_MS = 10000;
54
+ const DEFAULT_ABANDON_DAYS = 14;
55
+ const GC_LAST = 'gc-last.json';
56
+ const GC_LOG = 'gc-log.jsonl'; // r1 s5: mỗi lần gc tự động (verdict / hook / đĩa) một dòng
57
+ const GC_TOTALS = 'gc-totals.json'; // r1 s5: số liệu lớp cần duyệt của lần gc nền gần nhất (hook đọc để in cảnh báo thiếu đĩa)
58
+ // Lớp mà gc tự động được phép đụng. abandoned / keep-slot / keep KHÔNG BAO GIỜ nằm trong đây: dù nhánh gỡ sau này (0.7.7) thêm lớp abandoned, đường tự động vẫn bị chặn ở đây.
59
+ const AUTO_CLASSES = new Set(['safe', 'shell', 'prunable', 'temp-branch']);
60
+ const SEED_MAX_AGE_MS = 30 * 60 * 1000; // --all-repos: seed lại (sự kiện, run dir, thư mục cha) nếu lần seed trước cũ hơn 30 phút
61
+
62
+ const nrm = (p) => W.realKey(p);
63
+
64
+ // ---- đo dung lượng ----
65
+
66
+ /** Tổng byte (kích thước logic) dưới `dir`, không đi xuyên symlink/junction, dừng khi hết `ms`. { bytes, approx }. */
67
+ function measureDir(dir, ms, maxEntries) {
68
+ const end = Date.now() + ms;
69
+ let bytes = 0;
70
+ let seen = 0;
71
+ const stack = [dir];
72
+ while (stack.length) {
73
+ const d = stack.pop();
74
+ let ents;
75
+ try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch (_) { continue; }
76
+ for (const e of ents) {
77
+ const p = path.join(d, e.name);
78
+ if (e.isSymbolicLink()) continue;
79
+ if (e.isDirectory()) {
80
+ if (deps.isLink(p)) continue; // junction Windows
81
+ stack.push(p);
82
+ continue;
83
+ }
84
+ try { bytes += fs.lstatSync(p).size; } catch (_) { /* file vừa mất */ }
85
+ if ((++seen & 255) === 0 && (Date.now() > end || seen > (maxEntries || ITEM_MEASURE_ENTRIES))) return { bytes, approx: true };
86
+ }
87
+ if (Date.now() > end) return { bytes, approx: true };
88
+ }
89
+ return { bytes, approx: false };
90
+ }
91
+
92
+ function fmtBytes(n) {
93
+ if (!Number.isFinite(n)) return '?';
94
+ if (n >= 1073741824) return `${(n / 1073741824).toFixed(1)} GB`;
95
+ if (n >= 1048576) return `${Math.round(n / 1048576)} MB`;
96
+ if (n >= 1024) return `${Math.round(n / 1024)} KB`;
97
+ return `${n} B`;
98
+ }
99
+ const sizeStr = (it) => (it.bytes === null || it.bytes === undefined ? '-' : `${it.approx ? '≥ ' : ''}${fmtBytes(it.bytes)}${it.approx ? ' (đo một phần)' : ''}`);
100
+
101
+ // ---- cấu hình ----
102
+
103
+ /** wt_keep: mảng hoặc chuỗi phân tách bằng dấu phẩy/chấm phẩy; mỗi mẫu là đường dẫn đầy đủ, tên thư mục, hoặc glob có `*`. */
104
+ function keepPatterns(ctx) {
105
+ const raw = process.env.DIEU_PHOI_WT_KEEP !== undefined && process.env.DIEU_PHOI_WT_KEEP !== ''
106
+ ? process.env.DIEU_PHOI_WT_KEEP : ctx && ctx.config ? ctx.config.wt_keep : null;
107
+ if (!raw) return [];
108
+ const list = Array.isArray(raw) ? raw : String(raw).split(/[;,]/);
109
+ return list.map((s) => String(s).trim()).filter(Boolean).map((s) => {
110
+ const re = new RegExp(`^${s.replace(/\\/g, '/').replace(/[.+?^${}()|[\]]/g, '\\$&').replace(/\*/g, '.*')}$`, process.platform === 'win32' ? 'i' : '');
111
+ return re;
112
+ });
113
+ }
114
+
115
+ function matchesKeep(pats, wtPath) {
116
+ const full = String(wtPath).replace(/\\/g, '/').replace(/\/+$/, '');
117
+ const base = full.slice(full.lastIndexOf('/') + 1);
118
+ return pats.some((re) => re.test(full) || re.test(base));
119
+ }
120
+
121
+ /** Lý do slot giữ lại (chuỗi) hoặc null. Gồm các điều kiện Q3 của s1 + Library/, lane/*, .dieu-phoi-keep, wt_keep. */
122
+ function slotReason(main, w, mark, pats) {
123
+ const s1 = W.keepSlotReason(main, w.path, mark);
124
+ if (s1) return s1;
125
+ if (matchesKeep(pats, w.path)) return 'khớp wt_keep trong config';
126
+ if (w.branch && w.branch.startsWith('lane/')) return `nhánh ${w.branch} (lane/*) là slot giữ lại`;
127
+ try { if (fs.statSync(path.join(w.path, 'Library')).isDirectory()) return 'có thư mục Library/ (Unity) — slot giữ lại'; } catch (_) { /* không có */ }
128
+ if (fs.existsSync(path.join(w.path, '.dieu-phoi-keep'))) return 'có file .dieu-phoi-keep';
129
+ return null;
130
+ }
131
+
132
+ // ---- quét một repo ----
133
+
134
+ function verdictIndex(events) {
135
+ const last = new Map();
136
+ const discarded = new Set();
137
+ for (const e of events) {
138
+ if (!e || typeof e.task_id !== 'string') continue;
139
+ if (e.kind === 'verdict') last.set(e.task_id, agg.normalizeVerdict(e).outcome);
140
+ else if (e.kind === 'discard') discarded.add(e.task_id);
141
+ }
142
+ return { last, discarded };
143
+ }
144
+
145
+ function listHeads(main) {
146
+ const r = W.gitRun(main, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']);
147
+ return r.ok ? r.out.split(/\r?\n/).filter(Boolean) : [];
148
+ }
149
+
150
+ /** Vỏ thư mục khớp mẫu tên tạm nhưng không đăng ký trong git. */
151
+ function orphanShells(main, regKeys) {
152
+ const out = [];
153
+ const consider = (dir, re) => {
154
+ let names = [];
155
+ try { names = fs.readdirSync(dir); } catch (_) { return; }
156
+ for (const n of names) {
157
+ if (!re.test(n)) continue;
158
+ const p = path.join(dir, n);
159
+ if (regKeys.has(nrm(p))) continue;
160
+ try { if (!fs.lstatSync(p).isDirectory()) continue; } catch (_) { continue; }
161
+ out.push(p);
162
+ }
163
+ };
164
+ const mainBase = path.basename(main).replace(/[.+?^${}()|[\]\\]/g, '\\$&');
165
+ consider(path.dirname(main), new RegExp(`^${mainBase}-wt-[a-z0-9]{8}$`, process.platform === 'win32' ? 'i' : ''));
166
+ consider(path.join(main, '.claude', 'worktrees'), /^agent-/);
167
+ return out;
168
+ }
169
+
170
+ /** Vỏ chỉ còn junction/thư mục rỗng (đệ quy) thì xoá được; có file thật → false. */
171
+ function shellIsEmptyish(dir) {
172
+ if (deps.isLink(dir)) return true;
173
+ let ents;
174
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return false; }
175
+ for (const e of ents) {
176
+ const p = path.join(dir, e.name);
177
+ if (e.isSymbolicLink() || deps.isLink(p)) continue;
178
+ if (e.isDirectory()) { if (!shellIsEmptyish(p)) return false; continue; }
179
+ return false;
180
+ }
181
+ return true;
182
+ }
183
+
184
+ function loadWtCache(ctx) {
185
+ try { const j = JSON.parse(fs.readFileSync(path.join(ctx.paths.data, WT_CACHE), 'utf8')); return j && j.wts ? j : { wts: {} }; } catch (_) { return { wts: {} }; }
186
+ }
187
+ function saveWtCache(ctx, cache) {
188
+ const keep = {};
189
+ const now = Date.now();
190
+ for (const [k, v] of Object.entries(cache.wts || {})) if (v && now - Math.max((v.dirty && v.dirty.ts) || 0, (v.newest && v.newest.ts) || 0, (v.incomplete && v.incomplete.ts) || 0) < 7 * WT_CACHE_TTL_MS) keep[k] = v;
191
+ try { fs.writeFileSync(path.join(ctx.paths.data, WT_CACHE), JSON.stringify({ version: 1, wts: keep })); } catch (_) { /* best effort */ }
192
+ }
193
+ /** Đệm còn hiệu lực của worktree (HEAD sha và mtime thư mục worktree không đổi, chưa quá hạn): { dirty?: true, newest?: number|null, incomplete?: true } hoặc null.
194
+ * Chỉ dùng để ra kết quả ÂM nhanh (bẩn / mới hoạt động / không biết nhàn rỗi = giữ); kết quả dương luôn được kiểm lại tươi. */
195
+ function cacheOf(st, key, head, dirMs) {
196
+ const e = st.wtCache && st.wtCache.wts[key];
197
+ if (!e || !head || e.head !== head || e.dirMs !== dirMs) return null;
198
+ const fresh = (f) => f && Date.now() - f.ts < WT_CACHE_TTL_MS;
199
+ const out = {};
200
+ if (fresh(e.dirty) && e.dirty.v === true) out.dirty = true;
201
+ if (fresh(e.newest) && e.newest.v !== undefined) out.newest = e.newest.v;
202
+ else if (fresh(e.incomplete)) out.incomplete = true; // lần trước duyệt hết trần của MỘT worktree: không thử lại trong hạn đệm (nhàn rỗi vẫn không biết = giữ)
203
+ return out.dirty === undefined && out.newest === undefined && !out.incomplete ? null : out;
204
+ }
205
+
206
+ /** sf có thể cho kết quả dương (safe hoặc abandoned) không. */
207
+ function positiveOf(sf, w, gone, abandonMs) {
208
+ if (sf.ok) return true;
209
+ return !sf.merged && sf.dirty === false && !sf.live && !sf.lock && !sf.idle_unknown && !!w.branch && !w.detached
210
+ && (gone || (sf.idle_ms !== null && sf.idle_ms >= abandonMs));
211
+ }
212
+ /** sf đến từ kiểm tươi đầy đủ: git status thật cho kết quả sạch và duyệt mtime đầy đủ thật (không đệm, không bị cắt). */
213
+ function freshCheck(sf) {
214
+ return sf.dirty === false && !sf.dirty_cached && !sf.dirty_skipped && !!sf.walk && sf.walk.complete === true && sf.walk.cached !== true;
215
+ }
216
+
217
+ /** Quét một repo → { repo, items, error }. Chỉ đọc. `st` = { deadline, events, idx } dùng chung. */
218
+ function scanRepo(ctx, repoDir, st) {
219
+ const res = { repo: repoDir, main: null, items: [], error: null };
220
+ let info;
221
+ try { info = F.mainOf(repoDir); } catch (e) { res.error = e.message; return res; }
222
+ const { main, common } = info;
223
+ res.main = main;
224
+ // Đọc sổ worktree và tên nhánh từ .git (không gọi git); chỉ gọi git khi repo có worktree liên kết hoặc nhánh tạm.
225
+ let reg;
226
+ try { reg = F.registeredFs(main, common); } catch (e) { res.error = e.message; return res; }
227
+ // --all-repos: trần mỗi repo tăng theo số worktree liên kết (repo nhiều worktree như isle cần ~1 giây/worktree trên đĩa lạnh).
228
+ if (st.allRepos && st.repoStart) {
229
+ st.repoDeadline = Math.min(st.deadline, st.repoStart + Math.min(REPO_GIT_MAX_MS, REPO_GIT_MS + REPO_GIT_PER_WT_MS * reg.length));
230
+ W.setGitDeadline(st.repoDeadline, GIT_CALL_MS);
231
+ }
232
+ const tempNames = F.localHeadNames(common).filter((b) => W.isTempBranch(b));
233
+ // Repo không có worktree liên kết và không có nhánh tạm: không có gì để phân lớp ngoài thư mục vỏ mồ côi -> bỏ sớm, O(1).
234
+ if (reg.length <= 1 && !tempNames.length) {
235
+ for (const p of orphanShells(main, new Set(reg.map((r) => nrm(r.path))))) {
236
+ const ok = shellIsEmptyish(p);
237
+ res.items.push({
238
+ repo: main, path: p, branch: null, class: ok ? 'shell' : 'keep', marked: false, task_id: null, bytes: null, approx: false, idle_days: null,
239
+ merged_into: [], why: ok ? 'thư mục vỏ không đăng ký, chỉ còn junction/rỗng' : 'thư mục không đăng ký nhưng còn file thật: chỉ báo, xoá tay',
240
+ });
241
+ }
242
+ return res;
243
+ }
244
+ let facts = null;
245
+ const factsOf = () => { if (!facts) facts = F.makeFacts(main, reg); return facts; };
246
+ const marks = new Map();
247
+ for (const m of W.markedWorktrees(common)) if (m.path) marks.set(nrm(m.path), m);
248
+ const pats = keepPatterns(ctx);
249
+ const abandonMs = W.numSetting(ctx, 'DIEU_PHOI_WT_ABANDON_DAYS', 'wt_abandon_days', DEFAULT_ABANDON_DAYS) * DAY_MS;
250
+ const regKeys = new Set(reg.map((r) => nrm(r.path)));
251
+ const checkedOut = new Set(reg.map((r) => r.branch).filter(Boolean));
252
+ const walkCap = W.numSetting(ctx, 'DIEU_PHOI_WT_WALK_CAP', 'wt_walk_cap', SCAN_WALK_CAP);
253
+ const walkMs = W.numSetting(ctx, 'DIEU_PHOI_WT_WALK_MS', 'wt_walk_ms', SCAN_WALK_MS);
254
+ const dirMsOf = (p) => { try { return fs.statSync(p).mtimeMs; } catch (_) { return null; } };
255
+ const cacheFor = (w) => (st.wtCache && w.branch && !w.detached ? cacheOf(st, nrm(w.path), factsOf().shaOf(w.branch), dirMsOf(w.path)) : null);
256
+
257
+ // Tiền quét song song `git status` cho ứng viên có thể thành safe/abandoned (chỉ tín hiệu git rẻ: tuổi commit cuối là cận trên
258
+ // của nhàn rỗi, nên commit chưa đủ cũ thì chắc chắn không phải ứng viên). Việc còn lại của wtSafety vẫn đồng bộ.
259
+ try {
260
+ const cand = [];
261
+ for (const w of reg) {
262
+ if (nrm(w.path) === nrm(main) || w.prunable || !w.branch || w.detached || (w.locked !== null && w.locked !== undefined)) continue;
263
+ if (!fs.existsSync(w.path) || slotReason(main, w, marks.get(nrm(w.path)) || null, pats)) continue;
264
+ const cm = factsOf().commitMs(w.branch);
265
+ if (cm === null || cm === undefined) continue;
266
+ const base = Date.now() - cm;
267
+ if (base >= (factsOf().mergedOf(w.branch).merged ? HOUR_MS : abandonMs)) { const c = cacheFor(w); if (!(c && c.dirty === true)) cand.push(w.path); }
268
+ }
269
+ if (cand.length > 1) W.prefetchDirty(cand, { deadline: Math.min(st.deadline, st.repoDeadline || Infinity), concurrency: PREFETCH_CONC, callMs: PREFETCH_CALL_MS });
270
+ } catch (_) { /* tiền quét chỉ là tối ưu */ }
271
+
272
+ for (const w of reg) {
273
+ if (nrm(w.path) === nrm(main)) continue;
274
+ const mark = marks.get(nrm(w.path)) || null;
275
+ const item = {
276
+ repo: main, path: w.path, branch: w.branch || null, class: 'keep', why: null, marked: !!mark,
277
+ task_id: mark ? mark.task_id : null, bytes: null, approx: false, idle_days: null, merged_into: [], _w: w, _mark: mark,
278
+ };
279
+ res.items.push(item);
280
+ if (w.prunable || !fs.existsSync(w.path)) {
281
+ item.class = w.locked !== null && w.locked !== undefined ? 'keep' : 'prunable';
282
+ item.why = item.class === 'prunable' ? 'thư mục đã mất (git worktree prune)' : 'thư mục mất nhưng đang khoá';
283
+ continue;
284
+ }
285
+ const slot = slotReason(main, w, mark, pats);
286
+ if (slot) { item.class = 'keep-slot'; item.why = slot; continue; }
287
+ if (Date.now() > st.deadline || Date.now() > (st.repoDeadline || Infinity)) { item.why = 'chưa quét kịp (hết ngân sách thời gian), lần sau'; continue; }
288
+ // `git status` đắt (repo Unity: hàng chục giây): chỉ chạy khi nó còn đổi được phân lớp (ứng viên safe hoặc abandoned).
289
+ const gone = mark && (st.idx.discarded.has(mark.task_id) || st.idx.last.get(mark.task_id) === 'reject');
290
+ const minIdle = mark ? 0 : DAY_MS;
291
+ const sopts = {
292
+ reg, walkCap, walkMs, cache: cacheFor(w) || undefined,
293
+ // duyệt file chỉ khi còn đổi được phân lớp: đã merge và cận trên ≥ 1 giờ, hoặc chưa merge và cận trên ≥ ngưỡng bỏ dở.
294
+ // (không dấu cần nhàn rỗi ≥ 24 giờ nên commit mới hơn 24 giờ thì khỏi duyệt: idle ≤ tuổi commit)
295
+ walkIdleIf: (base, m) => base !== null && base >= (m && m.merged ? (mark ? HOUR_MS : DAY_MS) : abandonMs),
296
+ mergedOf: (b) => factsOf().mergedOf(b),
297
+ commitMsOf: (b) => factsOf().commitMs(b),
298
+ dirtyNeeded: (r, m) => !r.live && !(r.lock && !r.lock.stale) && !w.detached && !!w.branch
299
+ && ((m.merged && (minIdle === 0 || (r.idle_ms !== null && r.idle_ms >= minIdle)))
300
+ || (!m.merged && (gone || (r.idle_ms !== null && r.idle_ms >= abandonMs)))),
301
+ };
302
+ let sf = mark ? W.safetyOfMarked(ctx, main, mark, sopts, reg) : W.wtSafety(ctx, main, w, sopts);
303
+ // Lô chỉ thấy vài nhánh bền: nếu có vẻ bỏ dở mà lô nói "chưa merge", hỏi lại đầy đủ (git contains) trước khi gán nhãn.
304
+ if (!sf.merged && w.branch && !w.detached && sf.idle_ms !== null && sf.idle_ms >= abandonMs && W.mergedAnywhere(main, w.branch, reg).merged) {
305
+ const full = { reg, walkCap, walkMs, cache: sopts.cache, walkIdleIf: sopts.walkIdleIf, dirtyNeeded: sopts.dirtyNeeded, commitMsOf: sopts.commitMsOf };
306
+ sf = mark ? W.safetyOfMarked(ctx, main, mark, full, reg) : W.wtSafety(ctx, main, w, full);
307
+ }
308
+ // Bất biến: kết quả dương (safe hoặc abandoned) chỉ được nhận sau kiểm TƯƠI trong lượt này: git status thật (không đệm),
309
+ // duyệt mtime đầy đủ thật (không đệm, không bỏ qua), agent sống kiểm lại. Chưa đủ thì kiểm lại tươi; không xong trong trần -> idle-unknown = keep.
310
+ if (positiveOf(sf, w, gone, abandonMs) && !freshCheck(sf)) {
311
+ const fresh = { reg, walkCap, walkMs, mergedOf: sopts.mergedOf, commitMsOf: sopts.commitMsOf };
312
+ sf = mark ? W.safetyOfMarked(ctx, main, mark, fresh, reg) : W.wtSafety(ctx, main, w, fresh);
313
+ sf.rechecked_fresh = true;
314
+ }
315
+ item.branch = sf.branch_now || item.branch;
316
+ item.merged_into = sf.merged_into || [];
317
+ item.idle_days = sf.idle_ms === null || sf.idle_ms === undefined ? null : Math.floor(sf.idle_ms / DAY_MS);
318
+ item._sf = sf;
319
+ if (st.wtCache && w.branch && !w.detached) {
320
+ const key = nrm(w.path);
321
+ const head = factsOf().shaOf(w.branch);
322
+ const dirMs = dirMsOf(w.path);
323
+ const prev = st.wtCache.wts[key];
324
+ const same = prev && prev.head === head && prev.dirMs === dirMs;
325
+ const e = same ? prev : { head, dirMs };
326
+ const now = Date.now();
327
+ if (!same) { delete e.clean; delete e.dirty; delete e.newest; delete e.incomplete; }
328
+ delete e.clean; // định dạng cũ: "sạch" từng được đệm, nay không bao giờ tin
329
+ if (sf.dirty === true && !sf.dirty_cached) e.dirty = { v: true, ts: now };
330
+ else if (sf.dirty === false) delete e.dirty;
331
+ if (sf.walk && sf.walk.complete && !sf.walk.cached) { e.newest = { v: sf.walk.newest, ts: now }; delete e.incomplete; }
332
+ else if (sf.walk && !sf.walk.complete && !sf.walk.cached && now <= (st.repoDeadline || st.deadline)) e.incomplete = { ts: now }; // trần của worktree này, không phải hạn chót chung
333
+ if (head && dirMs !== null && (e.dirty || e.newest || e.incomplete)) st.wtCache.wts[key] = e;
334
+ }
335
+ if (sf.ok) {
336
+ item.class = 'safe';
337
+ item.why = `đã vào ${sf.merged_into.slice(0, 3).join(', ')}; sạch; nhàn rỗi ${sf.idle_ms === null ? '?' : Math.floor(sf.idle_ms / HOUR_MS)} giờ`;
338
+ if (mark) {
339
+ const v = st.idx.last.get(mark.task_id);
340
+ const age = W.ageMsOf(mark);
341
+ if (age < W.MERGED_MIN_AGE_MS) { item.class = 'keep'; item.why = 'mới tạo dưới 1 giờ'; }
342
+ else if (v === undefined || W.NOT_DONE.has(v)) {
343
+ // thiếu verdict pass/accept: coi như không dấu (cần nhàn rỗi > 24 giờ).
344
+ if (sf.idle_ms === null || sf.idle_ms < DAY_MS) {
345
+ item.class = 'keep';
346
+ item.why = `${v === undefined ? 'chưa có verdict' : `verdict ${v}`} và chưa nhàn rỗi đủ 24 giờ`;
347
+ }
348
+ }
349
+ }
350
+ continue;
351
+ }
352
+ item.why = sf.why;
353
+ // abandoned: chưa merge, sạch (kiểm tươi), không agent sống, không khoá, có nhánh, nhàn rỗi biết được.
354
+ if (positiveOf(sf, w, gone, abandonMs) && !sf.merged && freshCheck(sf)) {
355
+ item.class = 'abandoned';
356
+ item.why = `nhánh ${w.branch} chưa vào nhánh bền nào; sạch; ${gone ? 'task bị loại/discard' : `nhàn rỗi ≥ ${Math.round(abandonMs / DAY_MS)} ngày`} (s4: tag dp-archive rồi gỡ, cần duyệt)`;
357
+ }
358
+ }
359
+
360
+ for (const p of orphanShells(main, regKeys)) {
361
+ const ok = shellIsEmptyish(p);
362
+ res.items.push({
363
+ repo: main, path: p, branch: null, class: ok ? 'shell' : 'keep', marked: false, task_id: null, bytes: null, approx: false, idle_days: null,
364
+ merged_into: [], why: ok ? 'thư mục vỏ không đăng ký, chỉ còn junction/rỗng' : 'thư mục không đăng ký nhưng còn file thật: chỉ báo, xoá tay',
365
+ });
366
+ }
367
+
368
+ // Nhánh tạm đã merge, không còn worktree nào checkout.
369
+ for (const b of tempNames) {
370
+ if (checkedOut.has(b)) continue;
371
+ if (Date.now() > st.deadline || Date.now() > (st.repoDeadline || Infinity)) break;
372
+ const m = factsOf().mergedOf(b);
373
+ if (!m.merged) continue;
374
+ res.items.push({
375
+ repo: main, path: null, branch: b, class: 'temp-branch', marked: false, task_id: null, bytes: 0, approx: false, idle_days: null,
376
+ merged_into: m.into, why: `nhánh tạm đã vào ${m.into.slice(0, 3).join(', ')}, không còn worktree`,
377
+ });
378
+ }
379
+ return res;
380
+ }
381
+
382
+ // ---- gỡ ----
383
+
384
+ const RM_OPTS = { walkCap: REMOVE_WALK_CAP, walkMs: REMOVE_WALK_MS }; // gỡ thật: kiểm lại từ đầu (không dùng bộ nhớ đệm), duyệt file kỹ hơn
385
+
386
+ /** Gỡ một worktree `safe`: kiểm lại wtSafety, mở khoá mồ côi, gỡ junction trước, git worktree remove (không --force). */
387
+ function removeSafe(ctx, main, it, st) {
388
+ const w = it._w;
389
+ const reg = W.registeredOf(main);
390
+ const e = W.registeredEntry(main, w.path, reg);
391
+ if (!e) return { removed: false, msg: `giữ ${w.path}: không còn đăng ký trong git` };
392
+ const s = it._mark ? W.safetyOfMarked(ctx, main, it._mark, RM_OPTS, reg) : W.wtSafety(ctx, main, e, RM_OPTS);
393
+ if (!s.ok) return { removed: false, msg: `giữ ${w.path}: ${s.why}` };
394
+ if (s.lock && s.lock.stale) {
395
+ const u = W.gitRun(main, ['worktree', 'unlock', w.path]);
396
+ if (!u.ok) return { removed: false, msg: `giữ ${w.path}: không mở được khoá mồ côi (${u.err || 'không rõ'})` };
397
+ }
398
+ const depPath = it._mark && it._mark.deps_path ? it._mark.deps_path : '.';
399
+ let links;
400
+ try { links = deps.unlinkDepLinks(w.path, [depPath]); } catch (err) {
401
+ return { removed: false, msg: `giữ ${w.path}: không gỡ được junction node_modules (${err.message}); không gỡ để tránh xoá xuyên junction` };
402
+ }
403
+ const r = W.gitRun(main, ['worktree', 'remove', w.path]);
404
+ if (!r.ok) { deps.relink(links); return { removed: false, msg: `giữ ${w.path}: git worktree remove lỗi (${r.err || 'không rõ'})` }; }
405
+ let tail = '';
406
+ if (fs.existsSync(w.path) || deps.isLink(w.path)) {
407
+ try { deps.removeShell(w.path); } catch (err) { tail = `; còn vỏ thư mục chưa xoá được (${err.message})`; }
408
+ }
409
+ W.recordWt(ctx, it.task_id || GC_TASK, { action: 'remove', path: w.path, repo: main, reason: 'gc-safe', trigger: st.trigger || undefined });
410
+ // `git branch -d` CHỈ cho nhánh tạm (tự từ chối nếu chưa merge vào HEAD/upstream).
411
+ const names = new Set([s.branch_now, it._mark && it._mark.branch].filter(Boolean));
412
+ const msgs = [];
413
+ for (const b of names) {
414
+ if (!W.isTempBranch(b)) continue;
415
+ if (!W.mergedAnywhere(main, b).merged) { msgs.push(`giữ nhánh ${b}: chưa vào nhánh bền nào`); continue; }
416
+ const d = W.gitRun(main, ['branch', '-d', b]);
417
+ if (d.ok) noteBranch(ctx, main, st, it.task_id, b, 'gc-safe');
418
+ msgs.push(d.ok ? `đã xoá nhánh ${b}` : `giữ nhánh ${b}: git branch -d từ chối (${(d.err || 'không rõ').split(/\r?\n/)[0]})`);
419
+ }
420
+ return { removed: true, msg: `đã gỡ ${w.path}${msgs.length ? `; ${msgs.join('; ')}` : ''}${tail}` };
421
+ }
422
+
423
+ /** Mỗi lần gỡ tự động là một sự kiện riêng (action khác add/remove để không nhiễu reposOfTask); tên nhánh vào tóm tắt wt_gc_auto. */
424
+ function noteWt(ctx, st, taskId, ev) {
425
+ if (st.auto !== true) return;
426
+ try { W.recordWt(ctx, taskId || GC_TASK, Object.assign({}, ev, { trigger: st.trigger || undefined })); } catch (_) { /* sự kiện: best effort */ }
427
+ }
428
+
429
+ function noteBranch(ctx, main, st, taskId, branch, reason) {
430
+ if (st.auto !== true) return;
431
+ st.removedBranches.push(branch);
432
+ noteWt(ctx, st, taskId, { action: 'branch-delete', branch, repo: main, reason });
433
+ }
434
+
435
+ function applyRepo(ctx, rr0, st) {
436
+ const lines = [];
437
+ let removed = 0;
438
+ const main = rr0.main;
439
+ // verdict: chỉ lớp safe (worktree + nhánh đã merge); worktree của task vừa nghiệm thu mà chưa merge giữ nguyên cả cây lẫn nhánh
440
+ const allowedAuto = st.trigger === 'verdict' ? new Set(['safe']) : AUTO_CLASSES;
441
+ const rr = st.auto === true ?Object.assign({}, rr0, { items: rr0.items.filter((i) => allowedAuto.has(i.class)) }) : rr0; // tự động: chỉ lớp safe
442
+ for (const it of rr.items) {
443
+ if (st.shouldStop()) break;
444
+ if (it.class === 'safe') {
445
+ const r = removeSafe(ctx, main, it, st);
446
+ lines.push(r.msg);
447
+ it.done = r.removed;
448
+ if (r.removed) { removed++; st.reclaimed += it.bytes || 0; }
449
+ } else if (it.class === 'shell') {
450
+ try { deps.removeShell(it.path); it.done = true; removed++; lines.push(`đã xoá vỏ ${it.path}`); noteWt(ctx, st, null, { action: 'remove-shell', path: it.path, repo: main, reason: 'gc-shell' }); } catch (e) { lines.push(`giữ vỏ ${it.path}: ${e.message}`); }
451
+ }
452
+ }
453
+ if (rr.items.some((i) => i.class === 'prunable')) {
454
+ const r = W.gitRun(main, ['worktree', 'prune']);
455
+ for (const i of rr.items) {
456
+ if (i.class !== 'prunable') continue;
457
+ i.done = r.ok;
458
+ if (r.ok) { removed++; noteWt(ctx, st, null, { action: 'prune', path: i.path, repo: main, reason: 'gc-prune' }); }
459
+ }
460
+ lines.push(r.ok ? 'đã git worktree prune (mục có thư mục mất)' : `git worktree prune lỗi: ${r.err}`);
461
+ }
462
+ // Nhánh tạm: tính lại sau khi gỡ worktree (nhánh vừa mở khoá khỏi worktree có thể -d được).
463
+ const still = new Set(W.registeredOf(main).map((r) => r.branch).filter(Boolean));
464
+ for (const it of rr.items.filter((i) => i.class === 'temp-branch')) {
465
+ if (still.has(it.branch)) continue;
466
+ if (!W.mergedAnywhere(main, it.branch).merged) continue;
467
+ const d = W.gitRun(main, ['branch', '-d', it.branch]);
468
+ it.done = d.ok;
469
+ if (d.ok) { removed++; noteBranch(ctx, main, st, null, it.branch, 'gc-temp-branch'); }
470
+ lines.push(d.ok ? `đã xoá nhánh tạm ${it.branch}` : `giữ nhánh ${it.branch}: git branch -d từ chối (${(d.err || 'không rõ').split(/\r?\n/)[0]})`);
471
+ }
472
+ return { lines, removed };
473
+ }
474
+
475
+ // ---- lệnh ----
476
+
477
+ /** Repo không có worktree liên kết lẫn nhánh tạm không cần git: quét trước (tức thì); repo cần git sau, theo thứ tự đã sắp. */
478
+ function needGitLast(dirs, limitMs) {
479
+ const cheap = [];
480
+ const dear = [];
481
+ const end = Date.now() + (limitMs || 3000);
482
+ for (const d of dirs) {
483
+ let need = true;
484
+ if (Date.now() > end) { cheap.push(d); continue; } // hết thời gian sắp xếp: phần còn lại giữ thứ tự cũ
485
+ try {
486
+ const i = F.mainOf(d);
487
+ need = F.registeredFs(i.main, i.common).length > 1 || F.localHeadNames(i.common).some((b) => W.isTempBranch(b));
488
+ } catch (_) { need = false; }
489
+ (need ? dear : cheap).push(d);
490
+ }
491
+ return cheap.concat(dear);
492
+ }
493
+
494
+ function readScanned(ctx) {
495
+ try { return JSON.parse(fs.readFileSync(path.join(ctx.paths.data, GC_SCANNED), 'utf8')) || {}; } catch (_) { return {}; }
496
+ }
497
+
498
+ function reposToScan(ctx, opts, t0, seedMs, sink) {
499
+ if (Array.isArray(opts.repoDirs)) return opts.repoDirs.slice(); // verdict accept: chỉ repo của các task vừa nghiệm thu
500
+ if (opts['all-repos'] === true) {
501
+ try {
502
+ const r = repos.autoSeedIfStale(ctx.paths.data, {
503
+ eventsFile: ctx.paths.events, runsDir: core.runsDir(), maxAgeMs: SEED_MAX_AGE_MS, deadline: t0 + seedMs,
504
+ });
505
+ sink.seed = r;
506
+ } catch (e) { sink.seed = { seeded: false, error: e.message }; }
507
+ const seen = readScanned(ctx);
508
+ sink.scanned = seen;
509
+ // repo chưa quét / quét lâu nhất trước; cùng mức thì theo thứ tự gần đây của sổ repo.
510
+ return repos.listRepos(ctx.paths.data).map((r, i) => { const v = seen[r.path]; return { p: r.path, i, t: v && typeof v === 'object' ? Number(v.ts) || 0 : Number(v) || 0, c: v && typeof v === 'object' ? Number(v.ms) || 0 : 0 }; })
511
+ .sort((x, y) => x.t - y.t || x.c - y.c || x.i - y.i).map((x) => x.p);
512
+ }
513
+ return [repoOf(opts)];
514
+ }
515
+
516
+ function render(rep) {
517
+ const out = [];
518
+ const mode = rep.mode === 'apply' ? 'đã thực hiện' : 'dry-run';
519
+ out.push(`wt gc (${mode}): ${rep.repos.length} repo, ${rep.counts.safe} safe, ${rep.counts['keep-slot']} keep-slot, ${rep.counts.abandoned} abandoned, ${rep.counts.keep} keep`
520
+ + `${rep.counts.prunable ? `, ${rep.counts.prunable} prunable` : ''}${rep.counts.shell ? `, ${rep.counts.shell} vỏ` : ''}${rep.counts['temp-branch'] ? `, ${rep.counts['temp-branch']} nhánh tạm` : ''}`
521
+ + ` — ${rep.elapsed_ms} ms${rep.truncated ? ' (quét bị cắt theo ngân sách)' : ''}`);
522
+ if (rep.errors && rep.errors.length) out.push(`LỖI: ${rep.errors.join('; ')} (phần dưới là kết quả quét được trước khi lỗi)`);
523
+ if (rep.timing) {
524
+ const sd = rep.seed;
525
+ const ph = sd && sd.phases ? `; pha seed: ${Object.keys(sd.phases).map((k) => `${k}=${sd.phases[k]}ms`).join(' ')}` : '';
526
+ const cut = sd && sd.skipped && sd.skipped.length ? `; seed bị cắt, bỏ pha: ${sd.skipped.join(', ')}` : '';
527
+ out.push(`thời gian: seed ${rep.timing.seed_ms} ms, quét ${rep.timing.scan_ms} ms, đo dung lượng ${rep.timing.measure_ms} ms${cut}${ph}`);
528
+ }
529
+ const unscanned = rep.repos.filter((r) => !r.main && r.error).length;
530
+ if (rep.truncated) out.push(`CẮT THEO NGÂN SÁCH: ${unscanned} repo chưa quét kịp (lần sau quét repo cũ nhất trước); chỉnh wt_gc_budget_ms hoặc --budget-ms nếu cần.`);
531
+ for (const r of rep.repos) {
532
+ out.push(`\n${r.repo}${r.error ? ` [lỗi: ${r.error}]` : ''}`);
533
+ for (const it of r.items) {
534
+ const tag = it.done === true ? `${it.class}*` : it.class;
535
+ out.push(` ${tag.padEnd(12)} ${String(sizeStr(it)).padStart(9)} ${it.path || `(nhánh ${it.branch})`}${it.path && it.branch ? ` [${it.branch}]` : ''} — ${it.why}`);
536
+ }
537
+ for (const l of r.applied || []) out.push(` > ${l}`);
538
+ }
539
+ const t = rep.totals;
540
+ const lb = (n) => `≥ ${fmtBytes(n)} (đo một phần)`; // đo có hộp thời gian/số mục: luôn là cận dưới, không bao giờ là con số chính xác
541
+ out.push(`\nThu hồi được ngay (safe): ${lb(t.safe_bytes)}; bỏ dở cần duyệt (abandoned): ${lb(t.abandoned_bytes)}; slot giữ lại (không đụng): ${lb(t.keep_slot_bytes)}`);
542
+ if (rep.mode === 'apply') out.push(`Đã gỡ ${rep.removed} mục, giải phóng ~${fmtBytes(rep.reclaimed_bytes)}.`);
543
+ else if (rep.counts.safe + rep.counts.shell + rep.counts.prunable + rep.counts['temp-branch'] > 0) out.push('Dry-run: chưa xoá gì. Chạy lại với --yes để gỡ lớp safe (không đụng keep-slot / abandoned / keep).');
544
+ return out.join('\n');
545
+ }
546
+
547
+ /** Lõi: quét (và gỡ nếu apply) → báo cáo cấu trúc. Dùng chung cho CLI và hook (--auto). */
548
+ function runGc(ctx, opts) {
549
+ const t0 = Date.now();
550
+ const o = opts || {};
551
+ const budget = Number.isFinite(Number(o['budget-ms'])) && o['budget-ms'] !== true ? Number(o['budget-ms'])
552
+ : W.numSetting(ctx, 'DIEU_PHOI_WT_GC_BUDGET_MS', 'wt_gc_budget_ms', o.auto === true ? DEFAULT_BUDGET_MS : DEFAULT_MANUAL_BUDGET_MS);
553
+ const measureOn = o['no-measure'] !== true && (o.measure === true || o.auto !== true);
554
+ const apply = o.yes === true;
555
+ const seedMs = Math.min(budget, W.numSetting(ctx, 'DIEU_PHOI_WT_GC_SEED_MS', 'wt_gc_seed_ms', DEFAULT_SEED_MS));
556
+ const timing = { seed_ms: 0, scan_ms: 0, measure_ms: 0 };
557
+ const sink = {};
558
+ const progress = typeof o.onProgress === 'function' ? o.onProgress : () => {};
559
+ const st = {
560
+ deadline: t0 + budget, idx: { last: new Map(), discarded: new Set() }, reclaimed: 0,
561
+ shouldStop: () => false, auto: o.auto === true, trigger: o.trigger || null, removedBranches: [],
562
+ };
563
+ const reps = [];
564
+ let truncated = false;
565
+ const fatal = [];
566
+ try {
567
+ const dirs0 = reposToScan(ctx, o, t0, seedMs, sink);
568
+ timing.seed_ms = Date.now() - t0;
569
+ const dirs = o['all-repos'] === true ? needGitLast(dirs0, 3000) : dirs0;
570
+ const sk = sink.seed && sink.seed.skipped && sink.seed.skipped.length ? ` (bị cắt, bỏ pha: ${sink.seed.skipped.join(', ')})` : '';
571
+ progress(`seed ${timing.seed_ms} ms${sk}; ${dirs.length} repo`);
572
+ st.idx = verdictIndex(core.readEvents(ctx));
573
+ const scannedNow = {};
574
+ W.setGitDeadline(st.deadline, GIT_CALL_MS);
575
+ st.wtCache = o['no-cache'] === true || process.env.DIEU_PHOI_WT_GC_CACHE === '0' ? null : loadWtCache(ctx);
576
+ let n = 0;
577
+ for (const dir of dirs) {
578
+ n++;
579
+ if (Date.now() > st.deadline) { truncated = true; reps.push({ repo: dir, main: null, items: [], error: 'chưa quét kịp (hết ngân sách), lần sau' }); continue; }
580
+ const rt = Date.now();
581
+ // --repo tường minh: cả ngân sách cho repo đó (trần 6 giây/repo chỉ để một repo chậm không ăn hết lần quét --all-repos).
582
+ st.allRepos = o['all-repos'] === true;
583
+ st.repoStart = rt;
584
+ st.repoDeadline = o['all-repos'] === true ? Math.min(st.deadline, rt + REPO_GIT_MS) : st.deadline;
585
+ W.setGitDeadline(st.repoDeadline, GIT_CALL_MS);
586
+ let rr;
587
+ try { rr = scanRepo(ctx, dir, st); } catch (e) { rr = { repo: dir, main: null, items: [], error: e.message }; }
588
+ reps.push(rr);
589
+ if (!rr.error && Date.now() <= st.deadline) scannedNow[dir] = { ts: Date.now(), ms: Date.now() - rt };
590
+ else if (!rr.error) truncated = true;
591
+ progress(`[${n}/${dirs.length}] ${dir}: ${rr.error ? `lỗi ${rr.error}` : `${rr.items.length} mục`} (${Date.now() - rt} ms)`);
592
+ }
593
+ if (Date.now() > st.deadline) truncated = true;
594
+ if (o['all-repos'] === true) {
595
+ try { fs.writeFileSync(path.join(ctx.paths.data, GC_SCANNED), JSON.stringify(Object.assign({}, sink.scanned || {}, scannedNow))); } catch (_) { /* best effort */ }
596
+ }
597
+ } catch (e) {
598
+ fatal.push(e && e.message ? e.message : String(e));
599
+ truncated = true;
600
+ } finally {
601
+ if (st.wtCache) saveWtCache(ctx, st.wtCache);
602
+ W.setGitDeadline(0);
603
+ W.clearDirtyCache();
604
+ }
605
+ timing.scan_ms = Date.now() - t0 - timing.seed_ms;
606
+
607
+ // Đo dung lượng: lớp thu hồi trước, rồi slot, rồi phần còn lại; chung một ngân sách.
608
+ if (measureOn) {
609
+ const mStart = Date.now();
610
+ const itemMs = W.numSetting(ctx, 'DIEU_PHOI_WT_GC_ITEM_MS', 'wt_gc_item_ms', ITEM_MEASURE_MS);
611
+ const mEnd = Date.now() + W.numSetting(ctx, 'DIEU_PHOI_WT_GC_MEASURE_MS', 'wt_gc_measure_ms', DEFAULT_MEASURE_MS);
612
+ const order = { safe: 0, shell: 0, abandoned: 1, 'keep-slot': 2, keep: 3 };
613
+ const all = [].concat(...reps.map((r) => r.items)).filter((i) => i.path && i.bytes === null && fs.existsSync(i.path));
614
+ all.sort((a, b) => (order[a.class] === undefined ? 4 : order[a.class]) - (order[b.class] === undefined ? 4 : order[b.class]));
615
+ for (const it of all) {
616
+ const left = mEnd - Date.now();
617
+ if (left <= 0) break;
618
+ const m = measureDir(it.path, Math.min(itemMs, left), itemMs > ITEM_MEASURE_MS ? itemMs * 400 : ITEM_MEASURE_ENTRIES);
619
+ it.bytes = m.bytes;
620
+ it.approx = m.approx;
621
+ }
622
+ timing.measure_ms = Date.now() - mStart;
623
+ }
624
+
625
+ if (apply) {
626
+ for (const rr of reps) {
627
+ if (!rr.main) continue;
628
+ try {
629
+ const a = applyRepo(ctx, rr, st);
630
+ rr.applied = a.lines;
631
+ rr.removed = a.removed;
632
+ } catch (e) { rr.applied = [`lỗi khi gỡ: ${e.message}`]; }
633
+ }
634
+ }
635
+
636
+ const counts = { safe: 0, 'keep-slot': 0, abandoned: 0, keep: 0, prunable: 0, shell: 0, 'temp-branch': 0 };
637
+ const totals = { safe_bytes: 0, abandoned_bytes: 0, keep_slot_bytes: 0, approx: true, lower_bound: true, label: 'cận dưới (đo một phần)' };
638
+ for (const r of reps) {
639
+ for (const it of r.items) {
640
+ counts[it.class] = (counts[it.class] || 0) + 1;
641
+ if (it.approx) totals.approx = true;
642
+ if (it.class === 'safe' || it.class === 'shell') totals.safe_bytes += it.bytes || 0;
643
+ else if (it.class === 'abandoned') totals.abandoned_bytes += it.bytes || 0;
644
+ else if (it.class === 'keep-slot') totals.keep_slot_bytes += it.bytes || 0;
645
+ }
646
+ }
647
+ const clean = reps.map((r) => ({
648
+ repo: r.repo, main: r.main, error: r.error, applied: r.applied,
649
+ items: r.items.map((i) => ({
650
+ class: i.class, path: i.path, branch: i.branch, why: i.why, marked: i.marked, task_id: i.task_id,
651
+ bytes: i.bytes, approx: i.approx, idle_days: i.idle_days, merged_into: i.merged_into, done: i.done === true,
652
+ })),
653
+ }));
654
+ const removed = reps.reduce((n, r) => n + (r.removed || 0), 0);
655
+ const rep = {
656
+ mode: apply ? 'apply' : 'dry-run', repos: clean, counts, totals, truncated, removed, reclaimed_bytes: st.reclaimed,
657
+ elapsed_ms: Date.now() - t0, timing, seed: sink.seed || null, errors: fatal,
658
+ };
659
+ if (apply && o.trigger && removed > 0) {
660
+ // (d) gỡ tự động luôn thành sự kiện: `state` đọc kind=wt_gc_auto; từng worktree cũng có sự kiện worktree/remove kèm trigger
661
+ const paths = [].concat(...reps.map((r) => r.items.filter((i) => i.done === true && AUTO_CLASSES.has(i.class) && i.path).map((i) => i.path))).slice(0, 20);
662
+ const branches = st.removedBranches.slice(0, 20);
663
+ try {
664
+ core.appendEvent(ctx, Object.assign({
665
+ schema: core.SCHEMA, kind: 'wt_gc_auto', ts: core.nowIso(), agent_id: `wtgc:${o.trigger}`, task_id: GC_TASK,
666
+ trigger: o.trigger, removed, reclaimed_bytes: st.reclaimed, repos: reps.length, paths, branches, elapsed_ms: rep.elapsed_ms,
667
+ }, core.commonFields(ctx)));
668
+ } catch (_) { /* sự kiện: best effort */ }
669
+ }
670
+ if (apply) {
671
+ try {
672
+ fs.writeFileSync(path.join(ctx.paths.data, GC_LAST), JSON.stringify({ ts: Date.now(), removed, reclaimed_bytes: st.reclaimed, repos: reps.length }));
673
+ } catch (_) { /* dấu thời gian: best effort */ }
674
+ }
675
+ return rep;
676
+ }
677
+
678
+ /**
679
+ * r1 s5: gc TỰ ĐỘNG lớp safe (verdict accept / hook UserPromptSubmit / đĩa thấp). Chỉ chạy khi wt_auto_gc != off.
680
+ * Việc quyết định "safe" vẫn do kiểm tra tươi của runGc (--yes kiểm lại ngay trước mỗi lần gỡ); không lớp nào khác bị đụng.
681
+ * o: { trigger: 'verdict'|'hook'|'disk', allRepos?, repoDirs?, budgetMs? }. Ghi data/gc-log.jsonl; allRepos ghi thêm data/gc-totals.json.
682
+ */
683
+ function autoGc(ctx, o) {
684
+ const x = o || {};
685
+ if (core.autoGcModeOf(ctx) === 'off') return { skipped: 'off', removed: 0, repos: [], counts: {}, reclaimed_bytes: 0 };
686
+ const trigger = x.trigger || 'hook';
687
+ const opts = { yes: true, auto: true, measure: true, trigger };
688
+ if (x.allRepos) opts['all-repos'] = true;
689
+ if (Array.isArray(x.repoDirs)) opts.repoDirs = x.repoDirs;
690
+ if (x.budgetMs) opts['budget-ms'] = x.budgetMs;
691
+ const rep = runGc(ctx, opts);
692
+ try {
693
+ fs.appendFileSync(path.join(ctx.paths.data, GC_LOG), JSON.stringify({
694
+ ts: new Date().toISOString(), trigger, scope: x.allRepos ? 'all' : 'repos', removed: rep.removed, reclaimed_bytes: rep.reclaimed_bytes,
695
+ repos: rep.repos.length, counts: rep.counts, truncated: rep.truncated, elapsed_ms: rep.elapsed_ms, errors: rep.errors,
696
+ }) + '\n');
697
+ } catch (_) { /* log: best effort */ }
698
+ if (x.allRepos) {
699
+ try {
700
+ fs.writeFileSync(path.join(ctx.paths.data, GC_TOTALS), JSON.stringify({
701
+ ts: Date.now(), abandoned_bytes: rep.totals.abandoned_bytes, keep_slot_bytes: rep.totals.keep_slot_bytes, reclaimed_bytes: rep.reclaimed_bytes,
702
+ }) + '\n');
703
+ } catch (_) { /* best effort */ }
704
+ }
705
+ return rep;
706
+ }
707
+
708
+ /** r1 s5 (d): tóm tắt các sự kiện wt_gc_auto cho `state`: lần cuối + tổng 7 ngày. null nếu chưa có lần nào. */
709
+ function autoGcState(events, now) {
710
+ const t = typeof now === 'number' ? now : Date.now();
711
+ const evs = (events || []).filter((e) => e && e.kind === 'wt_gc_auto');
712
+ if (!evs.length) return null;
713
+ const last = evs[evs.length - 1];
714
+ const week = evs.filter((e) => t - Date.parse(e.ts) <= 7 * 86400000);
715
+ return {
716
+ last: { ts: last.ts, trigger: last.trigger, removed: last.removed, reclaimed_bytes: last.reclaimed_bytes, paths: last.paths || [], branches: last.branches || [] },
717
+ runs_7d: week.length,
718
+ removed_7d: week.reduce((n, e) => n + (Number(e.removed) || 0), 0),
719
+ reclaimed_bytes_7d: week.reduce((n, e) => n + (Number(e.reclaimed_bytes) || 0), 0),
720
+ };
721
+ }
722
+
723
+ function autoGcLines(events, now) {
724
+ const s = autoGcState(events, now);
725
+ if (!s) return [];
726
+ const l = s.last;
727
+ const shown = l.paths.slice(0, 3).map((p) => path.basename(p)).join(', ');
728
+ const more = l.paths.length > 3 ? ', …' : '';
729
+ const br = l.branches.slice(0, 3).join(', ');
730
+ const brMore = l.branches.length > 3 ? ', …' : '';
731
+ return ['gc tự động (safe/vỏ/prune/nhánh tạm): lần cuối ' + l.ts + ' [' + l.trigger + '] gỡ ' + l.removed + ' mục ~' + fmtBytes(l.reclaimed_bytes) + (shown ? ' (' + shown + more + ')' : '') + (br ? ' nhánh: ' + br + brMore : '')
732
+ + '; 7 ngày: ' + s.runs_7d + ' lần, ' + s.removed_7d + ' mục, ~' + fmtBytes(s.reclaimed_bytes_7d)];
733
+ }
734
+
735
+ /** Luôn trả một bản tóm tắt (kể cả lỗi / hết giờ): stdout rỗng là bug. Tiến trình từng repo ghi ra stderr. */
736
+ function cmdGc(opts) {
737
+ const t0 = Date.now();
738
+ const quiet = opts.auto === true;
739
+ const onProgress = quiet ? null : (m) => { try { process.stderr.write(`wt gc: ${m}\n`); } catch (_) { /* stderr đóng */ } };
740
+ try {
741
+ const ctx = ctxOf(opts);
742
+ const rep = runGc(ctx, Object.assign({}, opts, { onProgress }));
743
+ if (opts.json) return { code: rep.errors && rep.errors.length ? 1 : 0, out: JSON.stringify(rep) };
744
+ return { code: rep.errors && rep.errors.length ? 1 : 0, out: render(rep) };
745
+ } catch (e) {
746
+ const msg = e && e.message ? e.message : String(e);
747
+ const rep = {
748
+ mode: opts.yes === true ? 'apply' : 'dry-run', repos: [], counts: { safe: 0, 'keep-slot': 0, abandoned: 0, keep: 0, prunable: 0, shell: 0, 'temp-branch': 0 },
749
+ totals: { safe_bytes: 0, abandoned_bytes: 0, keep_slot_bytes: 0, approx: false }, truncated: true, removed: 0, reclaimed_bytes: 0,
750
+ elapsed_ms: Date.now() - t0, timing: null, seed: null, errors: [msg],
751
+ };
752
+ return { code: 1, out: opts.json ? JSON.stringify(rep) : `wt gc: LỖI — ${msg}\n${render(rep)}` };
753
+ }
754
+ }
755
+
756
+ module.exports = { autoGc, autoGcState, autoGcLines, GC_LOG, GC_TOTALS, cmdGc, runGc, scanRepo, measureDir, fmtBytes, slotReason, keepPatterns, GC_TASK, GC_LAST };