git-stint 0.5.0 → 0.6.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/README.md CHANGED
@@ -152,6 +152,8 @@ Create a `.stint.json` in your repo root:
152
152
  ```json
153
153
  {
154
154
  "shared_dirs": ["node_modules", ".venv", "dist"],
155
+ "shared_files": [".env", ".python-version"],
156
+ "post_create": ["uv sync"],
155
157
  "main_branch_policy": "block",
156
158
  "force_cleanup": "prompt",
157
159
  "adopt_changes": "always"
@@ -161,6 +163,8 @@ Create a `.stint.json` in your repo root:
161
163
  | Field | Values | Default | Description |
162
164
  |-------|--------|---------|-------------|
163
165
  | `shared_dirs` | `string[]` | `[]` | Directories to symlink from worktree to main repo. Use for gitignored dirs (caches, build outputs) that shouldn't be duplicated per session. |
166
+ | `shared_files` | `string[]` | `[]` | Files to symlink from main repo into each new worktree. Like `shared_dirs` but for individual files — use for untracked config files (`.env.keys`, `service-account.json`) that should stay in sync with main. |
167
+ | `post_create` | `string[]` or `string` | `[]` | Shell command(s) to run in the new worktree after creation. Use for project setup (e.g., `uv sync`, `pip install -r requirements.txt`). Commands run sequentially; failures warn but don't abort session creation. |
164
168
  | `main_branch_policy` | `"block"` / `"prompt"` / `"allow"` | `"prompt"` | What happens when an agent writes to main. `"block"` auto-creates a session. `"prompt"` blocks with instructions. `"allow"` passes through. |
165
169
  | `force_cleanup` | `"force"` / `"prompt"` / `"fail"` | `"prompt"` | Behavior when worktree removal fails. |
166
170
  | `adopt_changes` | `"always"` / `"never"` / `"prompt"` | `"always"` | Whether uncommitted changes on main carry into new sessions. |
@@ -188,6 +192,30 @@ git stint start my-feature --adopt # Force adopt regardless of config
188
192
  git stint start my-feature --no-adopt # Skip regardless of config
189
193
  ```
190
194
 
195
+ ### Shared Files
196
+
197
+ Like `shared_dirs` but for individual files. Symlinks each file from main into the worktree so changes stay in sync:
198
+
199
+ ```json
200
+ {
201
+ "shared_files": [".env.keys", "service-account.json", "config/local.yaml"]
202
+ }
203
+ ```
204
+
205
+ Files that don't exist are skipped with a warning. Files already present in the worktree (e.g., tracked by git) are not overwritten. Symlinked files are automatically added to the worktree's `.gitignore`.
206
+
207
+ ### Post-Create Hooks
208
+
209
+ Run setup commands automatically after worktree creation. Commands execute in the new worktree directory:
210
+
211
+ ```json
212
+ {
213
+ "post_create": ["uv sync", "cp .env.example .env"]
214
+ }
215
+ ```
216
+
217
+ A single string is also accepted: `"post_create": "npm install"`. Commands run sequentially. A failing command prints a warning but does not prevent session creation — subsequent commands still run.
218
+
191
219
  ## How It Works
192
220
 
193
221
  ### Session Model
@@ -94,6 +94,8 @@ if [ -f "$STINT_CONFIG" ]; then
94
94
  fi
95
95
 
96
96
  # The file is NOT in a stint worktree. Check if any stint session exists.
97
+ SOLE_SESSION=""
98
+ SESSION_COUNT=0
97
99
  SESSIONS_DIR="$(git rev-parse --git-common-dir 2>/dev/null)/sessions"
98
100
  if [ -d "$SESSIONS_DIR" ]; then
99
101
  MY_WORKTREE=""
@@ -134,7 +136,22 @@ if [ -d "$SESSIONS_DIR" ]; then
134
136
  fi
135
137
 
136
138
  # No session matches our clientId, but other sessions exist.
