pi-odd-worktree 0.0.0-stage → 1.0.0
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/README.md +234 -2
- package/extensions/odd-worktree-tools.ts +7593 -0
- package/lib/checkout-facts.js +598 -0
- package/lib/clipboard.js +226 -0
- package/lib/gh-issues.js +1555 -0
- package/lib/issue-preview.js +273 -0
- package/lib/issues-view.js +2274 -0
- package/lib/odd-docs.js +505 -0
- package/lib/odd-markdown.js +413 -0
- package/lib/odd-view.js +990 -0
- package/lib/open-external.js +106 -0
- package/lib/preview-scroll-keys.js +192 -0
- package/lib/session-auto-name.js +292 -0
- package/lib/session-worktree-register.js +62 -0
- package/lib/tui-paint.js +273 -0
- package/lib/view-layout.js +368 -0
- package/lib/warp-create-panel.js +722 -0
- package/lib/warp-create-picker.js +906 -0
- package/lib/warp-delete-confirm.js +465 -0
- package/lib/warp-selector-view.js +1265 -0
- package/lib/worktree-card.js +1117 -0
- package/lib/worktree-changes.js +1395 -0
- package/lib/worktree-close-herdr.js +73 -0
- package/lib/worktree-git.js +1048 -0
- package/lib/worktree-hooks.js +738 -0
- package/lib/worktree-model-result.js +291 -0
- package/lib/worktree-registry.js +886 -0
- package/lib/worktree-session-guard.js +595 -0
- package/lib/worktree-symlinks.js +336 -0
- package/lib/worktree-warp-sessions.js +566 -0
- package/package.json +36 -4
- package/tools/commands.test.js +176 -0
- package/tools/fixtures/gh-api-user.json +1 -0
- package/tools/fixtures/gh-issue-list.json +1 -0
- package/tools/fixtures/gh-issue-view.json +1 -0
- package/tools/fixtures/gh-label-list.json +1 -0
- package/tools/fixtures/gh-repo-view.json +1 -0
- package/tools/fixtures/load-extension.js +66 -0
- package/tools/gh-issues-feedback.test.js +359 -0
- package/tools/gh-issues.test.js +1502 -0
- package/tools/issue-preview.test.js +216 -0
- package/tools/issues-command.test.js +1217 -0
- package/tools/issues-view.test.js +4401 -0
- package/tools/odd-docs.test.js +803 -0
- package/tools/odd-markdown.test.js +236 -0
- package/tools/odd-view.test.js +2328 -0
- package/tools/package-surface.test.js +138 -0
- package/tools/t5-session-identity.test.js +306 -0
- package/tools/warp-create-panel.test.js +185 -0
- package/tools/warp-create-picker.test.js +1357 -0
- package/tools/warp-delete-confirm.test.js +616 -0
- package/tools/warp-selector-view.test.js +1463 -0
- package/tools/worktree-changes.test.js +951 -0
- package/tools/worktree-close-herdr-async.test.js +204 -0
- package/tools/worktree-odd-migration.test.js +224 -0
- package/tools/worktree-registry-cache.test.js +163 -0
- package/tools/worktree-registry-odd.test.js +231 -0
- package/tools/worktree-registry.test.js +244 -0
- package/tools/worktree-session-guard.test.js +507 -0
- package/tools/worktree-tools.test.js +434 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Principal-entry symlinks for the worktree tools (S51/T42, re-scoped by
|
|
3
|
+
// round-3 S3/L4: ignore-gated and never destructive).
|
|
4
|
+
//
|
|
5
|
+
// On every worktree creation, each name in `LINK_NAMES` — `.agents`, `.pi`,
|
|
6
|
+
// `.claude` and `odd` — is linked from the PRINCIPAL checkout into the new
|
|
7
|
+
// worktree ONLY when ALL of these hold:
|
|
8
|
+
//
|
|
9
|
+
// - the principal entry resolves to a directory,
|
|
10
|
+
// - git TRACKS nothing under it there (a tracked entry already arrives
|
|
11
|
+
// with the checkout, and its content is never replaced),
|
|
12
|
+
// - git IGNORES it there (a merely untracked entry is not eligible:
|
|
13
|
+
// the user did not ask for it to travel),
|
|
14
|
+
// - the new worktree has nothing at that path (a checkout entry and a
|
|
15
|
+
// path somebody else occupied are both preserved; nothing is ever
|
|
16
|
+
// removed or replaced).
|
|
17
|
+
//
|
|
18
|
+
// An existing link that already points at the principal entry is
|
|
19
|
+
// idempotent: it is reported as `existing`, no write happens, and it stays
|
|
20
|
+
// in `created` because that field means "links in place after this call"
|
|
21
|
+
// (the meaning the create tool already surfaces).
|
|
22
|
+
//
|
|
23
|
+
// Both git facts come from ONE fixed-argv call each (`git ls-files`,
|
|
24
|
+
// `git check-ignore`), never a shell string, with a bounded timeout. When
|
|
25
|
+
// git cannot answer for a name, that name is skipped — fail closed, never
|
|
26
|
+
// a guess.
|
|
27
|
+
//
|
|
28
|
+
// The tool's creation hooks use the sync runner; command-side hooks use the
|
|
29
|
+
// async twin so their git calls never block the event loop. Both runners
|
|
30
|
+
// share the eligibility classifier and never replace an occupied target.
|
|
31
|
+
|
|
32
|
+
const { spawnSync } = require('node:child_process');
|
|
33
|
+
const fs = require('node:fs');
|
|
34
|
+
const path = require('node:path');
|
|
35
|
+
|
|
36
|
+
const LINK_NAMES = ['.agents', '.pi', '.claude', 'odd'];
|
|
37
|
+
|
|
38
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
39
|
+
|
|
40
|
+
// Fixed argv, bounded timeout, no shell. Same shape as the hook runner so a
|
|
41
|
+
// caller can inject its own runner.
|
|
42
|
+
function defaultRunGit(bin, args, opts = {}) {
|
|
43
|
+
const result = spawnSync(bin, args, {
|
|
44
|
+
cwd: opts.cwd,
|
|
45
|
+
env: opts.env,
|
|
46
|
+
timeout: opts.timeoutMs,
|
|
47
|
+
encoding: 'utf8',
|
|
48
|
+
windowsHide: true,
|
|
49
|
+
});
|
|
50
|
+
if (result.error) {
|
|
51
|
+
return { status: null, stdout: '', stderr: '', error: String((result.error && result.error.message) || result.error) };
|
|
52
|
+
}
|
|
53
|
+
return { status: result.status, stdout: result.stdout || '', stderr: result.stderr || '', error: null };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Async default: the owned runner shared with the creation hooks, so a
|
|
57
|
+
// timed-out git child is killed and reaped instead of being abandoned.
|
|
58
|
+
function defaultRunGitAsync(bin, args, opts = {}) {
|
|
59
|
+
let runner = null;
|
|
60
|
+
try {
|
|
61
|
+
runner = require('./worktree-git.js').runArgvAsync;
|
|
62
|
+
} catch {
|
|
63
|
+
runner = null;
|
|
64
|
+
}
|
|
65
|
+
if (typeof runner === 'function') return runner(bin, args, opts);
|
|
66
|
+
return Promise.resolve({ status: null, stdout: '', stderr: '', error: 'async git runner unavailable' });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function callGit(run, args, cwd, timeoutMs, env) {
|
|
70
|
+
let res;
|
|
71
|
+
try {
|
|
72
|
+
res = run('git', args, { cwd, timeoutMs, env });
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
if (!res) return null;
|
|
77
|
+
if (res.status === null || res.status === undefined || res.error) return null;
|
|
78
|
+
return res;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function callGitAsync(runAsync, args, cwd, timeoutMs, env) {
|
|
82
|
+
let res;
|
|
83
|
+
try {
|
|
84
|
+
res = await runAsync('git', args, { cwd, timeoutMs, env });
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (!res) return null;
|
|
89
|
+
if (res.status === null || res.status === undefined || res.error) return null;
|
|
90
|
+
return res;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function namesFromArgs(names) {
|
|
94
|
+
return Array.isArray(names) && names.length > 0 ? names : LINK_NAMES;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Which names a `git ls-files` listing covers: a name is tracked when the
|
|
98
|
+
// listing names it or anything below it.
|
|
99
|
+
function trackedFromStdout(names, stdout) {
|
|
100
|
+
const tracked = new Set();
|
|
101
|
+
for (const line of String(stdout || '').split('\n')) {
|
|
102
|
+
if (line.length === 0) continue;
|
|
103
|
+
for (const name of names) {
|
|
104
|
+
if (line === name || line.startsWith(`${name}/`)) tracked.add(name);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return tracked;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Which names a `git check-ignore` listing reports. Exit 1 means "none of
|
|
111
|
+
// them is ignored", which is an answer, not a failure.
|
|
112
|
+
function ignoredFromStdout(names, stdout) {
|
|
113
|
+
const ignored = new Set();
|
|
114
|
+
for (const line of String(stdout || '').split('\n')) {
|
|
115
|
+
const name = line.trim();
|
|
116
|
+
if (name.length > 0 && names.includes(name)) ignored.add(name);
|
|
117
|
+
}
|
|
118
|
+
return ignored;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Names git tracks in the principal, or null when git cannot answer.
|
|
122
|
+
function trackedNames(run, principalRoot, names, timeoutMs, env) {
|
|
123
|
+
const res = callGit(run, ['ls-files', '--', ...names], principalRoot, timeoutMs, env);
|
|
124
|
+
if (!res || res.status !== 0) return null;
|
|
125
|
+
return trackedFromStdout(names, res.stdout);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Names git ignores in the principal, or null when git cannot answer.
|
|
129
|
+
// An ignored path is never reported for a tracked one, which is why the
|
|
130
|
+
// tracked read comes first: checkout content wins.
|
|
131
|
+
function ignoredNames(run, principalRoot, names, timeoutMs, env) {
|
|
132
|
+
const res = callGit(run, ['check-ignore', '--', ...names], principalRoot, timeoutMs, env);
|
|
133
|
+
if (!res) return null;
|
|
134
|
+
// 1 is "none of these is ignored"; any other non-zero is an error.
|
|
135
|
+
if (res.status !== 0 && res.status !== 1) return null;
|
|
136
|
+
return ignoredFromStdout(names, res.stdout);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function trackedNamesAsync(runAsync, principalRoot, names, timeoutMs, env) {
|
|
140
|
+
const res = await callGitAsync(runAsync, ['ls-files', '--', ...names], principalRoot, timeoutMs, env);
|
|
141
|
+
if (!res || res.status !== 0) return null;
|
|
142
|
+
return trackedFromStdout(names, res.stdout);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function ignoredNamesAsync(runAsync, principalRoot, names, timeoutMs, env) {
|
|
146
|
+
const res = await callGitAsync(runAsync, ['check-ignore', '--', ...names], principalRoot, timeoutMs, env);
|
|
147
|
+
if (!res) return null;
|
|
148
|
+
if (res.status !== 0 && res.status !== 1) return null;
|
|
149
|
+
return ignoredFromStdout(names, res.stdout);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function sourceState(src) {
|
|
153
|
+
let stat;
|
|
154
|
+
try {
|
|
155
|
+
stat = fs.statSync(src);
|
|
156
|
+
} catch {
|
|
157
|
+
return 'missing';
|
|
158
|
+
}
|
|
159
|
+
return stat.isDirectory() ? 'directory' : 'not-directory';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// `same-link` is the idempotent case: a symlink that already resolves to the
|
|
163
|
+
// principal entry. Anything else at that path is `occupied` and is kept —
|
|
164
|
+
// this hook never deletes or replaces an existing entry.
|
|
165
|
+
function targetState(dst, src) {
|
|
166
|
+
let lstat;
|
|
167
|
+
try {
|
|
168
|
+
lstat = fs.lstatSync(dst);
|
|
169
|
+
} catch {
|
|
170
|
+
return 'absent';
|
|
171
|
+
}
|
|
172
|
+
if (!lstat.isSymbolicLink()) return 'occupied';
|
|
173
|
+
try {
|
|
174
|
+
return fs.realpathSync(dst) === fs.realpathSync(src) ? 'same-link' : 'occupied';
|
|
175
|
+
} catch {
|
|
176
|
+
return 'occupied';
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function sourceStateAsync(src) {
|
|
181
|
+
try {
|
|
182
|
+
const stat = await fs.promises.stat(src);
|
|
183
|
+
return stat.isDirectory() ? 'directory' : 'not-directory';
|
|
184
|
+
} catch {
|
|
185
|
+
return 'missing';
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function targetStateAsync(dst, src) {
|
|
190
|
+
let lstat;
|
|
191
|
+
try {
|
|
192
|
+
lstat = await fs.promises.lstat(dst);
|
|
193
|
+
} catch {
|
|
194
|
+
return 'absent';
|
|
195
|
+
}
|
|
196
|
+
if (!lstat.isSymbolicLink()) return 'occupied';
|
|
197
|
+
try {
|
|
198
|
+
const [a, b] = await Promise.all([fs.promises.realpath(dst), fs.promises.realpath(src)]);
|
|
199
|
+
return a === b ? 'same-link' : 'occupied';
|
|
200
|
+
} catch {
|
|
201
|
+
return 'occupied';
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// The one place eligibility is decided, shared by both runners. Order is the
|
|
206
|
+
// contract: a source problem, an idempotent link, then the tracked gate (it
|
|
207
|
+
// explains why a checkout entry is already there), then an occupied target,
|
|
208
|
+
// then the ignore gate.
|
|
209
|
+
function classify({ name, source, target, tracked, ignored }) {
|
|
210
|
+
if (source === 'missing') return { action: 'skip', reason: `${name}: missing source` };
|
|
211
|
+
if (source !== 'directory') return { action: 'skip', reason: `${name}: source is not a directory` };
|
|
212
|
+
if (target === 'same-link') return { action: 'existing' };
|
|
213
|
+
if (tracked === null) return { action: 'skip', reason: `${name}: tracked status unknown` };
|
|
214
|
+
if (tracked.has(name)) return { action: 'skip', reason: `${name}: tracked`, trackedBlocked: true };
|
|
215
|
+
if (target === 'occupied') return { action: 'skip', reason: `${name}: target exists` };
|
|
216
|
+
if (ignored === null) return { action: 'skip', reason: `${name}: ignore status unknown` };
|
|
217
|
+
if (!ignored.has(name)) return { action: 'skip', reason: `${name}: not git-ignored` };
|
|
218
|
+
return { action: 'link' };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function emptyResult() {
|
|
222
|
+
return { created: [], existing: [], skipped: [], skippedTracked: [], errors: [] };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function record(result, entry, name) {
|
|
226
|
+
if (entry.action === 'existing') {
|
|
227
|
+
result.created.push(name);
|
|
228
|
+
result.existing.push(name);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
result.skipped.push(entry.reason);
|
|
232
|
+
if (entry.trackedBlocked) result.skippedTracked.push(name);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Shared context resolution; the caller supplies the git runner, so the sync
|
|
236
|
+
// and async entry points differ only in how they await filesystem work.
|
|
237
|
+
function resolveContext(opts) {
|
|
238
|
+
const principalRoot = opts.principalRoot;
|
|
239
|
+
const worktreePath = opts.worktreePath;
|
|
240
|
+
if (typeof principalRoot !== 'string' || principalRoot.length === 0) return null;
|
|
241
|
+
if (typeof worktreePath !== 'string' || worktreePath.length === 0) return null;
|
|
242
|
+
const names = namesFromArgs(opts.names);
|
|
243
|
+
const env = opts.env || process.env;
|
|
244
|
+
const timeoutMs = Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
245
|
+
return {
|
|
246
|
+
names,
|
|
247
|
+
principalRoot,
|
|
248
|
+
worktreePath,
|
|
249
|
+
env,
|
|
250
|
+
timeoutMs,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Apply the link policy synchronously. Returns `{created, existing, skipped,
|
|
255
|
+
// skippedTracked, errors}` and never throws: a failed link is reported, the
|
|
256
|
+
// other names still apply, and creation is never failed by this step.
|
|
257
|
+
function applyWorktreeSymlinks(opts = {}) {
|
|
258
|
+
const run = typeof opts.run === 'function'
|
|
259
|
+
? (bin, args, o) => opts.run(bin, args, o)
|
|
260
|
+
: defaultRunGit;
|
|
261
|
+
const ctx = resolveContext(opts);
|
|
262
|
+
const result = emptyResult();
|
|
263
|
+
if (!ctx) return result;
|
|
264
|
+
const {
|
|
265
|
+
names, principalRoot, worktreePath, env, timeoutMs,
|
|
266
|
+
} = ctx;
|
|
267
|
+
const tracked = trackedNames(run, principalRoot, names, timeoutMs, env);
|
|
268
|
+
const ignored = tracked === null ? null : ignoredNames(run, principalRoot, names, timeoutMs, env);
|
|
269
|
+
for (const name of names) {
|
|
270
|
+
const src = path.join(principalRoot, name);
|
|
271
|
+
const entry = classify({
|
|
272
|
+
name,
|
|
273
|
+
source: sourceState(src),
|
|
274
|
+
target: targetState(path.join(worktreePath, name), src),
|
|
275
|
+
tracked,
|
|
276
|
+
ignored,
|
|
277
|
+
});
|
|
278
|
+
if (entry.action !== 'link') {
|
|
279
|
+
record(result, entry, name);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
// Absolute target, so the link never depends on cwd spelling. The type
|
|
284
|
+
// only matters on Windows ('junction' needs no privilege for
|
|
285
|
+
// directories); POSIX ignores it.
|
|
286
|
+
fs.symlinkSync(src, path.join(worktreePath, name), 'junction');
|
|
287
|
+
result.created.push(name);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
result.errors.push(`${name}: ${(err && err.message) || err}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return result;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Async twin: identical plan and result shape, awaited filesystem and git
|
|
296
|
+
// work so the event loop keeps running while a large principal is inspected.
|
|
297
|
+
async function applyWorktreeSymlinksAsync(opts = {}) {
|
|
298
|
+
const runAsync = typeof opts.runAsync === 'function'
|
|
299
|
+
? (bin, args, o) => opts.runAsync(bin, args, o)
|
|
300
|
+
: defaultRunGitAsync;
|
|
301
|
+
const ctx = resolveContext(opts);
|
|
302
|
+
const result = emptyResult();
|
|
303
|
+
if (!ctx) return result;
|
|
304
|
+
const {
|
|
305
|
+
names, principalRoot, worktreePath, env, timeoutMs,
|
|
306
|
+
} = ctx;
|
|
307
|
+
const tracked = await trackedNamesAsync(runAsync, principalRoot, names, timeoutMs, env);
|
|
308
|
+
const ignored = tracked === null ? null : await ignoredNamesAsync(runAsync, principalRoot, names, timeoutMs, env);
|
|
309
|
+
for (const name of names) {
|
|
310
|
+
const src = path.join(principalRoot, name);
|
|
311
|
+
const entry = classify({
|
|
312
|
+
name,
|
|
313
|
+
source: await sourceStateAsync(src),
|
|
314
|
+
target: await targetStateAsync(path.join(worktreePath, name), src),
|
|
315
|
+
tracked,
|
|
316
|
+
ignored,
|
|
317
|
+
});
|
|
318
|
+
if (entry.action !== 'link') {
|
|
319
|
+
record(result, entry, name);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
await fs.promises.symlink(src, path.join(worktreePath, name), 'junction');
|
|
324
|
+
result.created.push(name);
|
|
325
|
+
} catch (err) {
|
|
326
|
+
result.errors.push(`${name}: ${(err && err.message) || err}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return result;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
module.exports = {
|
|
333
|
+
LINK_NAMES,
|
|
334
|
+
applyWorktreeSymlinks,
|
|
335
|
+
applyWorktreeSymlinksAsync,
|
|
336
|
+
};
|