@oomfware/cgr 0.1.7 → 0.2.0

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,10 +42,16 @@ cgr clean --all
42
42
  ## commands
43
43
 
44
44
  ```
45
- cgr ask [-m opus|sonnet|haiku] [-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
+ | `-d, --deep` | clone full history (enables git log/blame/show) |
53
+ | `-w, --with` | additional repository to include (repeatable) |
54
+
49
55
  | command | description |
50
56
  | ------- | ----------------------------------------------------------------- |
51
57
  | `ask` | clone/update a repository and ask Claude Code a question about it |
@@ -82,6 +88,7 @@ You can use `@oomfware/cgr` to ask questions about external repositories.
82
88
 
83
89
  options:
84
90
  -m, --model <model> model to use: opus, sonnet, haiku (default: haiku)
91
+ -d, --deep clone full history (enables git log/blame/show)
85
92
  -w, --with <repo> additional repository to include, supports #branch (repeatable)
86
93
 
87
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
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
- import { argument, choice, command, constant, message, object, option, or, string } from "@optique/core";
2
+ import { argument, choice, command, constant, flag, message, object, option, or, string } from "@optique/core";
3
3
  import { run } from "@optique/run";
4
4
  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,14 +103,24 @@ const gitOutput = (args, cwd) => new Promise((resolve, reject) => {
102
103
  proc.on("error", reject);
103
104
  });
104
105
  /**
106
+ * checks if a repository has full history.
107
+ * @param cachePath the local repository path
108
+ * @returns true if the repository has full history (not shallow)
109
+ */
110
+ const isDeepRepo = async (cachePath) => {
111
+ return await gitOutput(["rev-parse", "--is-shallow-repository"], cachePath) !== "true";
112
+ };
113
+ /**
105
114
  * clones a repository to the cache path.
106
115
  * @param remote the remote URL
107
116
  * @param cachePath the local cache path
108
117
  * @param branch optional branch to checkout
118
+ * @param deep if true, clones full history; otherwise shallow clone with depth 1
109
119
  */
110
- const cloneRepo = async (remote, cachePath, branch) => {
120
+ const cloneRepo = async (remote, cachePath, branch, deep) => {
111
121
  await mkdir(dirname(cachePath), { recursive: true });
112
122
  const args = ["clone"];
123
+ if (!deep) args.push("--depth", "1");
113
124
  if (branch) args.push("--branch", branch);
114
125
  args.push(remote, cachePath);
115
126
  await git(args);
@@ -119,9 +130,23 @@ const cloneRepo = async (remote, cachePath, branch) => {
119
130
  * discards any local modifications, staged changes, and untracked files.
120
131
  * @param cachePath the local cache path
121
132
  * @param branch optional branch to checkout (uses default branch if not specified)
133
+ * @param deep if true, unshallows if needed; if false, keeps shallow clone
122
134
  */
123
- const updateRepo = async (cachePath, branch) => {
124
- await git(["fetch", "origin"], cachePath);
135
+ const updateRepo = async (cachePath, branch, deep) => {
136
+ const currentlyDeep = await isDeepRepo(cachePath);
137
+ if (deep && !currentlyDeep) await git([
138
+ "fetch",
139
+ "--unshallow",
140
+ "origin"
141
+ ], cachePath);
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([
144
+ "fetch",
145
+ "--depth",
146
+ "1",
147
+ "origin"
148
+ ], cachePath);
149
+ else await git(["fetch", "origin"], cachePath);
125
150
  let targetBranch = branch;
126
151
  if (!targetBranch) targetBranch = (await gitOutput(["symbolic-ref", "refs/remotes/origin/HEAD"], cachePath)).replace("refs/remotes/origin/", "");
127
152
  await git([
@@ -143,13 +168,12 @@ const updateRepo = async (cachePath, branch) => {
143
168
  };
144
169
  /**
145
170
  * ensures a repository is cloned and up-to-date.
146
- * @param remote the remote URL
147
- * @param cachePath the local cache path
148
- * @param branch optional branch to checkout
171
+ * @param options repository options
149
172
  */
150
- const ensureRepo = async (remote, cachePath, branch) => {
151
- if (existsSync(cachePath)) await updateRepo(cachePath, branch);
152
- else await cloneRepo(remote, cachePath, branch);
173
+ const ensureRepo = async (options) => {
174
+ const { remote, cachePath, branch, deep } = options;
175
+ if (existsSync(cachePath)) await updateRepo(cachePath, branch, deep);
176
+ else await cloneRepo(remote, cachePath, branch, deep);
153
177
  };
154
178
 
155
179
  //#endregion
@@ -255,13 +279,16 @@ const parseRemoteWithBranch = (input) => {
255
279
  * @returns the sessions directory path
256
280
  */
257
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));
258
284
  /**
259
- * creates a new session directory with a random UUID.
285
+ * creates a new session directory with a random UUID and writes a PID lockfile.
260
286
  * @returns the path to the created session directory
261
287
  */
262
288
  const createSessionDir = async () => {
263
289
  const sessionPath = join(getSessionsDir(), randomUUID());
264
290
  await mkdir(sessionPath, { recursive: true });
291
+ await writeFile(join(sessionPath, PID_FILE), process.pid.toString());
265
292
  return sessionPath;
266
293
  };
267
294
  /**
@@ -274,6 +301,51 @@ const cleanupSessionDir = async (sessionPath) => {
274
301
  force: true
275
302
  });
276
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
+ };
277
349
 
278
350
  //#endregion
279
351
  //#region src/lib/symlink.ts
@@ -315,9 +387,9 @@ const schema$1 = object({
315
387
  "sonnet",
316
388
  "haiku"
317
389
  ]), { description: message`model to use for analysis` }), "haiku"),
318
- branch: optional(option("-b", "--branch", string(), { description: message`branch to checkout (deprecated: use repo#branch instead)` })),
390
+ deep: flag("-d", "--deep", { description: message`clone full history (enables git log/blame/show)` }),
319
391
  with: withDefault(multiple(option("-w", "--with", string(), { description: message`additional repository to include` })), []),
320
- 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` }),
321
393
  question: argument(string({ metavar: "QUESTION" }), { description: message`question to ask about the repository` })
322
394
  });
323
395
  /**
@@ -357,18 +429,20 @@ const parseRepoInput = (input) => {
357
429
  /**
358
430
  * builds a context prompt for a single repository.
359
431
  * @param repo the repo entry
432
+ * @param deep whether the clone has full history
360
433
  * @returns context prompt string
361
434
  */
362
- const buildSingleRepoContext = (repo) => {
363
- 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"}).`;
364
437
  };
