@trazum/cli 1.8.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/LICENSE +21 -0
- package/README.md +134 -0
- package/dist/git.d.ts +81 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +225 -0
- package/dist/git.js.map +1 -0
- package/dist/i18n/en.d.ts +10 -0
- package/dist/i18n/en.d.ts.map +1 -0
- package/dist/i18n/en.js +694 -0
- package/dist/i18n/en.js.map +1 -0
- package/dist/i18n/es.d.ts +4 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +694 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/index.d.ts +34 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +46 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/i18n/types.d.ts +363 -0
- package/dist/i18n/types.d.ts.map +1 -0
- package/dist/i18n/types.js +2 -0
- package/dist/i18n/types.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2824 -0
- package/dist/index.js.map +1 -0
- package/dist/markdown.d.ts +246 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +492 -0
- package/dist/markdown.js.map +1 -0
- package/dist/suggest-cache.d.ts +119 -0
- package/dist/suggest-cache.d.ts.map +1 -0
- package/dist/suggest-cache.js +225 -0
- package/dist/suggest-cache.js.map +1 -0
- package/package.json +49 -0
- package/src/git.ts +294 -0
- package/src/i18n/en.ts +825 -0
- package/src/i18n/es.ts +838 -0
- package/src/i18n/index.ts +57 -0
- package/src/i18n/types.ts +372 -0
- package/src/index.ts +3873 -0
- package/src/markdown.ts +717 -0
- package/src/suggest-cache.ts +268 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
/**
|
|
6
|
+
* Not asking the model the same question twice.
|
|
7
|
+
*
|
|
8
|
+
* The roadmap item this answers was "prompt caching for `--suggest`", meaning
|
|
9
|
+
* the API feature: mark a stable prefix with `cache_control` and pay a tenth of
|
|
10
|
+
* the price for it on every later call. **That cannot work here, and the reason
|
|
11
|
+
* is a number rather than an opinion.**
|
|
12
|
+
*
|
|
13
|
+
* Prompt caching has a minimum cacheable prefix — 512 tokens on the newest
|
|
14
|
+
* models, 1,024 on most, 4,096 on some — and a prefix shorter than the minimum
|
|
15
|
+
* is *silently* not cached: no error, no warning, `cache_creation_input_tokens`
|
|
16
|
+
* comes back zero. Trazum's suggest prompt is **291 tokens**. Marking it would
|
|
17
|
+
* have looked like an optimisation, cost a line of code, changed nothing, and
|
|
18
|
+
* been impossible to notice. `suggest-cache.test.js` measures it against the
|
|
19
|
+
* published minima so that stays true, or stops being true loudly.
|
|
20
|
+
*
|
|
21
|
+
* The stable prefix is also the only thing that *could* be cached: the rest of
|
|
22
|
+
* the request is the author's prompt, which is different every time. So there
|
|
23
|
+
* is no arrangement of `cache_control` that helps.
|
|
24
|
+
*
|
|
25
|
+
* What does help is the observation behind the request — running `--suggest`
|
|
26
|
+
* over a directory asks the same questions again on every run, and most of the
|
|
27
|
+
* prompts have not changed since the last one. Answering those from disk is not
|
|
28
|
+
* a 90% saving on the call, it is the whole call. On a re-run after editing two
|
|
29
|
+
* files out of forty, thirty-eight requests do not happen.
|
|
30
|
+
*
|
|
31
|
+
* Three decisions worth arguing with:
|
|
32
|
+
*
|
|
33
|
+
* **The raw response is cached, not the parsed suggestions.** Everything
|
|
34
|
+
* `suggestRewrites` does after the model answers — checking each `before`
|
|
35
|
+
* appears byte for byte, refusing anything that touches protected content,
|
|
36
|
+
* dropping overlaps — is deterministic and lives in the core. Caching the text
|
|
37
|
+
* means a hit is re-validated by *today's* rules rather than replaying a
|
|
38
|
+
* verdict reached by an older version. Same reasoning as recomputing token
|
|
39
|
+
* counts on read instead of storing them.
|
|
40
|
+
*
|
|
41
|
+
* **It is opt-in.** A cache hit returns what the model said last time, and a
|
|
42
|
+
* model is not a pure function — silently answering from a week-old response
|
|
43
|
+
* would be a surprise, in a tool whose other model-touching features
|
|
44
|
+
* (`--suggest`, `--apply-suggestions`, `--reorder`) all require asking twice.
|
|
45
|
+
*
|
|
46
|
+
* **The files are 0600 in a 0700 directory.** The cache holds prompt text, and
|
|
47
|
+
* a prompt is the most sensitive thing this tool ever touches — it is somebody's
|
|
48
|
+
* unreleased product behaviour. A world-readable cache in a shared home
|
|
49
|
+
* directory would publish it to every account on the machine.
|
|
50
|
+
*/
|
|
51
|
+
/**
|
|
52
|
+
* Bumped when anything that shapes the answer changes and is not already in the
|
|
53
|
+
* key — the suggest system prompt, the response format, the checking rules.
|
|
54
|
+
* A stale entry answers a question that is no longer the one being asked.
|
|
55
|
+
*
|
|
56
|
+
* Exported so the test can derive a key independently rather than comparing
|
|
57
|
+
* `cacheKey` to itself.
|
|
58
|
+
*/
|
|
59
|
+
export const SCHEMA = 2;
|
|
60
|
+
/** Seven days. Long enough for a working week, short enough that an alias that started pointing at a new model does not answer forever. */
|
|
61
|
+
export const DEFAULT_TTL_DAYS = 7;
|
|
62
|
+
/**
|
|
63
|
+
* Where the cache lives.
|
|
64
|
+
*
|
|
65
|
+
* `XDG_CACHE_HOME` first, because a user who set it meant it. Not the project
|
|
66
|
+
* directory: two checkouts of the same repository ask the same questions, and a
|
|
67
|
+
* per-checkout cache answers neither of them from the other.
|
|
68
|
+
*/
|
|
69
|
+
export function cacheDir(env = process.env) {
|
|
70
|
+
const base = env.XDG_CACHE_HOME?.trim() || join(homedir(), '.cache');
|
|
71
|
+
return join(base, 'trazum', 'suggestions');
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The key: everything that changes the answer, and nothing that does not.
|
|
75
|
+
*
|
|
76
|
+
* `provider` and `model` are in here rather than only in the entry because two
|
|
77
|
+
* models answer differently — a hit from the wrong one is not a hit. The system
|
|
78
|
+
* prompt is in here rather than relying on `SCHEMA` alone, so a caller passing
|
|
79
|
+
* their own system prompt gets their own entries without anybody remembering to
|
|
80
|
+
* bump a constant.
|
|
81
|
+
*/
|
|
82
|
+
export function cacheKey(input) {
|
|
83
|
+
// Length-prefixed rather than delimiter-joined: a delimiter that can occur
|
|
84
|
+
// inside a prompt lets two different inputs produce one key.
|
|
85
|
+
const parts = [String(SCHEMA), input.provider, input.model, input.system, input.user];
|
|
86
|
+
const canonical = parts.map((part) => `${part.length}:${part}`).join('');
|
|
87
|
+
return createHash('sha256').update(canonical, 'utf8').digest('hex');
|
|
88
|
+
}
|
|
89
|
+
function entryPath(dir, key) {
|
|
90
|
+
return join(dir, `${key}.json`);
|
|
91
|
+
}
|
|
92
|
+
export function readEntry(dir, key, now, ttlDays) {
|
|
93
|
+
let raw;
|
|
94
|
+
try {
|
|
95
|
+
raw = readFileSync(entryPath(dir, key), 'utf8');
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
let entry;
|
|
101
|
+
try {
|
|
102
|
+
entry = JSON.parse(raw);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// A truncated write from an interrupted run. Treated as a miss rather than
|
|
106
|
+
// an error: the answer is one API call away, and refusing to run because a
|
|
107
|
+
// cache file is corrupt would be worse than the problem.
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
if (entry.schema !== SCHEMA)
|
|
111
|
+
return null;
|
|
112
|
+
if (typeof entry.response !== 'string')
|
|
113
|
+
return null;
|
|
114
|
+
if (typeof entry.at !== 'number')
|
|
115
|
+
return null;
|
|
116
|
+
if (now - entry.at > ttlDays * 86_400_000)
|
|
117
|
+
return null;
|
|
118
|
+
return entry;
|
|
119
|
+
}
|
|
120
|
+
export function writeEntry(dir, key, entry) {
|
|
121
|
+
try {
|
|
122
|
+
// 0700: the cache holds prompt text. A default-permission directory in a
|
|
123
|
+
// shared home publishes it to every other account on the machine.
|
|
124
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
125
|
+
writeFileSync(entryPath(dir, key), `${JSON.stringify(entry, null, 2)}\n`, { mode: 0o600 });
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// A cache that cannot be written is a cache that is not used. Read-only
|
|
129
|
+
// home directory, full disk, hostile umask — none of them are reasons to
|
|
130
|
+
// fail a command that was going to work.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Delete every entry. Returns how many went. */
|
|
134
|
+
export function clearCache(dir) {
|
|
135
|
+
let removed = 0;
|
|
136
|
+
let names;
|
|
137
|
+
try {
|
|
138
|
+
names = readdirSync(dir);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
for (const name of names) {
|
|
144
|
+
// Only files this module wrote. A cache directory that deletes whatever it
|
|
145
|
+
// finds is a cache directory somebody eventually points at their home.
|
|
146
|
+
if (!/^[0-9a-f]{64}\.json$/.test(name))
|
|
147
|
+
continue;
|
|
148
|
+
try {
|
|
149
|
+
unlinkSync(join(dir, name));
|
|
150
|
+
removed += 1;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// Already gone, or not ours to delete.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return removed;
|
|
157
|
+
}
|
|
158
|
+
/** Entry count and total bytes, so `--clear-suggestion-cache` can say what it emptied. */
|
|
159
|
+
export function cacheStats(dir) {
|
|
160
|
+
let names;
|
|
161
|
+
try {
|
|
162
|
+
names = readdirSync(dir);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return { entries: 0, bytes: 0 };
|
|
166
|
+
}
|
|
167
|
+
let entries = 0;
|
|
168
|
+
let bytes = 0;
|
|
169
|
+
for (const name of names) {
|
|
170
|
+
if (!/^[0-9a-f]{64}\.json$/.test(name))
|
|
171
|
+
continue;
|
|
172
|
+
try {
|
|
173
|
+
bytes += statSync(join(dir, name)).size;
|
|
174
|
+
entries += 1;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// Raced with a clear. Not counted.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { entries, bytes };
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Wraps a provider so identical questions are asked once.
|
|
184
|
+
*
|
|
185
|
+
* A wrapper rather than a change inside `suggestRewrites`, for two reasons: the
|
|
186
|
+
* core stays free of `node:fs` (it is browser-safe, and a test asserts the
|
|
187
|
+
* import graph), and every command that reaches for an LLM gets the cache by
|
|
188
|
+
* passing through one function rather than by each remembering to.
|
|
189
|
+
*/
|
|
190
|
+
export function cachingProvider(inner, options) {
|
|
191
|
+
const { dir, ttlDays = DEFAULT_TTL_DAYS, now = Date.now } = options;
|
|
192
|
+
let hits = 0;
|
|
193
|
+
let misses = 0;
|
|
194
|
+
return {
|
|
195
|
+
name: inner.name,
|
|
196
|
+
model: inner.model,
|
|
197
|
+
get hits() {
|
|
198
|
+
return hits;
|
|
199
|
+
},
|
|
200
|
+
get misses() {
|
|
201
|
+
return misses;
|
|
202
|
+
},
|
|
203
|
+
async complete({ system, user }) {
|
|
204
|
+
const key = cacheKey({ provider: inner.name, model: inner.model, system, user });
|
|
205
|
+
const cached = readEntry(dir, key, now(), ttlDays);
|
|
206
|
+
if (cached) {
|
|
207
|
+
hits += 1;
|
|
208
|
+
return cached.response;
|
|
209
|
+
}
|
|
210
|
+
const response = await inner.complete({ system, user });
|
|
211
|
+
misses += 1;
|
|
212
|
+
// Written after the call succeeds, so a failed request is not remembered
|
|
213
|
+
// as an answer. A thrown error propagates untouched.
|
|
214
|
+
writeEntry(dir, key, {
|
|
215
|
+
schema: SCHEMA,
|
|
216
|
+
at: now(),
|
|
217
|
+
provider: inner.name,
|
|
218
|
+
model: inner.model,
|
|
219
|
+
response,
|
|
220
|
+
});
|
|
221
|
+
return response;
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
//# sourceMappingURL=suggest-cache.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"suggest-cache.js","sourceRoot":"","sources":["../src/suggest-cache.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@trazum/cli",
|
|
3
|
+
"version": "1.8.0",
|
|
4
|
+
"description": "Trazum CLI: find where your LLM bill goes, price every finding per month, and enforce token budgets in CI.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "David Mu\u00f1oz Rey",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Davmunrey/Trazum.git",
|
|
10
|
+
"directory": "packages/cli"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"prompt",
|
|
14
|
+
"llm",
|
|
15
|
+
"tokens",
|
|
16
|
+
"cost",
|
|
17
|
+
"cli",
|
|
18
|
+
"ci"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"type": "module",
|
|
24
|
+
"bin": {
|
|
25
|
+
"trazum": "dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"src",
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -p tsconfig.json && chmod +x dist/index.js",
|
|
35
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
36
|
+
"test": "npm run build && node --test test/*.test.js",
|
|
37
|
+
"prepublishOnly": "npm run build && npm test"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@trazum/core": "1.8.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^26.2.0",
|
|
44
|
+
"typescript": "^7.0.2"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=20"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The only place in this repository that runs another program.
|
|
6
|
+
*
|
|
7
|
+
* `trazum blame` needs a file's history, and the history lives in git. Nothing
|
|
8
|
+
* else here has ever shelled out, so this module is written as if it were the
|
|
9
|
+
* whole attack surface — because it is.
|
|
10
|
+
*
|
|
11
|
+
* The rules, and why each one is here rather than in a comment on the call site:
|
|
12
|
+
*
|
|
13
|
+
* - **No shell, ever.** `spawnSync` with an argv array and `shell: false`, which
|
|
14
|
+
* is the default and is stated anyway. The moment a path reaches a shell
|
|
15
|
+
* string, a file called `; rm -rf ~` is a command.
|
|
16
|
+
* - **Paths go after `--`.** Without it, a file named `--upload-pack=curl…` or
|
|
17
|
+
* `--output=…` is read by git as an option, and git has options that run
|
|
18
|
+
* programs. This is the specific reason `trazum blame -- <path>` is not
|
|
19
|
+
* enough on its own: the separator has to be in *our* argv, not the user's.
|
|
20
|
+
* - **Object names are validated before they are used.** `git show <sha>:<path>`
|
|
21
|
+
* glues two values into one argument, so the sha is checked against
|
|
22
|
+
* `/^[0-9a-f]{40}$/` before it can contribute anything to it.
|
|
23
|
+
* - **Everything is bounded.** A timeout so a hung git does not wedge the CLI, a
|
|
24
|
+
* `maxBuffer` so a large blob cannot exhaust memory, and a revision cap so
|
|
25
|
+
* `blame` on a file with 40,000 commits terminates.
|
|
26
|
+
* - **The path must be inside the repository.** Reading history for
|
|
27
|
+
* `../../elsewhere/secrets.txt` is not a thing this command exists to do.
|
|
28
|
+
*
|
|
29
|
+
* Failures are `null` or an empty array rather than exceptions with git's own
|
|
30
|
+
* wording. The caller turns them into a sentence in the reader's language.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Ten seconds is a long time for `git log`; a hung one should not be forever. */
|
|
34
|
+
const TIMEOUT_MS = 10_000;
|
|
35
|
+
|
|
36
|
+
/** 32 MB. A prompt file this big is not a prompt file. */
|
|
37
|
+
const MAX_BUFFER = 32 * 1024 * 1024;
|
|
38
|
+
|
|
39
|
+
const SHA = /^[0-9a-f]{40}$/;
|
|
40
|
+
|
|
41
|
+
export interface Revision {
|
|
42
|
+
sha: string;
|
|
43
|
+
shortSha: string;
|
|
44
|
+
author: string;
|
|
45
|
+
/** ISO 8601, author date. */
|
|
46
|
+
date: string;
|
|
47
|
+
subject: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Errors that mean *the process could not be started*, not that git said no.
|
|
52
|
+
*
|
|
53
|
+
* `EAGAIN` is the kernel refusing a fork because the process or thread limit is
|
|
54
|
+
* momentarily full; `ENOMEM` is the same story with memory. Both are properties
|
|
55
|
+
* of the machine at that instant and both are gone a moment later, which is
|
|
56
|
+
* exactly why they surface on a loaded CI runner and never on a laptop.
|
|
57
|
+
*/
|
|
58
|
+
const TRANSIENT = new Set(['EAGAIN', 'ENOMEM']);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Why a git invocation produced nothing — which is not one question but two.
|
|
62
|
+
*
|
|
63
|
+
* `git log` exiting 0 with no output means *this file has no history*. Failing
|
|
64
|
+
* to spawn git at all means *we do not know what history this file has*. They
|
|
65
|
+
* had the same representation here — `null` — so `revisionsFor` returned `[]`
|
|
66
|
+
* for both and `blame` told the author "git has no commits touching p.txt",
|
|
67
|
+
* confidently, on the strength of never having asked.
|
|
68
|
+
*
|
|
69
|
+
* That is the shape of issue #58: zero rows, exit 0, only on CI, never
|
|
70
|
+
* reproducible. A fork that fails under load is invisible and looks like a fact
|
|
71
|
+
* about the repository.
|
|
72
|
+
*/
|
|
73
|
+
type GitOutcome =
|
|
74
|
+
| { ran: true; stdout: string }
|
|
75
|
+
| { ran: true; stdout: null }
|
|
76
|
+
| { ran: false; stdout: null; detail: string };
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The spawn, injectable — the same seam the LLM providers use for `fetch`.
|
|
80
|
+
*
|
|
81
|
+
* Exported because the retry below is otherwise untestable: provoking a real
|
|
82
|
+
* `EAGAIN` means exhausting the process table, which is not something a test
|
|
83
|
+
* suite should do to the machine running it. A retry nothing checks is a retry
|
|
84
|
+
* somebody deletes in a refactor, and mutation testing said exactly that.
|
|
85
|
+
*
|
|
86
|
+
* It widens nothing in practice: the CLI has no library entry, so this module
|
|
87
|
+
* is reachable only from inside this package and from its tests.
|
|
88
|
+
*/
|
|
89
|
+
export type SpawnLike = typeof spawnSync;
|
|
90
|
+
|
|
91
|
+
function runGit(
|
|
92
|
+
args: readonly string[],
|
|
93
|
+
cwd: string,
|
|
94
|
+
spawn: SpawnLike = spawnSync,
|
|
95
|
+
): GitOutcome {
|
|
96
|
+
/**
|
|
97
|
+
* Bounded by the loop, not by a condition inside it.
|
|
98
|
+
*
|
|
99
|
+
* Written as `for (;;)` with a `continue` guarded by `attempt === 0` first,
|
|
100
|
+
* which is one edit away from retrying for ever — and mutation testing does
|
|
101
|
+
* not report that as a surviving mutant, it reports it as the suite hanging
|
|
102
|
+
* until the runner is killed. In CI that is a job that burns its whole
|
|
103
|
+
* timeout instead of failing in a second.
|
|
104
|
+
*
|
|
105
|
+
* Two attempts, and only for a failure to *start* the process. A git that ran
|
|
106
|
+
* and exited non-zero is answering, and asking it twice would just re-run a
|
|
107
|
+
* command that already failed for a reason.
|
|
108
|
+
*/
|
|
109
|
+
const ATTEMPTS = 2;
|
|
110
|
+
let last: GitOutcome = { ran: false, stdout: null, detail: 'not attempted' };
|
|
111
|
+
|
|
112
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
113
|
+
const result = spawn('git', args, {
|
|
114
|
+
cwd,
|
|
115
|
+
// Stated rather than left to the default: this is the line that matters.
|
|
116
|
+
shell: false,
|
|
117
|
+
encoding: 'utf8',
|
|
118
|
+
timeout: TIMEOUT_MS,
|
|
119
|
+
maxBuffer: MAX_BUFFER,
|
|
120
|
+
// No prompting for credentials, no pager waiting on a TTY that is not there.
|
|
121
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_PAGER: 'cat', PAGER: 'cat' },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (result.error !== undefined) {
|
|
125
|
+
const code = (result.error as NodeJS.ErrnoException).code ?? '';
|
|
126
|
+
last = { ran: false, stdout: null, detail: code || result.error.message };
|
|
127
|
+
if (TRANSIENT.has(code)) continue;
|
|
128
|
+
return last;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (result.status !== 0) return { ran: true, stdout: null };
|
|
132
|
+
return { ran: true, stdout: result.stdout };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Every attempt was refused before git started. The machine is out of
|
|
136
|
+
// whatever it ran out of, and saying so beats a third try.
|
|
137
|
+
return last;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function git(args: readonly string[], cwd: string): string | null {
|
|
141
|
+
return runGit(args, cwd).stdout;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Thrown when git could not be run, as distinct from git having nothing to say.
|
|
146
|
+
*
|
|
147
|
+
* A distinct type rather than a message, so a caller cannot accidentally treat
|
|
148
|
+
* it as an empty result — which is the whole bug.
|
|
149
|
+
*/
|
|
150
|
+
export class GitUnavailableError extends Error {
|
|
151
|
+
constructor(detail: string) {
|
|
152
|
+
super(`could not run git (${detail})`);
|
|
153
|
+
this.name = 'GitUnavailableError';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Whether `git` can be run at all.
|
|
159
|
+
*
|
|
160
|
+
* Separate from `repositoryRoot` because the two failures need different
|
|
161
|
+
* advice: "install git" and "run this inside a repository" have nothing to do
|
|
162
|
+
* with each other, and a single "could not read history" would send half the
|
|
163
|
+
* readers looking in the wrong place.
|
|
164
|
+
*/
|
|
165
|
+
export function gitAvailable(cwd: string): boolean {
|
|
166
|
+
return git(['--version'], cwd) !== null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The repository root containing `cwd`, or `null` if there is not one. */
|
|
170
|
+
export function repositoryRoot(cwd: string): string | null {
|
|
171
|
+
const out = git(['rev-parse', '--show-toplevel'], cwd);
|
|
172
|
+
return out === null ? null : out.trim() || null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The path as git knows it — relative to the repository root, forward slashes —
|
|
177
|
+
* or `null` when it falls outside the repository.
|
|
178
|
+
*/
|
|
179
|
+
export function pathInRepository(root: string, target: string): string | null {
|
|
180
|
+
const rel = relative(resolve(root), resolve(target));
|
|
181
|
+
// Empty means the target *is* the root, `..` at the front means it escaped,
|
|
182
|
+
// and an absolute result means the two are on different Windows drives. All
|
|
183
|
+
// three are "outside the repository".
|
|
184
|
+
if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
return rel.split(sep).join('/');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Commits that touched `repoPath`, newest first.
|
|
192
|
+
*
|
|
193
|
+
* `--follow` so a renamed prompt keeps its history: a file moved from
|
|
194
|
+
* `prompt.txt` to `prompts/support.txt` is the same prompt, and a cost history
|
|
195
|
+
* that restarts at the rename is telling you the growth began the day somebody
|
|
196
|
+
* tidied the directory.
|
|
197
|
+
*/
|
|
198
|
+
export function revisionsFor(
|
|
199
|
+
repoPath: string,
|
|
200
|
+
options: { cwd: string; max: number; spawn?: SpawnLike },
|
|
201
|
+
): Revision[] {
|
|
202
|
+
// A unit separator between fields and a record separator between commits:
|
|
203
|
+
// both are characters git will not emit inside a name or a subject, unlike
|
|
204
|
+
// the tab and newline that a commit subject can absolutely contain.
|
|
205
|
+
// Written as escapes, not typed in. A raw control byte in a source file is
|
|
206
|
+
// how `scripts/measure-token-band.mjs` ended up with no reviewable diff for
|
|
207
|
+
// three commits, one of which was a security fix.
|
|
208
|
+
const FIELD = '\u001f';
|
|
209
|
+
const RECORD = '\u001e';
|
|
210
|
+
const format = ['%H', '%h', '%an', '%aI', '%s'].join(FIELD) + RECORD;
|
|
211
|
+
|
|
212
|
+
const outcome = runGit(
|
|
213
|
+
[
|
|
214
|
+
'log',
|
|
215
|
+
'--follow',
|
|
216
|
+
`--max-count=${Math.max(1, Math.floor(options.max))}`,
|
|
217
|
+
`--format=${format}`,
|
|
218
|
+
// Everything after this is a path, whatever it looks like.
|
|
219
|
+
'--',
|
|
220
|
+
repoPath,
|
|
221
|
+
],
|
|
222
|
+
options.cwd,
|
|
223
|
+
options.spawn,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
// The distinction this function used to lose. An empty list now means git
|
|
227
|
+
// looked and found nothing; being unable to look throws instead, so it can
|
|
228
|
+
// never be reported to somebody as a fact about their repository.
|
|
229
|
+
if (!outcome.ran) throw new GitUnavailableError(outcome.detail);
|
|
230
|
+
const out = outcome.stdout;
|
|
231
|
+
if (out === null) return [];
|
|
232
|
+
|
|
233
|
+
return out
|
|
234
|
+
.split(RECORD)
|
|
235
|
+
.map((record) => record.replace(/^\n/, ''))
|
|
236
|
+
.filter((record) => record.trim() !== '')
|
|
237
|
+
.map((record) => {
|
|
238
|
+
const [sha = '', shortSha = '', author = '', date = '', subject = ''] = record.split(FIELD);
|
|
239
|
+
return { sha, shortSha, author, date, subject };
|
|
240
|
+
})
|
|
241
|
+
.filter((revision) => SHA.test(revision.sha));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The file's content at a commit, or `null` if it did not exist there.
|
|
246
|
+
*
|
|
247
|
+
* `--follow` above means the path can differ from the one at that commit, so
|
|
248
|
+
* this asks git for the name it had rather than assuming today's.
|
|
249
|
+
*/
|
|
250
|
+
export function contentAt(sha: string, repoPath: string, cwd: string): string | null {
|
|
251
|
+
// The sha becomes part of a single `sha:path` argument, so it is checked
|
|
252
|
+
// before it can contribute anything to one.
|
|
253
|
+
if (!SHA.test(sha)) return null;
|
|
254
|
+
return git(['show', `${sha}:${repoPath}`], cwd);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The name the file had at each commit, keyed by sha.
|
|
259
|
+
*
|
|
260
|
+
* One `git log` rather than one per revision, and it replaces a version that
|
|
261
|
+
* asked per commit and got nothing back: `git log --follow --max-count=1 <sha>
|
|
262
|
+
* -- <today's name>` returns an empty list for every commit before a rename,
|
|
263
|
+
* because at those commits that name did not exist. The effect was that a
|
|
264
|
+
* renamed prompt showed "not present" for its entire history before the move —
|
|
265
|
+
* the data was there, under the old name, and the report said there was none.
|
|
266
|
+
*
|
|
267
|
+
* Asking once, without a starting commit, lets `--follow` do the mapping it
|
|
268
|
+
* exists for: the output pairs each sha with the path it touched.
|
|
269
|
+
*/
|
|
270
|
+
export function namesByRevision(repoPath: string, cwd: string, max: number): Map<string, string> {
|
|
271
|
+
const MARK = '\u001e';
|
|
272
|
+
const out = git(
|
|
273
|
+
[
|
|
274
|
+
'log',
|
|
275
|
+
'--follow',
|
|
276
|
+
'--name-only',
|
|
277
|
+
`--max-count=${Math.max(1, Math.floor(max))}`,
|
|
278
|
+
`--format=${MARK}%H`,
|
|
279
|
+
'--',
|
|
280
|
+
repoPath,
|
|
281
|
+
],
|
|
282
|
+
cwd,
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const names = new Map<string, string>();
|
|
286
|
+
if (out === null) return names;
|
|
287
|
+
|
|
288
|
+
for (const record of out.split(MARK)) {
|
|
289
|
+
const lines = record.split('\n').map((line) => line.trim()).filter((line) => line !== '');
|
|
290
|
+
const [sha, name] = lines;
|
|
291
|
+
if (sha !== undefined && name !== undefined && SHA.test(sha)) names.set(sha, name);
|
|
292
|
+
}
|
|
293
|
+
return names;
|
|
294
|
+
}
|