137
- # Don't hijack them fall through to create our own session.
139
+ # With "block" policy: auto-resume the sole session (common case: Claude Code
140
+ # restarted with a new PID). With multiple sessions, still auto-create.
141
+ if [ -n "$OTHER_SESSIONS" ]; then
142
+ SOLE_SESSION=""
143
+ SESSION_COUNT=0
144
+ for manifest in "$SESSIONS_DIR"/*.json; do
145
+ [ -f "$manifest" ] || continue
146
+ case "$manifest" in *.tmp) continue ;; esac
147
+ SESSION_COUNT=$((SESSION_COUNT + 1))
148
+ if command -v jq &>/dev/null; then
149
+ SOLE_SESSION=$(jq -r '.name // empty' "$manifest" 2>/dev/null || true)
150
+ else
151
+ SOLE_SESSION=$(grep -o '"name"[[:space:]]*:[[:space:]]*"[^"]*"' "$manifest" | head -1 | sed 's/.*"name"[[:space:]]*:[[:space:]]*"//;s/"$//')
152
+ fi
153
+ done
154
+ fi
138
155
  fi
139
156
 
140
157
  # No session for this client — check main_branch_policy before auto-creating.
@@ -151,13 +168,39 @@ if [ "$MAIN_BRANCH_POLICY" = "prompt" ]; then
151
168
  exit 0
152
169
  fi
153
170
  echo "BLOCKED: Writing to main branch." >&2
171
+ if [ -n "$SOLE_SESSION" ] && [ "$SESSION_COUNT" -eq 1 ] 2>/dev/null; then
172
+ echo "Active session found: $SOLE_SESSION" >&2
173
+ echo "To resume: git stint resume $SOLE_SESSION" >&2
174
+ fi
154
175
  echo "To allow, run: git stint allow-main --client-id $CLIENT_ID" >&2
155
176
  echo "To create a session instead, run: git stint start <descriptive-name>" >&2
156
177
  echo " (pick a short name that describes your task, e.g. fix-auth-refresh, add-user-search)" >&2
157
178
  exit 2
158
179
  fi
159
180
 
160
- # Policy is "block" (or default) — auto-start a session, then block so agent retries in worktree.
181
+ # Policy is "block" — auto-resume if exactly one session exists, otherwise auto-create.
182
+ if [ -n "$SOLE_SESSION" ] && [ "$SESSION_COUNT" -eq 1 ] 2>/dev/null; then
183
+ # One session exists from a previous client — auto-resume it
184
+ RESUME_OUTPUT=$(git-stint resume "$SOLE_SESSION" --client-id "$CLIENT_ID" 2>&1) || {
185
+ # Resume failed — fall through to auto-create
186
+ SOLE_SESSION=""
187
+ }
188
+ fi
189
+
190
+ if [ -n "$SOLE_SESSION" ] && [ "$SESSION_COUNT" -eq 1 ] 2>/dev/null; then
191
+ # Resume succeeded — extract worktree and redirect
192
+ WORKTREE_PATH=$(echo "$RESUME_OUTPUT" | grep -o 'Worktree:.*' | sed 's/Worktree:[[:space:]]*//')
193
+ if [ -z "$WORKTREE_PATH" ]; then
194
+ WORKTREE_PATH="$REPO_ROOT/.stint/$SOLE_SESSION"
195
+ fi
196
+ echo "git-stint: Resumed session '${SOLE_SESSION}' (new client: ${CLIENT_ID})." >&2
197
+ echo "All edits must target the worktree: $WORKTREE_PATH" >&2
198
+ echo "Rewrite your file path from: $FILE_PATH" >&2
199
+ echo "To: ${WORKTREE_PATH}/${FILE_PATH#$REPO_ROOT/}" >&2
200
+ exit 2
201
+ fi
202
+
203
+ # No session to resume (0 or 2+ sessions) — auto-create a new one.
161
204
  SESSION_NAME="session-$(date +%Y%m%d-%H%M%S)"
162
205
  CLIENT_FLAG=""
