git-stint 0.2.3 → 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 +189 -37
- package/adapters/claude-code/hooks/git-stint-hook-pre-tool +116 -29
- package/dist/cli.js +13 -2
- package/dist/config.d.ts +10 -0
- package/dist/config.js +43 -0
- package/dist/git.d.ts +2 -0
- package/dist/git.js +6 -0
- package/dist/install-hooks.d.ts +3 -0
- package/dist/install-hooks.js +74 -1
- package/dist/manifest.d.ts +8 -0
- package/dist/session.d.ts +11 -1
- package/dist/session.js +179 -4
- package/package.json +1 -1
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 ~
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
|
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
|
-
###
|
|
200
|
+
### Setup for Claude Code
|
|
91
201
|
|
|
92
202
|
```bash
|
|
93
|
-
# Install
|
|
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
|
-
#
|
|
97
|
-
|
|
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
|
-
#
|
|
100
|
-
|
|
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"
|
|
133
|
-
|
|
273
|
+
|
|
|
274
|
+
"commit" -> changeset 1 (baseline advances to new SHA)
|
|
275
|
+
|
|
|
134
276
|
Edit server.ts (again), test.ts
|
|
135
|
-
|
|
136
|
-
"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
|
|
147
|
-
Session B: edits server.ts, constants.ts
|
|
148
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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`) | ~
|
|
184
|
-
| `src/manifest.ts` | Session state CRUD in `.git/sessions/` | ~
|
|
185
|
-
| `src/session.ts` | Core commands (start, commit, squash, pr, end...) | ~
|
|
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 | ~
|
|
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 | ~
|
|
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
|
|
11
|
-
#
|
|
12
|
-
# -
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 [ -
|
|
72
|
-
|
|
73
|
-
|
|
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 "$
|
|
78
|
-
#
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
#
|
|
96
|
-
|
|
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
|
-
|
|
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
|
package/dist/config.d.ts
ADDED
|
@@ -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");
|
package/dist/install-hooks.d.ts
CHANGED
|
@@ -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;
|
package/dist/install-hooks.js
CHANGED
|
@@ -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
|
}
|
package/dist/manifest.d.ts
CHANGED
|
@@ -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
|
-
//
|
|
569
|
-
|
|
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
|
+
}
|