@druumen/sessions-db 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/CHANGELOG.md +249 -0
- package/LICENSE +201 -0
- package/NOTICE +10 -0
- package/README.md +250 -0
- package/cli/_write-helpers.mjs +99 -0
- package/cli/alias.mjs +115 -0
- package/cli/argparse.mjs +296 -0
- package/cli/close.mjs +116 -0
- package/cli/find.mjs +185 -0
- package/cli/format.mjs +277 -0
- package/cli/link-parent.mjs +133 -0
- package/cli/link.mjs +132 -0
- package/cli/rebuild.mjs +98 -0
- package/cli/sessions-db-session-start-main.mjs +454 -0
- package/cli/sessions-db-session-start.mjs +56 -0
- package/cli/sessions-db.mjs +119 -0
- package/cli/sweep.mjs +171 -0
- package/cli/tree.mjs +127 -0
- package/lib/git-context.mjs +479 -0
- package/lib/identity.mjs +616 -0
- package/lib/index.mjs +145 -0
- package/lib/init.mjs +185 -0
- package/lib/lock.mjs +86 -0
- package/lib/operations.mjs +490 -0
- package/lib/paths.mjs +199 -0
- package/lib/projection.mjs +496 -0
- package/lib/sanitize.mjs +131 -0
- package/lib/storage.mjs +759 -0
- package/lib/sweep.mjs +209 -0
- package/lib/transcript.mjs +230 -0
- package/lib/types.mjs +276 -0
- package/lib/uuid.mjs +116 -0
- package/lib/watch.mjs +217 -0
- package/package.json +53 -0
- package/types/git-context.d.mts +98 -0
- package/types/identity.d.mts +658 -0
- package/types/index.d.mts +10 -0
- package/types/index.d.ts +127 -0
- package/types/init.d.mts +53 -0
- package/types/lock.d.mts +18 -0
- package/types/operations.d.mts +204 -0
- package/types/paths.d.mts +54 -0
- package/types/projection.d.mts +79 -0
- package/types/sanitize.d.mts +39 -0
- package/types/storage.d.mts +276 -0
- package/types/sweep.d.mts +58 -0
- package/types/transcript.d.mts +59 -0
- package/types/types.d.mts +255 -0
- package/types/uuid.d.mts +17 -0
- package/types/watch.d.mts +33 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared git/worktree probe for Claude Code hook scripts.
|
|
3
|
+
*
|
|
4
|
+
* Why a separate library: every SessionStart-class hook (sessions-db,
|
|
5
|
+
* hive-watcher, future ones) needs the same triplet: (worktree path, branch,
|
|
6
|
+
* HEAD) and the same survival posture — sub-second timeouts, soft-fail on
|
|
7
|
+
* every probe, never throw to the caller, never blow past the hook's overall
|
|
8
|
+
* 2-second budget. Centralising the probe here keeps each hook's main file
|
|
9
|
+
* tiny and lets us evolve the survival rules in one place.
|
|
10
|
+
*
|
|
11
|
+
* Design rules (enforced by the test suite):
|
|
12
|
+
* - `runGit` uses non-blocking `child_process.spawn` + Promise.race against
|
|
13
|
+
* a single global deadline. setTimeout(...).unref() in the hook bootstrap
|
|
14
|
+
* can ACTUALLY fire because we never block the event loop with spawnSync.
|
|
15
|
+
* - Every git invocation respects the SAME absolute deadline (computed once
|
|
16
|
+
* in `gitContext` from `totalBudgetMs`), so 6 sequential probes can never
|
|
17
|
+
* add up to > totalBudgetMs even if each individual probe runs slowly.
|
|
18
|
+
* When the deadline lapses mid-sequence we skip remaining probes and
|
|
19
|
+
* surface `status: 'partial'`.
|
|
20
|
+
* - We surface a `status` of `ok` | `partial` | `not_a_repo` | `error` plus a
|
|
21
|
+
* plain `errors[]` array of one-line diagnostics. Callers can branch on
|
|
22
|
+
* `status` without touching `errors`.
|
|
23
|
+
* - The dev-offload registry probe (`~/.claude-dev/druumen-dev/
|
|
24
|
+
* worktree-registry.json`) is best-effort only — file missing / unreadable
|
|
25
|
+
* leaves `registryName === null` without raising the overall status.
|
|
26
|
+
* - Default `totalBudgetMs` is 1500 ms total; the hook script's outer 2000 ms
|
|
27
|
+
* hard timeout is the ultimate guard (and now actually fires because we're
|
|
28
|
+
* not blocking the event loop).
|
|
29
|
+
*
|
|
30
|
+
* Zero new npm deps: only `node:child_process`, `node:fs`, `node:os`,
|
|
31
|
+
* `node:path`.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { spawn } from 'node:child_process';
|
|
35
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
36
|
+
import { homedir } from 'node:os';
|
|
37
|
+
import { join, resolve } from 'node:path';
|
|
38
|
+
|
|
39
|
+
const DEFAULT_TOTAL_BUDGET_MS = 1500;
|
|
40
|
+
// Floor for per-probe budget — if remaining < this, we treat the deadline as
|
|
41
|
+
// already lapsed (avoids spawning a process that has effectively no time).
|
|
42
|
+
const MIN_PROBE_BUDGET_MS = 25;
|
|
43
|
+
const REGISTRY_PATH_DEFAULT = join(homedir(), '.claude-dev', 'druumen-dev', 'worktree-registry.json');
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @typedef {Object} GitContext
|
|
47
|
+
* @property {string} cwd The cwd we ran probes against (always set).
|
|
48
|
+
* @property {string|null} worktreePath Output of `git rev-parse --show-toplevel` (worktree root).
|
|
49
|
+
* @property {string|null} worktreeRealpath realpath() of worktreePath, with symlinks resolved.
|
|
50
|
+
* @property {string|null} gitCommonDir Output of `git rev-parse --git-common-dir` (resolved to absolute).
|
|
51
|
+
* @property {boolean} isInWorktree True when worktree's `.git` is a file (linked worktree),
|
|
52
|
+
* i.e. gitCommonDir's parent != worktreePath.
|
|
53
|
+
* @property {boolean} isInsideRepo True when cwd is inside any git repo (linked worktree counts).
|
|
54
|
+
* @property {string|null} branch `git branch --show-current` (empty string => detached HEAD => null).
|
|
55
|
+
* @property {string|null} head `git rev-parse HEAD` (full SHA).
|
|
56
|
+
* @property {string|null} registryName Key in the dev-offload registry whose `worktree_path` matches us.
|
|
57
|
+
* @property {'ok'|'partial'|'not_a_repo'|'error'} status
|
|
58
|
+
* @property {string[]} errors One-liner error summaries, suitable for jsonl logging.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Probe git context for `cwd`. Never throws; returns a `GitContext` whose
|
|
63
|
+
* `status` field tells the caller what to trust.
|
|
64
|
+
*
|
|
65
|
+
* Budget model: `totalBudgetMs` is the wall-clock budget for ALL probes
|
|
66
|
+
* combined. Each individual probe gets `min(remaining, MIN_PROBE_BUDGET_MS)`
|
|
67
|
+
* — once the budget is exhausted, we stop probing and return whatever we
|
|
68
|
+
* have so far with `status: 'partial'`.
|
|
69
|
+
*
|
|
70
|
+
* @param {{ cwd?: string, totalBudgetMs?: number, registryPath?: string }} [opts]
|
|
71
|
+
* @returns {Promise<GitContext>}
|
|
72
|
+
*/
|
|
73
|
+
export async function gitContext(opts = {}) {
|
|
74
|
+
const cwd = typeof opts.cwd === 'string' && opts.cwd.length > 0 ? opts.cwd : process.cwd();
|
|
75
|
+
const totalBudgetMs = Number.isFinite(opts.totalBudgetMs) && opts.totalBudgetMs > 0
|
|
76
|
+
? opts.totalBudgetMs
|
|
77
|
+
: Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0
|
|
78
|
+
// Backward compat: old callers passed `timeoutMs` for per-call budget.
|
|
79
|
+
// Treat it as the total budget so behavior is at-most-as-slow as before.
|
|
80
|
+
? opts.timeoutMs
|
|
81
|
+
: DEFAULT_TOTAL_BUDGET_MS;
|
|
82
|
+
const registryPath = typeof opts.registryPath === 'string' && opts.registryPath.length > 0
|
|
83
|
+
? opts.registryPath
|
|
84
|
+
: REGISTRY_PATH_DEFAULT;
|
|
85
|
+
|
|
86
|
+
const deadlineAt = Date.now() + totalBudgetMs;
|
|
87
|
+
|
|
88
|
+
/** @type {GitContext} */
|
|
89
|
+
const ctx = {
|
|
90
|
+
cwd,
|
|
91
|
+
worktreePath: null,
|
|
92
|
+
worktreeRealpath: null,
|
|
93
|
+
gitCommonDir: null,
|
|
94
|
+
isInWorktree: false,
|
|
95
|
+
isInsideRepo: false,
|
|
96
|
+
branch: null,
|
|
97
|
+
head: null,
|
|
98
|
+
registryName: null,
|
|
99
|
+
status: 'ok',
|
|
100
|
+
errors: [],
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Probe 1: are we inside a git repo at all?
|
|
104
|
+
// `git rev-parse --is-inside-work-tree` returns "true" when in a working
|
|
105
|
+
// tree. Outside a repo git exits 128 with "fatal: not a git repository" on
|
|
106
|
+
// stderr; that is the canonical "not_a_repo" signal and is NOT a runtime
|
|
107
|
+
// error — we suppress the diagnostic that runGit recorded for it.
|
|
108
|
+
const insideProbe = await runGit(['rev-parse', '--is-inside-work-tree'], { cwd, deadlineAt }, ctx);
|
|
109
|
+
if ((insideProbe.stdout || '').trim() === 'true') {
|
|
110
|
+
ctx.isInsideRepo = true;
|
|
111
|
+
} else if (insideProbe.spawnFailed || insideProbe.timedOut) {
|
|
112
|
+
// git binary missing / spawn error / timed out — distinct from "outside
|
|
113
|
+
// a repo". Keep the recorded error as-is.
|
|
114
|
+
ctx.status = 'error';
|
|
115
|
+
return ctx;
|
|
116
|
+
} else {
|
|
117
|
+
// Ran fine but exit != 0: "not_a_repo". The diagnostic runGit added is
|
|
118
|
+
// noise for this expected case — pop it so callers see a clean errors[].
|
|
119
|
+
if (ctx.errors.length > 0) ctx.errors.pop();
|
|
120
|
+
ctx.status = 'not_a_repo';
|
|
121
|
+
return ctx;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// From here on, every probe is conditional on remaining budget. If the
|
|
125
|
+
// deadline lapses we stop probing and finalize with status='partial'.
|
|
126
|
+
const finalize = () => {
|
|
127
|
+
if (ctx.errors.length > 0 && ctx.status === 'ok') {
|
|
128
|
+
ctx.status = 'partial';
|
|
129
|
+
}
|
|
130
|
+
return ctx;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
if (deadlineLapsed(deadlineAt)) {
|
|
134
|
+
ctx.errors.push('git probes: total budget exhausted after probe 1');
|
|
135
|
+
return finalize();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Probe 2: worktree root.
|
|
139
|
+
const topProbe = await runGit(['rev-parse', '--show-toplevel'], { cwd, deadlineAt }, ctx);
|
|
140
|
+
if (topProbe.ok) {
|
|
141
|
+
const top = (topProbe.stdout || '').trim();
|
|
142
|
+
if (top.length > 0) {
|
|
143
|
+
ctx.worktreePath = top;
|
|
144
|
+
try {
|
|
145
|
+
ctx.worktreeRealpath = realpathSync(top);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
// realpath failure is non-fatal — keep worktreePath, log the issue.
|
|
148
|
+
ctx.errors.push(`realpath(${truncate(top, 80)}): ${shortMessage(err)}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (deadlineLapsed(deadlineAt)) return finalize();
|
|
154
|
+
|
|
155
|
+
// Probe 3: git common dir (the canonical .git directory; for linked
|
|
156
|
+
// worktrees this differs from worktreePath/.git). We resolve relative paths
|
|
157
|
+
// against worktreePath || cwd so callers always see an absolute path.
|
|
158
|
+
const commonProbe = await runGit(['rev-parse', '--git-common-dir'], { cwd, deadlineAt }, ctx);
|
|
159
|
+
if (commonProbe.ok) {
|
|
160
|
+
const raw = (commonProbe.stdout || '').trim();
|
|
161
|
+
if (raw.length > 0) {
|
|
162
|
+
const absBase = ctx.worktreePath || cwd;
|
|
163
|
+
const abs = raw.startsWith('/') ? raw : resolve(absBase, raw);
|
|
164
|
+
try {
|
|
165
|
+
ctx.gitCommonDir = realpathSync(abs);
|
|
166
|
+
} catch {
|
|
167
|
+
// Couldn't realpath — keep the resolved-but-unverified absolute path
|
|
168
|
+
// so callers still get a usable hint without crashing the probe.
|
|
169
|
+
ctx.gitCommonDir = abs;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (deadlineLapsed(deadlineAt)) return finalize();
|
|
175
|
+
|
|
176
|
+
// Linked worktree detection: when `git rev-parse --git-dir` returns a path
|
|
177
|
+
// whose realpath differs from `<worktreePath>/.git`, we know we're in a
|
|
178
|
+
// linked worktree. We use git-dir (per-worktree) for this — git-common-dir
|
|
179
|
+
// would always point to the main repo's .git regardless.
|
|
180
|
+
if (ctx.worktreePath) {
|
|
181
|
+
const gitDirProbe = await runGit(['rev-parse', '--git-dir'], { cwd, deadlineAt }, ctx);
|
|
182
|
+
if (gitDirProbe.ok) {
|
|
183
|
+
const raw = (gitDirProbe.stdout || '').trim();
|
|
184
|
+
if (raw.length > 0) {
|
|
185
|
+
const absBase = ctx.worktreePath;
|
|
186
|
+
const abs = raw.startsWith('/') ? raw : resolve(absBase, raw);
|
|
187
|
+
let resolvedGitDir = abs;
|
|
188
|
+
try {
|
|
189
|
+
resolvedGitDir = realpathSync(abs);
|
|
190
|
+
} catch {
|
|
191
|
+
// keep unresolved abs
|
|
192
|
+
}
|
|
193
|
+
// Linked worktree's git-dir lives at <commonDir>/worktrees/<name>,
|
|
194
|
+
// i.e. it is NOT == <worktreePath>/.git. Compare on resolved paths.
|
|
195
|
+
let mainGitDir = join(ctx.worktreePath, '.git');
|
|
196
|
+
try {
|
|
197
|
+
mainGitDir = realpathSync(mainGitDir);
|
|
198
|
+
} catch {
|
|
199
|
+
// .git might be a file in a linked worktree — keep the joined path
|
|
200
|
+
// for the comparison; the inequality is still meaningful.
|
|
201
|
+
}
|
|
202
|
+
ctx.isInWorktree = resolvedGitDir !== mainGitDir;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (deadlineLapsed(deadlineAt)) return finalize();
|
|
208
|
+
|
|
209
|
+
// Probe 4: branch (empty stdout => detached HEAD, leave null).
|
|
210
|
+
const branchProbe = await runGit(['branch', '--show-current'], { cwd, deadlineAt }, ctx);
|
|
211
|
+
if (branchProbe.ok) {
|
|
212
|
+
const b = (branchProbe.stdout || '').trim();
|
|
213
|
+
ctx.branch = b.length > 0 ? b : null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (deadlineLapsed(deadlineAt)) return finalize();
|
|
217
|
+
|
|
218
|
+
// Probe 5: HEAD SHA.
|
|
219
|
+
const headProbe = await runGit(['rev-parse', 'HEAD'], { cwd, deadlineAt }, ctx);
|
|
220
|
+
if (headProbe.ok) {
|
|
221
|
+
const h = (headProbe.stdout || '').trim();
|
|
222
|
+
if (/^[0-9a-f]{40}$/i.test(h)) {
|
|
223
|
+
ctx.head = h.toLowerCase();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Probe 6 (optional): dev-offload registry reverse lookup. Only runs when
|
|
228
|
+
// the file exists; missing-or-unreadable leaves registryName === null
|
|
229
|
+
// without escalating ctx.status. This is a sync fs probe — no budget cost.
|
|
230
|
+
ctx.registryName = lookupRegistryName(ctx.worktreeRealpath || ctx.worktreePath, registryPath);
|
|
231
|
+
|
|
232
|
+
return finalize();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Internal helpers
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
function deadlineLapsed(deadlineAt) {
|
|
240
|
+
return Date.now() + MIN_PROBE_BUDGET_MS > deadlineAt;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Run a single `git <args>` command with a per-call budget derived from the
|
|
245
|
+
* shared deadline. Uses non-blocking spawn + Promise.race so the hook's outer
|
|
246
|
+
* setTimeout can actually fire (was: spawnSync blocked the event loop).
|
|
247
|
+
*
|
|
248
|
+
* @returns {Promise<{ ok: boolean, stdout: string, stderr: string,
|
|
249
|
+
* code: number|null, signal: NodeJS.Signals|null,
|
|
250
|
+
* spawnFailed: boolean, timedOut: boolean }>}
|
|
251
|
+
*/
|
|
252
|
+
export async function runGit(args, { cwd, deadlineAt, encoding = 'utf8' }, ctx) {
|
|
253
|
+
const remaining = deadlineAt - Date.now();
|
|
254
|
+
if (remaining <= MIN_PROBE_BUDGET_MS) {
|
|
255
|
+
const msg = `git ${args.join(' ')}: deadline lapsed before spawn`;
|
|
256
|
+
if (ctx) ctx.errors.push(msg);
|
|
257
|
+
return {
|
|
258
|
+
ok: false,
|
|
259
|
+
stdout: '',
|
|
260
|
+
stderr: '',
|
|
261
|
+
code: null,
|
|
262
|
+
signal: null,
|
|
263
|
+
spawnFailed: false,
|
|
264
|
+
timedOut: true,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let child;
|
|
269
|
+
try {
|
|
270
|
+
child = spawn('git', args, {
|
|
271
|
+
cwd,
|
|
272
|
+
// Inherit env so credential helpers / GIT_DIR overrides behave as the
|
|
273
|
+
// user expects. We pass GIT_OPTIONAL_LOCKS=0 to prevent git from
|
|
274
|
+
// touching index.lock during read-only probes (cheap protection
|
|
275
|
+
// against blocking on a contended worktree).
|
|
276
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
|
277
|
+
// Detach stdin so git never tries to read from our hook's stdin (which
|
|
278
|
+
// is reserved for the JSON event payload).
|
|
279
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
280
|
+
});
|
|
281
|
+
} catch (err) {
|
|
282
|
+
const msg = `git ${args.join(' ')}: ${shortMessage(err)}`;
|
|
283
|
+
if (ctx) ctx.errors.push(msg);
|
|
284
|
+
return {
|
|
285
|
+
ok: false,
|
|
286
|
+
stdout: '',
|
|
287
|
+
stderr: '',
|
|
288
|
+
code: null,
|
|
289
|
+
signal: null,
|
|
290
|
+
spawnFailed: true,
|
|
291
|
+
timedOut: false,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const stdoutChunks = [];
|
|
296
|
+
const stderrChunks = [];
|
|
297
|
+
child.stdout.on('data', (c) => stdoutChunks.push(c));
|
|
298
|
+
child.stderr.on('data', (c) => stderrChunks.push(c));
|
|
299
|
+
|
|
300
|
+
const result = await new Promise((resolvePromise) => {
|
|
301
|
+
let settled = false;
|
|
302
|
+
const settle = (value) => {
|
|
303
|
+
if (settled) return;
|
|
304
|
+
settled = true;
|
|
305
|
+
resolvePromise(value);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// Per-call timer races against the global deadline. We use `remaining`
|
|
309
|
+
// (already computed above) as the per-call budget — every probe is
|
|
310
|
+
// bounded by the shared deadline, never accumulating beyond it.
|
|
311
|
+
const timer = setTimeout(() => {
|
|
312
|
+
// Try graceful kill first, then SIGKILL to guarantee descent doesn't
|
|
313
|
+
// outlive our budget. .unref() so the timer itself never keeps the
|
|
314
|
+
// event loop alive past natural completion.
|
|
315
|
+
try { child.kill('SIGTERM'); } catch { /* already gone */ }
|
|
316
|
+
// Hard-kill follow-up after a 50 ms grace window.
|
|
317
|
+
setTimeout(() => {
|
|
318
|
+
try { child.kill('SIGKILL'); } catch { /* ignore */ }
|
|
319
|
+
}, 50).unref();
|
|
320
|
+
settle({ kind: 'timeout' });
|
|
321
|
+
}, remaining);
|
|
322
|
+
timer.unref();
|
|
323
|
+
|
|
324
|
+
child.on('error', (err) => {
|
|
325
|
+
clearTimeout(timer);
|
|
326
|
+
settle({ kind: 'error', err });
|
|
327
|
+
});
|
|
328
|
+
// Use 'close' rather than 'exit': 'exit' fires when the child has exited
|
|
329
|
+
// but stdio pipes may still have buffered data not yet delivered to our
|
|
330
|
+
// 'data' listeners. On a contended Linux CI runner that race lets us read
|
|
331
|
+
// an empty stdout from a successful `git rev-parse --is-inside-work-tree`,
|
|
332
|
+
// mis-classifying a real repo as 'not_a_repo'. 'close' fires only after
|
|
333
|
+
// both the child exited AND stdio streams drained.
|
|
334
|
+
child.on('close', (code, signal) => {
|
|
335
|
+
clearTimeout(timer);
|
|
336
|
+
settle({ kind: 'exit', code, signal });
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const stdout = Buffer.concat(stdoutChunks).toString(encoding);
|
|
341
|
+
const stderr = Buffer.concat(stderrChunks).toString(encoding);
|
|
342
|
+
|
|
343
|
+
if (result.kind === 'timeout') {
|
|
344
|
+
const msg = `git ${args.join(' ')}: timed out after ${remaining}ms`;
|
|
345
|
+
if (ctx) ctx.errors.push(msg);
|
|
346
|
+
return {
|
|
347
|
+
ok: false,
|
|
348
|
+
stdout,
|
|
349
|
+
stderr,
|
|
350
|
+
code: null,
|
|
351
|
+
signal: 'SIGTERM',
|
|
352
|
+
spawnFailed: false,
|
|
353
|
+
timedOut: true,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (result.kind === 'error') {
|
|
358
|
+
const msg = `git ${args.join(' ')}: ${shortMessage(result.err)}`;
|
|
359
|
+
if (ctx) ctx.errors.push(msg);
|
|
360
|
+
return {
|
|
361
|
+
ok: false,
|
|
362
|
+
stdout,
|
|
363
|
+
stderr,
|
|
364
|
+
code: null,
|
|
365
|
+
signal: null,
|
|
366
|
+
spawnFailed: true,
|
|
367
|
+
timedOut: false,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// result.kind === 'exit'
|
|
372
|
+
if (result.signal) {
|
|
373
|
+
const msg = `git ${args.join(' ')}: killed by ${result.signal}`;
|
|
374
|
+
if (ctx) ctx.errors.push(msg);
|
|
375
|
+
return {
|
|
376
|
+
ok: false,
|
|
377
|
+
stdout,
|
|
378
|
+
stderr,
|
|
379
|
+
code: result.code,
|
|
380
|
+
signal: result.signal,
|
|
381
|
+
spawnFailed: false,
|
|
382
|
+
timedOut: true,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (result.code !== 0) {
|
|
387
|
+
// Non-zero exit. Some commands (rev-parse --is-inside-work-tree outside a
|
|
388
|
+
// repo) intentionally exit non-zero; the caller decides whether to log it
|
|
389
|
+
// by inspecting `ok`. We still record a one-line diagnostic for observers
|
|
390
|
+
// — callers that consider the non-zero exit "expected" can pop the last
|
|
391
|
+
// entry from ctx.errors.
|
|
392
|
+
const tail = (stderr || '').trim().split('\n').pop() || `exit ${result.code}`;
|
|
393
|
+
if (ctx) ctx.errors.push(`git ${args.join(' ')}: ${truncate(tail, 120)}`);
|
|
394
|
+
return {
|
|
395
|
+
ok: false,
|
|
396
|
+
stdout,
|
|
397
|
+
stderr,
|
|
398
|
+
code: result.code,
|
|
399
|
+
signal: null,
|
|
400
|
+
spawnFailed: false,
|
|
401
|
+
timedOut: false,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
ok: true,
|
|
407
|
+
stdout,
|
|
408
|
+
stderr,
|
|
409
|
+
code: 0,
|
|
410
|
+
signal: null,
|
|
411
|
+
spawnFailed: false,
|
|
412
|
+
timedOut: false,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Reverse-look-up the registry to find the worktree key whose `worktree_path`
|
|
418
|
+
* (resolved via realpath when possible) matches our worktreeRealpath. Matches
|
|
419
|
+
* are realpath-aware: the registry file may store the symlinked path while we
|
|
420
|
+
* receive the canonical one (or vice versa), so we resolve both before
|
|
421
|
+
* comparing.
|
|
422
|
+
*
|
|
423
|
+
* Returns `null` for any miss (file not found, not JSON, no match) — never
|
|
424
|
+
* throws.
|
|
425
|
+
*/
|
|
426
|
+
function lookupRegistryName(worktreeRealOrPath, registryPath) {
|
|
427
|
+
if (!worktreeRealOrPath) return null;
|
|
428
|
+
if (!existsSync(registryPath)) return null;
|
|
429
|
+
|
|
430
|
+
let parsed;
|
|
431
|
+
try {
|
|
432
|
+
const raw = readFileSync(registryPath, 'utf8');
|
|
433
|
+
parsed = JSON.parse(raw);
|
|
434
|
+
} catch {
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.worktrees ||
|
|
438
|
+
typeof parsed.worktrees !== 'object') {
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Pre-resolve our side once so we don't realpath() inside the loop.
|
|
443
|
+
let mySide = worktreeRealOrPath;
|
|
444
|
+
try {
|
|
445
|
+
mySide = realpathSync(worktreeRealOrPath);
|
|
446
|
+
} catch {
|
|
447
|
+
// Keep mySide as-is — best-effort.
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
for (const [name, entry] of Object.entries(parsed.worktrees)) {
|
|
451
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
452
|
+
const candidate = typeof entry.worktree_path === 'string' ? entry.worktree_path : null;
|
|
453
|
+
if (!candidate) continue;
|
|
454
|
+
if (candidate === mySide || candidate === worktreeRealOrPath) {
|
|
455
|
+
return name;
|
|
456
|
+
}
|
|
457
|
+
let candidateReal = candidate;
|
|
458
|
+
try {
|
|
459
|
+
candidateReal = realpathSync(candidate);
|
|
460
|
+
} catch {
|
|
461
|
+
// Skip — non-existent registry entry.
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (candidateReal === mySide) return name;
|
|
465
|
+
}
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function shortMessage(err) {
|
|
470
|
+
if (!err) return 'unknown error';
|
|
471
|
+
if (err.code) return `${err.code}${err.message ? `: ${truncate(err.message, 100)}` : ''}`;
|
|
472
|
+
return truncate(String(err.message || err), 120);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function truncate(s, max) {
|
|
476
|
+
if (typeof s !== 'string') return '';
|
|
477
|
+
if (s.length <= max) return s;
|
|
478
|
+
return s.slice(0, max - 1) + '…';
|
|
479
|
+
}
|