365
438
  /**
366
439
  * builds a context prompt for multiple repositories.
367
440
  * @param dirMap map of directory name -> repo entry
441
+ * @param deep whether the clones have full history
368
442
  * @returns context prompt string
369
443
  */
370
- const buildMultiRepoContext = (dirMap) => {
371
- 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"}):`, ""];
372
446
  for (const [dirName, repo] of dirMap) {
373
447
  const repoDisplay = `${repo.parsed.host}/${repo.parsed.path}`;
374
448
  const branchDisplay = repo.branch ?? "default branch";
@@ -405,7 +479,7 @@ const spawnClaude = (cwd, contextPrompt, args) => new Promise((resolve, reject)
405
479
  resolve(code ?? 0);
406
480
  });
407
481
  claude.on("error", (err) => {
408
- reject(/* @__PURE__ */ new Error(`failed to spawn claude: ${err}`));
482
+ reject(/* @__PURE__ */ new Error(`failed to summon claude: ${err}`));
409
483
  });
410
484
  });
411
485
  /**
@@ -414,18 +488,23 @@ const spawnClaude = (cwd, contextPrompt, args) => new Promise((resolve, reject)
414
488
  * @param args parsed command arguments
415
489
  */
416
490
  const handler$1 = async (args) => {
491
+ gcSessions();
417
492
  const mainRepo = parseRepoInput(args.remote);
418
- if (!mainRepo.branch && args.branch) mainRepo.branch = args.branch;
419
493
  if (args.with.length === 0) {
420
494
  const remoteUrl = normalizeRemote(mainRepo.remote);
421
495
  console.error(`preparing repository: ${mainRepo.parsed.host}/${mainRepo.parsed.path}`);
422
496
  try {
423
- await ensureRepo(remoteUrl, mainRepo.cachePath, mainRepo.branch);
497
+ await ensureRepo({
498
+ remote: remoteUrl,
499
+ cachePath: mainRepo.cachePath,
500
+ branch: mainRepo.branch,
501
+ deep: args.deep
502
+ });
424
503
  } catch (err) {
425
504
  console.error(`error: failed to prepare repository: ${err}`);
426
505
  process.exit(1);
427
506
  }
428
- const contextPrompt = buildSingleRepoContext(mainRepo);
507
+ const contextPrompt = buildSingleRepoContext(mainRepo, args.deep);
429
508
  const exitCode$1 = await spawnClaude(mainRepo.cachePath, contextPrompt, args);
430
509
  process.exit(exitCode$1);
431
510
  }
@@ -435,7 +514,12 @@ const handler$1 = async (args) => {
435
514
  const remoteUrl = normalizeRemote(repo.remote);
436
515
  const display = `${repo.parsed.host}/${repo.parsed.path}`;
437
516
  console.error(` preparing: ${display}`);
438
- await ensureRepo(remoteUrl, repo.cachePath, repo.branch);
517
+ await ensureRepo({
518
+ remote: remoteUrl,
519
+ cachePath: repo.cachePath,
520
+ branch: repo.branch,
521
+ deep: args.deep
522
+ });
439
523
  return repo;
440
524
  }));
