git-stint 0.1.1

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.
@@ -0,0 +1,587 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import * as git from "./git.js";
5
+ import { BRANCH_PREFIX, WORKTREE_DIR, MANIFEST_VERSION, saveManifest, deleteManifest, listManifests, resolveSession, getWorktreePath, getRepoRoot, } from "./manifest.js";
6
+ // --- Constants ---
7
+ const WIP_MESSAGE = "WIP: session checkpoint";
8
+ // --- Name generation ---
9
+ const ADJECTIVES = [
10
+ "swift", "keen", "bold", "calm", "warm", "cool", "bright", "quick",
11
+ "sharp", "fair", "kind", "deep", "soft", "pure", "fine", "clear",
12
+ "fresh", "glad", "neat", "safe", "wise", "lean", "fast", "true",
13
+ "rare", "prime", "tidy", "pale", "dense", "vivid", "plush", "brisk",
14
+ "deft", "crisp", "snug", "lush", "mild", "stark", "vast", "terse",
15
+ "grand", "dusk", "dawn", "sage", "sleek", "polar", "lunar", "coral",
16
+ "azure", "ivory",
17
+ ];
18
+ const NOUNS = [
19
+ "fox", "oak", "elm", "bay", "sky", "sun", "dew", "pine",
20
+ "ivy", "ash", "gem", "owl", "bee", "fin", "ray", "fern",
21
+ "lark", "wren", "hare", "cove", "vale", "reef", "glen", "peak",
22
+ "dale", "mist", "reed", "lynx", "dove", "hawk", "moss", "tide",
23
+ "crest", "leaf", "birch", "cliff", "brook", "ridge", "grove", "shore",
24
+ "stone", "flint", "cedar", "maple", "drift", "spark", "blaze", "frost",
25
+ "crane", "otter",
26
+ ];
27
+ function generateName() {
28
+ const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
29
+ const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
30
+ return `${adj}-${noun}`;
31
+ }
32
+ /**
33
+ * Validate session name: must be alphanumeric with hyphens/underscores only.
34
+ * Prevents path traversal, git branch issues, and shell injection.
35
+ */
36
+ function validateName(name) {
37
+ if (!name || name.trim().length === 0) {
38
+ throw new Error("Session name cannot be empty.");
39
+ }
40
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name)) {
41
+ throw new Error(`Invalid session name '${name}'. Use only letters, numbers, hyphens, underscores, or dots. Must start with alphanumeric.`);
42
+ }
43
+ if (name.includes("..")) {
44
+ throw new Error("Session name cannot contain '..'.");
45
+ }
46
+ }
47
+ function ensureUniqueName(name) {
48
+ const existing = new Set(listManifests().map((m) => m.name));
49
+ if (!existing.has(name) && !git.branchExists(`${BRANCH_PREFIX}${name}`))
50
+ return name;
51
+ for (let i = 2; i < 100; i++) {
52
+ const candidate = `${name}-${i}`;
53
+ if (!existing.has(candidate) && !git.branchExists(`${BRANCH_PREFIX}${candidate}`))
54
+ return candidate;
55
+ }
56
+ throw new Error(`Cannot generate unique name from '${name}'. Run 'git stint prune' to clean up stale sessions.`);
57
+ }
58
+ // --- Ensure .stint/ is excluded locally (not committed) ---
59
+ function ensureExcluded() {
60
+ // Use git's common dir — correct for both main repo and worktrees.
61
+ // In a worktree, .git is a file, not a directory, so join(topLevel, ".git", ...) would fail.
62
+ const commonDir = resolve(git.getGitCommonDir());
63
+ const excludePath = join(commonDir, "info", "exclude");
64
+ if (existsSync(excludePath)) {
65
+ const content = readFileSync(excludePath, "utf-8");
66
+ const lines = content.split("\n");
67
+ if (lines.some((l) => l.trim() === `${WORKTREE_DIR}/` || l.trim() === WORKTREE_DIR))
68
+ return;
69
+ }
70
+ // Append to local exclude (never committed, never affects other team members)
71
+ const entry = `\n# git-stint worktrees\n${WORKTREE_DIR}/\n`;
72
+ mkdirSync(dirname(excludePath), { recursive: true });
73
+ if (existsSync(excludePath)) {
74
+ const content = readFileSync(excludePath, "utf-8");
75
+ writeFileSync(excludePath, content.endsWith("\n") ? content + entry.slice(1) : content + entry);
76
+ }
77
+ else {
78
+ writeFileSync(excludePath, entry);
79
+ }
80
+ }
81
+ /** Warn if CWD is inside the worktree being removed. */
82
+ function warnIfInsideWorktree(worktree) {
83
+ if (process.cwd().startsWith(worktree)) {
84
+ const topLevel = getRepoRoot();
85
+ console.warn(`\nWarning: Your shell is inside the worktree being removed.`);
86
+ console.warn(`Run: cd ${topLevel}`);
87
+ }
88
+ }
89
+ // --- Commands ---
90
+ export function start(name) {
91
+ if (!git.isInsideGitRepo()) {
92
+ throw new Error("Not inside a git repository.");
93
+ }
94
+ if (!git.hasCommits()) {
95
+ throw new Error("Repository has no commits. Make an initial commit first.");
96
+ }
97
+ if (name)
98
+ validateName(name);
99
+ const sessionName = name ? ensureUniqueName(name) : ensureUniqueName(generateName());
100
+ const branchName = `${BRANCH_PREFIX}${sessionName}`;
101
+ if (git.branchExists(branchName)) {
102
+ throw new Error(`Branch '${branchName}' already exists. Run 'git stint prune' to clean orphaned branches, or choose a different name.`);
103
+ }
104
+ const head = git.getHead();
105
+ const topLevel = getRepoRoot();
106
+ const worktreeRel = `${WORKTREE_DIR}/${sessionName}`;
107
+ const worktreeAbs = resolve(topLevel, worktreeRel);
108
+ // Create branch first
109
+ git.createBranch(branchName, head);
110
+ // Create worktree — rollback branch on failure
111
+ try {
112
+ ensureExcluded();
113
+ git.addWorktree(worktreeAbs, branchName);
114
+ }
115
+ catch (err) {
116
+ // Rollback: delete the branch we just created
117
+ try {
118
+ git.deleteBranch(branchName);
119
+ }
120
+ catch { /* best effort */ }
121
+ throw err;
122
+ }
123
+ // Create manifest
124
+ const manifest = {
125
+ version: MANIFEST_VERSION,
126
+ name: sessionName,
127
+ startedAt: head,
128
+ baseline: head,
129
+ branch: branchName,
130
+ worktree: worktreeRel,
131
+ changesets: [],
132
+ pending: [],
133
+ };
134
+ saveManifest(manifest);
135
+ console.log(`Session '${sessionName}' started.`);
136
+ console.log(` Branch: ${branchName}`);
137
+ console.log(` Worktree: ${worktreeAbs}`);
138
+ console.log(`\ncd "${worktreeAbs}"`);
139
+ }
140
+ export function track(files, sessionName) {
141
+ const manifest = resolveSession(sessionName);
142
+ const worktree = getWorktreePath(manifest);
143
+ const topLevel = getRepoRoot();
144
+ for (const file of files) {
145
+ const absFile = resolve(file);
146
+ let relFile;
147
+ if (absFile.startsWith(worktree + "/")) {
148
+ // Absolute path inside the worktree — make relative to worktree root
149
+ relFile = absFile.slice(worktree.length + 1);
150
+ }
151
+ else if (absFile.startsWith(topLevel + "/")) {
152
+ // Absolute path inside the main repo — convert to repo-relative
153
+ relFile = absFile.slice(topLevel.length + 1);
154
+ }
155
+ else {
156
+ // Relative path or outside both — store as-is (already repo-relative)
157
+ relFile = file;
158
+ }
159
+ if (relFile && !manifest.pending.includes(relFile)) {
160
+ manifest.pending.push(relFile);
161
+ }
162
+ }
163
+ saveManifest(manifest);
164
+ }
165
+ export function status(sessionName) {
166
+ const manifest = resolveSession(sessionName);
167
+ const worktree = getWorktreePath(manifest);
168
+ console.log(`Session: ${manifest.name}`);
169
+ console.log(`Branch: ${manifest.branch}`);
170
+ console.log(`Base: ${manifest.startedAt.slice(0, 8)}`);
171
+ console.log(`Commits: ${manifest.changesets.length}`);
172
+ console.log();
173
+ if (manifest.pending.length > 0) {
174
+ console.log("Pending files:");
175
+ for (const f of manifest.pending) {
176
+ console.log(` ${f}`);
177
+ }
178
+ console.log();
179
+ }
180
+ if (manifest.changesets.length > 0) {
181
+ console.log("Changesets:");
182
+ for (const cs of manifest.changesets) {
183
+ console.log(` #${cs.id} ${cs.sha.slice(0, 8)} ${cs.message}`);
184
+ }
185
+ console.log();
186
+ }
187
+ // Show git status in worktree
188
+ try {
189
+ const st = git.statusShort(worktree);
190
+ if (st) {
191
+ console.log("Working directory:");
192
+ console.log(st);
193
+ }
194
+ else {
195
+ console.log("Working directory clean.");
196
+ }
197
+ }
198
+ catch {
199
+ console.log("(worktree not accessible)");
200
+ }
201
+ }
202
+ /** Show both staged and unstaged changes. */
203
+ export function diff(sessionName) {
204
+ const manifest = resolveSession(sessionName);
205
+ const worktree = getWorktreePath(manifest);
206
+ const unstaged = git.gitInDir(worktree, "diff");
207
+ const staged = git.gitInDir(worktree, "diff", "--cached");
208
+ if (unstaged) {
209
+ console.log(unstaged);
210
+ }
211
+ if (staged) {
212
+ if (unstaged)
213
+ console.log();
214
+ console.log("Staged changes:");
215
+ console.log(staged);
216
+ }
217
+ if (!unstaged && !staged) {
218
+ console.log("No changes.");
219
+ }
220
+ }
221
+ export function sessionCommit(message, sessionName) {
222
+ const manifest = resolveSession(sessionName);
223
+ const worktree = getWorktreePath(manifest);
224
+ if (!existsSync(worktree)) {
225
+ throw new Error(`Worktree missing at ${worktree}. Run 'git stint prune' to clean up.`);
226
+ }
227
+ // Check for changes
228
+ if (!git.hasUncommittedChanges(worktree)) {
229
+ console.log("Nothing to commit.");
230
+ return;
231
+ }
232
+ const oldBaseline = manifest.baseline;
233
+ // Stage and commit
234
+ git.addAll(worktree);
235
+ const newSha = git.commit(worktree, message);
236
+ // Determine files changed
237
+ const files = git.diffNameOnly(oldBaseline, newSha, worktree);
238
+ // Record changeset
239
+ const changeset = {
240
+ id: manifest.changesets.length + 1,
241
+ sha: newSha,
242
+ message,
243
+ files,
244
+ timestamp: new Date().toISOString(),
245
+ };
246
+ manifest.changesets.push(changeset);
247
+ manifest.baseline = newSha;
248
+ manifest.pending = [];
249
+ saveManifest(manifest);
250
+ console.log(`Committed: ${newSha.slice(0, 8)} ${message}`);
251
+ console.log(` ${files.length} file(s) changed`);
252
+ }
253
+ export function log(sessionName) {
254
+ const manifest = resolveSession(sessionName);
255
+ if (manifest.changesets.length === 0) {
256
+ console.log("No commits in this session.");
257
+ return;
258
+ }
259
+ console.log(`Session '${manifest.name}' — ${manifest.changesets.length} commit(s):\n`);
260
+ for (const cs of manifest.changesets) {
261
+ console.log(` ${cs.sha.slice(0, 8)} ${cs.message}`);
262
+ console.log(` ${cs.timestamp} — ${cs.files.length} file(s)`);
263
+ for (const f of cs.files) {
264
+ console.log(` ${f}`);
265
+ }
266
+ }
267
+ }
268
+ export function squash(message, sessionName) {
269
+ const manifest = resolveSession(sessionName);
270
+ const worktree = getWorktreePath(manifest);
271
+ if (manifest.changesets.length === 0) {
272
+ console.log("Nothing to squash.");
273
+ return;
274
+ }
275
+ // Refuse to squash with uncommitted changes — they'd be silently included
276
+ if (git.hasUncommittedChanges(worktree)) {
277
+ throw new Error("Uncommitted changes in worktree. Commit or stash them before squashing.");
278
+ }
279
+ // Capture count before overwriting
280
+ const originalCount = manifest.changesets.length;
281
+ const allFiles = [...new Set(manifest.changesets.flatMap((cs) => cs.files))];
282
+ // Soft reset to the starting point, keeping all changes staged
283
+ git.resetSoft(worktree, manifest.startedAt);
284
+ const newSha = git.commit(worktree, message);
285
+ manifest.changesets = [
286
+ {
287
+ id: 1,
288
+ sha: newSha,
289
+ message,
290
+ files: allFiles,
291
+ timestamp: new Date().toISOString(),
292
+ },
293
+ ];
294
+ manifest.baseline = newSha;
295
+ saveManifest(manifest);
296
+ console.log(`Squashed ${originalCount} commit(s) → ${newSha.slice(0, 8)} ${message}`);
297
+ }
298
+ export function merge(sessionName) {
299
+ const manifest = resolveSession(sessionName);
300
+ const worktree = getWorktreePath(manifest);
301
+ const topLevel = getRepoRoot();
302
+ const mainBranch = git.currentBranch(topLevel);
303
+ // Safety check: don't merge into the session branch itself
304
+ if (mainBranch === manifest.branch) {
305
+ throw new Error("Cannot merge: the main repo is checked out to the session branch. Switch to your main branch first.");
306
+ }
307
+ // Auto-commit pending changes before merging
308
+ if (existsSync(worktree) && git.hasUncommittedChanges(worktree)) {
309
+ console.log("Committing pending changes...");
310
+ try {
311
+ sessionCommit(WIP_MESSAGE, manifest.name);
312
+ }
313
+ catch (err) {
314
+ const msg = err instanceof Error ? err.message : String(err);
315
+ throw new Error(`Auto-commit before merge failed: ${msg}`);
316
+ }
317
+ }
318
+ // Merge the session branch into the current branch in the main repo
319
+ try {
320
+ git.gitInDir(topLevel, "merge", manifest.branch);
321
+ }
322
+ catch (err) {
323
+ const msg = err instanceof Error ? err.message : String(err);
324
+ if (msg.toLowerCase().includes("conflict")) {
325
+ throw new Error(`Merge conflict. Resolve conflicts in ${topLevel} then run:\n` +
326
+ ` cd "${topLevel}"\n` +
327
+ ` git commit\n` +
328
+ ` git stint end --session ${manifest.name}`);
329
+ }
330
+ throw new Error(`Merge failed: ${msg}`);
331
+ }
332
+ console.log(`Merged '${manifest.branch}' into '${mainBranch}'.`);
333
+ // Clean up — may fail if CWD is inside the worktree
334
+ try {
335
+ cleanup(manifest);
336
+ console.log("Session cleaned up.");
337
+ }
338
+ catch {
339
+ console.log(`Run: cd "${topLevel}" && git stint end --session ${manifest.name}`);
340
+ }
341
+ }
342
+ /** Push branch and create PR via GitHub CLI. Uses execFileSync to prevent command injection. */
343
+ export function pr(title, sessionName) {
344
+ const manifest = resolveSession(sessionName);
345
+ const prTitle = title || `stint: ${manifest.name}`;
346
+ const baseBranch = git.getDefaultBranch();
347
+ // Build PR body from session history
348
+ const body = buildPrBody(manifest);
349
+ // Push branch
350
+ console.log(`Pushing ${manifest.branch}...`);
351
+ git.push(manifest.branch);
352
+ // Create PR via gh CLI — execFileSync prevents shell injection
353
+ try {
354
+ const result = execFileSync("gh", [
355
+ "pr", "create",
356
+ "--base", baseBranch,
357
+ "--head", manifest.branch,
358
+ "--title", prTitle,
359
+ "--body", body,
360
+ ], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
361
+ console.log(result);
362
+ }
363
+ catch (err) {
364
+ const e = err;
365
+ const stderr = e.stderr?.trim() || "";
366
+ if (stderr.includes("already exists")) {
367
+ console.log("PR already exists for this branch.");
368
+ try {
369
+ const url = execFileSync("gh", [
370
+ "pr", "view", manifest.branch, "--json", "url", "-q", ".url",
371
+ ], { encoding: "utf-8" }).trim();
372
+ console.log(url);
373
+ }
374
+ catch { /* ignore */ }
375
+ }
376
+ else {
377
+ throw new Error(`Failed to create PR: ${stderr}`);
378
+ }
379
+ }
380
+ }
381
+ function buildPrBody(manifest) {
382
+ const lines = [];
383
+ if (manifest.changesets.length > 0) {
384
+ lines.push("## Changes\n");
385
+ for (const cs of manifest.changesets) {
386
+ lines.push(`- **${cs.message}** (${cs.files.length} file${cs.files.length === 1 ? "" : "s"})`);
387
+ }
388
+ const allFiles = [...new Set(manifest.changesets.flatMap((cs) => cs.files))];
389
+ lines.push(`\n## Files changed (${allFiles.length})\n`);
390
+ for (const f of allFiles.sort()) {
391
+ lines.push(`- \`${f}\``);
392
+ }
393
+ }
394
+ lines.push("\n---\n*Created with [git-stint](https://github.com/rchaz/git-stint)*");
395
+ return lines.join("\n");
396
+ }
397
+ export function end(sessionName) {
398
+ const manifest = resolveSession(sessionName);
399
+ const worktree = getWorktreePath(manifest);
400
+ // Auto-commit pending changes
401
+ if (existsSync(worktree) && git.hasUncommittedChanges(worktree)) {
402
+ console.log("Committing pending changes...");
403
+ try {
404
+ sessionCommit(WIP_MESSAGE, manifest.name);
405
+ }
406
+ catch (err) {
407
+ const msg = err instanceof Error ? err.message : String(err);
408
+ throw new Error(`Auto-commit before end failed: ${msg}`);
409
+ }
410
+ }
411
+ warnIfInsideWorktree(worktree);
412
+ console.log(`Ending session '${manifest.name}'...`);
413
+ cleanup(manifest);
414
+ console.log("Session ended.");
415
+ }
416
+ export function abort(sessionName) {
417
+ const manifest = resolveSession(sessionName);
418
+ const worktree = getWorktreePath(manifest);
419
+ warnIfInsideWorktree(worktree);
420
+ console.log(`Aborting session '${manifest.name}'...`);
421
+ cleanup(manifest, true);
422
+ console.log("Session discarded. All changes removed.");
423
+ }
424
+ /** Revert last commit, keeping changes as unstaged files. */
425
+ export function undo(sessionName) {
426
+ const manifest = resolveSession(sessionName);
427
+ const worktree = getWorktreePath(manifest);
428
+ if (manifest.changesets.length === 0) {
429
+ console.log("Nothing to undo.");
430
+ return;
431
+ }
432
+ const last = manifest.changesets[manifest.changesets.length - 1];
433
+ // Reset to the known previous baseline (not hardcoded HEAD~1)
434
+ const resetTarget = manifest.changesets.length > 1
435
+ ? manifest.changesets[manifest.changesets.length - 2].sha
436
+ : manifest.startedAt;
437
+ git.resetMixed(worktree, resetTarget);
438
+ // Update manifest
439
+ manifest.changesets.pop();
440
+ manifest.baseline = resetTarget;
441
+ manifest.pending = last.files;
442
+ saveManifest(manifest);
443
+ console.log(`Undid commit: ${last.sha.slice(0, 8)} ${last.message}`);
444
+ console.log(`${last.files.length} file(s) back to pending.`);
445
+ }
446
+ export function list() {
447
+ const manifests = listManifests();
448
+ if (manifests.length === 0) {
449
+ console.log("No active sessions.");
450
+ return;
451
+ }
452
+ console.log("Active sessions:\n");
453
+ const maxName = Math.max(...manifests.map((m) => m.name.length), 4);
454
+ console.log(` ${"NAME".padEnd(maxName)} COMMITS PENDING BASE`);
455
+ console.log(` ${"─".repeat(maxName)} ${"─".repeat(7)} ${"─".repeat(7)} ${"─".repeat(8)}`);
456
+ for (const m of manifests) {
457
+ const base = m.startedAt.slice(0, 8);
458
+ console.log(` ${m.name.padEnd(maxName)} ${String(m.changesets.length).padStart(7)} ${String(m.pending.length).padStart(7)} ${base}`);
459
+ }
460
+ }
461
+ export function listJson() {
462
+ const manifests = listManifests();
463
+ const result = manifests.map((m) => ({
464
+ name: m.name,
465
+ branch: m.branch,
466
+ worktree: m.worktree,
467
+ commits: m.changesets.length,
468
+ pending: m.pending.length,
469
+ startedAt: m.startedAt,
470
+ }));
471
+ console.log(JSON.stringify(result));
472
+ }
473
+ /** Clean up orphaned worktrees, manifests, and branches. */
474
+ export function prune() {
475
+ const topLevel = getRepoRoot();
476
+ const stintDir = resolve(topLevel, WORKTREE_DIR);
477
+ const manifests = listManifests();
478
+ const manifestNames = new Set(manifests.map((m) => m.name));
479
+ let cleaned = 0;
480
+ // Check for worktrees without manifests (including leftover stint-combine-* from crashed testCombine)
481
+ if (existsSync(stintDir)) {
482
+ const entries = readdirSync(stintDir).filter((entry) => {
483
+ try {
484
+ return statSync(resolve(stintDir, entry)).isDirectory();
485
+ }
486
+ catch {
487
+ return false;
488
+ }
489
+ });
490
+ for (const entry of entries) {
491
+ const isCombineLeftover = entry.startsWith("stint-combine-");
492
+ if (!isCombineLeftover && manifestNames.has(entry))
493
+ continue;
494
+ // Orphaned worktree (no manifest) or leftover combine worktree
495
+ const label = isCombineLeftover ? "leftover combine worktree" : "orphaned worktree";
496
+ console.log(`Removing ${label}: ${WORKTREE_DIR}/${entry}`);
497
+ try {
498
+ git.removeWorktree(resolve(stintDir, entry), true);
499
+ cleaned++;
500
+ }
501
+ catch (err) {
502
+ const e = err;
503
+ console.error(` Failed: ${e.message}`);
504
+ }
505
+ // Clean up combine branch if it exists
506
+ if (isCombineLeftover) {
507
+ try {
508
+ git.deleteBranch(entry);
509
+ }
510
+ catch { /* may not exist */ }
511
+ }
512
+ }
513
+ }
514
+ // Check for manifests without worktrees
515
+ for (const m of manifests) {
516
+ const wt = getWorktreePath(m);
517
+ if (!existsSync(wt)) {
518
+ console.log(`Removing orphaned manifest: ${m.name} (worktree missing)`);
519
+ deleteManifest(m.name);
520
+ try {
521
+ git.deleteBranch(m.branch);
522
+ console.log(` Deleted branch: ${m.branch}`);
523
+ }
524
+ catch { /* branch may not exist */ }
525
+ cleaned++;
526
+ }
527
+ }
528
+ // Check for stint/* branches without manifests
529
+ try {
530
+ const output = git.git("branch", "--list", `${BRANCH_PREFIX}*`);
531
+ const branches = output
532
+ .split("\n")
533
+ .map((b) => b.replace("*", "").trim()) // strip current-branch marker
534
+ .filter(Boolean);
535
+ for (const branch of branches) {
536
+ const name = branch.replace(BRANCH_PREFIX, "");
537
+ if (!manifestNames.has(name)) {
538
+ console.log(`Removing orphaned branch: ${branch}`);
539
+ try {
540
+ git.deleteBranch(branch);
541
+ cleaned++;
542
+ }
543
+ catch (err) {
544
+ const e = err;
545
+ console.error(` Failed: ${e.message}`);
546
+ }
547
+ }
548
+ }
549
+ }
550
+ catch { /* no stint branches */ }
551
+ if (cleaned === 0) {
552
+ console.log("Nothing to clean up.");
553
+ }
554
+ else {
555
+ console.log(`\nCleaned up ${cleaned} orphan(s).`);
556
+ }
557
+ }
558
+ // --- Helpers ---
559
+ function cleanup(manifest, force = false) {
560
+ const worktree = getWorktreePath(manifest);
561
+ // Remove worktree
562
+ if (existsSync(worktree)) {
563
+ try {
564
+ git.removeWorktree(worktree, force);
565
+ }
566
+ catch (err) {
567
+ if (!force) {
568
+ // Retry with force only if we didn't already try force
569
+ git.removeWorktree(worktree, true);
570
+ }
571
+ else {
572
+ throw err;
573
+ }
574
+ }
575
+ }
576
+ // Delete local branch
577
+ try {
578
+ git.deleteBranch(manifest.branch);
579
+ }
580
+ catch { /* branch may already be deleted */ }
581
+ // Delete remote tracking ref if it exists (no network call — just local ref).
582
+ // We intentionally do NOT delete the remote branch itself:
583
+ // the user may have an open PR. They can clean up with `git push origin --delete`.
584
+ // The local tracking ref is cleaned up by `git branch -D` above.
585
+ // Delete manifest last — if anything above fails, manifest persists for prune
586
+ deleteManifest(manifest.name);
587
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Run tests in the current session's worktree.
3
+ * The worktree already contains only this session's changes — it's isolated by default.
4
+ */
5
+ export declare function test(sessionName?: string, testCmd?: string): void;
6
+ /**
7
+ * Test multiple sessions combined by creating a temporary worktree
8
+ * with an octopus merge of all specified session branches.
9
+ *
10
+ * Uses --detach to avoid "branch already checked out" errors,
11
+ * then creates a temporary branch for the merge.
12
+ */
13
+ export declare function testCombine(sessionNames: string[], testCmd?: string): void;