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.
- package/LICENSE +21 -0
- package/README.md +238 -0
- package/adapters/claude-code/hooks/git-stint-hook-pre-tool +96 -0
- package/adapters/claude-code/hooks/git-stint-hook-stop +34 -0
- package/adapters/claude-code/install.d.ts +11 -0
- package/adapters/claude-code/install.ts +16 -0
- package/bin/git-stint +2 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +268 -0
- package/dist/conflicts.d.ts +1 -0
- package/dist/conflicts.js +49 -0
- package/dist/git.d.ts +40 -0
- package/dist/git.js +149 -0
- package/dist/install-hooks.d.ts +9 -0
- package/dist/install-hooks.js +111 -0
- package/dist/manifest.d.ts +59 -0
- package/dist/manifest.js +165 -0
- package/dist/session.d.ts +19 -0
- package/dist/session.js +587 -0
- package/dist/test-session.d.ts +13 -0
- package/dist/test-session.js +130 -0
- package/package.json +52 -0
package/dist/session.js
ADDED
|
@@ -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;
|