441
525
  const failures = [];
@@ -455,7 +539,7 @@ const handler$1 = async (args) => {
455
539
  const sessionPath = await createSessionDir();
456
540
  let exitCode = 1;
457
541
  try {
458
- exitCode = await spawnClaude(sessionPath, buildMultiRepoContext(await buildSymlinkDir(sessionPath, allRepos)), args);
542
+ exitCode = await spawnClaude(sessionPath, buildMultiRepoContext(await buildSymlinkDir(sessionPath, allRepos), args.deep), args);
459
543
  } finally {
460
544
  await cleanupSessionDir(sessionPath);
461
545
  }
@@ -554,14 +638,14 @@ const handler = async (args) => {
554
638
  const sessionsDir = getSessionsDir();
555
639
  if (args.all) {
556
640
  const reposExist = await exists(reposDir);
557
- const sessionsExist$1 = await exists(sessionsDir);
558
- if (!reposExist && !sessionsExist$1) {
641
+ const sessionsExist = await exists(sessionsDir);
642
+ if (!reposExist && !sessionsExist) {
559
643
  console.log("no cached data found");
560
644
  return;
561
645
  }
562
646
  let totalSize$1 = 0;
563
647
  if (reposExist) totalSize$1 += await getDirSize(reposDir);
564
- if (sessionsExist$1) totalSize$1 += await getDirSize(sessionsDir);
648
+ if (sessionsExist) totalSize$1 += await getDirSize(sessionsDir);
565
649
  if (!args.yes) {
566
650
  if (!await confirm({
567
651
  message: `remove all cached data? (${formatSize(totalSize$1)})`,
@@ -569,7 +653,7 @@ const handler = async (args) => {
569
653
  })) return;
570
654
  }
571
655
  if (reposExist) await rm(reposDir, { recursive: true });
572
- if (sessionsExist$1) await rm(sessionsDir, { recursive: true });
656
+ if (sessionsExist) await rm(sessionsDir, { recursive: true });
573
657
  console.log("done");
574
658
  return;
575
659
  }
@@ -595,30 +679,22 @@ const handler = async (args) => {
595
679
  return;
596
680
  }
597
681
  const repos = await listCachedRepos();
598
- const sessionsExist = await exists(sessionsDir);
599
- const sessionsSize = sessionsExist ? await getDirSize(sessionsDir) : 0;
600
- if (repos.length === 0 && !sessionsExist) {
601
- console.log("no cached data found");
682
+ if (repos.length === 0) {
683
+ console.log("no cached repositories found");
602
684
  return;
603
685
  }
604
- const maxNameLen = Math.max(...repos.map((r) => r.displayPath.length), sessionsExist ? 10 : 0);
605
- 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));
606
688
  const termWidth = process.stdout.columns ?? 80;
607
689
  const usePadding = maxNameLen + 2 + maxSizeLen + 6 <= termWidth;
608
690
  const formatChoice = (name, size) => usePadding ? `${name.padEnd(maxNameLen)} ${formatSize(size)}` : `${name} (${formatSize(size)})`;
609
- const choices = repos.map((repo) => ({
610
- name: formatChoice(repo.displayPath, repo.size),
611
- value: repo.path,
612
- short: repo.displayPath
613
- }));
614
- if (sessionsExist && sessionsSize > 0) choices.push({
615
- name: formatChoice("(sessions)", sessionsSize),
616
- value: sessionsDir,
617
- short: "(sessions)"
618
- });
619
691
  const selected = await checkbox({
620
692
  message: "select items to remove",
621
- choices,
693
+ choices: repos.map((repo) => ({
694
+ name: formatChoice(repo.displayPath, repo.size),
695
+ value: repo.path,
696
+ short: repo.displayPath
697
+ })),
622
698
  pageSize: 20
623
699
  });
624
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.7",
3
+ "version": "0.2.0",
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, 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,12 +39,9 @@ export const schema = object({
28
39
  }),
29
40
  'haiku',
30
41
  ),
31
- // TODO: deprecated in favor of #branch syntax, remove in future version
32
- branch: optional(
33
- option('-b', '--branch', string(), {
34
- description: message`branch to checkout (deprecated: use repo#branch instead)`,
35
- }),
36
- ),
42
+ deep: flag('-d', '--deep', {
43
+ description: message`clone full history (enables git log/blame/show)`,
44
+ }),
37
45
  with: withDefault(
38
46
  multiple(
39
47
  option('-w', '--with', string(), {
@@ -43,7 +51,7 @@ export const schema = object({
43
51
  [],
44
52
  ),
45
53
  remote: argument(string({ metavar: 'REPO' }), {
46
- description: message`git remote URL (HTTP/HTTPS/SSH), optionally with #branch`,
54
+ description: message`git remote URL (http/https/ssh), optionally with #branch`,
47
55
  }),
48
56
  question: argument(string({ metavar: 'QUESTION' }), {
49
57
  description: message`question to ask about the repository`,
@@ -88,26 +96,33 @@ const parseRepoInput = (input: string): RepoEntry => {
88
96
  /**
89
97
  * builds a context prompt for a single repository.
90
98
  * @param repo the repo entry
99
+ * @param deep whether the clone has full history
91
100
  * @returns context prompt string
92
101
  */
93
- const buildSingleRepoContext = (repo: RepoEntry): string => {
102
+ const buildSingleRepoContext = (repo: RepoEntry, deep: boolean): string => {
94
103
  const repoDisplay = `${repo.parsed.host}/${repo.parsed.path}`;
95
104
  const branchDisplay = repo.branch ?? 'default branch';
96
- return `You are examining ${repoDisplay} (checked out on ${branchDisplay}).`;
105
+ const cloneType = deep ? 'full clone' : 'shallow clone';
106
+
107
+ return `You are examining ${repoDisplay} (checked out on ${branchDisplay}, ${cloneType}).`;
97
108
  };
98
109
 
99
110
  /**
100
111
  * builds a context prompt for multiple repositories.
101
112
  * @param dirMap map of directory name -> repo entry
113
+ * @param deep whether the clones have full history
102
114
  * @returns context prompt string
103
115
  */
104
- const buildMultiRepoContext = (dirMap: Map<string, RepoEntry>): string => {
105
- const lines = ['You are examining multiple repositories:', ''];
116
+ const buildMultiRepoContext = (dirMap: Map<string, RepoEntry>, deep: boolean): string => {
117
+ const cloneType = deep ? 'full clone' : 'shallow clone';
118
+ const lines = [`You are examining multiple repositories (${cloneType}):`, ''];
119
+
106
120
  for (const [dirName, repo] of dirMap) {
107
121
  const repoDisplay = `${repo.parsed.host}/${repo.parsed.path}`;
108
122
  const branchDisplay = repo.branch ?? 'default branch';
109
123
  lines.push(`- ${dirName}/ -> ${repoDisplay} (checked out on ${branchDisplay})`);
110
124
  }
125
+
111
126
  return lines.join('\n');
112
127
  };
113
128
 
@@ -144,7 +159,7 @@ const spawnClaude = (cwd: string, contextPrompt: string, args: Args): Promise<nu
144
159
  });
145
160
 
146
161
  claude.on('error', (err) => {
147
- reject(new Error(`failed to spawn claude: ${err}`));
162
+ reject(new Error(`failed to summon claude: ${err}`));
148
163
  });
149
164
  });
150
165
 
@@ -154,27 +169,30 @@ const spawnClaude = (cwd: string, contextPrompt: string, args: Args): Promise<nu
154
169
  * @param args parsed command arguments
155
170
  */
156
171
  export const handler = async (args: Args): Promise<void> => {
172
+ // fire-and-forget cleanup of orphaned sessions
173
+ gcSessions();
174
+
157
175
  // parse main remote (with optional #branch)
158
176
  const mainRepo = parseRepoInput(args.remote);
159
177
 
160
- // #branch takes precedence over -b flag
161
- if (!mainRepo.branch && args.branch) {
162
- mainRepo.branch = args.branch;
163
- }
164
-
165
178
  // #region single repo mode
166
179
  if (args.with.length === 0) {
167
180
  // clone or update repository
168
181
  const remoteUrl = normalizeRemote(mainRepo.remote);
169
182
  console.error(`preparing repository: ${mainRepo.parsed.host}/${mainRepo.parsed.path}`);
170
183
  try {
171
- await ensureRepo(remoteUrl, mainRepo.cachePath, mainRepo.branch);
184
+ await ensureRepo({
185
+ remote: remoteUrl,
186
+ cachePath: mainRepo.cachePath,
187
+ branch: mainRepo.branch,
188
+ deep: args.deep,
189
+ });
172
190
  } catch (err) {
173
191
  console.error(`error: failed to prepare repository: ${err}`);
174
192
  process.exit(1);
175
193
  }
176
194
 
177
- const contextPrompt = buildSingleRepoContext(mainRepo);
195
+ const contextPrompt = buildSingleRepoContext(mainRepo, args.deep);
178
196
  const exitCode = await spawnClaude(mainRepo.cachePath, contextPrompt, args);
179
197
  process.exit(exitCode);
180
198
  }
@@ -192,7 +210,12 @@ export const handler = async (args: Args): Promise<void> => {
192
210
  const remoteUrl = normalizeRemote(repo.remote);
193
211
  const display = `${repo.parsed.host}/${repo.parsed.path}`;
194
212
  console.error(` preparing: ${display}`);
195
- await ensureRepo(remoteUrl, repo.cachePath, repo.branch);
213
+ await ensureRepo({
214
+ remote: remoteUrl,
215
+ cachePath: repo.cachePath,
216
+ branch: repo.branch,
217
+ deep: args.deep,
218
+ });
196
219
  return repo;
197
220
  }),
198
221
  );
@@ -222,7 +245,7 @@ export const handler = async (args: Args): Promise<void> => {
222
245
 
223
246
  try {
224
247
  const dirMap = await buildSymlinkDir(sessionPath, allRepos);
225
- const contextPrompt = buildMultiRepoContext(dirMap);
248
+ const contextPrompt = buildMultiRepoContext(dirMap, args.deep);
226
249
  exitCode = await spawnClaude(sessionPath, contextPrompt, args);
227
250
  } finally {
228
251
  // 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
@@ -85,15 +85,34 @@ const gitOutput = (args: string[], cwd?: string): Promise<string> =>
85
85
  proc.on('error', reject);
86
86
  });
87
87
 
88
+ /**
89
+ * checks if a repository has full history.
90
+ * @param cachePath the local repository path
91
+ * @returns true if the repository has full history (not shallow)
92
+ */
93
+ const isDeepRepo = async (cachePath: string): Promise<boolean> => {
94
+ const result = await gitOutput(['rev-parse', '--is-shallow-repository'], cachePath);
95
+ return result !== 'true';
96
+ };
97
+
88
98
  /**
89
99
  * clones a repository to the cache path.
90
100
  * @param remote the remote URL
91
101
  * @param cachePath the local cache path
92
102
  * @param branch optional branch to checkout
103
+ * @param deep if true, clones full history; otherwise shallow clone with depth 1
93
104
  */
94
- export const cloneRepo = async (remote: string, cachePath: string, branch?: string): Promise<void> => {
105
+ const cloneRepo = async (
106
+ remote: string,
107
+ cachePath: string,
108
+ branch: string | undefined,
109
+ deep: boolean,
110
+ ): Promise<void> => {
95
111
  await mkdir(dirname(cachePath), { recursive: true });
96
112
  const args = ['clone'];
113
+ if (!deep) {
114
+ args.push('--depth', '1');
115
+ }
97
116
  if (branch) {
98
117
  args.push('--branch', branch);
99
118
  }
@@ -106,9 +125,27 @@ export const cloneRepo = async (remote: string, cachePath: string, branch?: stri
106
125
  * discards any local modifications, staged changes, and untracked files.
107
126
  * @param cachePath the local cache path
108
127
  * @param branch optional branch to checkout (uses default branch if not specified)
128
+ * @param deep if true, unshallows if needed; if false, keeps shallow clone
109
129
  */
110
- export const updateRepo = async (cachePath: string, branch?: string): Promise<void> => {
111
- await git(['fetch', 'origin'], cachePath);
130
+ const updateRepo = async (cachePath: string, branch: string | undefined, deep: boolean): Promise<void> => {
131
+ const currentlyDeep = await isDeepRepo(cachePath);
132
+
133
+ // handle depth state transitions
134
+ if (deep && !currentlyDeep) {
135
+ // want full history but have shallow - unshallow first
136
+ await git(['fetch', '--unshallow', 'origin'], cachePath);
137
+ } else if (!deep && currentlyDeep) {
138
+ // want shallow but have full - just use the full clone as-is
139
+ console.error(' note: repository is already a full clone, use `cgr clean` to re-clone as shallow');
140
+ }
141
+
142
+ // fetch updates (use depth 1 for shallow repos that stay shallow)
143
+ if (!deep && !currentlyDeep) {
144
+ await git(['fetch', '--depth', '1', 'origin'], cachePath);
145
+ } else {
146
+ // either was unshallowed above, or is/was full
147
+ await git(['fetch', 'origin'], cachePath);
148
+ }
112
149
 
113
150
  // determine the branch to use
114
151
  let targetBranch = branch;
@@ -125,16 +162,26 @@ export const updateRepo = async (cachePath: string, branch?: string): Promise<vo
125
162
  await git(['reset', '--hard', `origin/${targetBranch}`], cachePath);
126
163
  };
127
164
 
165
+ export interface EnsureRepoOptions {
166
+ /** the remote URL */
167
+ remote: string;
168
+ /** the local cache path */
169
+ cachePath: string;
170
+ /** branch to checkout */
171
+ branch: string | undefined;
172
+ /** if true, clones full history; otherwise uses shallow clone with depth 1 */
173
+ deep: boolean;
174
+ }
175
+
128
176
  /**
129
177
  * ensures a repository is cloned and up-to-date.
130
- * @param remote the remote URL
131
- * @param cachePath the local cache path
132
- * @param branch optional branch to checkout
178
+ * @param options repository options
133
179
  */
134
- export const ensureRepo = async (remote: string, cachePath: string, branch?: string): Promise<void> => {
180
+ export const ensureRepo = async (options: EnsureRepoOptions): Promise<void> => {
181
+ const { remote, cachePath, branch, deep } = options;
135
182
  if (existsSync(cachePath)) {
136
- await updateRepo(cachePath, branch);
183
+ await updateRepo(cachePath, branch, deep);
137
184
  } else {
138
- await cloneRepo(remote, cachePath, branch);
185
+ await cloneRepo(remote, cachePath, branch, deep);
139
186
  }
140
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
+ };