pi-odd-worktree 0.0.0-stage → 0.1.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 +223 -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,566 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Per-checkout warp sessions (customize-command S17/T11).
|
|
3
|
+
//
|
|
4
|
+
// At most ONE forked session per checkout: the first warp to a checkout
|
|
5
|
+
// forks and records the session file here, keyed by the checkout's
|
|
6
|
+
// realpath; later warps to the same checkout resume that same file with
|
|
7
|
+
// a plain `switchSession` instead of forking again. The principal
|
|
8
|
+
// checkout is keyed exactly like every other checkout.
|
|
9
|
+
//
|
|
10
|
+
// The file lives at <git-common-dir>/pi-warp-sessions.json: beside the
|
|
11
|
+
// pi-worktrees.json registry, never inside it (the registry shape is a
|
|
12
|
+
// consumed contract) and never in the plugin state dir. Values carry
|
|
13
|
+
// {sessionFile, leafId, entryCount, updatedAt}; nothing secret is ever stored and no
|
|
14
|
+
// path or file content is echoed in errors.
|
|
15
|
+
//
|
|
16
|
+
// Reads tolerate damage: a missing file reads as empty, and a malformed
|
|
17
|
+
// file is treated as empty (never thrown), so a corrupt record fails
|
|
18
|
+
// safe by forking again and overwriting. Writes are atomic (a sibling
|
|
19
|
+
// temp file created exclusively with an unpredictable suffix plus
|
|
20
|
+
// rename, retrying a new name a bounded number of times) and refuse a
|
|
21
|
+
// symlinked or non-regular destination instead of following it. A failed
|
|
22
|
+
// record write never fails the warp: recording is best-effort, the warp
|
|
23
|
+
// itself already switched. Concurrent warps may each fork with the last
|
|
24
|
+
// writer winning; that orphans at most one extra session file and never
|
|
25
|
+
// corrupts the record.
|
|
26
|
+
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
const crypto = require('node:crypto');
|
|
30
|
+
const { commonDirFor } = require('./worktree-git.js');
|
|
31
|
+
|
|
32
|
+
const WARP_SESSIONS_VERSION = 1;
|
|
33
|
+
const WARP_SESSIONS_NAME = 'pi-warp-sessions.json';
|
|
34
|
+
|
|
35
|
+
function warpSessionsPath(repoRoot) {
|
|
36
|
+
// S22 (round 3): the shared memoized resolver — this file is read on warp
|
|
37
|
+
// preflight, where two spawnSync identity probes were pure overhead.
|
|
38
|
+
return path.join(commonDirFor(repoRoot), WARP_SESSIONS_NAME);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function emptySessions() {
|
|
42
|
+
return { ok: true, version: WARP_SESSIONS_VERSION, sessions: {}, error: null };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isRecordShape(entry) {
|
|
46
|
+
return typeof entry === 'object' && entry !== null && !Array.isArray(entry)
|
|
47
|
+
&& typeof entry.sessionFile === 'string' && entry.sessionFile.length > 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// S23/S43 filtered session identity: the POINT of a session is its ACTIVE
|
|
51
|
+
// branch (the getBranch(leaf) path, never all append-only entries)
|
|
52
|
+
// filtered to entries that are NOT our own bookkeeping. Our shapes are
|
|
53
|
+
// exactly these four, proven against the installed SessionManager
|
|
54
|
+
// (dist/core/session-manager.js) and the live warp flow:
|
|
55
|
+
// - `custom` + `worktree-warp`: the transition record the fork appends
|
|
56
|
+
// through appendCustomEntry;
|
|
57
|
+
// - `custom_message` + `worktree-warp`: the visible confirmation the
|
|
58
|
+
// switched-to session sends through sendMessage({customType}), which
|
|
59
|
+
// the runtime persists through appendCustomMessageEntry — the shape
|
|
60
|
+
// the old filter missed, so every warp back forked again;
|
|
61
|
+
// - `custom` + `gentle-pi.session-worktree/v1`: the registration entry
|
|
62
|
+
// the session_worktree_register host tool appends at startup;
|
|
63
|
+
// - every `session_info`: the checkout rename (appendSessionInfo).
|
|
64
|
+
// Anything else — user/assistant/tool entries, model/thinking changes,
|
|
65
|
+
// unknown custom shapes — is session content and COUNTS: no broad
|
|
66
|
+
// exclusion, nothing silently ignored. Because SessionManager.forkFrom
|
|
67
|
+
// copies source entries VERBATIM with their ids, a fresh fork's filtered
|
|
68
|
+
// point equals its source's: the fork is identical. {tipId, count} where
|
|
69
|
+
// tipId is the last remaining entry's id (null when no real entry exists)
|
|
70
|
+
// and count is the number of remaining entries.
|
|
71
|
+
const WARP_BOOKKEEPING_CUSTOM_TYPE = 'worktree-warp';
|
|
72
|
+
const SESSION_WORKTREE_CUSTOM_TYPE = 'gentle-pi.session-worktree/v1';
|
|
73
|
+
|
|
74
|
+
function isWarpBookkeepingEntry(entry) {
|
|
75
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false;
|
|
76
|
+
if (entry.type === 'session_info') return true;
|
|
77
|
+
if (entry.type === 'custom' && (entry.customType === WARP_BOOKKEEPING_CUSTOM_TYPE
|
|
78
|
+
|| entry.customType === SESSION_WORKTREE_CUSTOM_TYPE)) return true;
|
|
79
|
+
return entry.type === 'custom_message' && entry.customType === WARP_BOOKKEEPING_CUSTOM_TYPE;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The leaf of an entry list under installed SessionManager semantics:
|
|
83
|
+
// _buildIndex walks the file in order and keeps the LAST non-session
|
|
84
|
+
// entry's id (upstream packages/coding-agent/src/core/session-manager.ts
|
|
85
|
+
// _buildIndex; installed dist/core/session-manager.js _buildIndex). No
|
|
86
|
+
// separate leaf pointer is persisted, so the last line IS the leaf.
|
|
87
|
+
// Returns the id string, or null when there is no such entry.
|
|
88
|
+
function leafIdOfEntries(entries) {
|
|
89
|
+
try {
|
|
90
|
+
const list = Array.isArray(entries) ? entries : [];
|
|
91
|
+
for (let index = list.length - 1; index >= 0; index -= 1) {
|
|
92
|
+
const entry = list[index];
|
|
93
|
+
if (entry && typeof entry === 'object' && !Array.isArray(entry)
|
|
94
|
+
&& entry.type !== 'session' && typeof entry.id === 'string') {
|
|
95
|
+
return entry.id;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The ACTIVE branch of an entry list: walk parentId from the leaf back to
|
|
105
|
+
// the root, like SessionManager.getBranch does. `leafId` selects the
|
|
106
|
+
// start; an unknown or missing leaf falls back to the last entry, like
|
|
107
|
+
// the manager's own `leaf ??= entries[entries.length - 1]` rule in
|
|
108
|
+
// buildSessionPath. Off-branch entries (an abandoned leg after branch())
|
|
109
|
+
// are NOT on this path. Cycles and missing parents stop the walk
|
|
110
|
+
// conservatively instead of throwing. Returns the path in root-to-leaf
|
|
111
|
+
// order (possibly empty).
|
|
112
|
+
function activeBranch(entries, leafId) {
|
|
113
|
+
try {
|
|
114
|
+
const list = Array.isArray(entries) ? entries : [];
|
|
115
|
+
const byId = new Map();
|
|
116
|
+
for (const entry of list) {
|
|
117
|
+
if (entry && typeof entry === 'object' && !Array.isArray(entry)
|
|
118
|
+
&& typeof entry.id === 'string') {
|
|
119
|
+
byId.set(entry.id, entry);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
let start = null;
|
|
123
|
+
if (typeof leafId === 'string' && byId.has(leafId)) start = byId.get(leafId);
|
|
124
|
+
else {
|
|
125
|
+
const fallback = leafIdOfEntries(list);
|
|
126
|
+
if (fallback !== null) start = byId.get(fallback) || null;
|
|
127
|
+
}
|
|
128
|
+
if (!start) return [];
|
|
129
|
+
const path = [];
|
|
130
|
+
const visited = new Set();
|
|
131
|
+
let current = start;
|
|
132
|
+
while (current && typeof current.id === 'string' && !visited.has(current.id)) {
|
|
133
|
+
visited.add(current.id);
|
|
134
|
+
path.push(current);
|
|
135
|
+
const parentId = current.parentId;
|
|
136
|
+
if (typeof parentId !== 'string' || parentId.length === 0) break;
|
|
137
|
+
const parent = byId.get(parentId);
|
|
138
|
+
if (!parent) break;
|
|
139
|
+
current = parent;
|
|
140
|
+
}
|
|
141
|
+
path.reverse();
|
|
142
|
+
return path;
|
|
143
|
+
} catch {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sessionPoint(entries, leafId) {
|
|
149
|
+
const branch = activeBranch(entries, leafId);
|
|
150
|
+
let tipId = null;
|
|
151
|
+
let count = 0;
|
|
152
|
+
for (const entry of branch) {
|
|
153
|
+
if (isWarpBookkeepingEntry(entry)) continue;
|
|
154
|
+
count += 1;
|
|
155
|
+
if (entry && typeof entry.id === 'string') tipId = entry.id;
|
|
156
|
+
else tipId = null;
|
|
157
|
+
}
|
|
158
|
+
return { tipId, count };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Point evidence for the R3 fork-vs-resume rule: the source session's
|
|
162
|
+
// FILTERED position (S23 sessionPoint) when the record was written. Older
|
|
163
|
+
// records carry {leafId, entryCount} from the raw-leaf comparison; they
|
|
164
|
+
// read through the same shape, so a record written before the fix still
|
|
165
|
+
// resolves. Both names are stored on write for the same reason.
|
|
166
|
+
function asPoint(value) {
|
|
167
|
+
const tipId = value && typeof value.tipId === 'string' ? value.tipId
|
|
168
|
+
: value && typeof value.leafId === 'string' ? value.leafId : null;
|
|
169
|
+
const rawCount = value && typeof value.count === 'number' ? value.count
|
|
170
|
+
: value && typeof value.entryCount === 'number' ? value.entryCount : null;
|
|
171
|
+
const count = rawCount !== null && Number.isFinite(rawCount) && rawCount >= 0
|
|
172
|
+
? Math.floor(rawCount) : null;
|
|
173
|
+
return { tipId, count, leafId: tipId, entryCount: count };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// True when two points name the same filtered position. An unreadable
|
|
177
|
+
// count on either side leaves the tip to decide, so legacy records
|
|
178
|
+
// without a count keep their resume behaviour.
|
|
179
|
+
function samePoint(a, b) {
|
|
180
|
+
try {
|
|
181
|
+
const first = asPoint(a);
|
|
182
|
+
const second = asPoint(b);
|
|
183
|
+
if (first.tipId !== second.tipId) return false;
|
|
184
|
+
if (first.count === null || second.count === null) return true;
|
|
185
|
+
return first.count === second.count;
|
|
186
|
+
} catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// S43 parent priority: every session header carries `parentSession` (a
|
|
192
|
+
// session-file path), so a fork can climb to the HIGHEST ancestor whose
|
|
193
|
+
// filtered point still equals its own and fork from there — an untouched
|
|
194
|
+
// fork then yields a SIBLING (both children of the original) while an
|
|
195
|
+
// advanced session yields its own child. The climb is bounded by a small
|
|
196
|
+
// documented constant and fails safe to the LAST GOOD file: a missing or
|
|
197
|
+
// empty parent link, an unreadable ancestor, a point mismatch, a cycle
|
|
198
|
+
// (visited set) or the depth cap all stop the climb without throwing.
|
|
199
|
+
const WARP_ANCESTOR_MAX_DEPTH = 25;
|
|
200
|
+
|
|
201
|
+
// Read a session file from disk into {entries, parentSession}. The first
|
|
202
|
+
// line is the header (carrying `parentSession`, like the live reader in
|
|
203
|
+
// extensions/gentle-herdr-state.ts parses it); the remaining lines are
|
|
204
|
+
// the entries. Any unreadable file or unparseable line reads as null:
|
|
205
|
+
// the climb then keeps the last good file. Never throws.
|
|
206
|
+
function readSessionFileNode(sessionFile) {
|
|
207
|
+
try {
|
|
208
|
+
if (typeof sessionFile !== 'string' || sessionFile.length === 0) return null;
|
|
209
|
+
let raw;
|
|
210
|
+
try {
|
|
211
|
+
raw = fs.readFileSync(sessionFile, 'utf8');
|
|
212
|
+
} catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
|
|
216
|
+
const lines = raw.split('\n').filter((line) => line.length > 0);
|
|
217
|
+
if (lines.length === 0) return { entries: [], parentSession: null };
|
|
218
|
+
let header = null;
|
|
219
|
+
try {
|
|
220
|
+
header = JSON.parse(lines[0]);
|
|
221
|
+
} catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
const rawParent = header && typeof header === 'object'
|
|
225
|
+
? (header.parentSession !== undefined ? header.parentSession : header.parentSessionId)
|
|
226
|
+
: null;
|
|
227
|
+
const parentSession = typeof rawParent === 'string' && rawParent.length > 0 ? rawParent : null;
|
|
228
|
+
const firstEntry = header && typeof header === 'object' && header.type === 'session' ? 1 : 0;
|
|
229
|
+
const entries = [];
|
|
230
|
+
for (let index = firstEntry; index < lines.length; index += 1) {
|
|
231
|
+
try {
|
|
232
|
+
entries.push(JSON.parse(lines[index]));
|
|
233
|
+
} catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return { entries, parentSession };
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Normalize what an injected `open` returns: either the entries array
|
|
244
|
+
// itself or {entries, parentSession} shaped like the real format. Anything
|
|
245
|
+
// else reads as unreadable. Never throws.
|
|
246
|
+
function normalizeOpenResult(value) {
|
|
247
|
+
try {
|
|
248
|
+
if (Array.isArray(value)) return { entries: value, parentSession: null };
|
|
249
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
250
|
+
const entries = Array.isArray(value.entries) ? value.entries : null;
|
|
251
|
+
const rawParent = value.parentSession !== undefined ? value.parentSession : value.parent;
|
|
252
|
+
const parentSession = typeof rawParent === 'string' && rawParent.length > 0 ? rawParent : null;
|
|
253
|
+
return { entries, parentSession };
|
|
254
|
+
}
|
|
255
|
+
return { entries: null, parentSession: null };
|
|
256
|
+
} catch {
|
|
257
|
+
return { entries: null, parentSession: null };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Read one session node through the injectable `open` seam when it knows
|
|
262
|
+
// the file, else through the file itself. The seam models the live
|
|
263
|
+
// runtime's view of a session (used by tests and by the warp's ancestor
|
|
264
|
+
// climb); when it yields nothing for a file, the file's own lines are the
|
|
265
|
+
// actual destination state (the live runtime reopens the manager and
|
|
266
|
+
// keeps appending there), so they are read instead of inventing an empty
|
|
267
|
+
// session. A file that is unknown to both reads as unreadable (entries
|
|
268
|
+
// null): callers fail safe to the last good value. Never throws.
|
|
269
|
+
function readSessionNode(sessionFile, open) {
|
|
270
|
+
if (typeof open === 'function') {
|
|
271
|
+
let value = null;
|
|
272
|
+
try {
|
|
273
|
+
value = open(sessionFile);
|
|
274
|
+
} catch {
|
|
275
|
+
value = null;
|
|
276
|
+
}
|
|
277
|
+
const shaped = normalizeOpenResult(value);
|
|
278
|
+
if (shaped && Array.isArray(shaped.entries)) return shaped;
|
|
279
|
+
}
|
|
280
|
+
return readSessionFileNode(sessionFile);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// The filtered {tipId, count} point of a session FILE, or null when the
|
|
284
|
+
// file cannot be read. `open` is the injectable seam (a function of the
|
|
285
|
+
// file path, for tests); without it the file is read from disk. Never
|
|
286
|
+
// throws.
|
|
287
|
+
function pointOfSessionFile(sessionFile, { open } = {}) {
|
|
288
|
+
try {
|
|
289
|
+
const node = readSessionNode(sessionFile, open);
|
|
290
|
+
if (!node || !Array.isArray(node.entries)) return null;
|
|
291
|
+
return sessionPoint(node.entries);
|
|
292
|
+
} catch {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// The highest ancestor of a session file whose filtered point still
|
|
298
|
+
// equals `point` — the fork source under S43. Starts at the file itself
|
|
299
|
+
// (returned as-is when it has no readable identical parent) and climbs
|
|
300
|
+
// the `parentSession` chain, keeping the last good file at every stop:
|
|
301
|
+
// missing/empty/unreadable parent, point mismatch, cycle or depth cap.
|
|
302
|
+
// `open` is the same injectable seam as pointOfSessionFile; `maxDepth`
|
|
303
|
+
// overrides the bound (the default is WARP_ANCESTOR_MAX_DEPTH). Never
|
|
304
|
+
// throws: any failure returns the last good file.
|
|
305
|
+
function topmostIdenticalAncestor(sessionFile, point, { open, maxDepth } = {}) {
|
|
306
|
+
try {
|
|
307
|
+
if (typeof sessionFile !== 'string' || sessionFile.length === 0) return sessionFile;
|
|
308
|
+
const cap = Number.isFinite(maxDepth) && maxDepth >= 0 ? Math.floor(maxDepth) : WARP_ANCESTOR_MAX_DEPTH;
|
|
309
|
+
const me = asPoint(point);
|
|
310
|
+
let best = sessionFile;
|
|
311
|
+
let current = sessionFile;
|
|
312
|
+
const visited = new Set([sessionFile]);
|
|
313
|
+
for (let depth = 0; depth < cap; depth += 1) {
|
|
314
|
+
let parent = null;
|
|
315
|
+
try {
|
|
316
|
+
const node = readSessionNode(current, open);
|
|
317
|
+
parent = node ? node.parentSession : null;
|
|
318
|
+
} catch {
|
|
319
|
+
parent = null;
|
|
320
|
+
}
|
|
321
|
+
if (typeof parent !== 'string' || parent.length === 0) break;
|
|
322
|
+
if (visited.has(parent)) break;
|
|
323
|
+
visited.add(parent);
|
|
324
|
+
const parentPoint = pointOfSessionFile(parent, open === undefined ? {} : { open });
|
|
325
|
+
if (parentPoint === null) break;
|
|
326
|
+
if (!samePoint(me, parentPoint)) break;
|
|
327
|
+
best = parent;
|
|
328
|
+
current = parent;
|
|
329
|
+
}
|
|
330
|
+
return best;
|
|
331
|
+
} catch {
|
|
332
|
+
return sessionFile;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// The canonical key for a checkout: its realpath, falling back to the
|
|
337
|
+
// resolved absolute path when the filesystem cannot resolve it. A
|
|
338
|
+
// non-absolute input (or anything unresolvable to absolute) keys nothing.
|
|
339
|
+
function normalizeKey(checkoutPath) {
|
|
340
|
+
try {
|
|
341
|
+
if (typeof checkoutPath !== 'string' || checkoutPath.length === 0) return null;
|
|
342
|
+
try {
|
|
343
|
+
return fs.realpathSync(checkoutPath);
|
|
344
|
+
} catch {
|
|
345
|
+
// Unresolvable (a checkout removed between listing and lookup):
|
|
346
|
+
// the absolute spelling still keys the record lookup below.
|
|
347
|
+
}
|
|
348
|
+
if (path.isAbsolute(checkoutPath)) return path.resolve(checkoutPath);
|
|
349
|
+
return null;
|
|
350
|
+
} catch {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Read the record file. Missing reads as empty; malformed content is
|
|
356
|
+
// treated as empty too (never thrown, never reported as an error):
|
|
357
|
+
// the warp then forks again and overwrites. Never throws.
|
|
358
|
+
function readWarpSessions(repoRoot) {
|
|
359
|
+
try {
|
|
360
|
+
let file;
|
|
361
|
+
try {
|
|
362
|
+
file = warpSessionsPath(repoRoot);
|
|
363
|
+
} catch {
|
|
364
|
+
return emptySessions();
|
|
365
|
+
}
|
|
366
|
+
let raw;
|
|
367
|
+
try {
|
|
368
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
369
|
+
} catch (err) {
|
|
370
|
+
if (err && err.code === 'ENOENT') return emptySessions();
|
|
371
|
+
return emptySessions();
|
|
372
|
+
}
|
|
373
|
+
let doc;
|
|
374
|
+
try {
|
|
375
|
+
doc = JSON.parse(raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw);
|
|
376
|
+
} catch {
|
|
377
|
+
return emptySessions();
|
|
378
|
+
}
|
|
379
|
+
if (typeof doc !== 'object' || doc === null || Array.isArray(doc)
|
|
380
|
+
|| typeof doc.sessions !== 'object' || doc.sessions === null || Array.isArray(doc.sessions)) {
|
|
381
|
+
return emptySessions();
|
|
382
|
+
}
|
|
383
|
+
const sessions = {};
|
|
384
|
+
for (const [key, entry] of Object.entries(doc.sessions)) {
|
|
385
|
+
if (isRecordShape(entry)) {
|
|
386
|
+
const point = asPoint(entry);
|
|
387
|
+
sessions[key] = {
|
|
388
|
+
sessionFile: entry.sessionFile,
|
|
389
|
+
tipId: point.tipId,
|
|
390
|
+
count: point.count,
|
|
391
|
+
leafId: point.leafId,
|
|
392
|
+
entryCount: point.entryCount,
|
|
393
|
+
updatedAt: typeof entry.updatedAt === 'string' ? entry.updatedAt : null,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const version = doc.version === undefined ? WARP_SESSIONS_VERSION : doc.version;
|
|
398
|
+
return { ok: true, version, sessions, error: null };
|
|
399
|
+
} catch {
|
|
400
|
+
return emptySessions();
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// The recorded session for a checkout, or null when there is none, the
|
|
405
|
+
// record is corrupt, or the entry names no session file. Never throws:
|
|
406
|
+
// any failure reads as "no record", so the warp forks again.
|
|
407
|
+
function getWarpSession(repoRoot, checkoutPath) {
|
|
408
|
+
try {
|
|
409
|
+
const read = readWarpSessions(repoRoot);
|
|
410
|
+
if (!read.ok) return null;
|
|
411
|
+
const candidates = [];
|
|
412
|
+
try {
|
|
413
|
+
candidates.push(fs.realpathSync(checkoutPath));
|
|
414
|
+
} catch {
|
|
415
|
+
// Fall through to the absolute spelling below.
|
|
416
|
+
}
|
|
417
|
+
if (typeof checkoutPath === 'string' && path.isAbsolute(checkoutPath)) {
|
|
418
|
+
try {
|
|
419
|
+
candidates.push(path.resolve(checkoutPath));
|
|
420
|
+
} catch {
|
|
421
|
+
// No candidate: returns null below.
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
for (const key of candidates) {
|
|
425
|
+
const entry = read.sessions[key];
|
|
426
|
+
if (isRecordShape(entry)) {
|
|
427
|
+
const point = asPoint(entry);
|
|
428
|
+
return {
|
|
429
|
+
sessionFile: entry.sessionFile,
|
|
430
|
+
tipId: point.tipId,
|
|
431
|
+
count: point.count,
|
|
432
|
+
leafId: point.leafId,
|
|
433
|
+
entryCount: point.entryCount,
|
|
434
|
+
updatedAt: typeof entry.updatedAt === 'string' ? entry.updatedAt : null,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return null;
|
|
439
|
+
} catch {
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Atomically replace the record file: write a sibling temp file
|
|
445
|
+
// exclusively (`wx`) with an unpredictable suffix, then rename over the
|
|
446
|
+
// target. A pre-existing name is never followed or reused: on collision
|
|
447
|
+
// a new name is retried a bounded number of times, then the write is
|
|
448
|
+
// refused with a clear error. The temp file never survives the call.
|
|
449
|
+
function writeWarpSessionsAtomic(file, doc) {
|
|
450
|
+
const payload = `${JSON.stringify(doc, null, 2)}\n`;
|
|
451
|
+
let tmp = null;
|
|
452
|
+
let wrote = false;
|
|
453
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
454
|
+
let suffix;
|
|
455
|
+
try {
|
|
456
|
+
suffix = crypto.randomBytes(8).toString('hex');
|
|
457
|
+
} catch (err) {
|
|
458
|
+
throw new Error(`cannot write warp sessions: cannot generate a temporary file name: ${(err && err.message) || err}`);
|
|
459
|
+
}
|
|
460
|
+
tmp = `${file}.tmp.${process.pid}.${Date.now()}.${suffix}`;
|
|
461
|
+
try {
|
|
462
|
+
fs.writeFileSync(tmp, payload, { flag: 'wx' });
|
|
463
|
+
wrote = true;
|
|
464
|
+
break;
|
|
465
|
+
} catch (err) {
|
|
466
|
+
const failedTmp = tmp;
|
|
467
|
+
tmp = null;
|
|
468
|
+
if (err && err.code === 'EEXIST') continue;
|
|
469
|
+
if (failedTmp) {
|
|
470
|
+
try {
|
|
471
|
+
fs.unlinkSync(failedTmp);
|
|
472
|
+
} catch {
|
|
473
|
+
// Best effort: the rethrow below is what matters.
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
throw err;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (!wrote || !tmp) {
|
|
480
|
+
throw new Error('cannot write warp sessions: could not create a unique temporary file after 10 attempts; refusing to follow or reuse an existing path');
|
|
481
|
+
}
|
|
482
|
+
try {
|
|
483
|
+
fs.renameSync(tmp, file);
|
|
484
|
+
} finally {
|
|
485
|
+
try {
|
|
486
|
+
fs.unlinkSync(tmp);
|
|
487
|
+
} catch {
|
|
488
|
+
// Either renamed (gone) or never created; nothing to clean.
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Record (or overwrite) the session for a checkout. Never throws: every
|
|
494
|
+
// failure returns {ok: false} with a generic error naming no path, and
|
|
495
|
+
// the warp that already switched stays successful.
|
|
496
|
+
function recordWarpSession(repoRoot, checkoutPath, record) {
|
|
497
|
+
try {
|
|
498
|
+
const key = normalizeKey(checkoutPath);
|
|
499
|
+
const sessionFile = record && typeof record.sessionFile === 'string' ? record.sessionFile : null;
|
|
500
|
+
if (key === null || sessionFile === null || sessionFile.length === 0) {
|
|
501
|
+
return { ok: false, error: 'cannot record warp session: checkout or session file is missing' };
|
|
502
|
+
}
|
|
503
|
+
const point = asPoint(record);
|
|
504
|
+
const leafId = point.tipId;
|
|
505
|
+
const entryCount = point.count;
|
|
506
|
+
let file;
|
|
507
|
+
try {
|
|
508
|
+
file = warpSessionsPath(repoRoot);
|
|
509
|
+
} catch {
|
|
510
|
+
return { ok: false, error: 'cannot record warp session: repository identity unavailable' };
|
|
511
|
+
}
|
|
512
|
+
// Inspect the destination BEFORE writing: writeFileSync follows a
|
|
513
|
+
// symlink, so a symlinked record file would overwrite its target. A
|
|
514
|
+
// symlinked or non-regular destination is refused and nothing is
|
|
515
|
+
// touched; the warp itself already succeeded.
|
|
516
|
+
let stat = null;
|
|
517
|
+
try {
|
|
518
|
+
stat = fs.lstatSync(file);
|
|
519
|
+
} catch (err) {
|
|
520
|
+
if (!err || err.code !== 'ENOENT') {
|
|
521
|
+
return { ok: false, error: 'cannot record warp session: destination unreadable' };
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (stat) {
|
|
525
|
+
if (stat.isSymbolicLink()) {
|
|
526
|
+
return { ok: false, error: 'cannot record warp session: destination is a symlink; remove it first' };
|
|
527
|
+
}
|
|
528
|
+
if (!stat.isFile()) {
|
|
529
|
+
return { ok: false, error: 'cannot record warp session: destination is not a regular file' };
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
// Read-modify-write against the tolerant read: a malformed file is
|
|
533
|
+
// replaced, never merged — the incoming record is the only
|
|
534
|
+
// trustworthy data.
|
|
535
|
+
const read = readWarpSessions(repoRoot);
|
|
536
|
+
const sessions = read.ok ? { ...read.sessions } : {};
|
|
537
|
+
sessions[key] = {
|
|
538
|
+
sessionFile, tipId: leafId, count: entryCount,
|
|
539
|
+
leafId, entryCount, updatedAt: new Date().toISOString(),
|
|
540
|
+
};
|
|
541
|
+
writeWarpSessionsAtomic(file, { version: WARP_SESSIONS_VERSION, sessions });
|
|
542
|
+
return { ok: true, entry: sessions[key] };
|
|
543
|
+
} catch (err) {
|
|
544
|
+
return { ok: false, error: `cannot record warp session: ${(err && err.message) || err}` };
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
module.exports = {
|
|
549
|
+
WARP_SESSIONS_VERSION,
|
|
550
|
+
WARP_SESSIONS_NAME,
|
|
551
|
+
WARP_BOOKKEEPING_CUSTOM_TYPE,
|
|
552
|
+
SESSION_WORKTREE_CUSTOM_TYPE,
|
|
553
|
+
WARP_ANCESTOR_MAX_DEPTH,
|
|
554
|
+
leafIdOfEntries,
|
|
555
|
+
activeBranch,
|
|
556
|
+
warpSessionsPath,
|
|
557
|
+
readWarpSessions,
|
|
558
|
+
getWarpSession,
|
|
559
|
+
recordWarpSession,
|
|
560
|
+
isWarpBookkeepingEntry,
|
|
561
|
+
sessionPoint,
|
|
562
|
+
samePoint,
|
|
563
|
+
asPoint,
|
|
564
|
+
pointOfSessionFile,
|
|
565
|
+
topmostIdenticalAncestor,
|
|
566
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-odd-worktree",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi package for odd-worktree: the Agent Worktree tools (create, list, close, warp), the /warp worktree panel, the /odd-list ODD viewer, and the /issues GitHub issue reader. Works in Pi alone; Herdr is optional.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"worktree",
|
|
9
|
+
"odd"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/jonasotoaguilar/odd-worktree.git"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"@earendil-works/pi-ai": "*",
|
|
21
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
22
|
+
"@earendil-works/pi-tui": "*"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"extensions",
|
|
26
|
+
"lib",
|
|
27
|
+
"tools",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"pi": {
|
|
31
|
+
"extensions": [
|
|
32
|
+
"./extensions/odd-worktree-tools.ts"
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "node --test tools/*.test.js"
|
|
37
|
+
}
|
|
38
|
+
}
|