@oomfware/cgr 0.1.8 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,15 +42,15 @@ cgr clean --all
42
42
  ## commands
43
43
 
44
44
  ```
45
- cgr ask [-m opus|sonnet|haiku] [-s] [-w repo[#branch] ...] <repo>[#branch] <question>
45
+ cgr ask [-m opus|sonnet|haiku] [-d] [-w repo[#branch] ...] <repo>[#branch] <question>
46
46
  cgr clean [--all | <repo>]
47
47
  ```
48
48
 
49
- | option | description |
50
- | --------------- | ------------------------------------------------- |
51
- | `-m, --model` | model to use: opus, sonnet, haiku (default haiku) |
52
- | `-s, --shallow` | use shallow clone (depth 1) for faster cloning |
53
- | `-w, --with` | additional repository to include (repeatable) |
49
+ | option | description |
50
+ | ------------- | ------------------------------------------------- |
51
+ | `-m, --model` | model to use: opus, sonnet, haiku (default haiku) |
52
+ | `-d, --deep` | clone full history (enables git log/blame/show) |
53
+ | `-w, --with` | additional repository to include (repeatable) |
54
54
 
55
55
  | command | description |
56
56
  | ------- | ----------------------------------------------------------------- |
@@ -88,7 +88,7 @@ You can use `@oomfware/cgr` to ask questions about external repositories.
88
88
 
89
89
  options:
90
90
  -m, --model <model> model to use: opus, sonnet, haiku (default: haiku)
91
- -s, --shallow use shallow clone (depth 1) for faster cloning
91
+ -d, --deep clone full history (enables git log/blame/show)
92
92
  -w, --with <repo> additional repository to include, supports #branch (repeatable)
93
93
 
94
94
  Useful repositories:
@@ -111,6 +111,10 @@ where they can look—so they can ask informed follow-ups.
111
111
  **Compare implementations**: When examining multiple repositories, highlight differences in
112
112
  approach. Tables work well for summarizing tradeoffs.
113
113
 
114
- **Use history**: When relevant, use git log/blame/show to understand how code evolved.
114
+ **Use history**: When relevant, use git log/blame/show to understand how code evolved—why code
115
+ exists, when behavior changed, who authored a component. If the repository is a shallow clone and
116
+ the question would benefit from git history, suggest re-running with `--deep`.
115
117
 
116
118
  **Admit uncertainty**: If you're unsure about something, say so and explain what you did find.
119
+
120
+ ---
package/dist/index.mjs CHANGED
@@ -5,9 +5,10 @@ import { spawn } from "node:child_process";
5
5
  import { basename, dirname, join, relative } from "node:path";
6
6
  import { multiple, optional, withDefault } from "@optique/core/modifiers";
7
7
  import { existsSync } from "node:fs";
8
- import { mkdir, readdir, rm, stat, symlink } from "node:fs/promises";
8
+ import { mkdir, readFile, readdir, rm, stat, symlink, writeFile } from "node:fs/promises";
9
9
  import { randomUUID } from "node:crypto";
10
10
  import { homedir } from "node:os";
11
+ import * as v from "valibot";
11
12
  import checkbox from "@inquirer/checkbox";
12
13
  import confirm from "@inquirer/confirm";
13
14
 
@@ -102,24 +103,24 @@ const gitOutput = (args, cwd) => new Promise((resolve, reject) => {
102
103
  proc.on("error", reject);
103
104
  });
104
105
  /**
105
- * checks if a repository is a shallow clone.
106
+ * checks if a repository has full history.
106
107
  * @param cachePath the local repository path
107
- * @returns true if the repository is shallow
108
+ * @returns true if the repository has full history (not shallow)
108
109
  */
