@kienntpixon/dieu-phoi 0.7.5 → 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 +11 -0
- package/bin/dieu-phoi.cjs +9 -0
- package/hook.cjs +218 -9
- package/lib/args.cjs +1 -1
- package/lib/commands.cjs +9 -7
- package/lib/repos.cjs +387 -0
- package/lib/setup.cjs +22 -0
- package/lib/worktree.cjs +518 -43
- package/lib/wtfast.cjs +156 -0
- package/lib/wtgc.cjs +756 -0
- package/package.json +1 -1
package/lib/worktree.cjs
CHANGED
|
@@ -28,6 +28,7 @@ const core = require('../hook.cjs');
|
|
|
28
28
|
const agg = require('./aggregate.cjs');
|
|
29
29
|
const { ctxOf, repoOf } = require('./args.cjs');
|
|
30
30
|
const deps = require('./deps.cjs');
|
|
31
|
+
const repos = require('./repos.cjs'); // core 0.7.6 (s2): sổ repo đã chạm (data/repos.json)
|
|
31
32
|
|
|
32
33
|
const MARK = 'dieu-phoi.json';
|
|
33
34
|
const BRANCH_PREFIX = 'dp/';
|
|
@@ -40,8 +41,14 @@ const NOT_DONE = new Set(['reject', 'fail', 'timeout', 'aborted']);
|
|
|
40
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ỡ.
|
|
41
42
|
const MERGED_MIN_AGE_MS = 60 * 60 * 1000;
|
|
42
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; }
|
|
48
|
+
|
|
43
49
|
function git(cwd, args) {
|
|
44
|
-
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 });
|
|
45
52
|
const err = r.stderr || (r.error ? r.error.message : '') || '';
|
|
46
53
|
return { ok: r.status === 0, out: (r.stdout || '').trim(), err: err.trim() };
|
|
47
54
|
}
|
|
@@ -74,18 +81,65 @@ function markedWorktrees(common) {
|
|
|
74
81
|
out.push({
|
|
75
82
|
admin, name, task_id: mark.task_id, root: mark.root || null, branch: mark.branch || null,
|
|
76
83
|
created_ts: mark.created_ts || null, path: wtPath, exists: !!wtPath && fs.existsSync(wtPath),
|
|
84
|
+
keep: mark.keep === true,
|
|
77
85
|
deps: typeof mark.deps === 'string' ? mark.deps : null, deps_path: typeof mark.deps_path === 'string' ? mark.deps_path : '.',
|
|
78
86
|
});
|
|
79
87
|
}
|
|
80
88
|
return out;
|
|
81
89
|
}
|
|
82
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
|
+
|
|
83
134
|
/**
|
|
84
135
|
* true = còn thay đổi (kể cả file chưa theo dõi), false = sạch, null = không đọc được.
|
|
85
136
|
* s3: dòng `?? …node_modules/` là link (junction dùng chung do --deps link tạo) không tính là thay đổi.
|
|
86
137
|
*/
|
|
87
138
|
function dirtyOf(wtPath) {
|
|
88
|
-
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']);
|
|
89
143
|
if (!r.ok) return null;
|
|
90
144
|
const lines = r.out.split(/\r?\n/).filter(Boolean).filter((l) => {
|
|
91
145
|
const m = /^\?\? "?(.*?node_modules)\/?"?$/.exec(l);
|
|
@@ -103,35 +157,376 @@ function realKey(p) {
|
|
|
103
157
|
return process.platform === 'win32' ? r.toLowerCase() : r;
|
|
104
158
|
}
|
|
105
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
|
+
|
|
106
172
|
/**
|
|
107
|
-
* s8: kiểm "worktree đang
|
|
108
|
-
*
|
|
109
|
-
* (
|
|
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.
|
|
110
178
|
*/
|
|
111
|
-
function
|
|
179
|
+
function liveActivityOf(ctx, wtPath, taskIds) {
|
|
112
180
|
const wt = realKey(wtPath);
|
|
113
181
|
if (!wt) return null;
|
|
114
182
|
let live;
|
|
115
183
|
try { live = core.readLive(ctx); } catch (_) { return null; }
|
|
184
|
+
const ids = new Set(taskIds || []);
|
|
185
|
+
let best = null;
|
|
116
186
|
for (const r of live.agents.concat(live.pending)) {
|
|
117
187
|
const at = realKey(r.cwd || r.worktree);
|
|
118
|
-
|
|
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;
|
|
119
191
|
const who = r.agent_id ? `agent ${String(r.agent_id).slice(0, 8)}` : 'agent đang chờ nhận';
|
|
120
|
-
|
|
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);
|
|
121
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';
|
|
122
303
|
return null;
|
|
123
304
|
}
|
|
124
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 */ }
|
|
328
|
+
}
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
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
|
+
|
|
125
346
|
/**
|
|
126
|
-
*
|
|
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
|
+
|
|
405
|
+
/**
|
|
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ỡ)..
|
|
127
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.
|
|
128
|
-
*
|
|
518
|
+
* Khoá `initializing` mồ côi → `git worktree unlock` trước khi gỡ. Gỡ xong mà còn vỏ thư mục thì removeShell.
|
|
129
519
|
*/
|
|
130
|
-
function removeMarked(w, mainDir, ctx) {
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
+
}
|
|
135
530
|
let links;
|
|
136
531
|
try { links = deps.unlinkDepLinks(w.path, [w.deps_path || '.']); } catch (e) {
|
|
137
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` };
|
|
@@ -146,7 +541,7 @@ function removeMarked(w, mainDir, ctx) {
|
|
|
146
541
|
try { deps.removeShell(w.path); } catch (e) { shell = `; còn vỏ thư mục chưa xoá được (${e.message})`; }
|
|
147
542
|
}
|
|
148
543
|
const unl = links.length ? `; đã gỡ junction node_modules trước` : '';
|
|
149
|
-
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})` };
|
|
150
545
|
}
|
|
151
546
|
|
|
152
547
|
function recordWt(ctx, taskId, fields) {
|
|
@@ -171,7 +566,8 @@ function reposOfTask(events, taskId, repoDir) {
|
|
|
171
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.
|
|
172
567
|
* repoDir: --repo của lệnh (nếu có) — ngoài repo đã ghi trong sự kiện worktree.
|
|
173
568
|
*/
|
|
174
|
-
function cleanupTask(ctx, events, taskId, repoDir) {
|
|
569
|
+
function cleanupTask(ctx, events, taskId, repoDir, opts) {
|
|
570
|
+
const discard = !!(opts && opts.discard);
|
|
175
571
|
const lines = [];
|
|
176
572
|
for (const repo of reposOfTask(events, taskId, repoDir)) {
|
|
177
573
|
let info;
|
|
@@ -179,9 +575,13 @@ function cleanupTask(ctx, events, taskId, repoDir) {
|
|
|
179
575
|
const mine = markedWorktrees(info.common).filter((w) => w.task_id === taskId);
|
|
180
576
|
for (const w of mine) {
|
|
181
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).
|
|
182
580
|
const r = removeMarked(w, info.main, ctx);
|
|
183
581
|
if (r.removed) recordWt(ctx, taskId, { action: 'remove', path: w.path, repo: info.main, reason: 'verdict' });
|
|
184
|
-
|
|
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}`);
|
|
185
585
|
}
|
|
186
586
|
}
|
|
187
587
|
return lines;
|
|
@@ -220,9 +620,12 @@ function registeredOf(main) {
|
|
|
220
620
|
const out = [];
|
|
221
621
|
let cur = null;
|
|
222
622
|
for (const line of r.out.split(/\r?\n/)) {
|
|
223
|
-
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); }
|
|
224
624
|
else if (cur && line.startsWith('branch ')) cur.branch = line.slice(7).replace(/^refs\/heads\//, '');
|
|
225
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;
|
|
226
629
|
}
|
|
227
630
|
return out;
|
|
228
631
|
}
|
|
@@ -276,7 +679,7 @@ function reusableLines(ctx, main, common) {
|
|
|
276
679
|
if (v === 'pass' || v === 'accept') why = `verdict ${v}`;
|
|
277
680
|
else if (v !== undefined && !NOT_DONE.has(v) && branchMerged(main, w.branch)) why = 'nhánh đã vào HEAD';
|
|
278
681
|
if (!why) continue;
|
|
279
|
-
if (liveAgentInWorktree(ctx, w.path)) continue;
|
|
682
|
+
if (liveAgentInWorktree(ctx, w.path, taskIdsOfWorktree(w.path, w.branch, w))) continue;
|
|
280
683
|
if (dirtyOf(w.path) !== false) continue;
|
|
281
684
|
rows.push(` - ${w.path} task=${w.task_id} nhánh=${w.branch || '?'} (${why})`);
|
|
282
685
|
}
|
|
@@ -340,13 +743,33 @@ function wtAdd(opts) {
|
|
|
340
743
|
if (fs.existsSync(wtPath)) throw new Error(`wt add: ${wtPath} đã tồn tại`);
|
|
341
744
|
const branch = typeof opts.branch === 'string' && opts.branch ? opts.branch : `${BRANCH_PREFIX}${taskId}`;
|
|
342
745
|
const r = git(main, ['worktree', 'add', '-b', branch, wtPath, ...(base ? [base] : [])]);
|
|
343
|
-
|
|
344
|
-
|
|
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);
|
|
345
763
|
if (!adminDir) throw new Error(`worktree ${wtPath} tạo xong nhưng không đọc được git-dir để ghi dấu`);
|
|
346
764
|
const markPath = path.join(adminDir, MARK);
|
|
347
|
-
const mark = { task_id: taskId, root: main, created_ts: core.nowIso(), branch, deps: 'none', deps_path: depsPath };
|
|
348
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.
|
|
349
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
|
+
}
|
|
350
773
|
const d = deps.setupDeps({ main, wtPath, mode: depsMode, depsPath });
|
|
351
774
|
if (d.deps !== 'none') {
|
|
352
775
|
mark.deps = d.deps;
|
|
@@ -380,13 +803,16 @@ function wtClean(opts) {
|
|
|
380
803
|
.map((e) => e.task_id));
|
|
381
804
|
const gone = agg.discardedTaskIds(events);
|
|
382
805
|
const rows = [];
|
|
806
|
+
const reg = registeredOf(main);
|
|
383
807
|
for (const w of markedWorktrees(common)) {
|
|
384
808
|
let why = null;
|
|
385
809
|
if (!w.exists) why = 'thư mục đã mất';
|
|
386
810
|
else if (accepted.has(w.task_id)) why = 'đã nghiệm thu';
|
|
387
811
|
else if (gone.has(w.task_id)) why = 'đã loại (discard)';
|
|
388
|
-
else if (ageMsOf(w) >= staleDays * DAY_MS
|
|
389
|
-
|
|
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]})`;
|
|
390
816
|
}
|
|
391
817
|
if (why) rows.push(Object.assign({}, w, { why }));
|
|
392
818
|
}
|
|
@@ -404,12 +830,12 @@ function wtClean(opts) {
|
|
|
404
830
|
} else lines.push(`sẽ bỏ đăng ký ${w.path || w.name}: ${label}`);
|
|
405
831
|
continue;
|
|
406
832
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const
|
|
410
|
-
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; }
|
|
411
837
|
if (!apply) { lines.push(`sẽ gỡ ${w.path}: ${label}`); continue; }
|
|
412
|
-
const r = removeMarked(w, main, ctx);
|
|
838
|
+
const r = removeMarked(w, main, ctx, ropts);
|
|
413
839
|
if (r.removed) recordWt(ctx, w.task_id, { action: 'remove', path: w.path, repo: main, reason: w.why });
|
|
414
840
|
lines.push(`${r.msg}: ${label}`);
|
|
415
841
|
}
|
|
@@ -478,21 +904,20 @@ function mergedPlan(ctx, main, common, events) {
|
|
|
478
904
|
const tsOf = (w) => (w.created_ts && Number.isFinite(Date.parse(w.created_ts)) ? Date.parse(w.created_ts) : Infinity);
|
|
479
905
|
const go = [];
|
|
480
906
|
const kept = [];
|
|
907
|
+
const reg = registeredOf(main);
|
|
481
908
|
for (const w of markedWorktrees(common).sort((a, b) => tsOf(a) - tsOf(b))) {
|
|
482
909
|
if (!w.exists || !w.branch || pathKey(w.path) === pathKey(main)) continue;
|
|
483
|
-
|
|
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;
|
|
484
913
|
const v = last.get(w.task_id);
|
|
485
914
|
let why = null;
|
|
486
915
|
if (ageMsOf(w) < MERGED_MIN_AGE_MS) why = 'mới tạo dưới 1 giờ';
|
|
487
916
|
else if (v === undefined) why = 'chưa có verdict';
|
|
488
917
|
else if (NOT_DONE.has(v)) why = `verdict ${v}`;
|
|
489
|
-
else
|
|
490
|
-
const busy = liveAgentInWorktree(ctx, w.path);
|
|
491
|
-
if (busy) why = `đang là cwd của agent sống (${busy})`;
|
|
492
|
-
else if (dirtyOf(w.path) !== false) why = 'còn thay đổi chưa commit hoặc không đọc được trạng thái git';
|
|
493
|
-
}
|
|
918
|
+
else if (!sf.ok) why = sf.why;
|
|
494
919
|
if (why) kept.push({ w, why });
|
|
495
|
-
else go.push(w);
|
|
920
|
+
else go.push(Object.assign({}, w, { branch_now: sf.branch_now }));
|
|
496
921
|
}
|
|
497
922
|
return { go, kept };
|
|
498
923
|
}
|
|
@@ -511,12 +936,18 @@ function mergedRun(ctx, main, common, apply) {
|
|
|
511
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)`);
|
|
512
937
|
continue;
|
|
513
938
|
}
|
|
514
|
-
const r = removeMarked(w, main, ctx);
|
|
939
|
+
const r = removeMarked(w, main, ctx, { anyBranch: true });
|
|
515
940
|
if (!r.removed) { lines.push(`${r.msg}: ${label}`); continue; }
|
|
516
941
|
removed++;
|
|
517
942
|
recordWt(ctx, w.task_id, { action: 'remove', path: w.path, repo: main, reason: 'merged' });
|
|
518
|
-
|
|
519
|
-
|
|
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}`);
|
|
520
951
|
}
|
|
521
952
|
return { n: go.length, removed, lines };
|
|
522
953
|
}
|
|
@@ -562,15 +993,59 @@ function mergedAfterAccept(ctx, taskIds, repoDir, clean) {
|
|
|
562
993
|
return lines;
|
|
563
994
|
}
|
|
564
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
|
+
|
|
565
1026
|
function cmdWt(opts) {
|
|
566
1027
|
const sub = opts._[0];
|
|
567
|
-
if (sub === 'add')
|
|
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
|
+
}
|
|
568
1033
|
if (sub === 'clean') return opts.merged === true ? wtCleanMerged(opts) : wtClean(opts);
|
|
569
1034
|
if (sub === 'list') return wtList(opts);
|
|
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
|
|
570
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'
|
|
571
1038
|
+ ' dieu-phoi wt clean [--repo r] [--stale-days N] [--yes]\n'
|
|
572
1039
|
+ ' dieu-phoi wt clean --merged [--repo r] [--yes]\n'
|
|
573
|
-
+ ' dieu-phoi wt list [--repo r] [--all] [--json]'
|
|
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]');
|
|
574
1043
|
}
|
|
575
1044
|
|
|
576
|
-
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
|
+
};
|