163
206
  if [ -n "$CLIENT_ID" ]; then
package/dist/cli.js CHANGED
@@ -132,6 +132,16 @@ try {
132
132
  session.abort(getFlag("--session"));
133
133
  break;
134
134
  }
135
+ case "resume": {
136
+ const name = getPositional(0);
137
+ if (!name) {
138
+ console.error("Usage: git stint resume <session-name>");
139
+ process.exit(1);
140
+ }
141
+ const clientId = getFlag("--client-id");
142
+ session.resume(name, clientId);
143
+ break;
144
+ }
135
145
  case "undo": {
136
146
  session.undo(getFlag("--session"));
137
147
  break;
@@ -254,6 +264,7 @@ Commands:
254
264
  squash -m "msg" Collapse all commits into one
255
265
  merge Merge session into main (no PR)
256
266
  pr [--title "..."] Push branch and create GitHub PR
267
+ resume <name> Rebind session to current client (after restart)
257
268
  end Finalize session, clean up everything
258
269
  abort Discard session — delete all changes
259
270
  undo Revert last commit, changes become pending
package/dist/config.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export interface StintConfig {
2
2
  shared_dirs: string[];
3
+ shared_files: string[];
4
+ post_create: string[];
3
5
  main_branch_policy: "prompt" | "allow" | "block";
4
6
  force_cleanup: "prompt" | "force" | "fail";
5
7
  adopt_changes: "always" | "never" | "prompt";
package/dist/config.js CHANGED
@@ -2,6 +2,8 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  const DEFAULTS = {
4
4
  shared_dirs: [],
5
+ shared_files: [],
6
+ post_create: [],
5
7
  main_branch_policy: "block",
6
8
  force_cleanup: "prompt",
7
9
  adopt_changes: "always",
@@ -30,6 +32,15 @@ export function loadConfig(repoRoot) {
30
32
  if (Array.isArray(obj.shared_dirs)) {
31
33
  config.shared_dirs = obj.shared_dirs.filter((d) => typeof d === "string" && d.length > 0);
32
34
  }
35
+ if (Array.isArray(obj.shared_files)) {
36
+ config.shared_files = obj.shared_files.filter((f) => typeof f === "string" && f.length > 0);
37
+ }
38
+ if (Array.isArray(obj.post_create)) {
39
+ config.post_create = obj.post_create.filter((c) => typeof c === "string" && c.length > 0);
40
+ }
41
+ else if (typeof obj.post_create === "string" && obj.post_create.length > 0) {
42
+ config.post_create = [obj.post_create];
43
+ }
33
44
  if (typeof obj.main_branch_policy === "string" && VALID_POLICIES.has(obj.main_branch_policy)) {
34
45
  config.main_branch_policy = obj.main_branch_policy;
35
46
  }
@@ -83,6 +83,8 @@ export function install(scope) {
83
83
  }
84
84
  const DEFAULT_CONFIG = {
85
85
  shared_dirs: [],
86
+ shared_files: [],
87
+ post_create: [],
86
88
  main_branch_policy: "block",
87
89
  force_cleanup: "prompt",
88
90
  adopt_changes: "always",
@@ -104,6 +106,10 @@ The name becomes the branch (\`stint/<name>\`) and the PR title context.
104
106
  ## Session Lifecycle
105
107
 
106
108
  - If the hook blocks a write, create a session: \`git stint start <descriptive-name>\`
109
+ - **Resuming**: If a session already exists from a previous conversation, resume it
110
+ instead of creating a new one: \`git stint resume <session-name>\`
111
+ Use \`git stint list\` to see active sessions. With \`block\` policy, the hook
112
+ auto-resumes when exactly one session exists.
107
113
  - Any uncommitted files on main are automatically carried into the new session.
108
114
  Do NOT redo work that was already written — it is adopted into the worktree.
109
115
  - All edits redirect to \`.stint/<session>/\` worktree.
@@ -126,6 +132,12 @@ The name becomes the branch (\`stint/<name>\`) and the PR title context.
126
132
  - Directories listed under \`shared_dirs\` in \`.stint.json\` are symlinked into
127
133
  worktrees pointing to the main repo's real directories. They must never be
128
134
  staged or committed. The hooks auto-add them to the worktree's \`.gitignore\`.
135
+
136
+ ## Runtime
137
+
138
+ - Run tests and services from the worktree (your CWD), not the main repo. If
139
+ you spot paths or dependencies resolving back to main, warn the user.
140
+ - Use a non-default port to avoid collisions with other sessions.
129
141
  `;
130
142
  function scaffoldConfig(repoRoot) {
131
143
  const configPath = join(repoRoot, ".stint.json");
package/dist/session.d.ts CHANGED
@@ -20,6 +20,14 @@ export declare function listJson(): void;
20
20
  export declare function prune(): void;
21
21
  /** Clean up allow-main flags for PIDs that are no longer running. */
22
22
  export declare function pruneAllowMainFlags(): void;
23
+ /**
24
+ * Resume an existing session by rebinding it to the current client.
25
+ * Updates the clientId so hooks route writes to this session's worktree.
26
+ *
27
+ * @param name - Session name to resume
28
+ * @param clientId - Explicit client ID. If not provided, falls back to process.ppid.
29
+ */
30
+ export declare function resume(name: string, clientId?: string): void;
23
31
  /**
24
32
  * Create per-process flag file allowing writes to main branch.
25
33
  * Scoped to a client ID (typically Claude Code's PID), so other
package/dist/session.js CHANGED
@@ -3,7 +3,7 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync,
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import * as git from "./git.js";
5
5
  import { loadConfig } from "./config.js";
6
- import { BRANCH_PREFIX, WORKTREE_DIR, MANIFEST_VERSION, saveManifest, deleteManifest, listManifests, resolveSession, getWorktreePath, getRepoRoot, } from "./manifest.js";
6
+ import { BRANCH_PREFIX, WORKTREE_DIR, MANIFEST_VERSION, loadManifest, saveManifest, deleteManifest, listManifests, resolveSession, getWorktreePath, getRepoRoot, } from "./manifest.js";
7
7
  // --- Constants ---
8
8
  const WIP_MESSAGE = "WIP: session checkpoint";
9
9
  // --- Name generation ---
@@ -123,6 +123,19 @@ export function start(name, clientId, adoptOverride) {
123
123
  }
124
124
  // Adopt uncommitted changes from main repo (before symlinking, to avoid conflicts)
125
125
  const config = loadConfig(topLevel);
126
+ // Snapshot shared_files and .stint.json BEFORE adopt stash (which moves
127
+ // untracked files out of main). Without this, session 2 would find .env
128
+ // missing because session 1's adopt already moved it.
129
+ const configPath = join(topLevel, ".stint.json");
130
+ const configSnapshot = existsSync(configPath) ? readFileSync(configPath) : null;
131
+ const sharedFileSnapshots = new Map();
132
+ for (const file of config.shared_files) {
133
+ const source = resolve(topLevel, file);
134
+ if (!source.startsWith(topLevel + "/"))
135
+ continue;
136
+ if (existsSync(source))
137
+ sharedFileSnapshots.set(file, readFileSync(source));
138
+ }
126
139
  const shouldAdopt = adoptOverride !== undefined
127
140
  ? adoptOverride
128
141
  : config.adopt_changes === "always";
@@ -155,6 +168,17 @@ export function start(name, clientId, adoptOverride) {
155
168
  }
156
169
  }
157
170
  }
171
+ // Restore .stint.json and shared_files to main after adopt stash moved them
172
+ if (configSnapshot && !existsSync(configPath)) {
173
+ writeFileSync(configPath, configSnapshot);
174
+ }
175
+ for (const [file, content] of sharedFileSnapshots) {
176
+ const source = resolve(topLevel, file);
177
+ if (!existsSync(source)) {
178
+ mkdirSync(dirname(source), { recursive: true });
179
+ writeFileSync(source, content);
180
+ }
181
+ }
158
182
  // Symlink shared directories from config (after adopt, so stash pop doesn't conflict with symlinks)
159
183
  const linkedDirs = [];
160
184
  for (const dir of config.shared_dirs) {
@@ -201,6 +225,71 @@ export function start(name, clientId, adoptOverride) {
201
225
  }
202
226
  }
203
227
  }
228
+ // Symlink shared files from main repo into worktree.
229
+ const linkedFiles = [];
230
+ for (const file of config.shared_files) {
231
+ const source = resolve(topLevel, file);
232
+ if (!source.startsWith(topLevel + "/")) {
233
+ console.warn(`Warning: shared_files entry '${file}' escapes repo root, skipping.`);
234
+ continue;
235
+ }
236
+ const target = resolve(worktreeAbs, file);
237
+ if (!existsSync(source)) {
238
+ console.warn(`Warning: shared_files entry '${file}' not found in repo, skipping.`);
239
+ continue;
240
+ }
241
+ if (existsSync(target)) {
242
+ if (lstatSync(target).isSymbolicLink())
243
+ continue; // already symlinked
244
+ // Only replace untracked files (e.g., from adopt stash-pop).
245
+ // Git-tracked files keep their worktree version.
246
+ try {
247
+ git.gitInDir(worktreeAbs, "ls-files", "--error-unmatch", file);
248
+ continue; // tracked by git — don't replace
249
+ }
250
+ catch { /* untracked — safe to replace */ }
251
+ unlinkSync(target);
252
+ }
253
+ mkdirSync(dirname(target), { recursive: true });
254
+ symlinkSync(source, target);
255
+ linkedFiles.push(file);
256
+ }
257
+ // Prevent shared_files symlinks from being staged
258
+ if (linkedFiles.length > 0) {
259
+ const gitignorePath = join(worktreeAbs, ".gitignore");
260
+ const markerStart = "# git-stint shared_files (auto-generated, do not commit)";
261
+ const markerEnd = "# end git-stint shared_files";
262
+ let giContent = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : "";
263
+ const entries = linkedFiles.join("\n");
264
+ const block = `${markerStart}\n${entries}\n${markerEnd}`;
265
+ if (giContent.includes(markerStart)) {
266
+ const regex = new RegExp(`${markerStart.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?` +
267
+ `(?:${markerEnd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}|$)`);
268
+ giContent = giContent.replace(regex, block);
269
+ }
270
+ else {
271
+ giContent = giContent.endsWith("\n") ? giContent + "\n" + block + "\n" : giContent + "\n\n" + block + "\n";
272
+ }
273
+ writeFileSync(gitignorePath, giContent);
274
+ for (const f of linkedFiles) {
275
+ try {
276
+ git.gitInDir(worktreeAbs, "rm", "--cached", "--ignore-unmatch", f);
277
+ }
278
+ catch { /* best effort */ }
279
+ }
280
+ }
281
+ // Run post_create hooks in the new worktree
282
+ for (const cmd of config.post_create) {
283
+ try {
284
+ execFileSync("sh", ["-c", cmd], { cwd: worktreeAbs, stdio: ["pipe", "pipe", "pipe"], timeout: 300_000 });
285
+ }
286
+ catch (err) {
287
+ const e = err;
288
+ const stderr = e.stderr ? e.stderr.toString().trim() : (e.message || "unknown error");
289
+ console.warn(`Warning: post_create command failed: ${cmd}`);
290
+ console.warn(` ${stderr}`);
291
+ }
292
+ }
204
293
  // Revoke main-branch write pass when entering session mode
205
294
  removeAllowMainFlag();
206
295
  // Create manifest
@@ -225,6 +314,12 @@ export function start(name, clientId, adoptOverride) {
225
314
  console.log(` ${dir} → ${resolve(topLevel, dir)}`);
226
315
  }
227
316
  }
317
+ if (linkedFiles.length > 0) {
318
+ console.log(`\nShared files (symlinked from main repo):`);
319
+ for (const f of linkedFiles) {
320
+ console.log(` ${f} \u2192 ${resolve(topLevel, f)}`);
321
+ }
322
+ }
228
323
  if (adoptedFiles > 0) {
229
324
  console.log(`\nCarried over ${adoptedFiles} uncommitted file(s) into session.`);
230
325
  }
@@ -680,6 +775,17 @@ function cleanup(manifest, force = false) {
680
775
  }
681
776
  catch { /* best effort */ }
682
777
  }
778
+ for (const file of config.shared_files) {
779
+ const target = resolve(worktree, file);
780
+ if (!existsSync(target))
781
+ continue;
782
+ try {
783
+ if (lstatSync(target).isSymbolicLink()) {
784
+ unlinkSync(target);
785
+ }
786
+ }
787
+ catch { /* best effort */ }
788
+ }
683
789
  }
684
790
  // Remove worktree
685
791
  if (existsSync(worktree)) {
@@ -796,6 +902,53 @@ export function pruneAllowMainFlags() {
796
902
  if (cleaned > 0)
797
903
  console.log(`Cleaned ${cleaned} stale allow-main flag(s).`);
798
904
  }
905
+ /**
906
+ * Resume an existing session by rebinding it to the current client.
907
+ * Updates the clientId so hooks route writes to this session's worktree.
908
+ *
909
+ * @param name - Session name to resume
910
+ * @param clientId - Explicit client ID. If not provided, falls back to process.ppid.
911
+ */
912
+ export function resume(name, clientId) {
913
+ if (!git.isInsideGitRepo()) {
914
+ throw new Error("Not inside a git repository.");
915
+ }
916
+ if (!name || name.trim().length === 0) {
917
+ throw new Error("Session name is required. Usage: git stint resume <name>");
918
+ }
919
+ const manifest = loadManifest(name);
920
+ if (!manifest) {
921
+ throw new Error(`Session '${name}' not found.\n` +
922
+ `Run 'git stint list' to see active sessions.`);
923
+ }
924
+ // Verify the worktree still exists
925
+ const worktree = getWorktreePath(manifest);
926
+ if (!existsSync(worktree)) {
927
+ throw new Error(`Worktree missing for session '${name}' at ${worktree}.\n` +
928
+ `Run 'git stint prune' to clean up, then start a new session.`);
929
+ }
930
+ const newClientId = clientId || String(process.ppid);
931
+ // Check if already bound to this client
932
+ if (manifest.clientId === newClientId) {
933
+ console.log(`Session '${name}' is already bound to client ${newClientId}.`);
934
+ console.log(`\ncd "${worktree}"`);
935
+ return;
936
+ }
937
+ const oldClientId = manifest.clientId;
938
+ manifest.clientId = newClientId;
939
+ saveManifest(manifest);
940
+ console.log(`Session '${name}' resumed.`);
941
+ if (oldClientId) {
942
+ console.log(` Client: ${oldClientId} \u2192 ${newClientId}`);
943
+ }
944
+ else {
945
+ console.log(` Client: ${newClientId}`);
946
+ }
947
+ console.log(` Branch: ${manifest.branch}`);
948
+ console.log(` Worktree: ${worktree}`);
949
+ console.log(` Commits: ${manifest.changesets.length}`);
950
+ console.log(`\ncd "${worktree}"`);
951
+ }
799
952
  /**
800
953
  * Create per-process flag file allowing writes to main branch.
801
954
  * Scoped to a client ID (typically Claude Code's PID), so other
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-stint",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Session-scoped change tracking for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,6 +51,6 @@
51
51
  "license": "MIT",
52
52
  "devDependencies": {
53
53
  "typescript": "^5.7.0",
54
- "@types/node": "^22.0.0"
54
+ "@types/node": "^25.3.5"
55
55
  }
56
56
  }