109
- const isShallowRepo = async (cachePath) => {
110
- return await gitOutput(["rev-parse", "--is-shallow-repository"], cachePath) === "true";
110
+ const isDeepRepo = async (cachePath) => {
111
+ return await gitOutput(["rev-parse", "--is-shallow-repository"], cachePath) !== "true";
111
112
  };
112
113
  /**
113
114
  * clones a repository to the cache path.
114
115
  * @param remote the remote URL
115
116
  * @param cachePath the local cache path
116
117
  * @param branch optional branch to checkout
117
- * @param shallow if true, performs a shallow clone with depth 1
118
+ * @param deep if true, clones full history; otherwise shallow clone with depth 1
118
119
  */
119
- const cloneRepo = async (remote, cachePath, branch, shallow) => {
120
+ const cloneRepo = async (remote, cachePath, branch, deep) => {
120
121
  await mkdir(dirname(cachePath), { recursive: true });
121
122
  const args = ["clone"];
122
- if (shallow) args.push("--depth", "1");
123
+ if (!deep) args.push("--depth", "1");
123
124
  if (branch) args.push("--branch", branch);
124
125
  args.push(remote, cachePath);
125
126
  await git(args);
@@ -129,23 +130,23 @@ const cloneRepo = async (remote, cachePath, branch, shallow) => {
129
130
  * discards any local modifications, staged changes, and untracked files.
130
131
  * @param cachePath the local cache path
131
132
  * @param branch optional branch to checkout (uses default branch if not specified)
132
- * @param shallow if true, keeps shallow clone; if false, unshallows if needed
133
+ * @param deep if true, unshallows if needed; if false, keeps shallow clone
133
134
  */
134
- const updateRepo = async (cachePath, branch, shallow) => {
135
- const currentlyShallow = await isShallowRepo(cachePath);
136
- if (!shallow && currentlyShallow) await git([
135
+ const updateRepo = async (cachePath, branch, deep) => {
136
+ const currentlyDeep = await isDeepRepo(cachePath);
137
+ if (deep && !currentlyDeep) await git([
137
138
  "fetch",
138
139
  "--unshallow",
139
140
  "origin"
140
141
  ], cachePath);
141
- else if (shallow && !currentlyShallow) console.error(" note: repository is already a full clone, use `cgr clean` to re-clone as shallow");
142
- if (shallow && currentlyShallow) await git([
142
+ else if (!deep && currentlyDeep) console.error(" note: repository is already a full clone, use `cgr clean` to re-clone as shallow");
143
+ if (!deep && !currentlyDeep) await git([
143
144
  "fetch",
144
145
  "--depth",
145
146
  "1",
146
147
  "origin"
147
148
  ], cachePath);
148
- else if (!currentlyShallow || !shallow) await git(["fetch", "origin"], cachePath);
149
+ else await git(["fetch", "origin"], cachePath);
149
150
  let targetBranch = branch;
150
151
  if (!targetBranch) targetBranch = (await gitOutput(["symbolic-ref", "refs/remotes/origin/HEAD"], cachePath)).replace("refs/remotes/origin/", "");
151
152
  await git([
@@ -170,9 +171,9 @@ const updateRepo = async (cachePath, branch, shallow) => {
170
171
  * @param options repository options
171
172
  */
172
173
  const ensureRepo = async (options) => {
173
- const { remote, cachePath, branch, shallow } = options;
174
- if (existsSync(cachePath)) await updateRepo(cachePath, branch, shallow);
175
- else await cloneRepo(remote, cachePath, branch, shallow);
174
+ const { remote, cachePath, branch, deep } = options;
175
+ if (existsSync(cachePath)) await updateRepo(cachePath, branch, deep);
176
+ else await cloneRepo(remote, cachePath, branch, deep);
176
177
  };
177
178
 
178
179
  //#endregion
@@ -278,13 +279,16 @@ const parseRemoteWithBranch = (input) => {
278
279
  * @returns the sessions directory path
279
280
  */
280
281
  const getSessionsDir = () => join(getCacheDir(), "sessions");
282
+ const PID_FILE = ".pid";
283
+ const PidSchema = v.pipe(v.string(), v.trim(), v.toNumber(), v.integer(), v.minValue(1));
281
284
  /**
282
- * creates a new session directory with a random UUID.
285
+ * creates a new session directory with a random UUID and writes a PID lockfile.
283
286
  * @returns the path to the created session directory
284
287
  */
285
288
  const createSessionDir = async () => {
286
289
  const sessionPath = join(getSessionsDir(), randomUUID());
287
290
  await mkdir(sessionPath, { recursive: true });
291
+ await writeFile(join(sessionPath, PID_FILE), process.pid.toString());
288
292
  return sessionPath;
289
293
  };
290
294
  /**
@@ -297,6 +301,51 @@ const cleanupSessionDir = async (sessionPath) => {
297
301
  force: true
298
302
  });
299
303
  };
304
+ /**
305
+ * checks if a process with the given PID is running.
306
+ * @param pid the process ID to check
307
+ * @returns true if the process is running
308
+ */
309
+ const isProcessRunning = (pid) => {
310
+ try {
311
+ process.kill(pid, 0);
312
+ return true;
313
+ } catch {
314
+ return false;
315
+ }
316
+ };
317
+ /**
318
+ * garbage collects orphaned session directories.
319
+ * a session is orphaned if its PID file is missing or the process is no longer running.
320
+ * this function is meant to be called fire-and-forget (errors are silently ignored).
321
+ */
322
+ const gcSessions = async () => {
323
+ const sessionsDir = getSessionsDir();
324
+ let entries;
325
+ try {
326
+ entries = await readdir(sessionsDir, { withFileTypes: true });
327
+ } catch {
328
+ return;
329
+ }
330
+ for (const entry of entries) {
331
+ if (!entry.isDirectory()) continue;
332
+ const sessionPath = join(sessionsDir, entry.name);
333
+ const pidPath = join(sessionPath, PID_FILE);
334
+ try {
335
+ const pidContent = await readFile(pidPath, "utf-8");
336
+ const result$1 = v.safeParse(PidSchema, pidContent);
337
+ if (!result$1.success || !isProcessRunning(result$1.output)) await rm(sessionPath, {
338
+ recursive: true,
339
+ force: true
340
+ });
341
+ } catch {
342
+ await rm(sessionPath, {
343
+ recursive: true,
344
+ force: true
345
+ }).catch(() => {});
346
+ }
347
+ }
348
+ };
300
349
 
301
350
  //#endregion
302
351
  //#region src/lib/symlink.ts
@@ -338,10 +387,9 @@ const schema$1 = object({
338
387
  "sonnet",
339
388
  "haiku"
340
389
  ]), { description: message`model to use for analysis` }), "haiku"),
341
- shallow: flag("-s", "--shallow", { description: message`use shallow clone (depth 1) to save time and disk space` }),
342
- branch: optional(option("-b", "--branch", string(), { description: message`branch to checkout (deprecated: use repo#branch instead)` })),
390
+ deep: withDefault(flag("-d", "--deep", { description: message`clone full history (enables git log/blame/show)` }), false),
343
391
  with: withDefault(multiple(option("-w", "--with", string(), { description: message`additional repository to include` })), []),
344
- remote: argument(string({ metavar: "REPO" }), { description: message`git remote URL (HTTP/HTTPS/SSH), optionally with #branch` }),
392
+ remote: argument(string({ metavar: "REPO" }), { description: message`git remote URL (http/https/ssh), optionally with #branch` }),
345
393
  question: argument(string({ metavar: "QUESTION" }), { description: message`question to ask about the repository` })
346
394
  });
347
395
  /**
@@ -381,18 +429,20 @@ const parseRepoInput = (input) => {
381
429
  /**
382
430
  * builds a context prompt for a single repository.
383
431
  * @param repo the repo entry
432
+ * @param deep whether the clone has full history
384
433
  * @returns context prompt string
385
434
  */
386
- const buildSingleRepoContext = (repo) => {
387
- return `You are examining ${`${repo.parsed.host}/${repo.parsed.path}`} (checked out on ${repo.branch ?? "default branch"}).`;
435
+ const buildSingleRepoContext = (repo, deep) => {
436
+ return `You are examining ${`${repo.parsed.host}/${repo.parsed.path}`} (checked out on ${repo.branch ?? "default branch"}, ${deep ? "full clone" : "shallow clone"}).`;
388
437
  };
389
438
  /**
390
439
  * builds a context prompt for multiple repositories.
391
440
  * @param dirMap map of directory name -> repo entry
441
+ * @param deep whether the clones have full history
392
442
  * @returns context prompt string
393
443
  */
394
- const buildMultiRepoContext = (dirMap) => {
395
- const lines = ["You are examining multiple repositories:", ""];
444
+ const buildMultiRepoContext = (dirMap, deep) => {
445
+ const lines = [`You are examining multiple repositories (${deep ? "full clone" : "shallow clone"}):`, ""];
396
446
  for (const [dirName, repo] of dirMap) {
397
447
  const repoDisplay = `${repo.parsed.host}/${repo.parsed.path}`;
398
448
  const branchDisplay = repo.branch ?? "default branch";
@@ -429,7 +479,7 @@ const spawnClaude = (cwd, contextPrompt, args) => new Promise((resolve, reject)
429
479
  resolve(code ?? 0);
430
480
  });
431
481
  claude.on("error", (err) => {
432
- reject(/* @__PURE__ */ new Error(`failed to spawn claude: ${err}`));
482
+ reject(/* @__PURE__ */ new Error(`failed to summon claude: ${err}`));
433
483
  });
434
484
  });
435
485
  /**
@@ -438,8 +488,8 @@ const spawnClaude = (cwd, contextPrompt, args) => new Promise((resolve, reject)
438
488
  * @param args parsed command arguments
439
489
  */
440
490
  const handler$1 = async (args) => {
491
+ gcSessions();
441
492
  const mainRepo = parseRepoInput(args.remote);
442
- if (!mainRepo.branch && args.branch) mainRepo.branch = args.branch;
443
493
  if (args.with.length === 0) {
444
494
  const remoteUrl = normalizeRemote(mainRepo.remote);
445
495
  console.error(`preparing repository: ${mainRepo.parsed.host}/${mainRepo.parsed.path}`);
@@ -448,13 +498,13 @@ const handler$1 = async (args) => {
448
498
  remote: remoteUrl,
449
499
  cachePath: mainRepo.cachePath,
450
500
  branch: mainRepo.branch,
451
- shallow: args.shallow
501
+ deep: args.deep
452
502
  });
453
503
  } catch (err) {
454
504
  console.error(`error: failed to prepare repository: ${err}`);
455
505
  process.exit(1);
456
506
  }
457
- const contextPrompt = buildSingleRepoContext(mainRepo);
507
+ const contextPrompt = buildSingleRepoContext(mainRepo, args.deep);
458
508
  const exitCode$1 = await spawnClaude(mainRepo.cachePath, contextPrompt, args);
459
509
  process.exit(exitCode$1);
460
510
  }
@@ -468,7 +518,7 @@ const handler$1 = async (args) => {
468
518
  remote: remoteUrl,
469
519
  cachePath: repo.cachePath,
470
520
  branch: repo.branch,
471
- shallow: args.shallow
521
+ deep: args.deep
472
522
  });
473
523
  return repo;
474
524
  }));
@@ -489,7 +539,7 @@ const handler$1 = async (args) => {
489
539
  const sessionPath = await createSessionDir();
490
540
  let exitCode = 1;
491
541
  try {
492
- exitCode = await spawnClaude(sessionPath, buildMultiRepoContext(await buildSymlinkDir(sessionPath, allRepos)), args);
542
+ exitCode = await spawnClaude(sessionPath, buildMultiRepoContext(await buildSymlinkDir(sessionPath, allRepos), args.deep), args);
493
543
  } finally {
494
544
  await cleanupSessionDir(sessionPath);
495
545
  }
@@ -588,14 +638,14 @@ const handler = async (args) => {
588
638
  const sessionsDir = getSessionsDir();
589
639
  if (args.all) {
590
640
  const reposExist = await exists(reposDir);
591
- const sessionsExist$1 = await exists(sessionsDir);
592
- if (!reposExist && !sessionsExist$1) {
641
+ const sessionsExist = await exists(sessionsDir);
642
+ if (!reposExist && !sessionsExist) {
593
643
  console.log("no cached data found");
594
644
  return;
595
645
  }
596
646
  let totalSize$1 = 0;
597
647
  if (reposExist) totalSize$1 += await getDirSize(reposDir);
598
- if (sessionsExist$1) totalSize$1 += await getDirSize(sessionsDir);
648
+ if (sessionsExist) totalSize$1 += await getDirSize(sessionsDir);
599
649
  if (!args.yes) {
600
650
  if (!await confirm({
601
651
  message: `remove all cached data? (${formatSize(totalSize$1)})`,
@@ -603,7 +653,7 @@ const handler = async (args) => {
603
653
  })) return;
604
654
  }
605
655
  if (reposExist) await rm(reposDir, { recursive: true });
606
- if (sessionsExist$1) await rm(sessionsDir, { recursive: true });
656
+ if (sessionsExist) await rm(sessionsDir, { recursive: true });
607
657
  console.log("done");
608
658
  return;
609
659
  }
@@ -629,30 +679,22 @@ const handler = async (args) => {
629
679
  return;
630
680
  }
631
681
  const repos = await listCachedRepos();
632
- const sessionsExist = await exists(sessionsDir);
633
- const sessionsSize = sessionsExist ? await getDirSize(sessionsDir) : 0;
634
- if (repos.length === 0 && !sessionsExist) {
635
- console.log("no cached data found");
682
+ if (repos.length === 0) {
683
+ console.log("no cached repositories found");
636
684
  return;
637
685
  }
638
- const maxNameLen = Math.max(...repos.map((r) => r.displayPath.length), sessionsExist ? 10 : 0);
639
- const maxSizeLen = Math.max(...repos.map((r) => formatSize(r.size).length), sessionsExist ? formatSize(sessionsSize).length : 0);
686
+ const maxNameLen = Math.max(...repos.map((r) => r.displayPath.length));
687
+ const maxSizeLen = Math.max(...repos.map((r) => formatSize(r.size).length));
640
688
  const termWidth = process.stdout.columns ?? 80;
641
689
  const usePadding = maxNameLen + 2 + maxSizeLen + 6 <= termWidth;
642
690
  const formatChoice = (name, size) => usePadding ? `${name.padEnd(maxNameLen)} ${formatSize(size)}` : `${name} (${formatSize(size)})`;
643
- const choices = repos.map((repo) => ({
644
- name: formatChoice(repo.displayPath, repo.size),
645
- value: repo.path,
646
- short: repo.displayPath
647
- }));
648
- if (sessionsExist && sessionsSize > 0) choices.push({
649
- name: formatChoice("(sessions)", sessionsSize),
650
- value: sessionsDir,
651
- short: "(sessions)"
652
- });
653
691
  const selected = await checkbox({
654
692
  message: "select items to remove",
655
- choices,
693
+ choices: repos.map((repo) => ({
694
+ name: formatChoice(repo.displayPath, repo.size),
695
+ value: repo.path,
696
+ short: repo.displayPath
697
+ })),
656
698
  pageSize: 20
657
699
  });
658
700
  if (selected.length === 0) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oomfware/cgr",
3
- "version": "0.1.8",
3
+ "version": "0.2.1",
4
4
  "description": "ask questions about git repositories using Claude Code",
5
5
  "license": "0BSD",
6
6
  "repository": {
@@ -24,7 +24,8 @@
24
24
  "@inquirer/checkbox": "5.0.4",
25
25
  "@inquirer/confirm": "6.0.4",
26
26
  "@optique/core": "^0.9.1",
27
- "@optique/run": "^0.9.1"
27
+ "@optique/run": "^0.9.1",
28
+ "valibot": "^1.2.0"
28
29
  },
29
30
  "devDependencies": {
30
31
  "@types/node": "^25.0.10",
@@ -111,6 +111,10 @@ where they can look—so they can ask informed follow-ups.
111
111
  **Compare implementations**: When examining multiple repositories, highlight differences in
112
112
  approach. Tables work well for summarizing tradeoffs.
113
113
 
114
- **Use history**: When relevant, use git log/blame/show to understand how code evolved.
114
+ **Use history**: When relevant, use git log/blame/show to understand how code evolved—why code
115
+ exists, when behavior changed, who authored a component. If the repository is a shallow clone and
116
+ the question would benefit from git history, suggest re-running with `--deep`.
115
117
 
116
118
  **Admit uncertainty**: If you're unsure about something, say so and explain what you did find.
119
+
120
+ ---
@@ -1,13 +1,24 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { join } from 'node:path';
3
3
 
4
- import { argument, choice, constant, flag, type InferValue, message, object, option, string } from '@optique/core';
5
- import { multiple, optional, withDefault } from '@optique/core/modifiers';
4
+ import {
5
+ argument,
6
+ choice,
7
+ constant,
8
+ flag,
9
+ type InferValue,
10
+ message,
11
+ object,
12
+ option,
13
+ string,
14
+ } from '@optique/core';
15
+ import { multiple, withDefault } from '@optique/core/modifiers';
6
16
 
7
17
  import { ensureRepo } from '../lib/git.ts';
8
18
  import {
9
19
  cleanupSessionDir,
10
20
  createSessionDir,
21
+ gcSessions,
11
22
  getRepoCachePath,
12
23
  normalizeRemote,
13
24
  parseRemote,
@@ -28,14 +39,11 @@ export const schema = object({
28
39
  }),
29
40
  'haiku',
30
41
  ),
31
- shallow: flag('-s', '--shallow', {
32
- description: message`use shallow clone (depth 1) to save time and disk space`,
33
- }),
34
- // TODO: deprecated in favor of #branch syntax, remove in future version
35
- branch: optional(
36
- option('-b', '--branch', string(), {
37
- description: message`branch to checkout (deprecated: use repo#branch instead)`,
42
+ deep: withDefault(
43
+ flag('-d', '--deep', {
44
+ description: message`clone full history (enables git log/blame/show)`,
38
45
  }),
46
+ false,
39
47
  ),
40
48
  with: withDefault(
41
49
  multiple(
@@ -46,7 +54,7 @@ export const schema = object({
46
54
  [],
47
55
  ),
48
56
  remote: argument(string({ metavar: 'REPO' }), {
49
- description: message`git remote URL (HTTP/HTTPS/SSH), optionally with #branch`,
57
+ description: message`git remote URL (http/https/ssh), optionally with #branch`,
50
58
  }),
51
59
  question: argument(string({ metavar: 'QUESTION' }), {
52
60
  description: message`question to ask about the repository`,
@@ -91,26 +99,33 @@ const parseRepoInput = (input: string): RepoEntry => {
91
99
  /**
92
100
  * builds a context prompt for a single repository.
93
101
  * @param repo the repo entry
102
+ * @param deep whether the clone has full history
94
103
  * @returns context prompt string
95
104
  */
96
- const buildSingleRepoContext = (repo: RepoEntry): string => {
105
+ const buildSingleRepoContext = (repo: RepoEntry, deep: boolean): string => {
97
106
  const repoDisplay = `${repo.parsed.host}/${repo.parsed.path}`;
98
107
  const branchDisplay = repo.branch ?? 'default branch';
99
- return `You are examining ${repoDisplay} (checked out on ${branchDisplay}).`;
108
+ const cloneType = deep ? 'full clone' : 'shallow clone';
109
+
110
+ return `You are examining ${repoDisplay} (checked out on ${branchDisplay}, ${cloneType}).`;
100
111
  };
101
112
 
102
113
  /**
103
114
  * builds a context prompt for multiple repositories.
104
115
  * @param dirMap map of directory name -> repo entry
116
+ * @param deep whether the clones have full history
105
117
  * @returns context prompt string
106
118
  */
107
- const buildMultiRepoContext = (dirMap: Map<string, RepoEntry>): string => {
108
- const lines = ['You are examining multiple repositories:', ''];
119
+ const buildMultiRepoContext = (dirMap: Map<string, RepoEntry>, deep: boolean): string => {
120
+ const cloneType = deep ? 'full clone' : 'shallow clone';
121
+ const lines = [`You are examining multiple repositories (${cloneType}):`, ''];
122
+
109
123
  for (const [dirName, repo] of dirMap) {
110
124
  const repoDisplay = `${repo.parsed.host}/${repo.parsed.path}`;
111
125
  const branchDisplay = repo.branch ?? 'default branch';
112
126
  lines.push(`- ${dirName}/ -> ${repoDisplay} (checked out on ${branchDisplay})`);
113
127
  }
128
+
114
129
  return lines.join('\n');
115
130
  };
116
131
 
@@ -147,7 +162,7 @@ const spawnClaude = (cwd: string, contextPrompt: string, args: Args): Promise<nu
147
162
  });
148
163
 
149
164
  claude.on('error', (err) => {
150
- reject(new Error(`failed to spawn claude: ${err}`));
165
+ reject(new Error(`failed to summon claude: ${err}`));
151
166
  });
152
167
  });
153
168
 
@@ -157,14 +172,12 @@ const spawnClaude = (cwd: string, contextPrompt: string, args: Args): Promise<nu
157
172
  * @param args parsed command arguments
158
173
  */
159
174
  export const handler = async (args: Args): Promise<void> => {
175
+ // fire-and-forget cleanup of orphaned sessions
176
+ gcSessions();
177
+
160
178
  // parse main remote (with optional #branch)
161
179
  const mainRepo = parseRepoInput(args.remote);
162
180
 
163
- // #branch takes precedence over -b flag
164
- if (!mainRepo.branch && args.branch) {
165
- mainRepo.branch = args.branch;
166
- }
167
-
168
181
  // #region single repo mode
169
182
  if (args.with.length === 0) {
170
183
  // clone or update repository
@@ -175,14 +188,14 @@ export const handler = async (args: Args): Promise<void> => {
175
188
  remote: remoteUrl,
176
189
  cachePath: mainRepo.cachePath,
177
190
  branch: mainRepo.branch,
178
- shallow: args.shallow,
191
+ deep: args.deep,
179
192
  });
180
193
  } catch (err) {
181
194
  console.error(`error: failed to prepare repository: ${err}`);
182
195
  process.exit(1);
183
196
  }
184
197
 
185
- const contextPrompt = buildSingleRepoContext(mainRepo);
198
+ const contextPrompt = buildSingleRepoContext(mainRepo, args.deep);
186
199
  const exitCode = await spawnClaude(mainRepo.cachePath, contextPrompt, args);
187
200
  process.exit(exitCode);
188
201
  }
@@ -204,7 +217,7 @@ export const handler = async (args: Args): Promise<void> => {
204
217
  remote: remoteUrl,
205
218
  cachePath: repo.cachePath,
206
219
  branch: repo.branch,
207
- shallow: args.shallow,
220
+ deep: args.deep,
208
221
  });
209
222
  return repo;
210
223
  }),
@@ -235,7 +248,7 @@ export const handler = async (args: Args): Promise<void> => {
235
248
 
236
249
  try {
237
250
  const dirMap = await buildSymlinkDir(sessionPath, allRepos);
238
- const contextPrompt = buildMultiRepoContext(dirMap);
251
+ const contextPrompt = buildMultiRepoContext(dirMap, args.deep);
239
252
  exitCode = await spawnClaude(sessionPath, contextPrompt, args);
240
253
  } finally {
241
254
  // always clean up session directory
@@ -206,23 +206,15 @@ export const handler = async (args: Args): Promise<void> => {
206
206
 
207
207
  // #region interactive selection
208
208
  const repos = await listCachedRepos();
209
- const sessionsExist = await exists(sessionsDir);
210
- const sessionsSize = sessionsExist ? await getDirSize(sessionsDir) : 0;
211
209
 
212
- if (repos.length === 0 && !sessionsExist) {
213
- console.log('no cached data found');
210
+ if (repos.length === 0) {
211
+ console.log('no cached repositories found');
214
212
  return;
215
213
  }
216
214
 
217
215
  // build choices for checkbox
218
- const maxNameLen = Math.max(
219
- ...repos.map((r) => r.displayPath.length),
220
- sessionsExist ? '(sessions)'.length : 0,
221
- );
222
- const maxSizeLen = Math.max(
223
- ...repos.map((r) => formatSize(r.size).length),
224
- sessionsExist ? formatSize(sessionsSize).length : 0,
225
- );
216
+ const maxNameLen = Math.max(...repos.map((r) => r.displayPath.length));
217
+ const maxSizeLen = Math.max(...repos.map((r) => formatSize(r.size).length));
226
218
 
227
219
  // checkbox prefix is ~4 chars, leave some margin
228
220
  const termWidth = process.stdout.columns ?? 80;
@@ -237,14 +229,6 @@ export const handler = async (args: Args): Promise<void> => {
237
229
  short: repo.displayPath,
238
230
  }));
239
231
 
240
- if (sessionsExist && sessionsSize > 0) {
241
- choices.push({
242
- name: formatChoice('(sessions)', sessionsSize),
243
- value: sessionsDir,
244
- short: '(sessions)',
245
- });
246
- }
247
-
248
232
  const selected = await checkbox({
249
233
  message: 'select items to remove',
250
234
  choices,
package/src/lib/git.ts CHANGED
@@ -86,13 +86,13 @@ const gitOutput = (args: string[], cwd?: string): Promise<string> =>
86
86
  });
87
87
 
88
88
  /**
89
- * checks if a repository is a shallow clone.
89
+ * checks if a repository has full history.
90
90
  * @param cachePath the local repository path
91
- * @returns true if the repository is shallow
91
+ * @returns true if the repository has full history (not shallow)
92
92
  */
93
- export const isShallowRepo = async (cachePath: string): Promise<boolean> => {
93
+ const isDeepRepo = async (cachePath: string): Promise<boolean> => {
94
94
  const result = await gitOutput(['rev-parse', '--is-shallow-repository'], cachePath);
95
- return result === 'true';
95
+ return result !== 'true';
96
96
  };
97
97
 
98
98
  /**
@@ -100,17 +100,17 @@ export const isShallowRepo = async (cachePath: string): Promise<boolean> => {
100
100
  * @param remote the remote URL
101
101
  * @param cachePath the local cache path
102
102
  * @param branch optional branch to checkout
103
- * @param shallow if true, performs a shallow clone with depth 1
103
+ * @param deep if true, clones full history; otherwise shallow clone with depth 1
104
104
  */
105
- export const cloneRepo = async (
105
+ const cloneRepo = async (
106
106
  remote: string,
107
107
  cachePath: string,
108
- branch?: string,
109
- shallow?: boolean,
108
+ branch: string | undefined,
109
+ deep: boolean,
110
110
  ): Promise<void> => {
111
111
  await mkdir(dirname(cachePath), { recursive: true });
112
112
  const args = ['clone'];
113
- if (shallow) {
113
+ if (!deep) {
114
114
  args.push('--depth', '1');
115
115
  }
116
116
  if (branch) {
@@ -125,24 +125,24 @@ export const cloneRepo = async (
125
125
  * discards any local modifications, staged changes, and untracked files.
126
126
  * @param cachePath the local cache path
127
127
  * @param branch optional branch to checkout (uses default branch if not specified)
128
- * @param shallow if true, keeps shallow clone; if false, unshallows if needed
128
+ * @param deep if true, unshallows if needed; if false, keeps shallow clone
129
129
  */
130
- export const updateRepo = async (cachePath: string, branch?: string, shallow?: boolean): Promise<void> => {
131
- const currentlyShallow = await isShallowRepo(cachePath);
130
+ const updateRepo = async (cachePath: string, branch: string | undefined, deep: boolean): Promise<void> => {
131
+ const currentlyDeep = await isDeepRepo(cachePath);
132
132
 
133
- // handle shallow state transitions
134
- if (!shallow && currentlyShallow) {
133
+ // handle depth state transitions
134
+ if (deep && !currentlyDeep) {
135
135
  // want full history but have shallow - unshallow first
136
136
  await git(['fetch', '--unshallow', 'origin'], cachePath);
137
- } else if (shallow && !currentlyShallow) {
137
+ } else if (!deep && currentlyDeep) {
138
138
  // want shallow but have full - just use the full clone as-is
139
139
  console.error(' note: repository is already a full clone, use `cgr clean` to re-clone as shallow');
140
140
  }
141
141
 
142
- // fetch updates (use depth for shallow repos that stay shallow)
143
- if (shallow && currentlyShallow) {
142
+ // fetch updates (use depth 1 for shallow repos that stay shallow)
143
+ if (!deep && !currentlyDeep) {
144
144
  await git(['fetch', '--depth', '1', 'origin'], cachePath);
145
- } else if (!currentlyShallow || !shallow) {
145
+ } else {
146
146
  // either was unshallowed above, or is/was full
147
147
  await git(['fetch', 'origin'], cachePath);
148
148
  }
@@ -169,8 +169,8 @@ export interface EnsureRepoOptions {
169
169
  cachePath: string;
170
170
  /** branch to checkout */
171
171
  branch: string | undefined;
172
- /** if true, uses shallow clone with depth 1 */
173
- shallow: boolean;
172
+ /** if true, clones full history; otherwise uses shallow clone with depth 1 */
173
+ deep: boolean;
174
174
  }
175
175
 
176
176
  /**
@@ -178,10 +178,10 @@ export interface EnsureRepoOptions {
178
178
  * @param options repository options
179
179
  */
180
180
  export const ensureRepo = async (options: EnsureRepoOptions): Promise<void> => {
181
- const { remote, cachePath, branch, shallow } = options;
181
+ const { remote, cachePath, branch, deep } = options;
182
182
  if (existsSync(cachePath)) {
183
- await updateRepo(cachePath, branch, shallow);
183
+ await updateRepo(cachePath, branch, deep);
184
184
  } else {
185
- await cloneRepo(remote, cachePath, branch, shallow);
185
+ await cloneRepo(remote, cachePath, branch, deep);
186
186
  }
187
187
  };
package/src/lib/paths.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { mkdir, rm } from 'node:fs/promises';
2
+ import type { Dirent } from 'node:fs';
3
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
3
4
  import { homedir } from 'node:os';
4
5
  import { join } from 'node:path';
5
6
 
7
+ import * as v from 'valibot';
8
+
6
9
  /**
7
10
  * returns the cache directory for cgr.
8
11
  * uses `$XDG_CACHE_HOME/cgr` if set, otherwise falls back to:
@@ -157,13 +160,19 @@ export const parseRemoteWithBranch = (input: string): { remote: string; branch?:
157
160
  */
158
161
  export const getSessionsDir = (): string => join(getCacheDir(), 'sessions');
159
162
 
163
+ const PID_FILE = '.pid';
164
+
165
+ // linux PID limits: 1 to 2^22 (4194304) by default, configurable up to 2^22
166
+ const PidSchema = v.pipe(v.string(), v.trim(), v.toNumber(), v.integer(), v.minValue(1));
167
+
160
168
  /**
161
- * creates a new session directory with a random UUID.
169
+ * creates a new session directory with a random UUID and writes a PID lockfile.
162
170
  * @returns the path to the created session directory
163
171
  */
164
172
  export const createSessionDir = async (): Promise<string> => {
165
173
  const sessionPath = join(getSessionsDir(), randomUUID());
166
174
  await mkdir(sessionPath, { recursive: true });
175
+ await writeFile(join(sessionPath, PID_FILE), process.pid.toString());
167
176
  return sessionPath;
168
177
  };
169
178
 
@@ -174,3 +183,55 @@ export const createSessionDir = async (): Promise<string> => {
174
183
  export const cleanupSessionDir = async (sessionPath: string): Promise<void> => {
175
184
  await rm(sessionPath, { recursive: true, force: true });
176
185
  };
186
+
187
+ /**
188
+ * checks if a process with the given PID is running.
189
+ * @param pid the process ID to check
190
+ * @returns true if the process is running
191
+ */
192
+ const isProcessRunning = (pid: number): boolean => {
193
+ try {
194
+ // signal 0 doesn't send a signal but checks if process exists
195
+ process.kill(pid, 0);
196
+ return true;
197
+ } catch {
198
+ return false;
199
+ }
200
+ };
201
+
202
+ /**
203
+ * garbage collects orphaned session directories.
204
+ * a session is orphaned if its PID file is missing or the process is no longer running.
205
+ * this function is meant to be called fire-and-forget (errors are silently ignored).
206
+ */
207
+ export const gcSessions = async (): Promise<void> => {
208
+ const sessionsDir = getSessionsDir();
209
+
210
+ let entries: Dirent[];
211
+ try {
212
+ entries = await readdir(sessionsDir, { withFileTypes: true });
213
+ } catch {
214
+ // sessions dir doesn't exist or can't be read
215
+ return;
216
+ }
217
+
218
+ for (const entry of entries) {
219
+ if (!entry.isDirectory()) {
220
+ continue;
221
+ }
222
+ const sessionPath = join(sessionsDir, entry.name);
223
+ const pidPath = join(sessionPath, PID_FILE);
224
+
225
+ try {
226
+ const pidContent = await readFile(pidPath, 'utf-8');
227
+ const result = v.safeParse(PidSchema, pidContent);
228
+
229
+ if (!result.success || !isProcessRunning(result.output)) {
230
+ await rm(sessionPath, { recursive: true, force: true });
231
+ }
232
+ } catch {
233
+ // no PID file or can't read it - orphaned session
234
+ await rm(sessionPath, { recursive: true, force: true }).catch(() => {});
235
+ }
236
+ }
237
+ };