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