@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/CHANGELOG.md +21 -0
- package/bin/dieu-phoi.cjs +14 -1
- package/hook.cjs +218 -9
- package/lib/args.cjs +1 -1
- package/lib/commands.cjs +10 -5
- package/lib/knowledge.cjs +19 -6
- package/lib/repos.cjs +387 -0
- package/lib/setup.cjs +22 -0
- package/lib/worktree.cjs +672 -38
- package/lib/wtfast.cjs +156 -0
- package/lib/wtgc.cjs +756 -0
- package/package.json +1 -1
package/lib/worktree.cjs
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
// `git worktree remove` (không --force), nhánh giữ nguyên; còn thay đổi chưa commit thì giữ và báo.
|
|
8
8
|
// Worktree không dấu không bị đụng.
|
|
9
9
|
// - `wt clean` (dry-run mặc định) liệt kê worktree có dấu mà task đã nghiệm thu / bị loại, hoặc thư mục đã mất.
|
|
10
|
+
// core 0.7.5 (s4):
|
|
11
|
+
// - `wt clean --merged`: gỡ worktree có dấu mà nhánh đã vào HEAD checkout chính, có verdict (không reject/fail),
|
|
12
|
+
// tuổi ≥ 1 giờ, không agent sống, sạch; rồi `git branch -d`. verdict accept của người dùng chỉ nhắc (--clean-merged: gỡ).
|
|
10
13
|
// core 0.7.3 (s2):
|
|
11
14
|
// - Trần worktree/repo (max_worktrees_repo, mặc định 8, 0 = tắt): `wt add` đếm mọi mục của `git worktree list`
|
|
12
15
|
// (gồm checkout chính, có dấu hay không). Đủ trần → từ chối kèm ứng viên dọn (sạch, có dấu, cũ nhất); --force bỏ qua.
|
|
@@ -25,15 +28,27 @@ const core = require('../hook.cjs');
|
|
|
25
28
|
const agg = require('./aggregate.cjs');
|
|
26
29
|
const { ctxOf, repoOf } = require('./args.cjs');
|
|
27
30
|
const deps = require('./deps.cjs');
|
|
31
|
+
const repos = require('./repos.cjs'); // core 0.7.6 (s2): sổ repo đã chạm (data/repos.json)
|
|
28
32
|
|
|
29
33
|
const MARK = 'dieu-phoi.json';
|
|
30
34
|
const BRANCH_PREFIX = 'dp/';
|
|
31
35
|
const DAY_MS = 86400000;
|
|
32
36
|
const DEFAULT_MAX_WORKTREES = 8;
|
|
33
37
|
const DEFAULT_STALE_DAYS = 7;
|
|
38
|
+
// s3 (core 0.7.5): verdict cuối không thuộc nhóm này thì bước chưa xong (nhánh không có commit mới vẫn "đã merge").
|
|
39
|
+
const NOT_DONE = new Set(['reject', 'fail', 'timeout', 'aborted']);
|
|
40
|
+
// s4 (0.7.5): `wt clean --merged` chỉ gỡ worktree tuổi ≥ 1 giờ có verdict (pass/accept). Worktree vừa tạo có 0 commit
|
|
41
|
+
// nên "đã merge" tầm thường; không có ngưỡng này thì bước đang chờ agent đầu tiên có thể bị gỡ.
|
|
42
|
+
const MERGED_MIN_AGE_MS = 60 * 60 * 1000;
|
|
43
|
+
|
|
44
|
+
// Hạn chót toàn cục cho mọi lệnh git (ms epoch; 0 = không hạn). `wt gc` đặt để một lệnh git treo/chậm không kéo quá ngân sách.
|
|
45
|
+
let gitDeadline = 0;
|
|
46
|
+
let gitCallMs = 60000;
|
|
47
|
+
function setGitDeadline(ts, perCallMs) { gitDeadline = Number(ts) > 0 ? Number(ts) : 0; gitCallMs = Number(perCallMs) > 0 ? Number(perCallMs) : 60000; }
|
|
34
48
|
|
|
35
49
|
function git(cwd, args) {
|
|
36
|
-
const
|
|
50
|
+
const timeout = gitDeadline ? Math.max(1500, Math.min(gitCallMs, gitDeadline - Date.now())) : 60000;
|
|
51
|
+
const r = cp.spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true, timeout });
|
|
37
52
|
const err = r.stderr || (r.error ? r.error.message : '') || '';
|
|
38
53
|
return { ok: r.status === 0, out: (r.stdout || '').trim(), err: err.trim() };
|
|
39
54
|
}
|
|
@@ -66,18 +81,65 @@ function markedWorktrees(common) {
|
|
|
66
81
|
out.push({
|
|
67
82
|
admin, name, task_id: mark.task_id, root: mark.root || null, branch: mark.branch || null,
|
|
68
83
|
created_ts: mark.created_ts || null, path: wtPath, exists: !!wtPath && fs.existsSync(wtPath),
|
|
84
|
+
keep: mark.keep === true,
|
|
69
85
|
deps: typeof mark.deps === 'string' ? mark.deps : null, deps_path: typeof mark.deps_path === 'string' ? mark.deps_path : '.',
|
|
70
86
|
});
|
|
71
87
|
}
|
|
72
88
|
return out;
|
|
73
89
|
}
|
|
74
90
|
|
|
91
|
+
// `git status` trên đĩa lạnh + Defender mất 5–10 giây mỗi worktree: gc chạy trước song song (một tiến trình node phụ, spawnSync
|
|
92
|
+
// từ phía gọi nên scanRepo vẫn đồng bộ) rồi dirtyOf lấy kết quả từ bộ nhớ đệm một lần.
|
|
93
|
+
const dirtyCache = new Map();
|
|
94
|
+
const PREFETCH_SCRIPT = `
|
|
95
|
+
const cp = require('child_process');
|
|
96
|
+
const input = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
|
97
|
+
const { paths, concurrency, callMs } = input;
|
|
98
|
+
let i = 0;
|
|
99
|
+
function next() {
|
|
100
|
+
if (i >= paths.length) return;
|
|
101
|
+
const p = paths[i++];
|
|
102
|
+
cp.execFile('git', ['--no-optional-locks', 'status', '--porcelain'], { cwd: p, encoding: 'utf8', windowsHide: true, timeout: callMs, maxBuffer: 8 * 1024 * 1024 }, (err, out) => {
|
|
103
|
+
const lines = String(out || '').split(/\\r?\\n/).filter(Boolean).slice(0, 2000);
|
|
104
|
+
process.stdout.write(JSON.stringify({ p, ok: !err, out: lines.join('\\n') }) + '\\n');
|
|
105
|
+
next();
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
for (let c = 0; c < Math.min(concurrency, paths.length); c++) next();
|
|
109
|
+
`;
|
|
110
|
+
|
|
111
|
+
/** Chạy `git status` song song cho nhiều worktree; kết quả vào bộ nhớ đệm cho dirtyOf. Tới hạn chót thì dừng (phần còn lại dirtyOf tự gọi). */
|
|
112
|
+
function prefetchDirty(paths, opts) {
|
|
113
|
+
const o = opts || {};
|
|
114
|
+
const list = (paths || []).filter(Boolean);
|
|
115
|
+
if (list.length < 2) return 0;
|
|
116
|
+
const left = o.deadline ? o.deadline - Date.now() : 120000;
|
|
117
|
+
if (left < 3000) return 0;
|
|
118
|
+
const r = cp.spawnSync(process.execPath, ['-e', PREFETCH_SCRIPT], {
|
|
119
|
+
input: JSON.stringify({ paths: list, concurrency: o.concurrency || 8, callMs: o.callMs || 30000 }),
|
|
120
|
+
encoding: 'utf8', windowsHide: true, timeout: Math.max(1000, left - 500), maxBuffer: 64 * 1024 * 1024,
|
|
121
|
+
});
|
|
122
|
+
let n = 0;
|
|
123
|
+
for (const line of String(r.stdout || '').split('\n')) {
|
|
124
|
+
if (!line) continue;
|
|
125
|
+
try {
|
|
126
|
+
const j = JSON.parse(line);
|
|
127
|
+
if (j && j.ok) { dirtyCache.set(realKey(j.p), { ok: true, out: j.out, err: '' }); n++; }
|
|
128
|
+
} catch (_) { /* dòng bị cắt do hết giờ */ }
|
|
129
|
+
}
|
|
130
|
+
return n;
|
|
131
|
+
}
|
|
132
|
+
function clearDirtyCache() { dirtyCache.clear(); }
|
|
133
|
+
|
|
75
134
|
/**
|
|
76
135
|
* true = còn thay đổi (kể cả file chưa theo dõi), false = sạch, null = không đọc được.
|
|
77
136
|
* s3: dòng `?? …node_modules/` là link (junction dùng chung do --deps link tạo) không tính là thay đổi.
|
|
78
137
|
*/
|
|
79
138
|
function dirtyOf(wtPath) {
|
|
80
|
-
const
|
|
139
|
+
const k = realKey(wtPath);
|
|
140
|
+
const pre = dirtyCache.get(k);
|
|
141
|
+
if (pre) dirtyCache.delete(k); // dùng một lần: kết quả quét song song chỉ hợp lệ cho lượt quét đang chạy
|
|
142
|
+
const r = pre || git(wtPath, ['--no-optional-locks', 'status', '--porcelain']);
|
|
81
143
|
if (!r.ok) return null;
|
|
82
144
|
const lines = r.out.split(/\r?\n/).filter(Boolean).filter((l) => {
|
|
83
145
|
const m = /^\?\? "?(.*?node_modules)\/?"?$/.exec(l);
|
|
@@ -95,35 +157,376 @@ function realKey(p) {
|
|
|
95
157
|
return process.platform === 'win32' ? r.toLowerCase() : r;
|
|
96
158
|
}
|
|
97
159
|
|
|
160
|
+
/** Các task id có thể gắn với một worktree: dấu, hậu tố nhánh `<x>/<id8>` / `dp/<id8>`, hậu tố tên thư mục `<x>-<id8>`. */
|
|
161
|
+
function taskIdsOfWorktree(wtPath, branch, mark) {
|
|
162
|
+
const ids = new Set();
|
|
163
|
+
const add = (v) => { if (typeof v === 'string' && core.isValidTaskId(v)) ids.add(v); };
|
|
164
|
+
if (mark) add(mark.task_id);
|
|
165
|
+
if (branch) add(String(branch).slice(String(branch).lastIndexOf('/') + 1));
|
|
166
|
+
const base = realKey(wtPath);
|
|
167
|
+
const leaf = base.slice(base.lastIndexOf('/') + 1);
|
|
168
|
+
add(leaf.slice(leaf.lastIndexOf('-') + 1));
|
|
169
|
+
return ids;
|
|
170
|
+
}
|
|
171
|
+
|
|
98
172
|
/**
|
|
99
|
-
* s8: kiểm "worktree đang
|
|
100
|
-
*
|
|
101
|
-
* (
|
|
173
|
+
* s8: kiểm "worktree đang có agent sống" trước khi gỡ. Đọc sổ agent sống data/live (core.readLive: bỏ file quá TTL; kèm ý định
|
|
174
|
+
* giao chưa nhận SubagentStart, mang worktree của brief). Khớp theo ĐƯỜNG DẪN (cwd/worktree là worktree hoặc thư mục con) HOẶC
|
|
175
|
+
* theo TASK_ID (agent có cwd là run dir vẫn bắt được nếu task của nó = task gắn với worktree: dấu, hậu tố nhánh, hậu tố tên thư mục).
|
|
176
|
+
* `taskIds` (Set/mảng) truyền thêm nếu có. Trả { desc, lastMs } (lastMs = hoạt động cuối của agent khớp, ms epoch hoặc null) hoặc
|
|
177
|
+
* null. Không ném: sổ lỗi thì coi như không có agent.
|
|
102
178
|
*/
|
|
103
|
-
function
|
|
179
|
+
function liveActivityOf(ctx, wtPath, taskIds) {
|
|
104
180
|
const wt = realKey(wtPath);
|
|
105
181
|
if (!wt) return null;
|
|
106
182
|
let live;
|
|
107
183
|
try { live = core.readLive(ctx); } catch (_) { return null; }
|
|
184
|
+
const ids = new Set(taskIds || []);
|
|
185
|
+
let best = null;
|
|
108
186
|
for (const r of live.agents.concat(live.pending)) {
|
|
109
187
|
const at = realKey(r.cwd || r.worktree);
|
|
110
|
-
|
|
188
|
+
const byPath = at && (at === wt || at.startsWith(`${wt}/`));
|
|
189
|
+
const byTask = typeof r.task_id === 'string' && ids.has(r.task_id);
|
|
190
|
+
if (!byPath && !byTask) continue;
|
|
111
191
|
const who = r.agent_id ? `agent ${String(r.agent_id).slice(0, 8)}` : 'agent đang chờ nhận';
|
|
112
|
-
|
|
192
|
+
const t = Date.parse(r.last_activity || r.start || r.t || '');
|
|
193
|
+
const lastMs = Number.isFinite(t) ? t : (Number.isFinite(Number(r.last_activity)) ? Number(r.last_activity) : null);
|
|
194
|
+
if (!best || (lastMs || 0) > (best.lastMs || 0)) {
|
|
195
|
+
best = { desc: `${who}${r.task_id ? ` task ${r.task_id}` : ''}${byPath ? '' : ' (khớp theo task_id)'}`, lastMs };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return best;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Tương thích: mô tả agent sống (chuỗi) hoặc null. */
|
|
202
|
+
function liveAgentInWorktree(ctx, wtPath, taskIds) {
|
|
203
|
+
const r = liveActivityOf(ctx, wtPath, taskIds);
|
|
204
|
+
return r ? r.desc : null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---- r1 s1: bộ vị từ an toàn duy nhất cho MỌI worktree đăng ký trong git ----
|
|
208
|
+
|
|
209
|
+
const IDLE_UNMARKED_MS = 24 * 60 * 60 * 1000; // phụ lục Q1: worktree không dấu phải nhàn rỗi > 24 giờ
|
|
210
|
+
const LOCK_STALE_MS = 30 * 60 * 1000; // khoá `initializing` của `git worktree add` bị giết: mồ côi sau 30 phút
|
|
211
|
+
const INIT_LOCK = 'initializing';
|
|
212
|
+
|
|
213
|
+
/** Tên nhánh (bỏ `refs/heads/` hoặc `refs/remotes/<remote>/`) hoặc null nếu không phải nhánh. */
|
|
214
|
+
function shortBranchOf(ref) {
|
|
215
|
+
if (ref.startsWith('refs/heads/')) return ref.slice('refs/heads/'.length);
|
|
216
|
+
const m = /^refs\/remotes\/[^/]+\/(.+)$/.exec(ref);
|
|
217
|
+
return m ? m[1] : null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Nhánh tạm (không tính là nơi "đã merge vào"): CHỈ tên do công cụ sinh: dp/*, r####-*, worktree-agent-*, claude/*.
|
|
222
|
+
* feat/*, rust/<id8>, x/<id8>... KHÔNG tạm (nhánh tích hợp như feat/rust-poc phải được tính là đích merge); nhánh làm việc đang
|
|
223
|
+
* checkout trong worktree liên kết được loại riêng bởi durablePolicy, không nhờ tên.
|
|
224
|
+
*/
|
|
225
|
+
function isTempBranch(name) {
|
|
226
|
+
return name.startsWith(BRANCH_PREFIX) || name.startsWith('worktree-agent-') || name.startsWith('claude/') || /^r\d{4}-/.test(name);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Chính sách "ref bền" dùng chung (wtfast.makeFacts và mergedAnywhere): ref nào được tính là ĐÍCH mà một nhánh có thể "đã vào".
|
|
231
|
+
* reg = sổ worktree (mục đầu = checkout chính); hasUpstream(name) = nhánh cục bộ có upstream; exclude = nhánh đang xét.
|
|
232
|
+
* policy(name, local) → true khi ref bền:
|
|
233
|
+
* - không phải HEAD, không phải chính nhánh đang xét (cả bản remote cùng tên), không phải tên tạm (dp/*, r####-*, worktree-agent-*, claude/*)
|
|
234
|
+
* — trừ nhánh checkout của checkout CHÍNH (luôn bền: nhánh tích hợp như feat/overhaul);
|
|
235
|
+
* - nhánh CỤC BỘ đang checkout trong một worktree LIÊN KẾT là nhánh làm việc (trivially chứa chính nó, và chứa cả những gì nó
|
|
236
|
+
* đã nuốt từ nhánh khác): KHÔNG bền, TRỪ KHI nó có upstream (đã đẩy lên remote, vd feat/rust-poc là nhánh tích hợp đặt ở
|
|
237
|
+
* .claude/worktrees/rust-poc-int; nhánh làm việc rust/<id8> không upstream thì không bao giờ bền);
|
|
238
|
+
* - mọi ref khác (main/master/develop/release*, nhánh cục bộ không checkout ở worktree liên kết, ref remote) bền.
|
|
239
|
+
*/
|
|
240
|
+
function durablePolicy(reg, hasUpstream, exclude) {
|
|
241
|
+
const list = reg || [];
|
|
242
|
+
const mainBranch = list.length && list[0].branch ? list[0].branch : null;
|
|
243
|
+
const linked = new Set(list.slice(1).map((r) => r.branch).filter(Boolean));
|
|
244
|
+
return (name, local) => {
|
|
245
|
+
if (!name || name === 'HEAD' || name === exclude) return false;
|
|
246
|
+
if (name === mainBranch && local !== false) return true;
|
|
247
|
+
if (isTempBranch(name)) return false;
|
|
248
|
+
if (local !== false && linked.has(name)) return !!(hasUpstream && hasUpstream(name));
|
|
249
|
+
return true;
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Nhánh `branch` đã nằm trong MỘT ref bền chưa (durablePolicy: KHÔNG tính nhánh làm việc đang checkout trong worktree liên kết
|
|
255
|
+
* trừ khi có upstream, không tính chính nó và nhánh tạm). Dùng
|
|
256
|
+
* `for-each-ref --contains <sha> refs/heads refs/remotes`. `reg` truyền sẵn thì khỏi gọi `git worktree list`.
|
|
257
|
+
* Trả { merged, into: [tên nhánh] }; lỗi git / không có nhánh → merged=false (giữ lại).
|
|
258
|
+
*/
|
|
259
|
+
function mergedAnywhere(main, branch, reg) {
|
|
260
|
+
const none = { merged: false, into: [] };
|
|
261
|
+
if (!branch) return none;
|
|
262
|
+
const sha = git(main, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}^{commit}`]);
|
|
263
|
+
if (!sha.ok || !sha.out) return none;
|
|
264
|
+
const r = git(main, ['for-each-ref', '--contains', sha.out, '--format=%(refname)%09%(upstream)', 'refs/heads', 'refs/remotes']);
|
|
265
|
+
if (!r.ok) return none;
|
|
266
|
+
let list = reg;
|
|
267
|
+
if (!list) { try { list = registeredOf(main); } catch (_) { list = null; } }
|
|
268
|
+
if (!list) return none; // không biết ai đang checkout gì thì không dám tính nhánh nào là bền
|
|
269
|
+
const rows = r.out.split(String.fromCharCode(10)).map((l) => l.replace(String.fromCharCode(13), '')).filter(Boolean).map((l) => l.split(String.fromCharCode(9)));
|
|
270
|
+
const upstream = new Set();
|
|
271
|
+
for (const [ref, up] of rows) if (ref.startsWith('refs/heads/') && up) upstream.add(ref.slice('refs/heads/'.length));
|
|
272
|
+
const ok = durablePolicy(list, (n) => upstream.has(n), branch);
|
|
273
|
+
const into = [];
|
|
274
|
+
for (const [ref] of rows) {
|
|
275
|
+
const name = shortBranchOf(ref);
|
|
276
|
+
if (name && ok(name, ref.startsWith('refs/heads/')) && !into.includes(name)) into.push(name);
|
|
277
|
+
}
|
|
278
|
+
return { merged: into.length > 0, into };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Tự gỡ được (khi wtSafety ok) chỉ với: tên do `wt add` sinh (`<tên repo>-wt-<task id 8 ký tự>`) hoặc BẤT KỲ thư mục nào dưới
|
|
283
|
+
* `<repo>/.claude/worktrees/` (Claude Code quản lý: agent-*, rust-<id8>...; phán quyết điều phối viên cho Q3). Tên tự đặt ở nơi khác
|
|
284
|
+
* (`<repo>-wt/<lane>`, `<repo>-wt-<tên>`, đường dẫn --path...) là slot giữ lại.
|
|
285
|
+
*/
|
|
286
|
+
function isAutoName(main, wtPath) {
|
|
287
|
+
const key = realKey(wtPath);
|
|
288
|
+
const base = key.slice(key.lastIndexOf('/') + 1);
|
|
289
|
+
const mb = realKey(main);
|
|
290
|
+
const mainBase = mb.slice(mb.lastIndexOf('/') + 1);
|
|
291
|
+
const m = /^(.*)-wt-([a-z0-9]{8})$/.exec(base);
|
|
292
|
+
if (m && m[1] === mainBase) return true;
|
|
293
|
+
if (key.startsWith(`${mb}/.claude/worktrees/`) && key.length > `${mb}/.claude/worktrees/`.length) return true;
|
|
294
|
+
// tương thích s1: mẫu cô lập cũ `.../.claude/worktrees/agent-*` ở bất kỳ đâu.
|
|
295
|
+
return /\/\.claude\/worktrees\/agent-[^/]+$/.test(key);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Lý do KHÔNG tự gỡ (chuỗi) hoặc null: là checkout chính, dấu `keep: true` (tạo bằng --path), hoặc tên tự đặt. */
|
|
299
|
+
function keepSlotReason(main, wtPath, mark) {
|
|
300
|
+
if (realKey(wtPath) === realKey(main)) return 'là checkout chính';
|
|
301
|
+
if (mark && mark.keep === true) return 'slot giữ lại (dấu keep: true, tạo bằng --path tự đặt): chỉ gỡ tay';
|
|
302
|
+
if (!isAutoName(main, wtPath)) return 'slot giữ lại (tên thư mục tự đặt, không phải tên do wt add sinh, cũng không nằm dưới <repo>/.claude/worktrees/): chỉ gỡ tay';
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Mục `git worktree list` của đúng đường dẫn (so sánh realKey); `reg` truyền sẵn thì khỏi gọi git lại. */
|
|
307
|
+
function registeredEntry(main, wtPath, reg) {
|
|
308
|
+
const key = realKey(wtPath);
|
|
309
|
+
return (reg || registeredOf(main)).find((r) => realKey(r.path) === key) || null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Thư mục admin (git-dir) của một worktree: rev-parse trong worktree; hỏng thì quét <common>/worktrees/NAME/gitdir. */
|
|
313
|
+
function adminDirOf(wtPath, common) {
|
|
314
|
+
const r = git(wtPath, ['rev-parse', '--path-format=absolute', '--git-dir']);
|
|
315
|
+
// Thư mục không phải worktree đã đăng ký thì rev-parse trả .git của repo cha: chỉ nhận git-dir nằm trong <common>/worktrees.
|
|
316
|
+
const inWorktrees = (d) => !common || realKey(path.dirname(d)) === realKey(path.join(common, 'worktrees'));
|
|
317
|
+
if (r.ok && r.out && inWorktrees(r.out)) return r.out;
|
|
318
|
+
if (!common) return null;
|
|
319
|
+
const root = path.join(common, 'worktrees');
|
|
320
|
+
const key = realKey(wtPath);
|
|
321
|
+
let names = [];
|
|
322
|
+
try { names = fs.readdirSync(root); } catch (_) { return null; }
|
|
323
|
+
for (const n of names) {
|
|
324
|
+
try {
|
|
325
|
+
const g = path.dirname(fs.readFileSync(path.join(root, n, 'gitdir'), 'utf8').trim());
|
|
326
|
+
if (realKey(g) === key) return path.join(root, n);
|
|
327
|
+
} catch (_) { /* admin hỏng: bỏ qua */ }
|
|
113
328
|
}
|
|
114
329
|
return null;
|
|
115
330
|
}
|
|
116
331
|
|
|
332
|
+
/** Tuổi (ms) của khoá worktree: mtime file `locked` trong git-dir admin, không có thì mtime thư mục admin. null = không đọc được. */
|
|
333
|
+
function lockAgeMs(admin) {
|
|
334
|
+
if (!admin) return null;
|
|
335
|
+
for (const p of [path.join(admin, 'locked'), admin]) {
|
|
336
|
+
try { return Date.now() - fs.statSync(p).mtimeMs; } catch (_) { /* thử mốc kế */ }
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Thư mục không tính vào "mtime file mới nhất" (nặng/sinh tự động, và đụng vào là do công cụ, không phải người làm).
|
|
342
|
+
const IDLE_SKIP_DIRS = new Set(['.git', 'node_modules', 'target', 'build', 'dist', 'out', 'obj', 'bin', 'Library', 'Temp', 'Logs', '.next', '.nuxt', '.gradle', '.venv', 'venv', '__pycache__', '.cache', '.turbo', 'coverage']);
|
|
343
|
+
const IDLE_WALK_CAP = 3000; // số mục tối đa duyệt mỗi worktree
|
|
344
|
+
const IDLE_WALK_MS = 1500; // thời gian tối đa duyệt mỗi worktree (đĩa lạnh + Defender: ~4 ms/mục)
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Duyệt RỘNG (file nông trước) lấy mtime file MỚI NHẤT trong worktree, có trần số mục (opts.cap) và thời gian (opts.ms, và hạn chót
|
|
348
|
+
* git toàn cục nếu có); bỏ IDLE_SKIP_DIRS và con trỏ `.git`, không đi qua symlink/junction.
|
|
349
|
+
* Trả { newest, complete }: complete=false khi hết trần trước khi duyệt xong. Khi đó `newest` chỉ là CẬN DƯỚI của mtime thật:
|
|
350
|
+
* file mới có thể nằm ở phần chưa duyệt (chạm lại file không đổi nội dung, file bị gitignore: git status không thấy) nên người gọi
|
|
351
|
+
* PHẢI coi nhàn rỗi là không biết (null), không được dùng như một con số.
|
|
352
|
+
*/
|
|
353
|
+
function walkNewest(root, opts) {
|
|
354
|
+
const o = opts || {};
|
|
355
|
+
let budget = o.cap || IDLE_WALK_CAP;
|
|
356
|
+
const until = Math.min(Date.now() + (o.ms || IDLE_WALK_MS), gitDeadline || Infinity);
|
|
357
|
+
let newest = null;
|
|
358
|
+
let complete = true;
|
|
359
|
+
const queue = [root];
|
|
360
|
+
for (let qi = 0; qi < queue.length; qi++) {
|
|
361
|
+
if (budget <= 0 || Date.now() > until) { complete = false; break; }
|
|
362
|
+
let ents;
|
|
363
|
+
try { ents = fs.readdirSync(queue[qi], { withFileTypes: true }); } catch (_) { continue; }
|
|
364
|
+
for (const e of ents) {
|
|
365
|
+
if (budget-- <= 0 || Date.now() > until) { return { newest, complete: false }; }
|
|
366
|
+
if (e.isSymbolicLink() || e.name === '.git') continue; // `.git` trong worktree là FILE (con trỏ), mtime = lúc tạo
|
|
367
|
+
const full = path.join(queue[qi], e.name);
|
|
368
|
+
if (e.isDirectory()) { if (!IDLE_SKIP_DIRS.has(e.name)) queue.push(full); continue; }
|
|
369
|
+
try { const m = fs.statSync(full).mtimeMs; if (newest === null || m > newest) newest = m; } catch (_) { /* biến mất giữa chừng */ }
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return { newest, complete };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** mtime file mới nhất (số) hoặc null; KHÔNG báo bị cắt: chỉ dùng cho kiểm tra/test. Quyết định an toàn dùng walkNewest/idleMsOf. */
|
|
376
|
+
function newestFileMtimeMs(root, opts) { return walkNewest(root, opts).newest; }
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Thời gian nhàn rỗi (ms): mốc MỚI NHẤT trong {commit cuối của nhánh, mtime file mới nhất trong worktree (duyệt có trần),
|
|
380
|
+
* hoạt động cuối của agent sống gắn với worktree}. KHÔNG dùng mtime git-dir admin / index (git status, gc quét... tự làm chúng
|
|
381
|
+
* nhảy về "bây giờ"). null = không đọc được gì HOẶC duyệt file bị cắt trần (opts.cap/opts.ms): nhàn rỗi không biết, người gọi phải giữ.
|
|
382
|
+
* opts.cachedNewest (số|null): kết quả duyệt đầy đủ đã lưu; opts.out nhận { walk: {newest, complete, cached} }. `liveMs` = hoạt động cuối của agent sống (ms epoch) hoặc null.
|
|
383
|
+
* opts.walk === false: bỏ duyệt file (chỉ commit + agent sống): cận trên rẻ của nhàn rỗi thật.
|
|
384
|
+
*/
|
|
385
|
+
function idleMsOf(wtPath, commitMs, liveMs, opts) {
|
|
386
|
+
const o = opts || {};
|
|
387
|
+
const marks = [];
|
|
388
|
+
if (commitMs === undefined) {
|
|
389
|
+
const c = git(wtPath, ['log', '-1', '--format=%ct']);
|
|
390
|
+
if (c.ok && /^\d+$/.test(c.out)) marks.push(Number(c.out) * 1000);
|
|
391
|
+
} else if (commitMs) marks.push(commitMs); // gc quét hàng loạt: thời điểm commit đầu nhánh lấy sẵn từ một lệnh for-each-ref
|
|
392
|
+
if (o.walk !== false) {
|
|
393
|
+
// bộ nhớ đệm của gc (HEAD + mtime thư mục worktree không đổi): kết quả duyệt đầy đủ lần trước; không có thì duyệt thật.
|
|
394
|
+
const w = o.cachedIncomplete === true ? { newest: null, complete: false, cached: true }
|
|
395
|
+
: o.cachedNewest !== undefined ? { newest: o.cachedNewest, complete: true, cached: true } : walkNewest(wtPath, o);
|
|
396
|
+
if (o.out) o.out.walk = w;
|
|
397
|
+
// Duyệt bị cắt (hết trần): không biết file nào mới hơn → nhàn rỗi KHÔNG BIẾT, không phải một con số (an toàn: coi là còn hoạt động).
|
|
398
|
+
if (!w.complete) return null;
|
|
399
|
+
if (w.newest !== null) marks.push(w.newest);
|
|
400
|
+
}
|
|
401
|
+
if (Number.isFinite(liveMs) && liveMs) marks.push(liveMs);
|
|
402
|
+
return marks.length ? Math.max(0, Date.now() - Math.max(...marks)) : null;
|
|
403
|
+
}
|
|
404
|
+
|
|
117
405
|
/**
|
|
118
|
-
*
|
|
406
|
+
* wtSafety(ctx, main, w, opts): vị từ an toàn DUY NHẤT để gỡ một worktree (verdict accept, wt clean, --merged, gc).
|
|
407
|
+
* w = mục registeredOf ({path, branch, detached, locked}) + tuỳ chọn { mark: bản ghi markedWorktrees, admin }.
|
|
408
|
+
* ok khi: thư mục còn; không detached; nhánh đang checkout THẬT (porcelain, không phải mark.branch) đã vào một ref bền
|
|
409
|
+
* (mergedAnywhere); sạch; không agent sống; nhàn rỗi đủ lâu; không khoá — hoặc khoá `initializing` mồ côi (lock.stale).
|
|
410
|
+
* opts: requireMerged (mặc định true), minIdleMs (mặc định 0 nếu có dấu, 24 giờ nếu không dấu: phụ lục Q1).
|
|
411
|
+
* Chỉ đọc, không bao giờ sửa gì. Trả { ok, why, branch_now, merged, merged_into, dirty, live, lock, idle_ms };
|
|
412
|
+
* lock = null | { reason, stale, age_ms } (stale=true → cần `git worktree unlock` trước khi gỡ).
|
|
413
|
+
*/
|
|
414
|
+
function wtSafety(ctx, main, w, opts) {
|
|
415
|
+
const o = opts || {};
|
|
416
|
+
const requireMerged = o.requireMerged !== false;
|
|
417
|
+
const mark = w.mark || null;
|
|
418
|
+
const minIdle = Number.isFinite(o.minIdleMs) ? o.minIdleMs : (mark ? 0 : IDLE_UNMARKED_MS);
|
|
419
|
+
const res = {
|
|
420
|
+
ok: false, why: null, branch_now: w.branch || null, merged: false, merged_into: [], dirty: null, live: null, lock: null, idle_ms: null,
|
|
421
|
+
};
|
|
422
|
+
const fail = (why) => { if (!res.why) res.why = why; };
|
|
423
|
+
if (!w.path || !fs.existsSync(w.path)) { res.why = 'thư mục đã mất'; return res; }
|
|
424
|
+
// Q3: slot giữ lại (đường dẫn tự đặt) và checkout chính KHÔNG BAO GIỜ tự gỡ, với mọi đường gọi tự động.
|
|
425
|
+
const slot = keepSlotReason(main, w.path, mark);
|
|
426
|
+
if (slot) { res.why = slot; return res; }
|
|
427
|
+
if (w.detached || !w.branch) { res.why = 'đang ở detached HEAD (không có nhánh), có thể giữ commit riêng'; return res; }
|
|
428
|
+
const admin = w.admin || adminDirOf(w.path, null);
|
|
429
|
+
const commitMsOf = o.commitMsOf || null; // opts của gc: batch, không gọi git cho mỗi worktree
|
|
430
|
+
if (w.locked !== null && w.locked !== undefined) {
|
|
431
|
+
const reason = String(w.locked);
|
|
432
|
+
if (reason === INIT_LOCK) {
|
|
433
|
+
const age = lockAgeMs(admin);
|
|
434
|
+
const stale = age !== null && age >= LOCK_STALE_MS;
|
|
435
|
+
res.lock = { reason, stale, age_ms: age };
|
|
436
|
+
if (!stale) fail(`đang khoá "${INIT_LOCK}" (mới dưới 30 phút, có thể git worktree add còn chạy)`);
|
|
437
|
+
} else {
|
|
438
|
+
res.lock = { reason, stale: false, age_ms: lockAgeMs(admin) };
|
|
439
|
+
fail(`đang bị khoá${reason ? ` (${reason})` : ''}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// Đo nhàn rỗi TRƯỚC mọi lệnh git khác trong worktree (status/log có thể ghi lại index, làm mtime nhảy về "bây giờ").
|
|
443
|
+
const liveHit = liveActivityOf(ctx, w.path, taskIdsOfWorktree(w.path, w.branch, mark));
|
|
444
|
+
const liveMs = liveHit ? liveHit.lastMs : null;
|
|
445
|
+
res.live = liveHit ? liveHit.desc : null;
|
|
446
|
+
if (res.live) fail(`đang là cwd của agent sống (${res.live})`);
|
|
447
|
+
// Nhàn rỗi: cận trên rẻ (commit + agent sống) trước; chỉ duyệt file (đắt trên đĩa lạnh) khi opts.walkIdleIf(base, merged) đồng ý.
|
|
448
|
+
// Lô (for-each-ref) có thể hụt dưới tải (quá trần mỗi lệnh git): thiếu thì hỏi riêng worktree này, không coi là "không có commit".
|
|
449
|
+
const batched = commitMsOf ? commitMsOf(w.branch) : null;
|
|
450
|
+
const commitMs = Number.isFinite(batched) && batched > 0 ? batched : (() => {
|
|
451
|
+
const c = git(w.path, ['log', '-1', '--format=%ct']);
|
|
452
|
+
return c.ok && /^\d+$/.test(c.out) ? Number(c.out) * 1000 : null;
|
|
453
|
+
})();
|
|
454
|
+
const baseIdle = idleMsOf(w.path, commitMs || 0, liveMs, { walk: false });
|
|
455
|
+
// opts.dirtyNeeded(res, merged): gc bỏ `git status` (đắt trên repo Unity) khi kết quả không còn đổi được phân lớp.
|
|
456
|
+
const early = o.dirtyNeeded || o.mergedOf ? (o.mergedOf || ((b) => mergedAnywhere(main, b, o.reg)))(w.branch) : null;
|
|
457
|
+
const walkWanted = !o.walkIdleIf || o.walkIdleIf(baseIdle, early);
|
|
458
|
+
const cache = o.cache || {};
|
|
459
|
+
const walkOut = {};
|
|
460
|
+
const fullIdle = () => idleMsOf(w.path, commitMs || 0, liveMs, { cap: o.walkCap, ms: o.walkMs, cachedNewest: cache.newest, cachedIncomplete: cache.incomplete, out: walkOut });
|
|
461
|
+
// Đệm chỉ được rút ngắn kết quả ÂM: "bẩn" đã đệm -> giữ ngay; "sạch" không bao giờ lấy từ đệm (luôn git status thật).
|
|
462
|
+
const dirtyNow = () => { if (cache.dirty === true) { res.dirty_cached = true; return true; } return dirtyOf(w.path); };
|
|
463
|
+
if (!o.dirtyNeeded) {
|
|
464
|
+
res.idle_ms = walkWanted ? fullIdle() : baseIdle;
|
|
465
|
+
res.dirty = dirtyNow();
|
|
466
|
+
if (res.dirty !== false) fail(res.dirty ? 'còn thay đổi chưa commit' : 'không đọc được trạng thái git');
|
|
467
|
+
} else {
|
|
468
|
+
// gc: cận trên rẻ (commit + agent sống) quyết định có cần git status không (superset của nhu cầu thật). Duyệt file (đắt) CHỈ khi
|
|
469
|
+
// cây sạch: cây bẩn/không đọc được thì đằng nào cũng giữ, nhàn rỗi chính xác không đổi phân lớp.
|
|
470
|
+
res.idle_ms = baseIdle;
|
|
471
|
+
if (!o.dirtyNeeded(res, early)) {
|
|
472
|
+
res.dirty = null;
|
|
473
|
+
res.dirty_skipped = true;
|
|
474
|
+
} else {
|
|
475
|
+
res.dirty = dirtyNow();
|
|
476
|
+
if (res.dirty !== false) fail(res.dirty ? 'còn thay đổi chưa commit' : 'không đọc được trạng thái git');
|
|
477
|
+
else if (walkWanted) res.idle_ms = fullIdle();
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (walkOut.walk) {
|
|
481
|
+
res.walk = walkOut.walk;
|
|
482
|
+
// Tín hiệu nhàn rỗi nào không biết (duyệt bị cắt trần, hoặc không có mốc nào) thì KHÔNG an toàn dù minIdle = 0 (worktree có dấu).
|
|
483
|
+
if (res.idle_ms === null) { res.idle_unknown = true; fail(`idle-unknown: không xác định được nhàn rỗi (${walkOut.walk.complete ? 'không có mốc thời gian nào' : 'duyệt file bị cắt theo trần số mục/thời gian'}), giữ`); }
|
|
484
|
+
}
|
|
485
|
+
const m = early || mergedAnywhere(main, w.branch, o.reg);
|
|
486
|
+
res.merged = m.merged;
|
|
487
|
+
res.merged_into = m.into;
|
|
488
|
+
if (requireMerged && !m.merged) fail(`nhánh ${w.branch} chưa vào nhánh bền nào (gc sẽ gỡ khi nhánh vào main)`);
|
|
489
|
+
if (minIdle > 0 && (res.idle_ms === null || res.idle_ms < minIdle)) {
|
|
490
|
+
fail(`chưa nhàn rỗi đủ ${Math.round((minIdle / 3600000) * 10) / 10} giờ${res.idle_ms === null ? '' : ` (mới ${Math.round(res.idle_ms / 60000)} phút)`}`);
|
|
491
|
+
}
|
|
492
|
+
if (res.dirty_skipped) fail('chưa kiểm sạch (bỏ qua git status vì đã chắc chắn không gỡ được)');
|
|
493
|
+
res.ok = !res.why;
|
|
494
|
+
return res;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** wtSafety cho một worktree có dấu: tìm mục đăng ký tương ứng (reg truyền sẵn thì khỏi gọi git). Không đăng ký → ok=false. */
|
|
498
|
+
function safetyOfMarked(ctx, main, w, opts, reg) {
|
|
499
|
+
const e = w.path ? registeredEntry(main, w.path, reg) : null;
|
|
500
|
+
if (!e) {
|
|
501
|
+
return { ok: false, why: 'không còn đăng ký trong git worktree list', branch_now: null, merged: false, merged_into: [], dirty: null, live: null, lock: null, idle_ms: null };
|
|
502
|
+
}
|
|
503
|
+
return wtSafety(ctx, main, Object.assign({}, e, { mark: w, admin: w.admin }), opts);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Lý do GIỮ (chuỗi) hoặc null nếu gỡ được. Khác nhánh của dấu thì giữ, trừ khi opts.anyBranch (xét theo nhánh đang checkout). */
|
|
507
|
+
function keepReason(w, s, opts) {
|
|
508
|
+
if (!(opts && opts.anyBranch) && w.branch && s.branch_now && s.branch_now !== w.branch) {
|
|
509
|
+
return `đang checkout ${s.branch_now} (không phải nhánh của task ${w.branch})`;
|
|
510
|
+
}
|
|
511
|
+
return s.ok ? null : s.why;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Gỡ một worktree có dấu đang còn thư mục. Không --force. Mọi điều kiện an toàn qua wtSafety (+ nhánh đang checkout phải
|
|
516
|
+
* là nhánh của dấu, trừ opts.anyBranch; slot giữ lại / tên tự đặt không bao giờ được gỡ)..
|
|
119
517
|
* s3: gỡ junction node_modules (không đi xuyên) TRƯỚC `git worktree remove`; git lỗi thì khôi phục junction.
|
|
120
|
-
*
|
|
518
|
+
* Khoá `initializing` mồ côi → `git worktree unlock` trước khi gỡ. Gỡ xong mà còn vỏ thư mục thì removeShell.
|
|
121
519
|
*/
|
|
122
|
-
function removeMarked(w, mainDir, ctx) {
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
520
|
+
function removeMarked(w, mainDir, ctx, opts) {
|
|
521
|
+
const s = safetyOfMarked(ctx, mainDir, w, opts);
|
|
522
|
+
const why = keepReason(w, s, opts);
|
|
523
|
+
if (why) return { removed: false, msg: `giữ ${w.path}: ${why}` };
|
|
524
|
+
let unlocked = '';
|
|
525
|
+
if (s.lock && s.lock.stale) {
|
|
526
|
+
const u = git(mainDir, ['worktree', 'unlock', w.path]);
|
|
527
|
+
if (!u.ok) return { removed: false, msg: `giữ ${w.path}: không mở được khoá "${INIT_LOCK}" mồ côi (${u.err || 'không rõ'})` };
|
|
528
|
+
unlocked = `; đã mở khoá "${INIT_LOCK}" mồ côi`;
|
|
529
|
+
}
|
|
127
530
|
let links;
|
|
128
531
|
try { links = deps.unlinkDepLinks(w.path, [w.deps_path || '.']); } catch (e) {
|
|
129
532
|
return { removed: false, msg: `giữ ${w.path}: không gỡ được junction node_modules (${e.message}); KHÔNG gỡ worktree để tránh xoá xuyên junction` };
|
|
@@ -138,7 +541,7 @@ function removeMarked(w, mainDir, ctx) {
|
|
|
138
541
|
try { deps.removeShell(w.path); } catch (e) { shell = `; còn vỏ thư mục chưa xoá được (${e.message})`; }
|
|
139
542
|
}
|
|
140
543
|
const unl = links.length ? `; đã gỡ junction node_modules trước` : '';
|
|
141
|
-
return { removed: true, msg: `đã gỡ worktree ${w.path} (nhánh ${w.branch || '?'} giữ nguyên${unl}${shell})` };
|
|
544
|
+
return { removed: true, msg: `đã gỡ worktree ${w.path} (nhánh ${w.branch || '?'} giữ nguyên${unl}${unlocked}${shell})` };
|
|
142
545
|
}
|
|
143
546
|
|
|
144
547
|
function recordWt(ctx, taskId, fields) {
|
|
@@ -163,7 +566,8 @@ function reposOfTask(events, taskId, repoDir) {
|
|
|
163
566
|
* verdict --task accept (người dùng) / discard: gỡ worktree có dấu của đúng task. Trả các dòng báo, không ném.
|
|
164
567
|
* repoDir: --repo của lệnh (nếu có) — ngoài repo đã ghi trong sự kiện worktree.
|
|
165
568
|
*/
|
|
166
|
-
function cleanupTask(ctx, events, taskId, repoDir) {
|
|
569
|
+
function cleanupTask(ctx, events, taskId, repoDir, opts) {
|
|
570
|
+
const discard = !!(opts && opts.discard);
|
|
167
571
|
const lines = [];
|
|
168
572
|
for (const repo of reposOfTask(events, taskId, repoDir)) {
|
|
169
573
|
let info;
|
|
@@ -171,9 +575,13 @@ function cleanupTask(ctx, events, taskId, repoDir) {
|
|
|
171
575
|
const mine = markedWorktrees(info.common).filter((w) => w.task_id === taskId);
|
|
172
576
|
for (const w of mine) {
|
|
173
577
|
if (!w.exists) { lines.push(`wt: ${w.path || w.name} đã mất thư mục — dọn bằng dieu-phoi wt clean --yes`); continue; }
|
|
578
|
+
// r1 s1: accept VÀ discard chỉ gỡ khi wtSafety ok (nhánh đang checkout là nhánh của task, đã merge, sạch, không agent
|
|
579
|
+
// sống, không phải slot giữ lại). Việc bỏ dở chưa merge (Q2) thuộc `wt gc --abandoned` (tag dp-archive, người dùng duyệt).
|
|
174
580
|
const r = removeMarked(w, info.main, ctx);
|
|
175
581
|
if (r.removed) recordWt(ctx, taskId, { action: 'remove', path: w.path, repo: info.main, reason: 'verdict' });
|
|
176
|
-
|
|
582
|
+
const hint = discard && !r.removed && r.msg.includes('chưa vào nhánh bền nào')
|
|
583
|
+
? '; việc bỏ dở chưa merge được giữ nguyên: dùng dieu-phoi wt gc (lớp abandoned: lưu tag dp-archive/... rồi gỡ khi bạn duyệt)' : '';
|
|
584
|
+
lines.push(`wt: ${r.msg}${hint}`);
|
|
177
585
|
}
|
|
178
586
|
}
|
|
179
587
|
return lines;
|
|
@@ -212,9 +620,12 @@ function registeredOf(main) {
|
|
|
212
620
|
const out = [];
|
|
213
621
|
let cur = null;
|
|
214
622
|
for (const line of r.out.split(/\r?\n/)) {
|
|
215
|
-
if (line.startsWith('worktree ')) { cur = { path: line.slice(9), branch: null, detached: false }; out.push(cur); }
|
|
623
|
+
if (line.startsWith('worktree ')) { cur = { path: line.slice(9), branch: null, detached: false, locked: null, prunable: false }; out.push(cur); }
|
|
216
624
|
else if (cur && line.startsWith('branch ')) cur.branch = line.slice(7).replace(/^refs\/heads\//, '');
|
|
217
625
|
else if (cur && line === 'detached') cur.detached = true;
|
|
626
|
+
// r1 s1: `locked` (không lý do → '') hoặc `locked <lý do>`; null = không khoá.
|
|
627
|
+
else if (cur && (line === 'locked' || line.startsWith('locked '))) cur.locked = line.slice(7);
|
|
628
|
+
else if (cur && (line === 'prunable' || line.startsWith('prunable '))) cur.prunable = true;
|
|
218
629
|
}
|
|
219
630
|
return out;
|
|
220
631
|
}
|
|
@@ -247,6 +658,35 @@ function candidateLines(common) {
|
|
|
247
658
|
return `Ứng viên dọn (sạch, có dấu, cũ nhất trước):\n${c.map((w) => ` - ${w.path} task=${w.task_id} tạo ${w.created_ts || '?'}`).join('\n')}`;
|
|
248
659
|
}
|
|
249
660
|
|
|
661
|
+
/**
|
|
662
|
+
* s3 (core 0.7.5): worktree có dấu của bước đã xong, có thể tái dùng. Điều kiện: còn thư mục, sạch, không có agent
|
|
663
|
+
* sống, task không bị loại, và (verdict cuối của task là pass/accept, hoặc nhánh đã vào HEAD mà task đã có verdict).
|
|
664
|
+
* Nhánh "đã merge" một mình không đủ: worktree vừa tạo, chưa commit, cũng có 0 commit ngoài HEAD.
|
|
665
|
+
* Chỉ để báo, không bao giờ tự dùng lại. Không có ứng viên thì trả chuỗi rỗng.
|
|
666
|
+
*/
|
|
667
|
+
function reusableLines(ctx, main, common) {
|
|
668
|
+
const events = core.readEvents(ctx);
|
|
669
|
+
const gone = agg.discardedTaskIds(events);
|
|
670
|
+
const last = new Map();
|
|
671
|
+
for (const e of events) {
|
|
672
|
+
if (e && e.kind === 'verdict' && typeof e.task_id === 'string') last.set(e.task_id, agg.normalizeVerdict(e).outcome);
|
|
673
|
+
}
|
|
674
|
+
const rows = [];
|
|
675
|
+
for (const w of markedWorktrees(common)) {
|
|
676
|
+
if (!w.exists || gone.has(w.task_id)) continue;
|
|
677
|
+
const v = last.get(w.task_id);
|
|
678
|
+
let why = null;
|
|
679
|
+
if (v === 'pass' || v === 'accept') why = `verdict ${v}`;
|
|
680
|
+
else if (v !== undefined && !NOT_DONE.has(v) && branchMerged(main, w.branch)) why = 'nhánh đã vào HEAD';
|
|
681
|
+
if (!why) continue;
|
|
682
|
+
if (liveAgentInWorktree(ctx, w.path, taskIdsOfWorktree(w.path, w.branch, w))) continue;
|
|
683
|
+
if (dirtyOf(w.path) !== false) continue;
|
|
684
|
+
rows.push(` - ${w.path} task=${w.task_id} nhánh=${w.branch || '?'} (${why})`);
|
|
685
|
+
}
|
|
686
|
+
if (!rows.length) return '';
|
|
687
|
+
return `Worktree bước đã xong có thể tái dùng (chỉ báo, không tự dùng lại; gỡ bằng dieu-phoi wt clean sau khi xem dry-run):\n${rows.join('\n')}`;
|
|
688
|
+
}
|
|
689
|
+
|
|
250
690
|
/** Trạng thái node_modules của một worktree: link (symlink/junction), dir (thư mục thật), none. */
|
|
251
691
|
function nodeModulesOf(p) {
|
|
252
692
|
try {
|
|
@@ -256,8 +696,22 @@ function nodeModulesOf(p) {
|
|
|
256
696
|
} catch (_) { return 'none'; }
|
|
257
697
|
}
|
|
258
698
|
|
|
699
|
+
/**
|
|
700
|
+
* s3 (core 0.7.5): --base <ref> cho `wt add`. Không có --base → nhánh mới từ HEAD của checkout chính (như cũ).
|
|
701
|
+
* Ref phải là commit hợp lệ trong repo chính; không bắt đầu bằng `-` (tránh bị git hiểu là tuỳ chọn).
|
|
702
|
+
*/
|
|
703
|
+
function baseArg(opts, main) {
|
|
704
|
+
if (opts.base === undefined) return null;
|
|
705
|
+
if (typeof opts.base !== 'string' || !opts.base || opts.base.startsWith('-')) {
|
|
706
|
+
throw new Error('wt add: --base cần tên ref (nhánh, tag hoặc commit), không bắt đầu bằng "-"');
|
|
707
|
+
}
|
|
708
|
+
const v = git(main, ['rev-parse', '--verify', '--quiet', `${opts.base}^{commit}`]);
|
|
709
|
+
if (!v.ok || !v.out) throw new Error(`wt add: --base "${opts.base}" không phải commit/nhánh hợp lệ trong ${main}`);
|
|
710
|
+
return opts.base;
|
|
711
|
+
}
|
|
712
|
+
|
|
259
713
|
function wtAdd(opts) {
|
|
260
|
-
if (typeof opts.task !== 'string') throw new Error('Cú pháp: dieu-phoi wt add --task <task_id> [--repo r] [--path dir] [--branch b] [--force] [--deps auto|link|install|none] [--deps-path dir]');
|
|
714
|
+
if (typeof opts.task !== 'string') throw new Error('Cú pháp: dieu-phoi wt add --task <task_id> [--repo r] [--path dir] [--branch b] [--base ref] [--force] [--deps auto|link|install|none] [--deps-path dir]');
|
|
261
715
|
const taskId = requireTaskId(opts.task);
|
|
262
716
|
const ctx = ctxOf(opts);
|
|
263
717
|
const depsMode = deps.depsModeOf(opts, ctx);
|
|
@@ -265,29 +719,57 @@ function wtAdd(opts) {
|
|
|
265
719
|
const { main, common } = mainCheckoutOf(repoOf(opts));
|
|
266
720
|
const dup = markedWorktrees(common).filter((w) => w.task_id === taskId && w.exists);
|
|
267
721
|
if (dup.length) throw new Error(`task ${taskId} đã có worktree có dấu: ${dup.map((w) => w.path).join(', ')}`);
|
|
722
|
+
const base = baseArg(opts, main);
|
|
268
723
|
const max = maxWorktreesOf(ctx);
|
|
269
724
|
const total = registeredOf(main).length;
|
|
270
725
|
const capMsg = max > 0 && total >= max ? `repo đã có ${total}/${max} worktree (trần max_worktrees_repo)` : null;
|
|
726
|
+
const near = max > 0 && total + 1 >= warnAtOf(max);
|
|
271
727
|
if (capMsg && opts.force !== true) {
|
|
728
|
+
const hint = reusableLines(ctx, main, common);
|
|
272
729
|
throw new Error(`wt add: ${capMsg}; không tạo thêm cho task ${taskId}.\n${candidateLines(common)}\n`
|
|
730
|
+
+ (hint ? `${hint}\n` : '')
|
|
273
731
|
+ 'Dọn: dieu-phoi wt clean --yes (sau khi xem dry-run) hoặc dieu-phoi wt list --all; tạo vượt trần: thêm --force.');
|
|
274
732
|
}
|
|
275
733
|
const notes = [];
|
|
276
734
|
if (capMsg) notes.push(`CẢNH BÁO: vượt trần (--force): ${capMsg}`);
|
|
277
|
-
else if (
|
|
735
|
+
else if (near) notes.push(`CẢNH BÁO: repo sẽ có ${total + 1}/${max} worktree (đạt 75% trần)`);
|
|
736
|
+
if (capMsg || near) {
|
|
737
|
+
const hint = reusableLines(ctx, main, common);
|
|
738
|
+
if (hint) notes.push(hint);
|
|
739
|
+
}
|
|
278
740
|
const wtPath = typeof opts.path === 'string' && opts.path
|
|
279
741
|
? path.resolve(opts.path)
|
|
280
742
|
: path.join(path.dirname(main), `${path.basename(main)}-wt-${taskId}`);
|
|
281
743
|
if (fs.existsSync(wtPath)) throw new Error(`wt add: ${wtPath} đã tồn tại`);
|
|
282
744
|
const branch = typeof opts.branch === 'string' && opts.branch ? opts.branch : `${BRANCH_PREFIX}${taskId}`;
|
|
283
|
-
const r = git(main, ['worktree', 'add', '-b', branch, wtPath]);
|
|
284
|
-
|
|
285
|
-
|
|
745
|
+
const r = git(main, ['worktree', 'add', '-b', branch, wtPath, ...(base ? [base] : [])]);
|
|
746
|
+
const mark = { task_id: taskId, root: main, created_ts: core.nowIso(), branch, deps: 'none', deps_path: depsPath };
|
|
747
|
+
// Q3: --path tự đặt (khác tên mặc định do wt add sinh) → slot giữ lại, không bao giờ tự gỡ.
|
|
748
|
+
if (pathKey(wtPath) !== pathKey(path.join(path.dirname(main), `${path.basename(main)}-wt-${taskId}`))) mark.keep = true;
|
|
749
|
+
if (!r.ok) {
|
|
750
|
+
// r1 s1: git có thể đã đăng ký worktree (và khoá "initializing") trước khi lỗi/bị giết → vẫn ghi dấu + mở khoá,
|
|
751
|
+
// để discard / wt clean nhận ra, không để lại worktree mồ côi.
|
|
752
|
+
const adm = fs.existsSync(wtPath) && registeredEntry(main, wtPath) ? adminDirOf(wtPath, common) : null;
|
|
753
|
+
if (adm) {
|
|
754
|
+
fs.writeFileSync(path.join(adm, MARK), `${JSON.stringify(mark, null, 2)}\n`);
|
|
755
|
+
const e = registeredEntry(main, wtPath);
|
|
756
|
+
if (e && e.locked === INIT_LOCK) git(main, ['worktree', 'unlock', wtPath]);
|
|
757
|
+
recordWt(ctx, taskId, { action: 'add', path: wtPath, repo: main, branch, deps: 'none' });
|
|
758
|
+
throw new Error(`git worktree add lỗi: ${r.err || 'không rõ'}. Worktree ${wtPath} đã được đăng ký dở nên đã ghi dấu task ${taskId}; dọn bằng dieu-phoi discard --task ${taskId} hoặc wt clean.`);
|
|
759
|
+
}
|
|
760
|
+
throw new Error(`git worktree add lỗi: ${r.err || 'không rõ'}`);
|
|
761
|
+
}
|
|
762
|
+
const adminDir = adminDirOf(wtPath, common);
|
|
286
763
|
if (!adminDir) throw new Error(`worktree ${wtPath} tạo xong nhưng không đọc được git-dir để ghi dấu`);
|
|
287
764
|
const markPath = path.join(adminDir, MARK);
|
|
288
|
-
const mark = { task_id: taskId, root: main, created_ts: core.nowIso(), branch, deps: 'none', deps_path: depsPath };
|
|
289
765
|
// Dấu ghi trước khi chuẩn bị deps: nếu chết giữa chừng, worktree vẫn có dấu để wt clean nhận ra.
|
|
290
766
|
fs.writeFileSync(markPath, `${JSON.stringify(mark, null, 2)}\n`);
|
|
767
|
+
// r1 s1: git trên Windows có thể để lại khoá "initializing" nếu tiến trình bị giết giữa checkout → mở khoá và báo.
|
|
768
|
+
const lk = registeredEntry(main, wtPath);
|
|
769
|
+
if (lk && lk.locked === INIT_LOCK) {
|
|
770
|
+
const u = git(main, ['worktree', 'unlock', wtPath]);
|
|
771
|
+
notes.push(u.ok ? `Đã mở khoá "${INIT_LOCK}" git để lại trên ${wtPath}.` : `CẢNH BÁO: ${wtPath} còn khoá "${INIT_LOCK}" và không mở được (${u.err || 'không rõ'}); chạy git worktree unlock "${wtPath}".`);
|
|
772
|
+
}
|
|
291
773
|
const d = deps.setupDeps({ main, wtPath, mode: depsMode, depsPath });
|
|
292
774
|
if (d.deps !== 'none') {
|
|
293
775
|
mark.deps = d.deps;
|
|
@@ -299,7 +781,7 @@ function wtAdd(opts) {
|
|
|
299
781
|
const text = [msg, d.note, warning].filter(Boolean).join('\n');
|
|
300
782
|
return {
|
|
301
783
|
code: 0,
|
|
302
|
-
out: opts.json ? JSON.stringify({ task_id: taskId, path: wtPath, branch, total: total + 1, max, warning, deps: mark.deps, deps_path: depsPath, deps_note: d.note }) : text,
|
|
784
|
+
out: opts.json ? JSON.stringify({ task_id: taskId, path: wtPath, branch, base: base || null, total: total + 1, max, warning, deps: mark.deps, deps_path: depsPath, deps_note: d.note }) : text,
|
|
303
785
|
};
|
|
304
786
|
}
|
|
305
787
|
|
|
@@ -321,13 +803,16 @@ function wtClean(opts) {
|
|
|
321
803
|
.map((e) => e.task_id));
|
|
322
804
|
const gone = agg.discardedTaskIds(events);
|
|
323
805
|
const rows = [];
|
|
806
|
+
const reg = registeredOf(main);
|
|
324
807
|
for (const w of markedWorktrees(common)) {
|
|
325
808
|
let why = null;
|
|
326
809
|
if (!w.exists) why = 'thư mục đã mất';
|
|
327
810
|
else if (accepted.has(w.task_id)) why = 'đã nghiệm thu';
|
|
328
811
|
else if (gone.has(w.task_id)) why = 'đã loại (discard)';
|
|
329
|
-
else if (ageMsOf(w) >= staleDays * DAY_MS
|
|
330
|
-
|
|
812
|
+
else if (ageMsOf(w) >= staleDays * DAY_MS) {
|
|
813
|
+
// r1 s1: "đã merge" xét theo nhánh đang checkout THẬT, vào một ref bền bất kỳ (không chỉ HEAD checkout chính).
|
|
814
|
+
const sf = safetyOfMarked(ctx, main, w, { requireMerged: true }, reg);
|
|
815
|
+
if (sf.merged && sf.dirty === false) why = `cũ ≥ ${staleDays} ngày, nhánh đã merge (${sf.branch_now} vào ${sf.merged_into[0]})`;
|
|
331
816
|
}
|
|
332
817
|
if (why) rows.push(Object.assign({}, w, { why }));
|
|
333
818
|
}
|
|
@@ -345,12 +830,12 @@ function wtClean(opts) {
|
|
|
345
830
|
} else lines.push(`sẽ bỏ đăng ký ${w.path || w.name}: ${label}`);
|
|
346
831
|
continue;
|
|
347
832
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
const
|
|
351
|
-
if (
|
|
833
|
+
// r1 s1: cùng vị từ wtSafety cho dry-run lẫn --yes (kể cả task bị loại: chưa merge thì để lớp abandoned của gc xử lý).
|
|
834
|
+
const ropts = { anyBranch: w.why.startsWith('cũ ≥') };
|
|
835
|
+
const hold = keepReason(w, safetyOfMarked(ctx, main, w, ropts, reg), ropts);
|
|
836
|
+
if (hold) { lines.push(`giữ ${w.path}: ${hold} — ${label}`); continue; }
|
|
352
837
|
if (!apply) { lines.push(`sẽ gỡ ${w.path}: ${label}`); continue; }
|
|
353
|
-
const r = removeMarked(w, main, ctx);
|
|
838
|
+
const r = removeMarked(w, main, ctx, ropts);
|
|
354
839
|
if (r.removed) recordWt(ctx, w.task_id, { action: 'remove', path: w.path, repo: main, reason: w.why });
|
|
355
840
|
lines.push(`${r.msg}: ${label}`);
|
|
356
841
|
}
|
|
@@ -404,14 +889,163 @@ function wtList(opts) {
|
|
|
404
889
|
}
|
|
405
890
|
|
|
406
891
|
/** `dieu-phoi wt add|clean|list` (core 0.7.2 s17; 0.7.3 s2: trần, --stale-days, list). */
|
|
892
|
+
// ---- s4 (0.7.5): wt clean --merged và nhắc sau verdict accept ----
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Worktree có dấu mà nhánh đã vào HEAD của checkout chính (không gồm checkout chính). go = gỡ được: có verdict không
|
|
896
|
+
* phải reject/fail/timeout/aborted, tuổi ≥ MERGED_MIN_AGE_MS, không agent sống, sạch. kept = [{ w, why }] còn lại kèm
|
|
897
|
+
* lý do. Chỉ đọc; cũ nhất trước.
|
|
898
|
+
*/
|
|
899
|
+
function mergedPlan(ctx, main, common, events) {
|
|
900
|
+
const last = new Map();
|
|
901
|
+
for (const e of events) {
|
|
902
|
+
if (e && e.kind === 'verdict' && typeof e.task_id === 'string') last.set(e.task_id, agg.normalizeVerdict(e).outcome);
|
|
903
|
+
}
|
|
904
|
+
const tsOf = (w) => (w.created_ts && Number.isFinite(Date.parse(w.created_ts)) ? Date.parse(w.created_ts) : Infinity);
|
|
905
|
+
const go = [];
|
|
906
|
+
const kept = [];
|
|
907
|
+
const reg = registeredOf(main);
|
|
908
|
+
for (const w of markedWorktrees(common).sort((a, b) => tsOf(a) - tsOf(b))) {
|
|
909
|
+
if (!w.exists || !w.branch || pathKey(w.path) === pathKey(main)) continue;
|
|
910
|
+
// r1 s1: xét theo nhánh đang checkout THẬT (có thể đã đổi khỏi nhánh của dấu) và ref bền bất kỳ, qua wtSafety.
|
|
911
|
+
const sf = safetyOfMarked(ctx, main, w, { requireMerged: true }, reg);
|
|
912
|
+
if (!sf.merged) continue;
|
|
913
|
+
const v = last.get(w.task_id);
|
|
914
|
+
let why = null;
|
|
915
|
+
if (ageMsOf(w) < MERGED_MIN_AGE_MS) why = 'mới tạo dưới 1 giờ';
|
|
916
|
+
else if (v === undefined) why = 'chưa có verdict';
|
|
917
|
+
else if (NOT_DONE.has(v)) why = `verdict ${v}`;
|
|
918
|
+
else if (!sf.ok) why = sf.why;
|
|
919
|
+
if (why) kept.push({ w, why });
|
|
920
|
+
else go.push(Object.assign({}, w, { branch_now: sf.branch_now }));
|
|
921
|
+
}
|
|
922
|
+
return { go, kept };
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Thực hiện (apply) hoặc liệt kê (dry-run) mergedPlan. Gỡ worktree bằng removeMarked (không --force), rồi
|
|
927
|
+
* `git branch -d` nhánh (từ chối nếu chưa merge vào HEAD: an toàn). Trả { n, removed, lines }. Không ném.
|
|
928
|
+
*/
|
|
929
|
+
function mergedRun(ctx, main, common, apply) {
|
|
930
|
+
const { go, kept } = mergedPlan(ctx, main, common, core.readEvents(ctx));
|
|
931
|
+
const lines = kept.map(({ w, why }) => `giữ ${w.path}: ${why} — task=${w.task_id} nhánh=${w.branch}`);
|
|
932
|
+
let removed = 0;
|
|
933
|
+
for (const w of go) {
|
|
934
|
+
const label = `task=${w.task_id} nhánh=${w.branch}`;
|
|
935
|
+
if (!apply) {
|
|
936
|
+
lines.push(`sẽ gỡ ${w.path} và xoá nhánh (git branch -d): ${label} (đã merge, có verdict, sạch, không agent sống)`);
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
const r = removeMarked(w, main, ctx, { anyBranch: true });
|
|
940
|
+
if (!r.removed) { lines.push(`${r.msg}: ${label}`); continue; }
|
|
941
|
+
removed++;
|
|
942
|
+
recordWt(ctx, w.task_id, { action: 'remove', path: w.path, repo: main, reason: 'merged' });
|
|
943
|
+
// Nhánh của dấu chỉ bị `git branch -d` nếu nó cũng đã vào một ref bền (nhánh đã đổi sang cái khác thì có thể chưa).
|
|
944
|
+
let brMsg;
|
|
945
|
+
if (!mergedAnywhere(main, w.branch).merged) brMsg = `giữ nhánh ${w.branch}: chưa vào nhánh bền nào`;
|
|
946
|
+
else {
|
|
947
|
+
const b = git(main, ['branch', '-d', w.branch]);
|
|
948
|
+
brMsg = b.ok ? `đã xoá nhánh ${w.branch} (git branch -d)` : `giữ nhánh ${w.branch}: git branch -d lỗi (${b.err || 'không rõ'})`;
|
|
949
|
+
}
|
|
950
|
+
lines.push(`đã gỡ worktree ${w.path}; ${brMsg}: ${label}`);
|
|
951
|
+
}
|
|
952
|
+
return { n: go.length, removed, lines };
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** `dieu-phoi wt clean --merged [--repo R] [--yes]`: dry-run mặc định; không đụng --stale-days hay worktree không dấu. */
|
|
956
|
+
function wtCleanMerged(opts) {
|
|
957
|
+
const ctx = ctxOf(opts);
|
|
958
|
+
const { main, common } = mainCheckoutOf(repoOf(opts));
|
|
959
|
+
const apply = opts.yes === true;
|
|
960
|
+
const r = mergedRun(ctx, main, common, apply);
|
|
961
|
+
if (!r.lines.length) return { code: 0, out: 'wt clean --merged: không có worktree có dấu nào đã merge vào HEAD.' };
|
|
962
|
+
const tail = apply ? '' : '\nDry-run: chưa xoá gì. Chạy lại với --yes để thực hiện.';
|
|
963
|
+
const head = `wt clean --merged (${apply ? 'đã thực hiện' : 'dry-run'}): ${r.n} worktree đã merge${apply ? `, đã gỡ ${r.removed}` : ''}`;
|
|
964
|
+
return { code: 0, out: `${head}\n${r.lines.join('\n')}${tail}` };
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Sau verdict accept của NGƯỜI DÙNG (--task hoặc --root): một dòng nhắc số worktree đã merge của từng repo liên quan
|
|
969
|
+
* (repo lấy từ sự kiện worktree của các task, cộng --repo nếu có). clean (--clean-merged): gỡ luôn như
|
|
970
|
+
* `wt clean --merged --yes`. Không ném: lỗi thành một dòng báo.
|
|
971
|
+
*/
|
|
972
|
+
function mergedAfterAccept(ctx, taskIds, repoDir, clean) {
|
|
973
|
+
const lines = [];
|
|
974
|
+
try {
|
|
975
|
+
const events = core.readEvents(ctx);
|
|
976
|
+
const repos = new Set();
|
|
977
|
+
for (const id of taskIds) for (const r of reposOfTask(events, id, repoDir)) repos.add(r);
|
|
978
|
+
for (const repo of repos) {
|
|
979
|
+
let info;
|
|
980
|
+
try { info = mainCheckoutOf(repo); } catch (e) { lines.push(`wt --merged: bỏ qua ${repo} (${e.message})`); continue; }
|
|
981
|
+
if (clean) {
|
|
982
|
+
const r = mergedRun(ctx, info.main, info.common, true);
|
|
983
|
+
if (!r.lines.length) lines.push(`wt --clean-merged: không có worktree đã merge nào của ${info.main}`);
|
|
984
|
+
for (const x of r.lines) lines.push(`wt --clean-merged: ${x}`);
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
const n = mergedPlan(ctx, info.main, info.common, events).go.length;
|
|
988
|
+
if (n) lines.push(`wt: ${n} worktree đã merge của ${info.main}; dọn: dieu-phoi wt clean --merged --repo ${info.main} --yes (hoặc thêm --clean-merged vào lệnh verdict)`);
|
|
989
|
+
}
|
|
990
|
+
} catch (e) {
|
|
991
|
+
lines.push(`wt --merged: bỏ qua (${e.message})`);
|
|
992
|
+
}
|
|
993
|
+
return lines;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* r1 s5 (a): sau verdict accept của người dùng — gc lớp safe giới hạn ở repo của các task vừa nghiệm thu, in những gì đã gỡ.
|
|
998
|
+
* wt_auto_gc=off → quay về hành vi cũ (nhắc / --clean-merged). --clean-merged giữ làm bí danh (gc safe bao trùm nó).
|
|
999
|
+
*/
|
|
1000
|
+
function gcAfterAccept(ctx, taskIds, repoDir, legacyClean) {
|
|
1001
|
+
if (core.autoGcModeOf(ctx) === 'off') return mergedAfterAccept(ctx, taskIds, repoDir, legacyClean === true);
|
|
1002
|
+
const lines = [];
|
|
1003
|
+
try {
|
|
1004
|
+
const events = core.readEvents(ctx);
|
|
1005
|
+
const dirs = new Set();
|
|
1006
|
+
for (const id of taskIds) {
|
|
1007
|
+
for (const r of reposOfTask(events, id, repoDir)) {
|
|
1008
|
+
try { dirs.add(mainCheckoutOf(r).main); } catch (e) { lines.push('wt gc: bỏ qua ' + r + ' (' + e.message + ')'); }
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
if (!dirs.size) return lines;
|
|
1012
|
+
const G = require('./wtgc.cjs');
|
|
1013
|
+
const rep = G.autoGc(ctx, { repoDirs: Array.from(dirs), trigger: 'verdict', budgetMs: 20000 });
|
|
1014
|
+
for (const r of rep.repos) {
|
|
1015
|
+
for (const l of r.applied || []) if (/^đã (gỡ|xoá|git worktree prune)/.test(l)) lines.push('wt gc (tự động, lớp safe) ' + r.repo + ': ' + l);
|
|
1016
|
+
if (r.error) lines.push('wt gc: ' + r.repo + ' lỗi: ' + r.error);
|
|
1017
|
+
}
|
|
1018
|
+
const c = rep.counts || {};
|
|
1019
|
+
lines.push('wt gc (tự động, lớp safe): đã gỡ ' + rep.removed + ' mục, ~' + G.fmtBytes(rep.reclaimed_bytes) + '; còn giữ ' + (c.abandoned || 0) + ' bỏ dở / ' + (c['keep-slot'] || 0) + ' slot / ' + (c.keep || 0) + ' khác (không tự gỡ; xem: dieu-phoi wt gc --repo <repo> --measure)');
|
|
1020
|
+
} catch (e) {
|
|
1021
|
+
lines.push('wt gc (tự động): bỏ qua (' + e.message + ')');
|
|
1022
|
+
}
|
|
1023
|
+
return lines;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
407
1026
|
function cmdWt(opts) {
|
|
408
1027
|
const sub = opts._[0];
|
|
409
|
-
if (sub === 'add')
|
|
410
|
-
|
|
1028
|
+
if (sub === 'add') {
|
|
1029
|
+
const out = wtAdd(opts);
|
|
1030
|
+
try { repos.recordRepo(ctxOf(opts).paths.data, opts.repo || process.cwd(), 'wt'); } catch (_) { /* sổ repo: best effort */ }
|
|
1031
|
+
return out;
|
|
1032
|
+
}
|
|
1033
|
+
if (sub === 'clean') return opts.merged === true ? wtCleanMerged(opts) : wtClean(opts);
|
|
411
1034
|
if (sub === 'list') return wtList(opts);
|
|
412
|
-
|
|
1035
|
+
if (sub === 'repos') return repos.cmdRepos(opts, ctxOf(opts));
|
|
1036
|
+
if (sub === 'gc') return require('./wtgc.cjs').cmdGc(opts); // r1 s3: tự dọn mọi repo đã chạm
|
|
1037
|
+
throw new Error('Cú pháp: dieu-phoi wt add --task <task_id> [--repo r] [--path dir] [--branch b] [--base ref] [--force] [--deps auto|link|install|none] [--deps-path dir]\n'
|
|
413
1038
|
+ ' dieu-phoi wt clean [--repo r] [--stale-days N] [--yes]\n'
|
|
414
|
-
+ ' dieu-phoi wt
|
|
1039
|
+
+ ' dieu-phoi wt clean --merged [--repo r] [--yes]\n'
|
|
1040
|
+
+ ' dieu-phoi wt list [--repo r] [--all] [--json]\n'
|
|
1041
|
+
+ ' dieu-phoi wt repos [--seed <thư mục…>] [--json]\n'
|
|
1042
|
+
+ ' dieu-phoi wt gc [--repo r | --all-repos] [--yes] [--json] [--no-measure]');
|
|
415
1043
|
}
|
|
416
1044
|
|
|
417
|
-
module.exports = {
|
|
1045
|
+
module.exports = {
|
|
1046
|
+
walkNewest,
|
|
1047
|
+
cmdWt, cleanupTask, mergedAfterAccept, gcAfterAccept, mainCheckoutOf, markedWorktrees, registeredOf, liveAgentInWorktree, MARK,
|
|
1048
|
+
prefetchDirty, clearDirtyCache, wtSafety, safetyOfMarked, mergedAnywhere, durablePolicy, registeredEntry, adminDirOf, liveActivityOf, taskIdsOfWorktree, idleMsOf, newestFileMtimeMs, setGitDeadline,
|
|
1049
|
+
// r1 s3: dùng bởi lib/wtgc.cjs
|
|
1050
|
+
gitRun: git, realKey, dirtyOf, isTempBranch, keepSlotReason, isAutoName, numSetting, ageMsOf, recordWt, NOT_DONE, MERGED_MIN_AGE_MS,
|
|
1051
|
+
};
|