git-stint 0.2.2 → 0.2.4

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
@@ -16,7 +16,7 @@ AI coding agents (Claude Code, Cursor, Copilot) edit files but have no clean way
16
16
  3. **Produce clean commits** — agent work should result in reviewable, mergeable PRs
17
17
  4. **Test in isolation** — verify one session's changes without interference
18
18
 
19
- git-stint solves this with ~600 lines of TypeScript on top of standard git primitives.
19
+ git-stint solves this with ~2,000 lines of TypeScript + bash on top of standard git primitives.
20
20
 
21
21
  ## Prerequisites
22
22
 
@@ -24,11 +24,44 @@ git-stint solves this with ~600 lines of TypeScript on top of standard git primi
24
24
  - [git](https://git-scm.com) 2.20+ (worktree support)
25
25
  - [`gh` CLI](https://cli.github.com) (optional, for PR creation)
26
26
 
27
- ## Quick Start
27
+ ## Install
28
+
29
+ ### With Claude Code (recommended)
30
+
31
+ Tell Claude Code:
32
+
33
+ > Install git-stint globally (`npm install -g git-stint`), set up hooks for this repo, and create a .stint.json
34
+
35
+ Claude Code will:
36
+ 1. Run `npm install -g git-stint`
37
+ 2. Run `git stint install-hooks` (writes to `.claude/settings.json`)
38
+ 3. Create a `.stint.json` with your preferred `main_branch_policy`
39
+
40
+ ### Manual install
28
41
 
29
42
  ```bash
30
- npm install -g git-stint
43
+ # 1. Install
44
+ npm install -g git-stint # from npm
45
+ # — or from source —
46
+ git clone https://github.com/rchaz/git-stint.git
47
+ cd git-stint && npm install && npm run build && npm link
48
+
49
+ # 2. Set up hooks in your project
50
+ cd /path/to/your/repo
51
+ git stint install-hooks
52
+
53
+ # 3. Configure (optional)
54
+ cat > .stint.json << 'EOF'
55
+ {
56
+ "shared_dirs": [],
57
+ "main_branch_policy": "prompt"
58
+ }
59
+ EOF
60
+ ```
61
+
62
+ ## Quick Start
31
63
 
64
+ ```bash
32
65
  # Start a session (creates branch + worktree)
33
66
  git stint start auth-fix
34
67
  cd .stint/auth-fix/
@@ -70,38 +103,133 @@ git stint end
70
103
  | `git stint test [-- cmd]` | Run tests in the session worktree |
71
104
  | `git stint test --combine A B` | Test multiple sessions merged together |
72
105
  | `git stint prune` | Clean up orphaned worktrees/branches |
106
+ | `git stint allow-main [--client-id <PID>]` | Allow writes to main branch (scoped to one process/session) |
73
107
  | `git stint install-hooks` | Install Claude Code hooks |
74
108
  | `git stint uninstall-hooks` | Remove Claude Code hooks |
75
109
 
76
110
  ### Options
77
111
 
78
112
  - `--session <name>` — Specify which session (auto-detected from CWD)
113
+ - `--client-id <id>` — Client identifier (used by hooks). For `start`, tags the session. For `allow-main`, scopes the flag to that client.
114
+ - `--adopt` / `--no-adopt` — Override `adopt_changes` config for this start
79
115
  - `-m "message"` — Commit or squash message
80
116
  - `--title "title"` — PR title
81
117
  - `--version` — Show version number
82
118
 
119
+ ## Configuration — `.stint.json`
120
+
121
+ Create a `.stint.json` file in your repo root to configure git-stint behavior:
122
+
123
+ ```json
124
+ {
125
+ "shared_dirs": [
126
+ "backend/data",
127
+ "backend/results",
128
+ "backend/logs"
129
+ ],
130
+ "main_branch_policy": "prompt",
131
+ "force_cleanup": "prompt",
132
+ "adopt_changes": "always"
133
+ }
134
+ ```
135
+
136
+ | Field | Values | Default | Description |
137
+ |-------|--------|---------|-------------|
138
+ | `shared_dirs` | `string[]` | `[]` | Directories to symlink from worktree to main repo on `start`. Use for gitignored data dirs (caches, build outputs, logs) that shouldn't be duplicated per session. |
139
+ | `main_branch_policy` | `"prompt"` / `"allow"` / `"block"` | `"prompt"` | What happens when writing to main with hooks enabled. `"block"` auto-creates a session. `"allow"` passes through. `"prompt"` blocks with instructions to run `git stint allow-main` or `git stint start`. |
140
+ | `force_cleanup` | `"prompt"` / `"force"` / `"fail"` | `"prompt"` | What happens when non-force worktree removal fails. `"force"` retries with `--force`. `"fail"` throws an error. `"prompt"` retries with force (default, same as previous behavior). |
141
+ | `adopt_changes` | `"always"` / `"never"` / `"prompt"` | `"always"` | What happens when `git stint start` is called with uncommitted changes on main. `"always"` stashes and moves them into the new worktree. `"never"` leaves them on main. `"prompt"` warns and suggests `--adopt` or `--no-adopt`. |
142
+
143
+ ### Shared Directories
144
+
145
+ When a worktree is created, gitignored directories (caches, build outputs, data) don't exist in it. Without `shared_dirs`, you'd need to manually symlink or recreate them.
146
+
147
+ With `shared_dirs` configured, `git stint start` automatically:
148
+ 1. Creates symlinks from the worktree to the main repo for each listed directory
149
+ 2. On `git stint end` / `abort`, removes the symlinks before deleting the worktree — so linked data is never lost
150
+
151
+ ```
152
+ # Main repo # Worktree (.stint/my-session/)
153
+ backend/data/ (200MB cache) ←── backend/data → symlink to main
154
+ backend/results/ ←── backend/results → symlink to main
155
+ ```
156
+
157
+ The directories listed in `shared_dirs` should typically be gitignored, since they contain large or generated data that shouldn't be committed.
158
+
159
+ ### Main Branch Policy
160
+
161
+ Controls what happens when Claude Code (or another agent) tries to write directly to the main branch while hooks are installed:
162
+
163
+ - **`"block"`** — Auto-creates a session and blocks the write, forcing the agent to work in the worktree. This is the most protective mode.
164
+ - **`"prompt"`** (default) — Blocks with a message that includes the exact command to unblock, e.g. `git stint allow-main --client-id 56193`. Lets you choose per-situation.
165
+ - **`"allow"`** — Passes through silently. Hooks still track files in existing worktrees, but don't enforce session usage.
166
+
167
+ The `allow-main` flag is scoped per-process. When the hook blocks a write, it prints the exact command with the correct `--client-id` (the Claude Code instance's PID). Running that command creates `.git/stint-main-allowed-<PID>`, which only unblocks that specific instance. Other Claude Code sessions remain blocked.
168
+
169
+ **Important:** Always use the `--client-id` value from the hook's block message. Running `allow-main` without `--client-id` from a separate terminal will NOT unblock Claude Code (different process tree). The intended flow is:
170
+
171
+ 1. Hook blocks Claude's write and prints: `git stint allow-main --client-id <PID>`
172
+ 2. Claude (or the user telling Claude) runs that exact command
173
+ 3. Claude retries the write — it succeeds
174
+
175
+ Stale flags from dead processes are cleaned up by `git stint prune`.
176
+
177
+ ### Adopting Uncommitted Changes
178
+
179
+ When you run `git stint start` with uncommitted changes on main, behavior depends on `adopt_changes`:
180
+
181
+ - **`"always"`** (default) — Stashes changes (staged + unstaged + untracked), pops them into the new worktree, leaves main clean. Your work carries over seamlessly.
182
+ - **`"never"`** — Leaves uncommitted changes on main. The new worktree starts clean.
183
+ - **`"prompt"`** — Warns about uncommitted changes and suggests using `--adopt` or `--no-adopt`.
184
+
185
+ CLI flags override the config for a single invocation:
186
+
187
+ ```bash
188
+ git stint start my-feature --adopt # Force adopt (overrides "never")
189
+ git stint start my-feature --no-adopt # Force skip (overrides "always")
190
+ ```
191
+
83
192
  ## Claude Code Integration
84
193
 
85
- git-stint includes hooks that make it work seamlessly with Claude Code:
194
+ git-stint includes hooks that make it work seamlessly with [Claude Code](https://docs.anthropic.com/en/docs/claude-code):
86
195
 
87
- - **PreToolUse hook**: When Claude writes/edits a file inside a session worktree, the file is automatically tracked. If Claude tries to write to the main repo while a session is active, the hook blocks the write and redirects to the worktree.
196
+ - **PreToolUse hook**: When Claude writes/edits a file inside a session worktree, the file is automatically tracked. If Claude tries to write to the main repo, behavior depends on `main_branch_policy` in `.stint.json`. Writes to gitignored files (e.g. `node_modules/`, `dist/`, `.env`) are always allowed through — they can't be committed, so they don't need branch isolation.
88
197
  - **Stop hook**: When a Claude Code conversation ends, pending changes are auto-committed as a WIP checkpoint.
198
+ - **Session affinity**: Each Claude Code instance is mapped to its own session via `clientId` (process ID). Multiple Claude instances can work in parallel without hijacking each other's sessions.
89
199
 
90
- ### Install Hooks
200
+ ### Setup for Claude Code
91
201
 
92
202
  ```bash
93
- # Install to project settings (.claude/settings.json)
203
+ # 1. Install git-stint globally
204
+ npm install -g git-stint
205
+
206
+ # 2. Navigate to your project
207
+ cd /path/to/your/repo
208
+
209
+ # 3. Install hooks (writes to .claude/settings.json)
94
210
  git stint install-hooks
95
211
 
96
- # Or install to user settings (~/.claude/settings.json)
97
- git stint install-hooks --user
212
+ # 4. (Optional) Configure shared dirs and branch policy
213
+ cat > .stint.json << 'EOF'
214
+ {
215
+ "shared_dirs": [],
216
+ "main_branch_policy": "prompt"
217
+ }
218
+ EOF
98
219
 
99
- # To remove hooks later
100
- git stint uninstall-hooks
220
+ # 5. Done Claude Code will now auto-track files in sessions
221
+ ```
222
+
223
+ To install hooks globally (all repos):
224
+
225
+ ```bash
226
+ git stint install-hooks --user
101
227
  ```
102
228
 
103
229
  ### Workflow with Claude Code
104
230
 
231
+ **Option A: Session-based (isolated branch)**
232
+
105
233
  ```bash
106
234
  # Start a session before asking Claude to work
107
235
  git stint start my-feature
@@ -115,6 +243,20 @@ git stint pr
115
243
  git stint end
116
244
  ```
117
245
 
246
+ Or let the hooks auto-create sessions — just start coding with Claude and the hook will create a session on the first write (when `main_branch_policy` is `"block"`).
247
+
248
+ **Option B: Quick edits on main (allow-main)**
249
+
250
+ For small changes that don't need a branch, the hook blocks and tells Claude exactly what to run:
251
+
252
+ ```
253
+ BLOCKED: Writing to main branch.
254
+ To allow, run: git stint allow-main --client-id 56193
255
+ To create a session instead, run: git stint start <name>
256
+ ```
257
+
258
+ Claude runs the printed command, then retries the write — it succeeds. The flag only applies to that specific Claude Code session. Other instances stay blocked.
259
+
118
260
  ## How It Works
119
261
 
120
262
  ### Session Model
@@ -126,14 +268,14 @@ Each session creates:
126
268
 
127
269
  ```
128
270
  Session starts at HEAD = abc123
129
-
271
+ |
130
272
  Edit config.ts, server.ts
131
-
132
- "commit" changeset 1 (baseline advances to new SHA)
133
-
273
+ |
274
+ "commit" -> changeset 1 (baseline advances to new SHA)
275
+ |
134
276
  Edit server.ts (again), test.ts
135
-
136
- "commit" changeset 2 (only NEW changes since last commit)
277
+ |
278
+ "commit" -> changeset 2 (only NEW changes since last commit)
137
279
  ```
138
280
 
139
281
  The **baseline cursor** advances on each commit. `git diff baseline..HEAD` always gives exactly the uncommitted work. No virtual branches, no custom merge engine.
@@ -143,9 +285,9 @@ The **baseline cursor** advances on each commit. `git diff baseline..HEAD` alway
143
285
  Multiple sessions run simultaneously with full isolation:
144
286
 
145
287
  ```
146
- Session A: edits config.ts, server.ts .stint/session-a/
147
- Session B: edits server.ts, constants.ts .stint/session-b/
148
- overlap detected by `git stint conflicts`
288
+ Session A: edits config.ts, server.ts -> .stint/session-a/
289
+ Session B: edits server.ts, constants.ts -> .stint/session-b/
290
+ ^ overlap detected by `git stint conflicts`
149
291
  ```
150
292
 
151
293
  Each session has its own worktree — no interference. Conflicts resolve at PR merge time, using git's standard merge machinery.
@@ -165,27 +307,35 @@ Combined testing creates a temporary octopus merge of the specified sessions, ru
165
307
  ## Architecture
166
308
 
167
309
  ```
168
- ┌─────────────┐ ┌──────────────┐ ┌────────────────┐
169
- CLI │ │ Session │ │ Manifest │
170
- (cli.ts) │────▶│ (session.ts) │────▶│ (manifest.ts) │
171
- │ arg parsing │ │ commands │ │ JSON state │
172
- └─────────────┘ └──────┬───────┘ └────────────────┘
173
-
174
- ┌──────▼───────┐
175
- Git │
176
- (git.ts) │
177
- plumbing │
178
- └──────────────┘
310
+ +----------------+
311
+ | .stint.json |
312
+ | (config) |
313
+ +-------+--------+
314
+ |
315
+ +-----------+ +-----------+ v +----------------+
316
+ | CLI | | Session |------->| Config |
317
+ | (cli.ts) |--->|(session.ts)| | (config.ts) |
318
+ | arg parse | | commands |---+ +----------------+
319
+ +-----------+ +-----+-----+ |
320
+ | +--->+----------------+
321
+ +------v------+ | Manifest |
322
+ | Git | | (manifest.ts) |
323
+ | (git.ts) | | JSON state |
324
+ | plumbing | +----------------+
325
+ +-------------+
179
326
  ```
180
327
 
181
328
  | File | Purpose | Lines |
182
329
  |------|---------|-------|
183
- | `src/git.ts` | Git command wrapper (`execFileSync`) | ~170 |
184
- | `src/manifest.ts` | Session state CRUD in `.git/sessions/` | ~160 |
185
- | `src/session.ts` | Core commands (start, commit, squash, pr, end...) | ~600 |
330
+ | `src/git.ts` | Git command wrapper (`execFileSync`) | ~180 |
331
+ | `src/manifest.ts` | Session state CRUD in `.git/sessions/` | ~200 |
332
+ | `src/session.ts` | Core commands (start, commit, squash, pr, end...) | ~830 |
333
+ | `src/config.ts` | `.stint.json` loading and validation | ~60 |
186
334
  | `src/conflicts.ts` | Cross-session file overlap detection | ~55 |
187
335
  | `src/test-session.ts` | Worktree-based testing + combined testing | ~140 |
188
- | `src/cli.ts` | Entry point, argument parsing | ~230 |
336
+ | `src/cli.ts` | Entry point, argument parsing | ~300 |
337
+ | `src/install-hooks.ts` | Claude Code hook installation/removal | ~170 |
338
+ | `adapters/claude-code/hooks/` | Bash hooks (PreToolUse + Stop) | ~220 |
189
339
 
190
340
  ### Design Decisions
191
341
 
@@ -195,6 +345,8 @@ Combined testing creates a temporary octopus merge of the specified sessions, ru
195
345
  - **No custom merge engine** — git's built-in merge handles everything. Source of most GitButler complexity eliminated.
196
346
  - **`execFileSync` everywhere** — array arguments prevent shell injection. No `execSync` with string interpolation.
197
347
  - **Atomic manifest writes** — write to `.tmp`, then `rename()`. Crash-safe.
348
+ - **Symlinks for shared data** — gitignored dirs (caches, data) symlink into worktrees instead of being copied or lost.
349
+ - **Zero runtime dependencies** — only Node.js built-ins. Dev deps are TypeScript and @types/node.
198
350
 
199
351
  ## git-stint vs GitButler
200
352
 
@@ -206,7 +358,7 @@ Combined testing creates a temporary octopus merge of the specified sessions, ru
206
358
  | Merge engine | Git's built-in | Custom hunk-level engine |
207
359
  | Git compatibility | Full — all git tools work | Partial — writes break state |
208
360
  | State | JSON manifests (disposable) | SQLite + TOML (can corrupt) |
209
- | Code size | ~600 lines TypeScript | ~100k+ lines Rust |
361
+ | Code size | ~2,000 lines TypeScript + bash | ~100k+ lines Rust |
210
362
  | Dependencies | git, gh (optional) | Tauri desktop app |
211
363
 
212
364
  git-stint is designed for AI agent workflows where sessions are independent and short-lived. GitButler is a full-featured branch management GUI for teams.
@@ -224,7 +376,7 @@ npm link # Install globally for testing
224
376
  npm test # Unit tests
225
377
  npm run test:security # Security tests
226
378
  npm run test:integration # Integration tests
227
- npm run test:all # Everything
379
+ npm run test:all # Everything (build + all tests)
228
380
  ```
229
381
 
230
382
  ## Contributing
@@ -5,15 +5,28 @@
5
5
  #
6
6
  # Reads tool input from stdin (JSON), extracts file_path, and tracks it.
7
7
  #
8
+ # Session affinity via clientId:
9
+ # Each Claude Code instance has a unique PID. Hooks spawned by that instance
10
+ # see it as $PPID. We use $PPID as a clientId to map each Claude instance
11
+ # to its own stint session, preventing cross-instance session hijacking.
12
+ #
8
13
  # Behavior:
14
+ # - File is outside the current repo: allows silently (not our business).
9
15
  # - File is inside a .stint/ worktree: tracks the file, allows the write.
10
- # - File is in main repo with active session: blocks the write and tells
11
- # the agent to work in the worktree directory instead.
12
- # - No session active: allows the write silently.
16
+ # - File is in main repo, session with matching clientId exists: blocks and
17
+ # redirects to that session's worktree.
18
+ # - File is in main repo, no matching clientId but other sessions exist:
19
+ # creates a NEW session for this client (doesn't hijack others).
20
+ # - No sessions at all: auto-creates a session with clientId, then blocks
21
+ # so the agent retries using the worktree path.
13
22
  #
14
23
 
15
24
  set -euo pipefail
16
25
 
26
+ # Client identity: PPID is the Claude Code Node.js process that spawned this hook.
27
+ # Stable across all hook invocations within one conversation, different between instances.
28
+ CLIENT_ID="${PPID:-}"
29
+
17
30
  # Check that git-stint is available
18
31
  if ! command -v git-stint &>/dev/null; then
19
32
  echo "Warning: git-stint is not in PATH. Hooks will not work." >&2
@@ -24,9 +37,10 @@ fi
24
37
  # Read the tool input from stdin
25
38
  INPUT=$(cat)
26
39
 
27
- # Extract file_path using jq if available, fallback to grep/sed
40
+ # Extract file_path using jq if available, fallback to grep/sed.
41
+ # Claude Code wraps tool args in {"tool_input": {...}}, so check both paths.
28
42
  if command -v jq &>/dev/null; then
29
- FILE_PATH=$(echo "$INPUT" | jq -r '.file_path // .notebook_path // empty' 2>/dev/null || true)
43
+ FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.notebook_path // .file_path // .notebook_path // empty' 2>/dev/null || true)
30
44
  else
31
45
  FILE_PATH=$(echo "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"file_path"[[:space:]]*:[[:space:]]*"//' | sed 's/"$//')
32
46
  if [ -z "$FILE_PATH" ]; then
@@ -39,6 +53,19 @@ if [ -z "$FILE_PATH" ]; then
39
53
  exit 0
40
54
  fi
41
55
 
56
+ # Determine repo root early — needed for all checks below.
57
+ REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
58
+ if [ -z "$REPO_ROOT" ]; then
59
+ # Not in a git repo — allow silently
60
+ exit 0
61
+ fi
62
+
63
+ # If the file is outside this repo, allow silently (not our business).
64
+ case "$FILE_PATH" in
65
+ "$REPO_ROOT"/*) ;; # inside repo — continue
66
+ *) exit 0 ;; # outside repo — allow
67
+ esac
68
+
42
69
  # Check if the file being edited is inside a stint worktree (check the file path, not pwd)
43
70
  # Match /.stint/<name>/ precisely — avoid false positives like /my.stint/ or /.stinted/
44
71
  if echo "$FILE_PATH" | grep -qE '/\.stint/[^/]+/'; then
@@ -47,50 +74,110 @@ if echo "$FILE_PATH" | grep -qE '/\.stint/[^/]+/'; then
47
74
  exit 0
48
75
  fi
49
76
 
77
+ # Gitignored files can't be committed — no branch isolation needed.
78
+ if git check-ignore -q "$FILE_PATH" 2>/dev/null; then
79
+ exit 0
80
+ fi
81
+
82
+ # --- Read .stint.json config for main_branch_policy ---
83
+ MAIN_BRANCH_POLICY="prompt" # default
84
+ STINT_CONFIG="$REPO_ROOT/.stint.json"
85
+ if [ -f "$STINT_CONFIG" ]; then
86
+ if command -v jq &>/dev/null; then
87
+ _policy=$(jq -r '.main_branch_policy // empty' "$STINT_CONFIG" 2>/dev/null || true)
88
+ else
89
+ _policy=$(grep -o '"main_branch_policy"[[:space:]]*:[[:space:]]*"[^"]*"' "$STINT_CONFIG" 2>/dev/null | head -1 | sed 's/.*"main_branch_policy"[[:space:]]*:[[:space:]]*"//;s/"$//')
90
+ fi
91
+ case "$_policy" in
92
+ allow|block|prompt) MAIN_BRANCH_POLICY="$_policy" ;;
93
+ esac
94
+ fi
95
+
50
96
  # The file is NOT in a stint worktree. Check if any stint session exists.
51
97
  SESSIONS_DIR="$(git rev-parse --git-common-dir 2>/dev/null)/sessions"
52
98
  if [ -d "$SESSIONS_DIR" ]; then
53
- # Iterate session manifests to find the best one to suggest.
54
- REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
55
- BEST_WORKTREE=""
56
- BEST_SESSION=""
99
+ MY_WORKTREE=""
100
+ MY_SESSION=""
101
+ OTHER_SESSIONS=""
57
102
 
58
103
  for manifest in "$SESSIONS_DIR"/*.json; do
59
104
  [ -f "$manifest" ] || continue
60
- # Skip .tmp files (glob won't match, but be safe)
61
105
  case "$manifest" in *.tmp) continue ;; esac
62
106
 
63
107
  if command -v jq &>/dev/null; then
64
108
  wt=$(jq -r '.worktree // empty' "$manifest" 2>/dev/null || true)
65
109
  sn=$(jq -r '.name // empty' "$manifest" 2>/dev/null || true)
110
+ cid=$(jq -r '.clientId // empty' "$manifest" 2>/dev/null || true)
66
111
  else
67
112
  wt=$(grep -o '"worktree"[[:space:]]*:[[:space:]]*"[^"]*"' "$manifest" | head -1 | sed 's/.*"worktree"[[:space:]]*:[[:space:]]*"//;s/"$//')
68
113
  sn=$(grep -o '"name"[[:space:]]*:[[:space:]]*"[^"]*"' "$manifest" | head -1 | sed 's/.*"name"[[:space:]]*:[[:space:]]*"//;s/"$//')
114
+ cid=$(grep -o '"clientId"[[:space:]]*:[[:space:]]*"[^"]*"' "$manifest" | head -1 | sed 's/.*"clientId"[[:space:]]*:[[:space:]]*"//;s/"$//')
69
115
  fi
70
116
 
71
- if [ -z "$BEST_WORKTREE" ] && [ -n "$wt" ]; then
72
- BEST_WORKTREE="$wt"
73
- BEST_SESSION="$sn"
117
+ if [ -n "$CLIENT_ID" ] && [ "$cid" = "$CLIENT_ID" ] && [ -n "$wt" ]; then
118
+ # This session belongs to us
119
+ MY_WORKTREE="$wt"
120
+ MY_SESSION="$sn"
121
+ elif [ -n "$sn" ]; then
122
+ OTHER_SESSIONS="${OTHER_SESSIONS:+$OTHER_SESSIONS, }$sn"
74
123
  fi
75
124
  done
76
125
 
77
- if [ -n "$BEST_WORKTREE" ]; then
78
- # Active session exists but agent is writing to the main repo — block.
79
- if [ -n "$REPO_ROOT" ]; then
80
- ABS_WORKTREE="$REPO_ROOT/$BEST_WORKTREE"
81
- echo "BLOCKED: You have an active stint session '${BEST_SESSION}'." >&2
82
- echo "You must work in the session worktree, not the main repo." >&2
83
- echo "Run: cd \"$ABS_WORKTREE\"" >&2
84
- echo "Then retry your edit. The file you need is: $ABS_WORKTREE/${FILE_PATH#$REPO_ROOT/}" >&2
85
- else
86
- echo "BLOCKED: An active stint session exists. Work in the session worktree instead." >&2
87
- echo "Run: git stint list" >&2
88
- fi
126
+ if [ -n "$MY_WORKTREE" ]; then
127
+ # Found our session redirect to it
128
+ ABS_WORKTREE="$REPO_ROOT/$MY_WORKTREE"
129
+ echo "BLOCKED: You have an active stint session '${MY_SESSION}'." >&2
130
+ echo "You must work in the session worktree, not the main repo." >&2
131
+ echo "Rewrite your file path from: $FILE_PATH" >&2
132
+ echo "To: $ABS_WORKTREE/${FILE_PATH#$REPO_ROOT/}" >&2
133
+ exit 2
134
+ fi
135
+
136
+ # No session matches our clientId, but other sessions exist.
137
+ # Don't hijack them — fall through to create our own session.
138
+ fi
139
+
140
+ # No session for this client — check main_branch_policy before auto-creating.
141
+ GIT_COMMON_DIR="$(git rev-parse --git-common-dir 2>/dev/null)"
142
+
143
+ if [ "$MAIN_BRANCH_POLICY" = "allow" ]; then
144
+ # Policy explicitly allows writes to main — pass through
145
+ exit 0
146
+ fi
89
147
 
90
- # Exit non-zero to block the tool call
91
- exit 1
148
+ if [ "$MAIN_BRANCH_POLICY" = "prompt" ]; then
149
+ # Check for per-process allow-main flag (scoped to this Claude Code instance via PPID)
150
+ if [ -n "$CLIENT_ID" ] && [ -f "$GIT_COMMON_DIR/stint-main-allowed-${CLIENT_ID}" ]; then
151
+ exit 0
92
152
  fi
153
+ echo "BLOCKED: Writing to main branch." >&2
154
+ echo "To allow, run: git stint allow-main --client-id $CLIENT_ID" >&2
155
+ echo "To create a session instead, run: git stint start <name>" >&2
156
+ exit 2
93
157
  fi
94
158
 
95
- # No session activeallow the write
96
- exit 0
159
+ # Policy is "block" (or default) auto-start a session, then block so agent retries in worktree.
160
+ SESSION_NAME="session-$(date +%Y%m%d-%H%M%S)"
161
+ CLIENT_FLAG=""
162
+ if [ -n "$CLIENT_ID" ]; then
163
+ CLIENT_FLAG="--client-id $CLIENT_ID"
164
+ fi
165
+
166
+ START_OUTPUT=$(git-stint start "$SESSION_NAME" $CLIENT_FLAG 2>&1) || {
167
+ # If start fails, allow the write
168
+ exit 0
169
+ }
170
+
171
+ # Extract worktree path from the start output
172
+ WORKTREE_PATH=$(echo "$START_OUTPUT" | grep -o 'Worktree:.*' | sed 's/Worktree:[[:space:]]*//')
173
+ if [ -z "$WORKTREE_PATH" ]; then
174
+ WORKTREE_PATH="$REPO_ROOT/.stint/$SESSION_NAME"
175
+ fi
176
+
177
+ echo "git-stint: Auto-created session '${SESSION_NAME}'." >&2
178
+ echo "All edits must target the worktree: $WORKTREE_PATH" >&2
179
+ echo "Rewrite your file path from: $FILE_PATH" >&2
180
+ echo "To: ${WORKTREE_PATH}/${FILE_PATH#$REPO_ROOT/}" >&2
181
+
182
+ # Block so the agent retries with the worktree path
183
+ exit 2
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { test, testCombine } from "./test-session.js";
7
7
  const args = process.argv.slice(2);
8
8
  const command = args[0];
9
9
  // Known flags that take a value (used by arg parser to skip correctly)
10
- const VALUE_FLAGS = new Set(["-m", "--session", "--title", "--combine"]);
10
+ const VALUE_FLAGS = new Set(["-m", "--session", "--title", "--combine", "--client-id"]);
11
11
  function getFlag(flag) {
12
12
  // Check for --flag=value syntax
13
13
  const eqPrefix = flag + "=";
@@ -68,7 +68,11 @@ try {
68
68
  switch (command) {
69
69
  case "start": {
70
70
  const name = getPositional(0);
71
- session.start(name);
71
+ const clientId = getFlag("--client-id");
72
+ const adoptOverride = args.includes("--adopt") ? true
73
+ : args.includes("--no-adopt") ? false
74
+ : undefined;
75
+ session.start(name, clientId, adoptOverride);
72
76
  break;
73
77
  }
74
78
  case "track": {
@@ -175,6 +179,10 @@ try {
175
179
  session.prune();
176
180
  break;
177
181
  }
182
+ case "allow-main": {
183
+ session.allowMain(getFlag("--client-id"));
184
+ break;
185
+ }
178
186
  case "install-hooks": {
179
187
  const { install } = await import("./install-hooks.js");
180
188
  const scope = args.includes("--user") ? "user" : "project";
@@ -249,11 +257,14 @@ Commands:
249
257
  test [-- <cmd>] Run tests in the session worktree
250
258
  test --combine A B Test multiple sessions merged together
251
259
  prune Clean up orphaned worktrees/branches
260
+ allow-main Allow writes to main branch (scoped to this client session)
252
261
  install-hooks [--user] Install Claude Code hooks
253
262
  uninstall-hooks [--user] Remove Claude Code hooks
254
263
 
255
264
  Options:
256
265
  --session <name> Specify session (auto-detected from CWD)
266
+ --client-id <id> Tag session with a client identifier (used by hooks)
267
+ --adopt / --no-adopt Override adopt_changes config for this start
257
268
  -m "message" Commit/squash message
258
269
  --title "title" PR title
259
270
  --version Show version number
@@ -0,0 +1,10 @@
1
+ export interface StintConfig {
2
+ shared_dirs: string[];
3
+ main_branch_policy: "prompt" | "allow" | "block";
4
+ force_cleanup: "prompt" | "force" | "fail";
5
+ adopt_changes: "always" | "never" | "prompt";
6
+ }
7
+ /**
8
+ * Load .stint.json from repo root. Returns defaults if file missing or invalid.
9
+ */
10
+ export declare function loadConfig(repoRoot: string): StintConfig;
package/dist/config.js ADDED
@@ -0,0 +1,43 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const DEFAULTS = {
4
+ shared_dirs: [],
5
+ main_branch_policy: "prompt",
6
+ force_cleanup: "prompt",
7
+ adopt_changes: "always",
8
+ };
9
+ const VALID_POLICIES = new Set(["prompt", "allow", "block"]);
10
+ const VALID_CLEANUP = new Set(["prompt", "force", "fail"]);
11
+ const VALID_ADOPT = new Set(["always", "never", "prompt"]);
12
+ /**
13
+ * Load .stint.json from repo root. Returns defaults if file missing or invalid.
14
+ */
15
+ export function loadConfig(repoRoot) {
16
+ const configPath = join(repoRoot, ".stint.json");
17
+ if (!existsSync(configPath))
18
+ return { ...DEFAULTS };
19
+ let raw;
20
+ try {
21
+ raw = JSON.parse(readFileSync(configPath, "utf-8"));
22
+ }
23
+ catch {
24
+ return { ...DEFAULTS };
25
+ }
26
+ if (!raw || typeof raw !== "object")
27
+ return { ...DEFAULTS };
28
+ const obj = raw;
29
+ const config = { ...DEFAULTS };
30
+ if (Array.isArray(obj.shared_dirs)) {
31
+ config.shared_dirs = obj.shared_dirs.filter((d) => typeof d === "string" && d.length > 0);
32
+ }
33
+ if (typeof obj.main_branch_policy === "string" && VALID_POLICIES.has(obj.main_branch_policy)) {
34
+ config.main_branch_policy = obj.main_branch_policy;
35
+ }
36
+ if (typeof obj.force_cleanup === "string" && VALID_CLEANUP.has(obj.force_cleanup)) {
37
+ config.force_cleanup = obj.force_cleanup;
38
+ }
39
+ if (typeof obj.adopt_changes === "string" && VALID_ADOPT.has(obj.adopt_changes)) {
40
+ config.adopt_changes = obj.adopt_changes;
41
+ }
42
+ return config;
43
+ }
package/dist/git.d.ts CHANGED
@@ -36,5 +36,7 @@ export declare function resetSoft(dir: string, to: string): void;
36
36
  export declare function resetMixed(dir: string, to: string): void;
37
37
  export declare function mergeInto(targetDir: string, ...branches: string[]): void;
38
38
  export declare function push(branch: string): void;
39
+ export declare function stash(dir: string): string;
40
+ export declare function stashPop(dir: string): string;
39
41
  export declare function isInsideGitRepo(): boolean;
40
42
  export declare function hasCommits(): boolean;
package/dist/git.js CHANGED
@@ -129,6 +129,12 @@ export function mergeInto(targetDir, ...branches) {
129
129
  export function push(branch) {
130
130
  git("push", "-u", "origin", branch);
131
131
  }
132
+ export function stash(dir) {
133
+ return gitInDir(dir, "stash", "--include-untracked");
134
+ }
135
+ export function stashPop(dir) {
136
+ return gitInDir(dir, "stash", "pop");
137
+ }
132
138
  export function isInsideGitRepo() {
133
139
  try {
134
140
  git("rev-parse", "--is-inside-work-tree");
@@ -4,6 +4,9 @@
4
4
  * Hooks installed:
5
5
  * PreToolUse (Write/Edit): Track files written in session worktrees.
6
6
  * Stop: Commit pending changes as WIP checkpoint.
7
+ *
8
+ * Also installs:
9
+ * .claude/rules/git-stint.md: Workflow instructions for Claude Code.
7
10
  */
8
11
  export declare function install(scope: "project" | "user"): void;
9
12
  export declare function uninstall(scope: "project" | "user"): void;
@@ -4,8 +4,11 @@
4
4
  * Hooks installed:
5
5
  * PreToolUse (Write/Edit): Track files written in session worktrees.
6
6
  * Stop: Commit pending changes as WIP checkpoint.
7
+ *
8
+ * Also installs:
9
+ * .claude/rules/git-stint.md: Workflow instructions for Claude Code.
7
10
  */
8
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
11
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
9
12
  import { join, resolve } from "node:path";
10
13
  /** Hook command names owned by git-stint. */
11
14
  const STINT_COMMANDS = new Set([
@@ -72,6 +75,72 @@ export function install(scope) {
72
75
  console.log("\nHooks added:");
73
76
  console.log(" PreToolUse (Write/Edit): track files in session worktrees");
74
77
  console.log(" Stop: commit pending changes as WIP checkpoint");
78
+ // Scaffold .stint.json and .claude/rules/git-stint.md if they don't exist
79
+ if (scope === "project") {
80
+ scaffoldConfig(process.cwd());
81
+ scaffoldRules(process.cwd());
82
+ }
83
+ }
84
+ const DEFAULT_CONFIG = {
85
+ shared_dirs: [],
86
+ main_branch_policy: "prompt",
87
+ force_cleanup: "prompt",
88
+ adopt_changes: "always",
89
+ };
90
+ const RULES_FILENAME = "git-stint.md";
91
+ const RULES_CONTENT = `# Git Stint Workflow
92
+
93
+ All file edits are intercepted by git-stint hooks and redirected to isolated
94
+ worktrees. One stint session = one branch = one PR.
95
+
96
+ ## Session Lifecycle
97
+
98
+ - Session auto-creates on first Write/Edit (via PreToolUse hook).
99
+ - All edits redirect to \`.stint/<session>/\` worktree.
100
+ - \`git stint commit -m "msg"\` to commit logical units of work.
101
+ - \`git stint pr\` to push and create PR.
102
+ - \`git stint end\` ONLY after ALL related work is done.
103
+
104
+ ## Rules
105
+
106
+ - Do NOT call \`git stint end\` until all changes are committed (code, tests,
107
+ config updates, follow-up tasks). Premature \`end\` kills the session; the
108
+ next edit auto-creates a NEW session, fragmenting work across multiple PRs.
109
+ - Sub-agents share the same session (same PPID). No special handling needed.
110
+ - Files outside the repo bypass hooks — edit freely.
111
+ - Gitignored files bypass hooks — edit freely.
112
+ - Directories listed under \`shared_dirs\` in \`.stint.json\` are symlinked into
113
+ worktrees pointing to the main repo's real directories. They must never be
114
+ staged or committed. The hooks auto-add them to the worktree's \`.gitignore\`.
115
+ `;
116
+ function scaffoldConfig(repoRoot) {
117
+ const configPath = join(repoRoot, ".stint.json");
118
+ if (existsSync(configPath)) {
119
+ return;
120
+ }
121
+ const content = JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n";
122
+ writeFileSync(configPath, content);
123
+ console.log(`\nConfig created: ${configPath}`);
124
+ }
125
+ function scaffoldRules(repoRoot) {
126
+ const rulesDir = join(repoRoot, ".claude", "rules");
127
+ const rulesPath = join(rulesDir, RULES_FILENAME);
128
+ if (existsSync(rulesPath)) {
129
+ return;
130
+ }
131
+ if (!existsSync(rulesDir)) {
132
+ mkdirSync(rulesDir, { recursive: true });
133
+ }
134
+ writeFileSync(rulesPath, RULES_CONTENT);
135
+ console.log(`\nRules created: ${rulesPath}`);
136
+ }
137
+ function removeRules(repoRoot) {
138
+ const rulesPath = join(repoRoot, ".claude", "rules", RULES_FILENAME);
139
+ if (!existsSync(rulesPath)) {
140
+ return;
141
+ }
142
+ unlinkSync(rulesPath);
143
+ console.log(`Removed rules file: ${rulesPath}`);
75
144
  }
76
145
  /** Check if a hook entry (old or new format) contains a stint command. */
77
146
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -126,4 +195,8 @@ export function uninstall(scope) {
126
195
  writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
127
196
  renameSync(tmp, settingsPath);
128
197
  console.log(`Removed ${removed} git-stint hook(s) from ${settingsPath}`);
198
+ // Clean up rules file
199
+ if (scope === "project") {
200
+ removeRules(process.cwd());
201
+ }
129
202
  }
@@ -21,6 +21,14 @@ export interface SessionManifest {
21
21
  changesets: Changeset[];
22
22
  /** Files tracked since last commit. */
23
23
  pending: string[];
24
+ /**
25
+ * Opaque identifier for the client that owns this session.
26
+ * Used by hooks to route writes to the correct worktree when multiple
27
+ * sessions are active (e.g., two Claude Code instances). Typically the
28
+ * PPID of the hook process, which equals the Claude Code Node.js PID.
29
+ * Optional for backward compatibility with existing manifests.
30
+ */
31
+ clientId?: string;
24
32
  }
25
33
  declare const MANIFEST_VERSION = 1;
26
34
  declare const BRANCH_PREFIX = "stint/";
package/dist/session.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare function start(name?: string): void;
1
+ export declare function start(name?: string, clientId?: string, adoptOverride?: boolean): void;
2
2
  export declare function track(files: string[], sessionName?: string): void;
3
3
  export declare function status(sessionName?: string): void;
4
4
  /** Show both staged and unstaged changes. */
@@ -17,3 +17,13 @@ export declare function list(): void;
17
17
  export declare function listJson(): void;
18
18
  /** Clean up orphaned worktrees, manifests, and branches. */
19
19
  export declare function prune(): void;
20
+ /** Clean up allow-main flags for PIDs that are no longer running. */
21
+ export declare function pruneAllowMainFlags(): void;
22
+ /**
23
+ * Create per-process flag file allowing writes to main branch.
24
+ * Scoped to a client ID (typically Claude Code's PID), so other
25
+ * instances remain blocked.
26
+ *
27
+ * @param clientId - Explicit client ID. If not provided, falls back to process.ppid.
28
+ */
29
+ export declare function allowMain(clientId?: string): void;
package/dist/session.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import * as git from "./git.js";
5
+ import { loadConfig } from "./config.js";
5
6
  import { BRANCH_PREFIX, WORKTREE_DIR, MANIFEST_VERSION, saveManifest, deleteManifest, listManifests, resolveSession, getWorktreePath, getRepoRoot, } from "./manifest.js";
6
7
  // --- Constants ---
7
8
  const WIP_MESSAGE = "WIP: session checkpoint";
@@ -87,7 +88,7 @@ function warnIfInsideWorktree(worktree) {
87
88
  }
88
89
  }
89
90
  // --- Commands ---
90
- export function start(name) {
91
+ export function start(name, clientId, adoptOverride) {
91
92
  if (!git.isInsideGitRepo()) {
92
93
  throw new Error("Not inside a git repository.");
93
94
  }
@@ -120,6 +121,88 @@ export function start(name) {
120
121
  catch { /* best effort */ }
121
122
  throw err;
122
123
  }
124
+ // Adopt uncommitted changes from main repo (before symlinking, to avoid conflicts)
125
+ const config = loadConfig(topLevel);
126
+ const shouldAdopt = adoptOverride !== undefined
127
+ ? adoptOverride
128
+ : config.adopt_changes === "always";
129
+ let adoptedFiles = 0;
130
+ if (git.hasUncommittedChanges(topLevel)) {
131
+ const statusOutput = git.statusShort(topLevel);
132
+ const fileCount = statusOutput.split("\n").filter(Boolean).length;
133
+ if (adoptOverride === undefined && config.adopt_changes === "prompt") {
134
+ console.warn(`Warning: ${fileCount} uncommitted file(s) on main. Use --adopt to carry them over, or --no-adopt to leave them.`);
135
+ }
136
+ else if (shouldAdopt) {
137
+ adoptedFiles = fileCount;
138
+ try {
139
+ git.stash(topLevel);
140
+ try {
141
+ git.stashPop(worktreeAbs);
142
+ }
143
+ catch {
144
+ // Stash pop failed — restore stash to main repo
145
+ console.warn("Warning: Could not apply uncommitted changes to worktree. Stash preserved in main repo.");
146
+ try {
147
+ git.stashPop(topLevel);
148
+ }
149
+ catch { /* leave stash intact */ }
150
+ }
151
+ }
152
+ catch {
153
+ // Nothing to stash (git stash can fail if changes are only untracked and gitignored)
154
+ adoptedFiles = 0;
155
+ }
156
+ }
157
+ }
158
+ // Symlink shared directories from config (after adopt, so stash pop doesn't conflict with symlinks)
159
+ const linkedDirs = [];
160
+ for (const dir of config.shared_dirs) {
161
+ const source = resolve(topLevel, dir);
162
+ const target = resolve(worktreeAbs, dir);
163
+ if (!existsSync(source)) {
164
+ console.warn(`Warning: shared_dirs entry '${dir}' not found in repo, skipping.`);
165
+ continue;
166
+ }
167
+ if (existsSync(target))
168
+ continue; // already exists (e.g., tracked in git or adopted from stash)
169
+ mkdirSync(dirname(target), { recursive: true });
170
+ symlinkSync(source, target);
171
+ linkedDirs.push(dir);
172
+ }
173
+ // Prevent shared_dirs symlinks from being staged by `git add -A`.
174
+ // .gitignore rules with trailing slash (e.g., "backend/data/") only match directories,
175
+ // NOT symlinks. Symlinks are files with mode 120000 in git. We must add entries WITHOUT
176
+ // trailing slash so git ignores both the directory (in main) and the symlink (in worktree).
177
+ if (linkedDirs.length > 0) {
178
+ const gitignorePath = join(worktreeAbs, ".gitignore");
179
+ const markerStart = "# git-stint shared_dirs (auto-generated, do not commit)";
180
+ const markerEnd = "# end git-stint shared_dirs";
181
+ let content = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : "";
182
+ const entries = linkedDirs.map((d) => `${d}`).join("\n");
183
+ const block = `${markerStart}\n${entries}\n${markerEnd}`;
184
+ if (content.includes(markerStart)) {
185
+ // Replace existing block with current shared_dirs (handles additions/removals)
186
+ const regex = new RegExp(`${markerStart.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?` +
187
+ `(?:${markerEnd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}|$)`);
188
+ content = content.replace(regex, block);
189
+ }
190
+ else {
191
+ content = content.endsWith("\n") ? content + "\n" + block + "\n" : content + "\n\n" + block + "\n";
192
+ }
193
+ writeFileSync(gitignorePath, content);
194
+ // Immediately unstage the symlinks if they were already tracked
195
+ for (const d of linkedDirs) {
196
+ try {
197
+ git.gitInDir(worktreeAbs, "rm", "--cached", "--ignore-unmatch", "-r", d);
198
+ }
199
+ catch {
200
+ // best effort — may not be tracked
201
+ }
202
+ }
203
+ }
204
+ // Revoke main-branch write pass when entering session mode
205
+ removeAllowMainFlag();
123
206
  // Create manifest
124
207
  const manifest = {
125
208
  version: MANIFEST_VERSION,
@@ -130,11 +213,21 @@ export function start(name) {
130
213
  worktree: worktreeRel,
131
214
  changesets: [],
132
215
  pending: [],
216
+ ...(clientId ? { clientId } : {}),
133
217
  };
134
218
  saveManifest(manifest);
135
219
  console.log(`Session '${sessionName}' started.`);
136
220
  console.log(` Branch: ${branchName}`);
137
221
  console.log(` Worktree: ${worktreeAbs}`);
222
+ if (linkedDirs.length > 0) {
223
+ console.log(`\nShared directories (symlinked — changes affect main repo):`);
224
+ for (const dir of linkedDirs) {
225
+ console.log(` ${dir} → ${resolve(topLevel, dir)}`);
226
+ }
227
+ }
228
+ if (adoptedFiles > 0) {
229
+ console.log(`\nCarried over ${adoptedFiles} uncommitted file(s) into session.`);
230
+ }
138
231
  console.log(`\ncd "${worktreeAbs}"`);
139
232
  }
140
233
  export function track(files, sessionName) {
@@ -548,6 +641,8 @@ export function prune() {
548
641
  }
549
642
  }
550
643
  catch { /* no stint branches */ }
644
+ // Clean up stale allow-main flags from dead processes
645
+ pruneAllowMainFlags();
551
646
  if (cleaned === 0) {
552
647
  console.log("Nothing to clean up.");
553
648
  }
@@ -558,6 +653,25 @@ export function prune() {
558
653
  // --- Helpers ---
559
654
  function cleanup(manifest, force = false) {
560
655
  const worktree = getWorktreePath(manifest);
656
+ const topLevel = getRepoRoot();
657
+ const config = loadConfig(topLevel);
658
+ // Remove shared dir symlinks before removing worktree to protect linked data
659
+ if (existsSync(worktree)) {
660
+ for (const dir of config.shared_dirs) {
661
+ const target = resolve(worktree, dir);
662
+ if (!existsSync(target))
663
+ continue;
664
+ try {
665
+ if (lstatSync(target).isSymbolicLink()) {
666
+ unlinkSync(target);
667
+ }
668
+ else {
669
+ console.warn(`Warning: '${dir}' in worktree is a real directory (not a symlink). Data will be lost on cleanup.`);
670
+ }
671
+ }
672
+ catch { /* best effort */ }
673
+ }
674
+ }
561
675
  // Remove worktree
562
676
  if (existsSync(worktree)) {
563
677
  try {
@@ -565,8 +679,17 @@ function cleanup(manifest, force = false) {
565
679
  }
566
680
  catch (err) {
567
681
  if (!force) {
568
- // Retry with force only if we didn't already try force
569
- git.removeWorktree(worktree, true);
682
+ // Apply force_cleanup policy
683
+ if (config.force_cleanup === "fail") {
684
+ throw err;
685
+ }
686
+ if (config.force_cleanup === "force") {
687
+ git.removeWorktree(worktree, true);
688
+ }
689
+ else {
690
+ // "prompt" (default) — retry with force, matching previous behavior
691
+ git.removeWorktree(worktree, true);
692
+ }
570
693
  }
571
694
  else {
572
695
  throw err;
@@ -585,3 +708,55 @@ function cleanup(manifest, force = false) {
585
708
  // Delete manifest last — if anything above fails, manifest persists for prune
586
709
  deleteManifest(manifest.name);
587
710
  }
711
+ // --- Allow-main flag ---
712
+ const ALLOW_MAIN_PREFIX = "stint-main-allowed-";
713
+ function getAllowMainPath(pid) {
714
+ const commonDir = resolve(git.getGitCommonDir());
715
+ return join(commonDir, `${ALLOW_MAIN_PREFIX}${pid}`);
716
+ }
717
+ function removeAllowMainFlag() {
718
+ // Remove flag for current process tree only (called from `start`)
719
+ const flagPath = getAllowMainPath(process.ppid);
720
+ if (existsSync(flagPath))
721
+ unlinkSync(flagPath);
722
+ }
723
+ /** Clean up allow-main flags for PIDs that are no longer running. */
724
+ export function pruneAllowMainFlags() {
725
+ const commonDir = resolve(git.getGitCommonDir());
726
+ const entries = readdirSync(commonDir).filter((e) => e.startsWith(ALLOW_MAIN_PREFIX));
727
+ let cleaned = 0;
728
+ for (const entry of entries) {
729
+ const pid = parseInt(entry.slice(ALLOW_MAIN_PREFIX.length), 10);
730
+ if (isNaN(pid)) {
731
+ unlinkSync(join(commonDir, entry));
732
+ cleaned++;
733
+ continue;
734
+ }
735
+ try {
736
+ process.kill(pid, 0); // signal 0 = existence check, no actual signal
737
+ }
738
+ catch {
739
+ // Process doesn't exist — stale flag
740
+ unlinkSync(join(commonDir, entry));
741
+ cleaned++;
742
+ }
743
+ }
744
+ if (cleaned > 0)
745
+ console.log(`Cleaned ${cleaned} stale allow-main flag(s).`);
746
+ }
747
+ /**
748
+ * Create per-process flag file allowing writes to main branch.
749
+ * Scoped to a client ID (typically Claude Code's PID), so other
750
+ * instances remain blocked.
751
+ *
752
+ * @param clientId - Explicit client ID. If not provided, falls back to process.ppid.
753
+ */
754
+ export function allowMain(clientId) {
755
+ if (!git.isInsideGitRepo()) {
756
+ throw new Error("Not inside a git repository.");
757
+ }
758
+ const id = clientId || String(process.ppid);
759
+ const flagPath = getAllowMainPath(Number(id));
760
+ writeFileSync(flagPath, new Date().toISOString() + "\n");
761
+ console.log(`Main branch writes allowed for this session (client ${id}).`);
762
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-stint",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Session-scoped change tracking for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {