@oomfware/cgr 0.1.8 → 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 +7 -7
- package/dist/assets/system-prompt.md +5 -1
- package/dist/index.mjs +95 -53
- package/package.json +3 -2
- package/src/assets/system-prompt.md +5 -1
- package/src/commands/ask.ts +35 -25
- package/src/commands/clean.ts +4 -20
- package/src/lib/git.ts +23 -23
- package/src/lib/paths.ts +63 -2
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] [-
|
|
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
|
|
50
|
-
|
|
|
51
|
-
| `-m, --model`
|
|
52
|
-
| `-
|
|
53
|
-
| `-w, --with`
|
|
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
|
-
-
|
|
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
|
|
106
|
+
* checks if a repository has full history.
|
|
106
107
|
* @param cachePath the local repository path
|
|
107
|
-
* @returns true if the repository
|
|
108
|
+
* @returns true if the repository has full history (not shallow)
|
|
108
109
|
*/
|
|
109
|
-
const
|
|
110
|
-
return await gitOutput(["rev-parse", "--is-shallow-repository"], cachePath)
|
|
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
|
|
118
|
+
* @param deep if true, clones full history; otherwise shallow clone with depth 1
|
|
118
119
|
*/
|
|
119
|
-
const cloneRepo = async (remote, cachePath, branch,
|
|
120
|
+
const cloneRepo = async (remote, cachePath, branch, deep) => {
|
|
120
121
|
await mkdir(dirname(cachePath), { recursive: true });
|
|
121
122
|
const args = ["clone"];
|
|
122
|
-
if (
|
|
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
|
|
133
|
+
* @param deep if true, unshallows if needed; if false, keeps shallow clone
|
|
133
134
|
*/
|
|
134
|
-
const updateRepo = async (cachePath, branch,
|
|
135
|
-
const
|
|
136
|
-
if (
|
|
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 (
|
|
142
|
-
if (
|
|
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
|
|
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,
|
|
174
|
-
if (existsSync(cachePath)) await updateRepo(cachePath, branch,
|
|
175
|
-
else await cloneRepo(remote, cachePath, branch,
|
|
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
|
-
|
|
342
|
-
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)` }),
|
|
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 (
|
|
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 = [
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
592
|
-
if (!reposExist && !sessionsExist
|
|
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
|
|
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
|
|
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
|
-
|
|
633
|
-
|
|
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)
|
|
639
|
-
const maxSizeLen = Math.max(...repos.map((r) => formatSize(r.size).length)
|
|
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.
|
|
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
|
+
---
|
package/src/commands/ask.ts
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
|
|
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,15 +39,9 @@ export const schema = object({
|
|
|
28
39
|
}),
|
|
29
40
|
'haiku',
|
|
30
41
|
),
|
|
31
|
-
|
|
32
|
-
description: message`
|
|
42
|
+
deep: flag('-d', '--deep', {
|
|
43
|
+
description: message`clone full history (enables git log/blame/show)`,
|
|
33
44
|
}),
|
|
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)`,
|
|
38
|
-
}),
|
|
39
|
-
),
|
|
40
45
|
with: withDefault(
|
|
41
46
|
multiple(
|
|
42
47
|
option('-w', '--with', string(), {
|
|
@@ -46,7 +51,7 @@ export const schema = object({
|
|
|
46
51
|
[],
|
|
47
52
|
),
|
|
48
53
|
remote: argument(string({ metavar: 'REPO' }), {
|
|
49
|
-
description: message`git remote URL (
|
|
54
|
+
description: message`git remote URL (http/https/ssh), optionally with #branch`,
|
|
50
55
|
}),
|
|
51
56
|
question: argument(string({ metavar: 'QUESTION' }), {
|
|
52
57
|
description: message`question to ask about the repository`,
|
|
@@ -91,26 +96,33 @@ const parseRepoInput = (input: string): RepoEntry => {
|
|
|
91
96
|
/**
|
|
92
97
|
* builds a context prompt for a single repository.
|
|
93
98
|
* @param repo the repo entry
|
|
99
|
+
* @param deep whether the clone has full history
|
|
94
100
|
* @returns context prompt string
|
|
95
101
|
*/
|
|
96
|
-
const buildSingleRepoContext = (repo: RepoEntry): string => {
|
|
102
|
+
const buildSingleRepoContext = (repo: RepoEntry, deep: boolean): string => {
|
|
97
103
|
const repoDisplay = `${repo.parsed.host}/${repo.parsed.path}`;
|
|
98
104
|
const branchDisplay = repo.branch ?? 'default branch';
|
|
99
|
-
|
|
105
|
+
const cloneType = deep ? 'full clone' : 'shallow clone';
|
|
106
|
+
|
|
107
|
+
return `You are examining ${repoDisplay} (checked out on ${branchDisplay}, ${cloneType}).`;
|
|
100
108
|
};
|
|
101
109
|
|
|
102
110
|
/**
|
|
103
111
|
* builds a context prompt for multiple repositories.
|
|
104
112
|
* @param dirMap map of directory name -> repo entry
|
|
113
|
+
* @param deep whether the clones have full history
|
|
105
114
|
* @returns context prompt string
|
|
106
115
|
*/
|
|
107
|
-
const buildMultiRepoContext = (dirMap: Map<string, RepoEntry
|
|
108
|
-
const
|
|
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
|
+
|
|
109
120
|
for (const [dirName, repo] of dirMap) {
|
|
110
121
|
const repoDisplay = `${repo.parsed.host}/${repo.parsed.path}`;
|
|
111
122
|
const branchDisplay = repo.branch ?? 'default branch';
|
|
112
123
|
lines.push(`- ${dirName}/ -> ${repoDisplay} (checked out on ${branchDisplay})`);
|
|
113
124
|
}
|
|
125
|
+
|
|
114
126
|
return lines.join('\n');
|
|
115
127
|
};
|
|
116
128
|
|
|
@@ -147,7 +159,7 @@ const spawnClaude = (cwd: string, contextPrompt: string, args: Args): Promise<nu
|
|
|
147
159
|
});
|
|
148
160
|
|
|
149
161
|
claude.on('error', (err) => {
|
|
150
|
-
reject(new Error(`failed to
|
|
162
|
+
reject(new Error(`failed to summon claude: ${err}`));
|
|
151
163
|
});
|
|
152
164
|
});
|
|
153
165
|
|
|
@@ -157,14 +169,12 @@ const spawnClaude = (cwd: string, contextPrompt: string, args: Args): Promise<nu
|
|
|
157
169
|
* @param args parsed command arguments
|
|
158
170
|
*/
|
|
159
171
|
export const handler = async (args: Args): Promise<void> => {
|
|
172
|
+
// fire-and-forget cleanup of orphaned sessions
|
|
173
|
+
gcSessions();
|
|
174
|
+
|
|
160
175
|
// parse main remote (with optional #branch)
|
|
161
176
|
const mainRepo = parseRepoInput(args.remote);
|
|
162
177
|
|
|
163
|
-
// #branch takes precedence over -b flag
|
|
164
|
-
if (!mainRepo.branch && args.branch) {
|
|
165
|
-
mainRepo.branch = args.branch;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
178
|
// #region single repo mode
|
|
169
179
|
if (args.with.length === 0) {
|
|
170
180
|
// clone or update repository
|
|
@@ -175,14 +185,14 @@ export const handler = async (args: Args): Promise<void> => {
|
|
|
175
185
|
remote: remoteUrl,
|
|
176
186
|
cachePath: mainRepo.cachePath,
|
|
177
187
|
branch: mainRepo.branch,
|
|
178
|
-
|
|
188
|
+
deep: args.deep,
|
|
179
189
|
});
|
|
180
190
|
} catch (err) {
|
|
181
191
|
console.error(`error: failed to prepare repository: ${err}`);
|
|
182
192
|
process.exit(1);
|
|
183
193
|
}
|
|
184
194
|
|
|
185
|
-
const contextPrompt = buildSingleRepoContext(mainRepo);
|
|
195
|
+
const contextPrompt = buildSingleRepoContext(mainRepo, args.deep);
|
|
186
196
|
const exitCode = await spawnClaude(mainRepo.cachePath, contextPrompt, args);
|
|
187
197
|
process.exit(exitCode);
|
|
188
198
|
}
|
|
@@ -204,7 +214,7 @@ export const handler = async (args: Args): Promise<void> => {
|
|
|
204
214
|
remote: remoteUrl,
|
|
205
215
|
cachePath: repo.cachePath,
|
|
206
216
|
branch: repo.branch,
|
|
207
|
-
|
|
217
|
+
deep: args.deep,
|
|
208
218
|
});
|
|
209
219
|
return repo;
|
|
210
220
|
}),
|
|
@@ -235,7 +245,7 @@ export const handler = async (args: Args): Promise<void> => {
|
|
|
235
245
|
|
|
236
246
|
try {
|
|
237
247
|
const dirMap = await buildSymlinkDir(sessionPath, allRepos);
|
|
238
|
-
const contextPrompt = buildMultiRepoContext(dirMap);
|
|
248
|
+
const contextPrompt = buildMultiRepoContext(dirMap, args.deep);
|
|
239
249
|
exitCode = await spawnClaude(sessionPath, contextPrompt, args);
|
|
240
250
|
} finally {
|
|
241
251
|
// always clean up session directory
|
package/src/commands/clean.ts
CHANGED
|
@@ -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
|
|
213
|
-
console.log('no cached
|
|
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
|
-
|
|
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
|
|
89
|
+
* checks if a repository has full history.
|
|
90
90
|
* @param cachePath the local repository path
|
|
91
|
-
* @returns true if the repository
|
|
91
|
+
* @returns true if the repository has full history (not shallow)
|
|
92
92
|
*/
|
|
93
|
-
|
|
93
|
+
const isDeepRepo = async (cachePath: string): Promise<boolean> => {
|
|
94
94
|
const result = await gitOutput(['rev-parse', '--is-shallow-repository'], cachePath);
|
|
95
|
-
return result
|
|
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
|
|
103
|
+
* @param deep if true, clones full history; otherwise shallow clone with depth 1
|
|
104
104
|
*/
|
|
105
|
-
|
|
105
|
+
const cloneRepo = async (
|
|
106
106
|
remote: string,
|
|
107
107
|
cachePath: string,
|
|
108
|
-
branch
|
|
109
|
-
|
|
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 (
|
|
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
|
|
128
|
+
* @param deep if true, unshallows if needed; if false, keeps shallow clone
|
|
129
129
|
*/
|
|
130
|
-
|
|
131
|
-
const
|
|
130
|
+
const updateRepo = async (cachePath: string, branch: string | undefined, deep: boolean): Promise<void> => {
|
|
131
|
+
const currentlyDeep = await isDeepRepo(cachePath);
|
|
132
132
|
|
|
133
|
-
// handle
|
|
134
|
-
if (
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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,
|
|
181
|
+
const { remote, cachePath, branch, deep } = options;
|
|
182
182
|
if (existsSync(cachePath)) {
|
|
183
|
-
await updateRepo(cachePath, branch,
|
|
183
|
+
await updateRepo(cachePath, branch, deep);
|
|
184
184
|
} else {
|
|
185
|
-
await cloneRepo(remote, cachePath, branch,
|
|
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 {
|
|
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
|
+
};
|