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/cli.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import * as session from "./session.js";
|
|
5
|
+
import { checkConflicts } from "./conflicts.js";
|
|
6
|
+
import { test, testCombine } from "./test-session.js";
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
const command = args[0];
|
|
9
|
+
// Known flags that take a value (used by arg parser to skip correctly)
|
|
10
|
+
const VALUE_FLAGS = new Set(["-m", "--session", "--title", "--combine"]);
|
|
11
|
+
function getFlag(flag) {
|
|
12
|
+
// Check for --flag=value syntax
|
|
13
|
+
const eqPrefix = flag + "=";
|
|
14
|
+
for (const arg of args) {
|
|
15
|
+
if (arg.startsWith(eqPrefix)) {
|
|
16
|
+
return arg.slice(eqPrefix.length);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// Check for --flag value syntax
|
|
20
|
+
const idx = args.indexOf(flag);
|
|
21
|
+
if (idx === -1)
|
|
22
|
+
return undefined;
|
|
23
|
+
const value = args[idx + 1];
|
|
24
|
+
if (value === undefined) {
|
|
25
|
+
throw new Error(`Flag '${flag}' requires a value.`);
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
/** Check if an arg is a value flag, handling both --flag and --flag=value forms. */
|
|
30
|
+
function isValueFlag(arg) {
|
|
31
|
+
if (VALUE_FLAGS.has(arg))
|
|
32
|
+
return true;
|
|
33
|
+
// --flag=value: the flag consumed its value inline, don't skip next arg
|
|
34
|
+
for (const f of VALUE_FLAGS) {
|
|
35
|
+
if (arg.startsWith(f + "="))
|
|
36
|
+
return false; // value is inline, no skip
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
function getPositional(index) {
|
|
41
|
+
let pos = 0;
|
|
42
|
+
for (let i = 1; i < args.length; i++) {
|
|
43
|
+
if (args[i].startsWith("-")) {
|
|
44
|
+
// Only skip next arg if this flag takes a separate value (not --flag=value)
|
|
45
|
+
if (isValueFlag(args[i]))
|
|
46
|
+
i++;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (pos === index)
|
|
50
|
+
return args[i];
|
|
51
|
+
pos++;
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
function getAllPositional() {
|
|
56
|
+
const result = [];
|
|
57
|
+
for (let i = 1; i < args.length; i++) {
|
|
58
|
+
if (args[i].startsWith("-")) {
|
|
59
|
+
if (isValueFlag(args[i]))
|
|
60
|
+
i++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
result.push(args[i]);
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
switch (command) {
|
|
69
|
+
case "start": {
|
|
70
|
+
const name = getPositional(0);
|
|
71
|
+
session.start(name);
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case "track": {
|
|
75
|
+
const files = getAllPositional();
|
|
76
|
+
if (files.length === 0) {
|
|
77
|
+
console.error("Usage: git stint track <file...>");
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
const sessionFlag = getFlag("--session");
|
|
81
|
+
session.track(files, sessionFlag);
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
case "status": {
|
|
85
|
+
session.status(getFlag("--session"));
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
case "diff": {
|
|
89
|
+
session.diff(getFlag("--session"));
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
case "commit": {
|
|
93
|
+
const message = getFlag("-m");
|
|
94
|
+
if (!message) {
|
|
95
|
+
console.error("Usage: git stint commit -m \"message\"");
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
session.sessionCommit(message, getFlag("--session"));
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
case "log": {
|
|
102
|
+
session.log(getFlag("--session"));
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
case "squash": {
|
|
106
|
+
const message = getFlag("-m");
|
|
107
|
+
if (!message) {
|
|
108
|
+
console.error("Usage: git stint squash -m \"message\"");
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
session.squash(message, getFlag("--session"));
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
case "merge": {
|
|
115
|
+
session.merge(getFlag("--session"));
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
case "pr": {
|
|
119
|
+
const title = getFlag("--title");
|
|
120
|
+
session.pr(title, getFlag("--session"));
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
case "end": {
|
|
124
|
+
session.end(getFlag("--session"));
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
case "abort": {
|
|
128
|
+
session.abort(getFlag("--session"));
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
case "undo": {
|
|
132
|
+
session.undo(getFlag("--session"));
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
case "conflicts": {
|
|
136
|
+
checkConflicts(getFlag("--session"));
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
case "test": {
|
|
140
|
+
const combineNames = getFlag("--combine");
|
|
141
|
+
if (combineNames) {
|
|
142
|
+
// Collect all names after --combine
|
|
143
|
+
const idx = args.indexOf("--combine");
|
|
144
|
+
const names = [];
|
|
145
|
+
for (let i = idx + 1; i < args.length; i++) {
|
|
146
|
+
if (args[i].startsWith("-"))
|
|
147
|
+
break;
|
|
148
|
+
names.push(args[i]);
|
|
149
|
+
}
|
|
150
|
+
if (names.length < 2) {
|
|
151
|
+
console.error("Usage: git stint test --combine <session1> <session2> [...]");
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
const dashIdx = args.indexOf("--");
|
|
155
|
+
const testCmd = dashIdx >= 0 ? args.slice(dashIdx + 1).join(" ") : undefined;
|
|
156
|
+
testCombine(names, testCmd);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
const dashIdx = args.indexOf("--");
|
|
160
|
+
const testCmd = dashIdx >= 0 ? args.slice(dashIdx + 1).join(" ") : undefined;
|
|
161
|
+
test(getFlag("--session"), testCmd);
|
|
162
|
+
}
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
case "list": {
|
|
166
|
+
if (args.includes("--json")) {
|
|
167
|
+
session.listJson();
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
session.list();
|
|
171
|
+
}
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
case "prune": {
|
|
175
|
+
session.prune();
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
case "install-hooks": {
|
|
179
|
+
const { install } = await import("./install-hooks.js");
|
|
180
|
+
const scope = args.includes("--user") ? "user" : "project";
|
|
181
|
+
install(scope);
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
case "uninstall-hooks": {
|
|
185
|
+
const { uninstall } = await import("./install-hooks.js");
|
|
186
|
+
const scope = args.includes("--user") ? "user" : "project";
|
|
187
|
+
uninstall(scope);
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
case "version":
|
|
191
|
+
case "--version":
|
|
192
|
+
case "-v": {
|
|
193
|
+
printVersion();
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
case "help":
|
|
197
|
+
case "--help":
|
|
198
|
+
case "-h":
|
|
199
|
+
case undefined: {
|
|
200
|
+
printHelp();
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
default: {
|
|
204
|
+
console.error(`Unknown command: ${command}`);
|
|
205
|
+
console.error("Run `git stint help` for usage.");
|
|
206
|
+
process.exit(1);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
212
|
+
console.error(`Error: ${message}`);
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
function getVersion() {
|
|
216
|
+
try {
|
|
217
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
218
|
+
const pkgPath = join(thisDir, "..", "package.json");
|
|
219
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
220
|
+
return pkg.version || "unknown";
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return "unknown";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function printVersion() {
|
|
227
|
+
console.log(`git-stint ${getVersion()}`);
|
|
228
|
+
}
|
|
229
|
+
function printHelp() {
|
|
230
|
+
console.log(`git-stint ${getVersion()} — Session-scoped change tracking for AI coding agents
|
|
231
|
+
|
|
232
|
+
Usage: git stint <command> [options]
|
|
233
|
+
|
|
234
|
+
Commands:
|
|
235
|
+
start [name] Create a new session (branch + worktree)
|
|
236
|
+
list List all active sessions
|
|
237
|
+
status Show current session state
|
|
238
|
+
track <file...> Add files to the pending list
|
|
239
|
+
diff Show uncommitted changes
|
|
240
|
+
commit -m "msg" Commit changes, advance baseline
|
|
241
|
+
log Show session commit history
|
|
242
|
+
squash -m "msg" Collapse all commits into one
|
|
243
|
+
merge Merge session into main (no PR)
|
|
244
|
+
pr [--title "..."] Push branch and create GitHub PR
|
|
245
|
+
end Finalize session, clean up everything
|
|
246
|
+
abort Discard session — delete all changes
|
|
247
|
+
undo Revert last commit, changes become pending
|
|
248
|
+
conflicts Check file overlap with other sessions
|
|
249
|
+
test [-- <cmd>] Run tests in the session worktree
|
|
250
|
+
test --combine A B Test multiple sessions merged together
|
|
251
|
+
prune Clean up orphaned worktrees/branches
|
|
252
|
+
install-hooks [--user] Install Claude Code hooks
|
|
253
|
+
uninstall-hooks [--user] Remove Claude Code hooks
|
|
254
|
+
|
|
255
|
+
Options:
|
|
256
|
+
--session <name> Specify session (auto-detected from CWD)
|
|
257
|
+
-m "message" Commit/squash message
|
|
258
|
+
--title "title" PR title
|
|
259
|
+
--version Show version number
|
|
260
|
+
|
|
261
|
+
Examples:
|
|
262
|
+
git stint start auth-fix
|
|
263
|
+
cd .stint/auth-fix/
|
|
264
|
+
# make changes...
|
|
265
|
+
git stint commit -m "Fix auth token refresh"
|
|
266
|
+
git stint pr --title "Fix auth bug"
|
|
267
|
+
git stint end`);
|
|
268
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function checkConflicts(sessionName?: string): void;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import * as git from "./git.js";
|
|
2
|
+
import { listManifests, resolveSession, getWorktreePath } from "./manifest.js";
|
|
3
|
+
export function checkConflicts(sessionName) {
|
|
4
|
+
const current = resolveSession(sessionName);
|
|
5
|
+
const others = listManifests().filter((m) => m.name !== current.name);
|
|
6
|
+
if (others.length === 0) {
|
|
7
|
+
console.log("No other active sessions.");
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
// Get current session's changed files
|
|
11
|
+
const currentFiles = new Set();
|
|
12
|
+
for (const cs of current.changesets) {
|
|
13
|
+
cs.files.forEach((f) => currentFiles.add(f));
|
|
14
|
+
}
|
|
15
|
+
current.pending.forEach((f) => currentFiles.add(f));
|
|
16
|
+
// Also check uncommitted changes in worktree
|
|
17
|
+
const worktree = getWorktreePath(current);
|
|
18
|
+
try {
|
|
19
|
+
const uncommitted = git.gitInDir(worktree, "diff", "--name-only");
|
|
20
|
+
if (uncommitted) {
|
|
21
|
+
uncommitted.split("\n").forEach((f) => currentFiles.add(f));
|
|
22
|
+
}
|
|
23
|
+
const staged = git.gitInDir(worktree, "diff", "--cached", "--name-only");
|
|
24
|
+
if (staged) {
|
|
25
|
+
staged.split("\n").forEach((f) => currentFiles.add(f));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch { /* worktree may not be accessible */ }
|
|
29
|
+
let hasConflicts = false;
|
|
30
|
+
for (const other of others) {
|
|
31
|
+
const otherFiles = new Set();
|
|
32
|
+
for (const cs of other.changesets) {
|
|
33
|
+
cs.files.forEach((f) => otherFiles.add(f));
|
|
34
|
+
}
|
|
35
|
+
other.pending.forEach((f) => otherFiles.add(f));
|
|
36
|
+
const overlap = [...currentFiles].filter((f) => otherFiles.has(f));
|
|
37
|
+
if (overlap.length > 0) {
|
|
38
|
+
hasConflicts = true;
|
|
39
|
+
console.log(`Overlap with session '${other.name}':`);
|
|
40
|
+
for (const f of overlap) {
|
|
41
|
+
console.log(` ${f}`);
|
|
42
|
+
}
|
|
43
|
+
console.log();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (!hasConflicts) {
|
|
47
|
+
console.log("No file overlaps with other sessions.");
|
|
48
|
+
}
|
|
49
|
+
}
|
package/dist/git.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export declare function git(...args: string[]): string;
|
|
2
|
+
export declare function gitInDir(dir: string, ...args: string[]): string;
|
|
3
|
+
export declare function getHead(dir?: string): string;
|
|
4
|
+
export declare function getGitDir(): string;
|
|
5
|
+
/** Returns the main .git dir (shared across worktrees). */
|
|
6
|
+
export declare function getGitCommonDir(): string;
|
|
7
|
+
export declare function getTopLevel(): string;
|
|
8
|
+
export declare function currentBranch(dir?: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Detect the default branch (main/master/etc) by checking the remote HEAD ref.
|
|
11
|
+
* Falls back to "main" if detection fails.
|
|
12
|
+
*/
|
|
13
|
+
export declare function getDefaultBranch(): string;
|
|
14
|
+
export declare function branchExists(name: string): boolean;
|
|
15
|
+
export declare function createBranch(name: string, from: string): void;
|
|
16
|
+
export declare function deleteBranch(name: string): void;
|
|
17
|
+
/**
|
|
18
|
+
* Check if a remote-tracking ref exists locally (no network call).
|
|
19
|
+
* Uses `git branch -r --list` which reads the local ref cache.
|
|
20
|
+
*/
|
|
21
|
+
export declare function remoteBranchExists(name: string): boolean;
|
|
22
|
+
export declare function deleteRemoteBranch(name: string): void;
|
|
23
|
+
export declare function addWorktree(path: string, branch: string): void;
|
|
24
|
+
/** Create a worktree in detached HEAD mode at a given ref. */
|
|
25
|
+
export declare function addWorktreeDetached(path: string, ref: string): void;
|
|
26
|
+
export declare function removeWorktree(path: string, force?: boolean): void;
|
|
27
|
+
export declare function diffNameOnly(base: string, head: string, dir?: string): string[];
|
|
28
|
+
export declare function diffStat(base: string, head: string): string;
|
|
29
|
+
export declare function logOneline(base: string, head: string): string;
|
|
30
|
+
export declare function statusShort(dir: string): string;
|
|
31
|
+
export declare function hasUncommittedChanges(dir: string): boolean;
|
|
32
|
+
export declare function addAll(dir: string): void;
|
|
33
|
+
export declare function commit(dir: string, message: string): string;
|
|
34
|
+
export declare function resetSoft(dir: string, to: string): void;
|
|
35
|
+
/** Reset HEAD to a specific target, keeping changes as unstaged. */
|
|
36
|
+
export declare function resetMixed(dir: string, to: string): void;
|
|
37
|
+
export declare function mergeInto(targetDir: string, ...branches: string[]): void;
|
|
38
|
+
export declare function push(branch: string): void;
|
|
39
|
+
export declare function isInsideGitRepo(): boolean;
|
|
40
|
+
export declare function hasCommits(): boolean;
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
export function git(...args) {
|
|
3
|
+
try {
|
|
4
|
+
return execFileSync("git", args, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
5
|
+
}
|
|
6
|
+
catch (err) {
|
|
7
|
+
const e = err;
|
|
8
|
+
const stderr = e.stderr?.trim() || e.message || "unknown error";
|
|
9
|
+
throw new Error(`git ${args[0]} failed: ${stderr}`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function gitInDir(dir, ...args) {
|
|
13
|
+
return git("-C", dir, ...args);
|
|
14
|
+
}
|
|
15
|
+
export function getHead(dir) {
|
|
16
|
+
return dir ? gitInDir(dir, "rev-parse", "HEAD") : git("rev-parse", "HEAD");
|
|
17
|
+
}
|
|
18
|
+
export function getGitDir() {
|
|
19
|
+
return git("rev-parse", "--git-dir");
|
|
20
|
+
}
|
|
21
|
+
/** Returns the main .git dir (shared across worktrees). */
|
|
22
|
+
export function getGitCommonDir() {
|
|
23
|
+
return git("rev-parse", "--git-common-dir");
|
|
24
|
+
}
|
|
25
|
+
export function getTopLevel() {
|
|
26
|
+
return git("rev-parse", "--show-toplevel");
|
|
27
|
+
}
|
|
28
|
+
export function currentBranch(dir) {
|
|
29
|
+
const args = ["rev-parse", "--abbrev-ref", "HEAD"];
|
|
30
|
+
return dir ? gitInDir(dir, ...args) : git(...args);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Detect the default branch (main/master/etc) by checking the remote HEAD ref.
|
|
34
|
+
* Falls back to "main" if detection fails.
|
|
35
|
+
*/
|
|
36
|
+
export function getDefaultBranch() {
|
|
37
|
+
try {
|
|
38
|
+
const ref = git("symbolic-ref", "refs/remotes/origin/HEAD");
|
|
39
|
+
return ref.replace("refs/remotes/origin/", "");
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// No remote HEAD — check if 'main' or 'master' exists locally
|
|
43
|
+
if (branchExists("main"))
|
|
44
|
+
return "main";
|
|
45
|
+
if (branchExists("master"))
|
|
46
|
+
return "master";
|
|
47
|
+
return "main";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function branchExists(name) {
|
|
51
|
+
try {
|
|
52
|
+
git("show-ref", "--verify", "--quiet", `refs/heads/${name}`);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function createBranch(name, from) {
|
|
60
|
+
git("branch", name, from);
|
|
61
|
+
}
|
|
62
|
+
export function deleteBranch(name) {
|
|
63
|
+
git("branch", "-D", name);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Check if a remote-tracking ref exists locally (no network call).
|
|
67
|
+
* Uses `git branch -r --list` which reads the local ref cache.
|
|
68
|
+
*/
|
|
69
|
+
export function remoteBranchExists(name) {
|
|
70
|
+
try {
|
|
71
|
+
const output = git("branch", "-r", "--list", `origin/${name}`);
|
|
72
|
+
return output.trim().length > 0;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export function deleteRemoteBranch(name) {
|
|
79
|
+
git("push", "origin", "--delete", name);
|
|
80
|
+
}
|
|
81
|
+
export function addWorktree(path, branch) {
|
|
82
|
+
git("worktree", "add", path, branch);
|
|
83
|
+
}
|
|
84
|
+
/** Create a worktree in detached HEAD mode at a given ref. */
|
|
85
|
+
export function addWorktreeDetached(path, ref) {
|
|
86
|
+
git("worktree", "add", "--detach", path, ref);
|
|
87
|
+
}
|
|
88
|
+
export function removeWorktree(path, force = false) {
|
|
89
|
+
const args = force
|
|
90
|
+
? ["worktree", "remove", "--force", path]
|
|
91
|
+
: ["worktree", "remove", path];
|
|
92
|
+
git(...args);
|
|
93
|
+
}
|
|
94
|
+
export function diffNameOnly(base, head, dir) {
|
|
95
|
+
const args = ["diff", "--name-only", `${base}..${head}`];
|
|
96
|
+
const output = dir ? gitInDir(dir, ...args) : git(...args);
|
|
97
|
+
return output ? output.split("\n") : [];
|
|
98
|
+
}
|
|
99
|
+
export function diffStat(base, head) {
|
|
100
|
+
return git("diff", "--stat", `${base}..${head}`);
|
|
101
|
+
}
|
|
102
|
+
export function logOneline(base, head) {
|
|
103
|
+
return git("log", "--oneline", `${base}..${head}`);
|
|
104
|
+
}
|
|
105
|
+
export function statusShort(dir) {
|
|
106
|
+
return gitInDir(dir, "status", "--short");
|
|
107
|
+
}
|
|
108
|
+
export function hasUncommittedChanges(dir) {
|
|
109
|
+
const status = statusShort(dir);
|
|
110
|
+
return status.length > 0;
|
|
111
|
+
}
|
|
112
|
+
export function addAll(dir) {
|
|
113
|
+
gitInDir(dir, "add", "-A");
|
|
114
|
+
}
|
|
115
|
+
export function commit(dir, message) {
|
|
116
|
+
gitInDir(dir, "commit", "-m", message);
|
|
117
|
+
return gitInDir(dir, "rev-parse", "HEAD");
|
|
118
|
+
}
|
|
119
|
+
export function resetSoft(dir, to) {
|
|
120
|
+
gitInDir(dir, "reset", "--soft", to);
|
|
121
|
+
}
|
|
122
|
+
/** Reset HEAD to a specific target, keeping changes as unstaged. */
|
|
123
|
+
export function resetMixed(dir, to) {
|
|
124
|
+
gitInDir(dir, "reset", to);
|
|
125
|
+
}
|
|
126
|
+
export function mergeInto(targetDir, ...branches) {
|
|
127
|
+
gitInDir(targetDir, "merge", ...branches);
|
|
128
|
+
}
|
|
129
|
+
export function push(branch) {
|
|
130
|
+
git("push", "-u", "origin", branch);
|
|
131
|
+
}
|
|
132
|
+
export function isInsideGitRepo() {
|
|
133
|
+
try {
|
|
134
|
+
git("rev-parse", "--is-inside-work-tree");
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
export function hasCommits() {
|
|
142
|
+
try {
|
|
143
|
+
git("rev-parse", "HEAD");
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installs git-stint hooks into Claude Code's settings.
|
|
3
|
+
*
|
|
4
|
+
* Hooks installed:
|
|
5
|
+
* PreToolUse (Write/Edit): Track files written in session worktrees.
|
|
6
|
+
* Stop: Commit pending changes as WIP checkpoint.
|
|
7
|
+
*/
|
|
8
|
+
export declare function install(scope: "project" | "user"): void;
|
|
9
|
+
export declare function uninstall(scope: "project" | "user"): void;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installs git-stint hooks into Claude Code's settings.
|
|
3
|
+
*
|
|
4
|
+
* Hooks installed:
|
|
5
|
+
* PreToolUse (Write/Edit): Track files written in session worktrees.
|
|
6
|
+
* Stop: Commit pending changes as WIP checkpoint.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join, resolve } from "node:path";
|
|
10
|
+
const HOOKS = {
|
|
11
|
+
hooks: {
|
|
12
|
+
PreToolUse: [
|
|
13
|
+
{
|
|
14
|
+
matcher: "Write|Edit|NotebookEdit",
|
|
15
|
+
command: "git-stint-hook-pre-tool",
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
Stop: [
|
|
19
|
+
{
|
|
20
|
+
command: "git-stint-hook-stop",
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
function getSettingsPath(scope) {
|
|
26
|
+
if (scope === "user") {
|
|
27
|
+
const home = process.env.HOME || process.env.USERPROFILE || "~";
|
|
28
|
+
return join(home, ".claude", "settings.json");
|
|
29
|
+
}
|
|
30
|
+
// Project scope
|
|
31
|
+
return join(process.cwd(), ".claude", "settings.json");
|
|
32
|
+
}
|
|
33
|
+
export function install(scope) {
|
|
34
|
+
const settingsPath = getSettingsPath(scope);
|
|
35
|
+
const dir = resolve(settingsPath, "..");
|
|
36
|
+
// Read existing settings or start fresh
|
|
37
|
+
let settings = {};
|
|
38
|
+
if (existsSync(settingsPath)) {
|
|
39
|
+
settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
40
|
+
}
|
|
41
|
+
// Merge hooks (don't overwrite existing hooks)
|
|
42
|
+
const hooks = (settings.hooks || {});
|
|
43
|
+
for (const [event, hookList] of Object.entries(HOOKS.hooks)) {
|
|
44
|
+
if (!hooks[event]) {
|
|
45
|
+
hooks[event] = [];
|
|
46
|
+
}
|
|
47
|
+
for (const hook of hookList) {
|
|
48
|
+
const exists = hooks[event].some((h) => h.command === hook.command);
|
|
49
|
+
if (!exists) {
|
|
50
|
+
hooks[event].push(hook);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
settings.hooks = hooks;
|
|
55
|
+
if (!existsSync(dir)) {
|
|
56
|
+
mkdirSync(dir, { recursive: true });
|
|
57
|
+
}
|
|
58
|
+
// Atomic write: temp file + rename to prevent corruption if interrupted
|
|
59
|
+
const tmp = settingsPath + ".tmp";
|
|
60
|
+
writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
|
|
61
|
+
renameSync(tmp, settingsPath);
|
|
62
|
+
console.log(`Hooks installed to ${settingsPath}`);
|
|
63
|
+
console.log("\nHooks added:");
|
|
64
|
+
console.log(" PreToolUse (Write/Edit): track files in session worktrees");
|
|
65
|
+
console.log(" Stop: commit pending changes as WIP checkpoint");
|
|
66
|
+
}
|
|
67
|
+
/** Known hook commands installed by git-stint. */
|
|
68
|
+
const STINT_COMMANDS = new Set(Object.values(HOOKS.hooks).flat().map((h) => h.command));
|
|
69
|
+
export function uninstall(scope) {
|
|
70
|
+
const settingsPath = getSettingsPath(scope);
|
|
71
|
+
if (!existsSync(settingsPath)) {
|
|
72
|
+
console.log("No settings file found. Nothing to uninstall.");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
let settings;
|
|
76
|
+
try {
|
|
77
|
+
settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
console.error(`Failed to parse ${settingsPath}. Fix it manually.`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const hooks = settings.hooks;
|
|
84
|
+
if (!hooks) {
|
|
85
|
+
console.log("No hooks configured. Nothing to uninstall.");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
let removed = 0;
|
|
89
|
+
for (const event of Object.keys(hooks)) {
|
|
90
|
+
const before = hooks[event].length;
|
|
91
|
+
hooks[event] = hooks[event].filter((h) => !STINT_COMMANDS.has(h.command));
|
|
92
|
+
removed += before - hooks[event].length;
|
|
93
|
+
// Remove empty arrays to keep settings clean
|
|
94
|
+
if (hooks[event].length === 0) {
|
|
95
|
+
delete hooks[event];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Remove empty hooks object
|
|
99
|
+
if (Object.keys(hooks).length === 0) {
|
|
100
|
+
delete settings.hooks;
|
|
101
|
+
}
|
|
102
|
+
if (removed === 0) {
|
|
103
|
+
console.log("No git-stint hooks found. Nothing to uninstall.");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// Atomic write
|
|
107
|
+
const tmp = settingsPath + ".tmp";
|
|
108
|
+
writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
|
|
109
|
+
renameSync(tmp, settingsPath);
|
|
110
|
+
console.log(`Removed ${removed} git-stint hook(s) from ${settingsPath}`);
|
|
111
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export interface Changeset {
|
|
2
|
+
id: number;
|
|
3
|
+
sha: string;
|
|
4
|
+
message: string;
|
|
5
|
+
files: string[];
|
|
6
|
+
/** ISO 8601 timestamp */
|
|
7
|
+
timestamp: string;
|
|
8
|
+
}
|
|
9
|
+
export interface SessionManifest {
|
|
10
|
+
/** Schema version for forward compatibility. Current: 1. */
|
|
11
|
+
version: number;
|
|
12
|
+
name: string;
|
|
13
|
+
/** HEAD sha when session was created (never changes). */
|
|
14
|
+
startedAt: string;
|
|
15
|
+
/** Advances on each commit. Used to compute diffs for the next changeset. */
|
|
16
|
+
baseline: string;
|
|
17
|
+
/** Git branch name, e.g. "stint/my-feature" */
|
|
18
|
+
branch: string;
|
|
19
|
+
/** Worktree path relative to repo root, e.g. ".stint/my-feature" */
|
|
20
|
+
worktree: string;
|
|
21
|
+
changesets: Changeset[];
|
|
22
|
+
/** Files tracked since last commit. */
|
|
23
|
+
pending: string[];
|
|
24
|
+
}
|
|
25
|
+
declare const MANIFEST_VERSION = 1;
|
|
26
|
+
declare const BRANCH_PREFIX = "stint/";
|
|
27
|
+
declare const WORKTREE_DIR = ".stint";
|
|
28
|
+
export { BRANCH_PREFIX, WORKTREE_DIR, MANIFEST_VERSION };
|
|
29
|
+
export declare function getSessionsDir(): string;
|
|
30
|
+
export declare function loadManifest(name: string): SessionManifest | null;
|
|
31
|
+
/**
|
|
32
|
+
* Atomic write: write to temp file, then rename.
|
|
33
|
+
* Prevents corruption if the process is killed mid-write.
|
|
34
|
+
*/
|
|
35
|
+
export declare function saveManifest(manifest: SessionManifest): void;
|
|
36
|
+
export declare function listManifests(): SessionManifest[];
|
|
37
|
+
export declare function deleteManifest(name: string): void;
|
|
38
|
+
/**
|
|
39
|
+
* Resolve which session is active.
|
|
40
|
+
* Priority:
|
|
41
|
+
* 1. Explicit name passed via --session flag
|
|
42
|
+
* 2. CWD is inside a .stint/<name>/ worktree
|
|
43
|
+
* 3. Only one session exists → use it
|
|
44
|
+
* 4. Error
|
|
45
|
+
*/
|
|
46
|
+
export declare function resolveSession(explicit?: string): SessionManifest;
|
|
47
|
+
/**
|
|
48
|
+
* Get the repo root (main worktree root, not a stint worktree).
|
|
49
|
+
* Uses git's --show-toplevel from the main worktree context.
|
|
50
|
+
*/
|
|
51
|
+
export declare function getRepoRoot(): string;
|
|
52
|
+
/**
|
|
53
|
+
* Get the absolute worktree path for a session.
|
|
54
|
+
*/
|
|
55
|
+
export declare function getWorktreePath(manifest: SessionManifest): string;
|
|
56
|
+
/**
|
|
57
|
+
* Check if any sessions exist. Cheaper than loading all manifests.
|
|
58
|
+
*/
|
|
59
|
+
export declare function hasAnySessions(): boolean;
|