@blastin-dev/clocktopus-cli 0.2.0 → 0.3.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 +91 -36
- package/dist/src/commands/agent/disable.d.ts +8 -1
- package/dist/src/commands/agent/disable.d.ts.map +1 -1
- package/dist/src/commands/agent/disable.js +79 -45
- package/dist/src/commands/agent/doctor.d.ts.map +1 -1
- package/dist/src/commands/agent/doctor.js +157 -95
- package/dist/src/commands/agent/hook.d.ts +4 -1
- package/dist/src/commands/agent/hook.d.ts.map +1 -1
- package/dist/src/commands/agent/hook.js +239 -98
- package/dist/src/commands/agent/setup.d.ts +1 -16
- package/dist/src/commands/agent/setup.d.ts.map +1 -1
- package/dist/src/commands/agent/setup.js +198 -81
- package/dist/src/commands/agent/status.d.ts.map +1 -1
- package/dist/src/commands/agent/status.js +46 -24
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +18 -4
- package/dist/src/lib/agent-config.d.ts +5 -23
- package/dist/src/lib/agent-config.d.ts.map +1 -1
- package/dist/src/lib/agent-config.js +50 -36
- package/dist/src/lib/agent-hook-state.d.ts +11 -7
- package/dist/src/lib/agent-hook-state.d.ts.map +1 -1
- package/dist/src/lib/agent-hook-state.js +34 -29
- package/dist/src/lib/agents.d.ts +52 -0
- package/dist/src/lib/agents.d.ts.map +1 -0
- package/dist/src/lib/agents.js +238 -0
- package/dist/src/lib/claude-settings.d.ts +0 -36
- package/dist/src/lib/claude-settings.d.ts.map +1 -1
- package/dist/src/lib/claude-settings.js +37 -62
- package/dist/src/lib/codex-config.d.ts +87 -0
- package/dist/src/lib/codex-config.d.ts.map +1 -0
- package/dist/src/lib/codex-config.js +399 -0
- package/dist/src/lib/codex-config.test.d.ts +2 -0
- package/dist/src/lib/codex-config.test.d.ts.map +1 -0
- package/dist/src/lib/codex-config.test.js +359 -0
- package/dist/src/lib/declared-commits.d.ts +43 -0
- package/dist/src/lib/declared-commits.d.ts.map +1 -0
- package/dist/src/lib/declared-commits.js +114 -0
- package/dist/src/lib/declared-commits.test.d.ts +2 -0
- package/dist/src/lib/declared-commits.test.d.ts.map +1 -0
- package/dist/src/lib/declared-commits.test.js +129 -0
- package/dist/src/lib/git-remotes.d.ts +9 -0
- package/dist/src/lib/git-remotes.d.ts.map +1 -0
- package/dist/src/lib/git-remotes.js +50 -0
- package/dist/src/lib/git-remotes.test.d.ts +2 -0
- package/dist/src/lib/git-remotes.test.d.ts.map +1 -0
- package/dist/src/lib/git-remotes.test.js +52 -0
- package/dist/src/lib/opencode-config.d.ts +23 -0
- package/dist/src/lib/opencode-config.d.ts.map +1 -0
- package/dist/src/lib/opencode-config.js +290 -0
- package/dist/src/lib/opencode-config.test.d.ts +2 -0
- package/dist/src/lib/opencode-config.test.d.ts.map +1 -0
- package/dist/src/lib/opencode-config.test.js +140 -0
- package/package.json +2 -1
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parse as parseToml } from "smol-toml";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { applyCodexHooks, applyCodexOtel, buildCodexOtel, CodexConfigParseError, readCodexConfig, readCodexHooks, readCodexHookTrust, readCodexTelemetry, removeCodexHooks, removeCodexOtel, spliceOtelSection, writeCodexConfig, writeCodexHooks, } from "./codex-config";
|
|
7
|
+
/**
|
|
8
|
+
* These tests exist because this module edits two files we do not own.
|
|
9
|
+
* `~/.codex/config.toml` holds the user's model, sandbox policy, MCP
|
|
10
|
+
* servers and project trust decisions — hand-written and usually commented
|
|
11
|
+
* — and `hooks.json` may already hold hooks that have nothing to do with
|
|
12
|
+
* us. A merge bug here destroys work unrelated to Clocktopus.
|
|
13
|
+
*/
|
|
14
|
+
const INSTALL = {
|
|
15
|
+
token: "ctop_agt_testtoken",
|
|
16
|
+
endpoint: "https://otel.example.com",
|
|
17
|
+
hookCommand: "clocktopus agent hook --provider codex_cli",
|
|
18
|
+
};
|
|
19
|
+
/** A config.toml with the user's own settings, comments and layout. */
|
|
20
|
+
const USER_CONFIG = `# my codex setup
|
|
21
|
+
model = "gpt-5.6-sol"
|
|
22
|
+
approval_policy = "on-request"
|
|
23
|
+
|
|
24
|
+
[sandbox_workspace_write]
|
|
25
|
+
network_access = true # needed for npm
|
|
26
|
+
|
|
27
|
+
[mcp_servers.linear]
|
|
28
|
+
command = "npx"
|
|
29
|
+
args = ["-y", "linear-mcp"]
|
|
30
|
+
`;
|
|
31
|
+
let dir;
|
|
32
|
+
let configPath;
|
|
33
|
+
let hooksPath;
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
dir = mkdtempSync(join(tmpdir(), "clocktopus-codex-"));
|
|
36
|
+
configPath = join(dir, "config.toml");
|
|
37
|
+
hooksPath = join(dir, "hooks.json");
|
|
38
|
+
});
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
rmSync(dir, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
describe("buildCodexOtel", () => {
|
|
43
|
+
it("puts the receiver path on the endpoint, because Codex does not add one", () => {
|
|
44
|
+
const otel = buildCodexOtel(INSTALL);
|
|
45
|
+
const http = otel.exporter["otlp-http"];
|
|
46
|
+
// Codex POSTs to the configured URL verbatim — unlike the OTel SDK, it
|
|
47
|
+
// does not append `/v1/logs`. Omitting the path here would send every
|
|
48
|
+
// export to the receiver's root and 404 silently.
|
|
49
|
+
expect(http.endpoint).toBe("https://otel.example.com/v1/logs");
|
|
50
|
+
expect(http.protocol).toBe("json");
|
|
51
|
+
expect(http.headers.Authorization).toBe("Bearer ctop_agt_testtoken");
|
|
52
|
+
});
|
|
53
|
+
it("does not double the path when the endpoint already ends in a slash", () => {
|
|
54
|
+
const otel = buildCodexOtel({
|
|
55
|
+
...INSTALL,
|
|
56
|
+
endpoint: "https://otel.example.com/",
|
|
57
|
+
});
|
|
58
|
+
const http = otel.exporter["otlp-http"];
|
|
59
|
+
expect(http.endpoint).toBe("https://otel.example.com/v1/logs");
|
|
60
|
+
});
|
|
61
|
+
it("turns prompt logging off explicitly rather than trusting the default", () => {
|
|
62
|
+
expect(buildCodexOtel(INSTALL).log_user_prompt).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe("splicing [otel] into a hand-written config.toml", () => {
|
|
66
|
+
it("adds the section without disturbing comments or layout", () => {
|
|
67
|
+
const next = spliceOtelSection(USER_CONFIG, buildCodexOtel(INSTALL));
|
|
68
|
+
// Every line the user wrote survives, byte for byte. A round trip
|
|
69
|
+
// through a TOML serialiser would silently drop all three comments.
|
|
70
|
+
expect(next).toContain("# my codex setup");
|
|
71
|
+
expect(next).toContain("network_access = true # needed for npm");
|
|
72
|
+
expect(next).toContain('args = ["-y", "linear-mcp"]');
|
|
73
|
+
const parsed = parseToml(next);
|
|
74
|
+
expect(parsed.model).toBe("gpt-5.6-sol");
|
|
75
|
+
expect(parsed.mcp_servers).toEqual({
|
|
76
|
+
linear: { command: "npx", args: ["-y", "linear-mcp"] },
|
|
77
|
+
});
|
|
78
|
+
expect(parsed.otel.log_user_prompt).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
it("replaces an existing [otel] section instead of appending a second", () => {
|
|
81
|
+
const withOtel = spliceOtelSection(USER_CONFIG, buildCodexOtel(INSTALL));
|
|
82
|
+
const twice = spliceOtelSection(withOtel, buildCodexOtel({ ...INSTALL, token: "ctop_agt_second" }));
|
|
83
|
+
// TOML rejects a duplicated table outright, so an append bug here would
|
|
84
|
+
// stop Codex booting at all.
|
|
85
|
+
expect(twice.match(/^\[otel\]/gm)).toHaveLength(1);
|
|
86
|
+
expect(twice).toContain("ctop_agt_second");
|
|
87
|
+
expect(twice).not.toContain("ctop_agt_testtoken");
|
|
88
|
+
expect(() => parseToml(twice)).not.toThrow();
|
|
89
|
+
});
|
|
90
|
+
it("keeps the user's own [otel] keys when merging ours in", () => {
|
|
91
|
+
const config = `${USER_CONFIG}
|
|
92
|
+
[otel]
|
|
93
|
+
environment = "staging"
|
|
94
|
+
`;
|
|
95
|
+
const parsed = parseToml(config);
|
|
96
|
+
const next = applyCodexOtel({ text: config, config: parsed }, INSTALL);
|
|
97
|
+
const result = parseToml(next);
|
|
98
|
+
expect(result.otel.environment).toBe("staging");
|
|
99
|
+
expect(result.otel.log_user_prompt).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
it("removes only our keys, leaving the rest of [otel] behind", () => {
|
|
102
|
+
const config = `${USER_CONFIG}
|
|
103
|
+
[otel]
|
|
104
|
+
environment = "staging"
|
|
105
|
+
`;
|
|
106
|
+
const withOurs = applyCodexOtel({ text: config, config: parseToml(config) }, INSTALL);
|
|
107
|
+
const { nextText, removedKeys } = removeCodexOtel({
|
|
108
|
+
text: withOurs,
|
|
109
|
+
config: parseToml(withOurs),
|
|
110
|
+
});
|
|
111
|
+
expect(removedKeys.sort()).toEqual(["exporter", "log_user_prompt"]);
|
|
112
|
+
const parsed = parseToml(nextText);
|
|
113
|
+
expect(parsed.otel).toEqual({ environment: "staging" });
|
|
114
|
+
expect(parsed.model).toBe("gpt-5.6-sol");
|
|
115
|
+
});
|
|
116
|
+
it("drops [otel] entirely when nothing of the user's is left in it", () => {
|
|
117
|
+
const withOurs = spliceOtelSection(USER_CONFIG, buildCodexOtel(INSTALL));
|
|
118
|
+
const { nextText } = removeCodexOtel({
|
|
119
|
+
text: withOurs,
|
|
120
|
+
config: parseToml(withOurs),
|
|
121
|
+
});
|
|
122
|
+
expect(nextText).not.toContain("[otel]");
|
|
123
|
+
expect(parseToml(nextText)).toEqual(parseToml(USER_CONFIG));
|
|
124
|
+
});
|
|
125
|
+
it("survives an add/remove cycle without accumulating blank lines", () => {
|
|
126
|
+
let text = USER_CONFIG;
|
|
127
|
+
for (let i = 0; i < 3; i++) {
|
|
128
|
+
text = spliceOtelSection(text, buildCodexOtel(INSTALL));
|
|
129
|
+
text = removeCodexOtel({
|
|
130
|
+
text,
|
|
131
|
+
config: parseToml(text),
|
|
132
|
+
}).nextText;
|
|
133
|
+
}
|
|
134
|
+
expect(text).toBe(USER_CONFIG);
|
|
135
|
+
});
|
|
136
|
+
it("creates the section from nothing when the file does not exist yet", () => {
|
|
137
|
+
const next = spliceOtelSection("", buildCodexOtel(INSTALL));
|
|
138
|
+
expect(parseToml(next).otel).toBeDefined();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
describe("writeCodexConfig — the guard on the splice", () => {
|
|
142
|
+
it("writes the edit and backs up what was there", () => {
|
|
143
|
+
writeFileSync(configPath, USER_CONFIG);
|
|
144
|
+
const current = readCodexConfig(configPath);
|
|
145
|
+
const { backupPath } = writeCodexConfig({
|
|
146
|
+
path: configPath,
|
|
147
|
+
previousText: current.text,
|
|
148
|
+
previousConfig: current.config,
|
|
149
|
+
nextText: applyCodexOtel(current, INSTALL),
|
|
150
|
+
});
|
|
151
|
+
expect(backupPath).toBe(`${configPath}.clocktopus-backup`);
|
|
152
|
+
expect(readFileSync(backupPath, "utf8")).toBe(USER_CONFIG);
|
|
153
|
+
expect(readCodexTelemetry(configPath).token).toBe(INSTALL.token);
|
|
154
|
+
});
|
|
155
|
+
it("refuses to write an edit that would change anything outside [otel]", () => {
|
|
156
|
+
writeFileSync(configPath, USER_CONFIG);
|
|
157
|
+
const current = readCodexConfig(configPath);
|
|
158
|
+
// Stands in for a splice that misread the file's layout. Rather than
|
|
159
|
+
// trusting the region scanner, the write verifies its own result — so a
|
|
160
|
+
// document it cannot handle costs the user nothing.
|
|
161
|
+
expect(() => writeCodexConfig({
|
|
162
|
+
path: configPath,
|
|
163
|
+
previousText: current.text,
|
|
164
|
+
previousConfig: current.config,
|
|
165
|
+
nextText: 'model = "gpt-5.6-sol"\n',
|
|
166
|
+
})).toThrow(/would have changed/);
|
|
167
|
+
expect(readFileSync(configPath, "utf8")).toBe(USER_CONFIG);
|
|
168
|
+
});
|
|
169
|
+
it("refuses to write anything that is not valid TOML", () => {
|
|
170
|
+
writeFileSync(configPath, USER_CONFIG);
|
|
171
|
+
const current = readCodexConfig(configPath);
|
|
172
|
+
expect(() => writeCodexConfig({
|
|
173
|
+
path: configPath,
|
|
174
|
+
previousText: current.text,
|
|
175
|
+
previousConfig: current.config,
|
|
176
|
+
nextText: "[[[nope",
|
|
177
|
+
})).toThrow(/did not produce valid TOML/);
|
|
178
|
+
expect(readFileSync(configPath, "utf8")).toBe(USER_CONFIG);
|
|
179
|
+
});
|
|
180
|
+
it("raises rather than overwriting a config.toml it cannot parse", () => {
|
|
181
|
+
// Far more likely a half-finished edit than something to clobber.
|
|
182
|
+
writeFileSync(configPath, "model = = =");
|
|
183
|
+
expect(() => readCodexConfig(configPath)).toThrow(CodexConfigParseError);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
describe("readCodexTelemetry", () => {
|
|
187
|
+
it("strips the receiver path back off so the hook gets a base URL", () => {
|
|
188
|
+
writeFileSync(configPath, spliceOtelSection(USER_CONFIG, buildCodexOtel(INSTALL)));
|
|
189
|
+
const telemetry = readCodexTelemetry(configPath);
|
|
190
|
+
// The hook posts to `<endpoint>/v1/agent-session` and doctor to
|
|
191
|
+
// `<endpoint>/v1/verify`; leaving `/v1/logs` on would make both 404.
|
|
192
|
+
expect(telemetry.endpoint).toBe("https://otel.example.com");
|
|
193
|
+
expect(telemetry.token).toBe(INSTALL.token);
|
|
194
|
+
expect(telemetry.protocol).toBe("json");
|
|
195
|
+
});
|
|
196
|
+
it("reports nothing configured rather than throwing on a missing file", () => {
|
|
197
|
+
expect(readCodexTelemetry(configPath)).toMatchObject({
|
|
198
|
+
exists: false,
|
|
199
|
+
token: null,
|
|
200
|
+
endpoint: null,
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
describe("hooks.json", () => {
|
|
205
|
+
/** Reads one event's matcher groups back out with the shape Codex expects. */
|
|
206
|
+
const groups = (file, event) => file.hooks[event] ?? [];
|
|
207
|
+
const userHooks = () => ({
|
|
208
|
+
description: "my hooks",
|
|
209
|
+
hooks: {
|
|
210
|
+
SessionStart: [
|
|
211
|
+
{ hooks: [{ type: "command", command: "echo user-session-start" }] },
|
|
212
|
+
],
|
|
213
|
+
PreToolUse: [
|
|
214
|
+
{ matcher: "shell", hooks: [{ type: "command", command: "echo pre" }] },
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
it("appends our hook without removing the user's", () => {
|
|
219
|
+
const next = applyCodexHooks(userHooks(), INSTALL);
|
|
220
|
+
const start = groups(next, "SessionStart");
|
|
221
|
+
expect(start).toHaveLength(2);
|
|
222
|
+
expect(start[0].hooks[0].command).toBe("echo user-session-start");
|
|
223
|
+
expect(start[1].hooks[0].command).toBe(INSTALL.hookCommand);
|
|
224
|
+
expect(groups(next, "PreToolUse")).toEqual(userHooks().hooks.PreToolUse);
|
|
225
|
+
});
|
|
226
|
+
it("gives SessionEnd the 3s budget Codex clamps it to", () => {
|
|
227
|
+
const next = applyCodexHooks({}, INSTALL);
|
|
228
|
+
// Codex hard-clamps SessionEnd and prints a warning on every session
|
|
229
|
+
// start if we ask for more. 10s for SessionStart, which it honours.
|
|
230
|
+
expect(groups(next, "SessionStart")[0].hooks[0].timeout).toBe(10);
|
|
231
|
+
expect(groups(next, "SessionEnd")[0].hooks[0].timeout).toBe(3);
|
|
232
|
+
});
|
|
233
|
+
it("never writes an `async` key, on either event", () => {
|
|
234
|
+
// The regression this pins cost a real user their repository
|
|
235
|
+
// attribution. Codex 0.147 does not skip an async hook quietly-ish and
|
|
236
|
+
// carry on: it drops SessionStart entirely, so `cwd` — the only source
|
|
237
|
+
// of repository, branch and starting SHA anywhere in the pipeline —
|
|
238
|
+
// never arrives, while spend keeps flowing in unattributed. 0.148
|
|
239
|
+
// honours it on SessionStart but still forces SessionEnd synchronous
|
|
240
|
+
// and warns once per session.
|
|
241
|
+
//
|
|
242
|
+
// `agent hook` backgrounds itself instead, so this key has no upside on
|
|
243
|
+
// any version.
|
|
244
|
+
const next = applyCodexHooks({}, INSTALL);
|
|
245
|
+
for (const event of ["SessionStart", "SessionEnd"]) {
|
|
246
|
+
const entry = groups(next, event)[0].hooks[0];
|
|
247
|
+
expect(entry).not.toHaveProperty("async");
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
it("replaces our hook on re-run instead of installing a second", () => {
|
|
251
|
+
const once = applyCodexHooks(userHooks(), INSTALL);
|
|
252
|
+
const twice = applyCodexHooks(once, {
|
|
253
|
+
hookCommand: "/usr/local/bin/clocktopus agent hook --provider codex_cli",
|
|
254
|
+
});
|
|
255
|
+
// Two of our hooks would POST every SessionStart twice.
|
|
256
|
+
const start = groups(twice, "SessionStart");
|
|
257
|
+
expect(start).toHaveLength(2);
|
|
258
|
+
expect(start[1].hooks[0].command).toContain("/usr/local/bin/clocktopus");
|
|
259
|
+
});
|
|
260
|
+
it("removes ours and leaves the user's intact", () => {
|
|
261
|
+
const installed = applyCodexHooks(userHooks(), INSTALL);
|
|
262
|
+
const { file, removed } = removeCodexHooks(installed);
|
|
263
|
+
expect(removed).toBe(true);
|
|
264
|
+
expect(file).toMatchObject({ hooks: userHooks().hooks });
|
|
265
|
+
});
|
|
266
|
+
it("round-trips through disk and finds our command again", () => {
|
|
267
|
+
writeCodexHooks(applyCodexHooks(userHooks(), INSTALL), hooksPath);
|
|
268
|
+
const read = readCodexHooks(hooksPath);
|
|
269
|
+
expect(read.commands.SessionStart).toBe(INSTALL.hookCommand);
|
|
270
|
+
expect(read.commands.SessionEnd).toBe(INSTALL.hookCommand);
|
|
271
|
+
});
|
|
272
|
+
it("raises rather than overwriting a hooks.json it cannot parse", () => {
|
|
273
|
+
writeFileSync(hooksPath, "{ not json");
|
|
274
|
+
expect(() => readCodexHooks(hooksPath)).toThrow(CodexConfigParseError);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
describe("readCodexHookTrust — Codex's per-entry trust records", () => {
|
|
278
|
+
/**
|
|
279
|
+
* The key format is taken verbatim from a real `~/.codex/config.toml`
|
|
280
|
+
* after Codex 0.147 prompted and the answer was persisted. Trust is
|
|
281
|
+
* recorded per hook *entry* — `<path>:<event>:<group>:<index>` — not per
|
|
282
|
+
* file, which an earlier version of this function assumed; matching the
|
|
283
|
+
* bare path found nothing and reported a correctly-approved install as
|
|
284
|
+
* still pending, forever.
|
|
285
|
+
*/
|
|
286
|
+
const trustState = (entries) => `[hooks.state]\n${entries
|
|
287
|
+
.map((key) => `\n[hooks.state."${key}"]\ntrusted_hash = "sha256:c3ffbe52"\nenabled = true\n`)
|
|
288
|
+
.join("")}`;
|
|
289
|
+
it("reads a fully approved install", () => {
|
|
290
|
+
writeFileSync(configPath, trustState([
|
|
291
|
+
`${hooksPath}:session_start:0:0`,
|
|
292
|
+
`${hooksPath}:session_end:0:0`,
|
|
293
|
+
]));
|
|
294
|
+
// `hooksPath` is the temp dir's file, so point the reader at it too.
|
|
295
|
+
process.env.CODEX_HOME = dir;
|
|
296
|
+
try {
|
|
297
|
+
expect(readCodexHookTrust(configPath)).toEqual({
|
|
298
|
+
trusted: ["SessionStart", "SessionEnd"],
|
|
299
|
+
untrusted: [],
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
delete process.env.CODEX_HOME;
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
it("spots the half-approved install that loses repository context", () => {
|
|
307
|
+
// Exactly the state an `async: true` SessionStart left behind on Codex
|
|
308
|
+
// 0.147: the hook was skipped outright, so it was never offered for
|
|
309
|
+
// approval, while SessionEnd was. Sessions close but never open.
|
|
310
|
+
writeFileSync(configPath, trustState([`${hooksPath}:session_end:0:0`]));
|
|
311
|
+
process.env.CODEX_HOME = dir;
|
|
312
|
+
try {
|
|
313
|
+
expect(readCodexHookTrust(configPath)).toEqual({
|
|
314
|
+
trusted: ["SessionEnd"],
|
|
315
|
+
untrusted: ["SessionStart"],
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
delete process.env.CODEX_HOME;
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
it("reads Codex 0.148's hash-only record, which omits `enabled`", () => {
|
|
323
|
+
// 0.148 persists `hooks.state."<key>".trusted_hash` and nothing else —
|
|
324
|
+
// its `HookStateToml` has no `enabled` field. Demanding `enabled = true`
|
|
325
|
+
// reported a just-approved install as still pending, and no amount of
|
|
326
|
+
// restarting Codex could clear it: it never prompts again once trusted.
|
|
327
|
+
writeFileSync(configPath, `[hooks.state."${hooksPath}:session_start:0:0"]\ntrusted_hash = "sha256:c3ffbe52"\n` +
|
|
328
|
+
`\n[hooks.state."${hooksPath}:session_end:0:0"]\ntrusted_hash = "sha256:8b152fa9"\nenabled = true\n`);
|
|
329
|
+
process.env.CODEX_HOME = dir;
|
|
330
|
+
try {
|
|
331
|
+
expect(readCodexHookTrust(configPath)).toEqual({
|
|
332
|
+
trusted: ["SessionStart", "SessionEnd"],
|
|
333
|
+
untrusted: [],
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
finally {
|
|
337
|
+
delete process.env.CODEX_HOME;
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
it("treats an entry the user explicitly disabled as untrusted", () => {
|
|
341
|
+
writeFileSync(configPath, `[hooks.state."${hooksPath}:session_start:0:0"]\ntrusted_hash = "sha256:c3ffbe52"\nenabled = false\n`);
|
|
342
|
+
process.env.CODEX_HOME = dir;
|
|
343
|
+
try {
|
|
344
|
+
expect(readCodexHookTrust(configPath).untrusted).toEqual([
|
|
345
|
+
"SessionStart",
|
|
346
|
+
"SessionEnd",
|
|
347
|
+
]);
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
delete process.env.CODEX_HOME;
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
it("reports everything untrusted when there is no config at all", () => {
|
|
354
|
+
expect(readCodexHookTrust(join(dir, "missing.toml")).untrusted).toEqual([
|
|
355
|
+
"SessionStart",
|
|
356
|
+
"SessionEnd",
|
|
357
|
+
]);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arguments for reading the session's slice of the reflog, or `null` when the
|
|
3
|
+
* session's start was never recorded.
|
|
4
|
+
*
|
|
5
|
+
* A null return means *declare nothing*, deliberately. Without a lower bound the
|
|
6
|
+
* reflog would hand back the checkout's entire history; the time-window rule already
|
|
7
|
+
* covers a session that cannot declare, and over-declaring is the one mistake it
|
|
8
|
+
* cannot undo — a `declared` link outranks the window and is permanent.
|
|
9
|
+
*/
|
|
10
|
+
export declare function buildReflogArgs(input: {
|
|
11
|
+
/** ISO timestamp recorded at SessionStart. */
|
|
12
|
+
startedAt?: string;
|
|
13
|
+
/** Upper bound, for the sweep: a session that died hours ago owns nothing since. */
|
|
14
|
+
until?: string;
|
|
15
|
+
}): string[] | null;
|
|
16
|
+
/**
|
|
17
|
+
* The commits created in this checkout, newest first, from `buildReflogArgs` output.
|
|
18
|
+
*
|
|
19
|
+
* Three actions always author a commit here and are kept: `commit` (with its
|
|
20
|
+
* `(initial)`, `(amend)` and `(merge)` variants), `revert` and `cherry-pick`.
|
|
21
|
+
* `checkout` and `reset` move HEAD without writing anything.
|
|
22
|
+
*
|
|
23
|
+
* **`merge` and `pull` are excluded even though a clean merge does author a commit**,
|
|
24
|
+
* because nothing in the reflog separates one you made from one you fast-forwarded
|
|
25
|
+
* onto. Parent count looks like it would and does not — a plain `git pull` on a
|
|
26
|
+
* repository that merges its pull requests lands on a two-parent commit somebody else
|
|
27
|
+
* wrote:
|
|
28
|
+
*
|
|
29
|
+
* parents=14c3726 6d88961 | pull: Fast-forward
|
|
30
|
+
*
|
|
31
|
+
* A conflicted merge is still caught, since resolving one ends in `commit (merge)`.
|
|
32
|
+
* A clean one falls to the time-window rule.
|
|
33
|
+
*
|
|
34
|
+
* `rebase (pick)` is excluded for the matching reason: replaying a branch would
|
|
35
|
+
* declare every commit on it, including work authored long before the session. The
|
|
36
|
+
* cost is that a session which commits and then rebases declares the pre-rebase SHA,
|
|
37
|
+
* which was never pushed and simply joins to nothing.
|
|
38
|
+
*
|
|
39
|
+
* Every exclusion here is recoverable and every wrong inclusion is not: a `declared`
|
|
40
|
+
* link outranks the time window and is permanent.
|
|
41
|
+
*/
|
|
42
|
+
export declare function parseReflogCommits(output: string | undefined): string[];
|
|
43
|
+
//# sourceMappingURL=declared-commits.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"declared-commits.d.ts","sourceRoot":"","sources":["../../../src/lib/declared-commits.ts"],"names":[],"mappings":"AAoBA;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE;IACrC,8CAA8C;IAC9C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oFAAoF;IACpF,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,GAAG,MAAM,EAAE,GAAG,IAAI,CA2BlB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,EAAE,CAyBvE"}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Resolving the commits a session actually created.
|
|
2
|
+
//
|
|
3
|
+
// The obvious answer, `git rev-list before..head`, answers a different question:
|
|
4
|
+
// "what is reachable from here that was not reachable from there". Merged and pulled
|
|
5
|
+
// history is reachable, so it gets declared — one real session claimed 105 commits
|
|
6
|
+
// reaching two months back (BLA-595) — and a mid-session switch to a *pre-existing*
|
|
7
|
+
// branch makes the session's own earlier commits unreachable, so they vanish.
|
|
8
|
+
//
|
|
9
|
+
// The reflog answers the right question. It records HEAD movements in this checkout,
|
|
10
|
+
// tagged with what caused them, so a `pull` or a `merge` is one entry rather than one
|
|
11
|
+
// per commit it brought along, and a branch switch is irrelevant to what was authored.
|
|
12
|
+
// It is also per-worktree (`.git/worktrees/<name>/logs/HEAD`), so parallel agents in
|
|
13
|
+
// separate worktrees observe disjoint histories and cannot both claim a commit.
|
|
14
|
+
//
|
|
15
|
+
// Two agents in one worktree share a reflog and remain genuinely indistinguishable.
|
|
16
|
+
// That is a real limit, not an oversight.
|
|
17
|
+
/** Ceiling on what one session may declare. */
|
|
18
|
+
const MAX_DECLARED_COMMITS = 200;
|
|
19
|
+
/**
|
|
20
|
+
* Arguments for reading the session's slice of the reflog, or `null` when the
|
|
21
|
+
* session's start was never recorded.
|
|
22
|
+
*
|
|
23
|
+
* A null return means *declare nothing*, deliberately. Without a lower bound the
|
|
24
|
+
* reflog would hand back the checkout's entire history; the time-window rule already
|
|
25
|
+
* covers a session that cannot declare, and over-declaring is the one mistake it
|
|
26
|
+
* cannot undo — a `declared` link outranks the window and is permanent.
|
|
27
|
+
*/
|
|
28
|
+
export function buildReflogArgs(input) {
|
|
29
|
+
// No grace before `startedAt`, deliberately. Both timestamps come from the same
|
|
30
|
+
// machine clock, so there is no skew to absorb, and SessionStart fires before the
|
|
31
|
+
// agent can act, so nothing it wrote can predate the stamp. A backward grace could
|
|
32
|
+
// therefore only admit a commit made *before* the session — the `wip` a developer
|
|
33
|
+
// makes just before launching an agent — as `declared`, which outranks the time
|
|
34
|
+
// window and is permanent. `--prune` cannot undo it either: that cuts at an hour.
|
|
35
|
+
const since = asIso(input.startedAt);
|
|
36
|
+
if (!since)
|
|
37
|
+
return null;
|
|
38
|
+
const args = [
|
|
39
|
+
"reflog",
|
|
40
|
+
"show",
|
|
41
|
+
"HEAD",
|
|
42
|
+
"--format=%H %gs",
|
|
43
|
+
`--since=${since}`,
|
|
44
|
+
];
|
|
45
|
+
const until = asIso(input.until);
|
|
46
|
+
if (until)
|
|
47
|
+
args.push(`--until=${until}`);
|
|
48
|
+
// A working tree containing a file called `HEAD` makes git refuse the whole read —
|
|
49
|
+
// "ambiguous argument 'HEAD': both revision and filename" — which `git()` swallows,
|
|
50
|
+
// so the session would silently declare nothing.
|
|
51
|
+
args.push("--");
|
|
52
|
+
return args;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The commits created in this checkout, newest first, from `buildReflogArgs` output.
|
|
56
|
+
*
|
|
57
|
+
* Three actions always author a commit here and are kept: `commit` (with its
|
|
58
|
+
* `(initial)`, `(amend)` and `(merge)` variants), `revert` and `cherry-pick`.
|
|
59
|
+
* `checkout` and `reset` move HEAD without writing anything.
|
|
60
|
+
*
|
|
61
|
+
* **`merge` and `pull` are excluded even though a clean merge does author a commit**,
|
|
62
|
+
* because nothing in the reflog separates one you made from one you fast-forwarded
|
|
63
|
+
* onto. Parent count looks like it would and does not — a plain `git pull` on a
|
|
64
|
+
* repository that merges its pull requests lands on a two-parent commit somebody else
|
|
65
|
+
* wrote:
|
|
66
|
+
*
|
|
67
|
+
* parents=14c3726 6d88961 | pull: Fast-forward
|
|
68
|
+
*
|
|
69
|
+
* A conflicted merge is still caught, since resolving one ends in `commit (merge)`.
|
|
70
|
+
* A clean one falls to the time-window rule.
|
|
71
|
+
*
|
|
72
|
+
* `rebase (pick)` is excluded for the matching reason: replaying a branch would
|
|
73
|
+
* declare every commit on it, including work authored long before the session. The
|
|
74
|
+
* cost is that a session which commits and then rebases declares the pre-rebase SHA,
|
|
75
|
+
* which was never pushed and simply joins to nothing.
|
|
76
|
+
*
|
|
77
|
+
* Every exclusion here is recoverable and every wrong inclusion is not: a `declared`
|
|
78
|
+
* link outranks the time window and is permanent.
|
|
79
|
+
*/
|
|
80
|
+
export function parseReflogCommits(output) {
|
|
81
|
+
if (!output?.trim())
|
|
82
|
+
return [];
|
|
83
|
+
const shas = [];
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
for (const line of output.split("\n")) {
|
|
86
|
+
// The SHA can contain no spaces, so the first one ends it; the subject keeps its
|
|
87
|
+
// own.
|
|
88
|
+
const separator = line.indexOf(" ");
|
|
89
|
+
if (separator === -1)
|
|
90
|
+
continue;
|
|
91
|
+
const sha = line.slice(0, separator).trim();
|
|
92
|
+
const subject = line.slice(separator + 1).trim();
|
|
93
|
+
if (!/^[0-9a-f]{7,64}$/i.test(sha))
|
|
94
|
+
continue;
|
|
95
|
+
if (!/^(commit|revert|cherry-pick)\b/.test(subject))
|
|
96
|
+
continue;
|
|
97
|
+
if (seen.has(sha))
|
|
98
|
+
continue;
|
|
99
|
+
seen.add(sha);
|
|
100
|
+
shas.push(sha);
|
|
101
|
+
if (shas.length === MAX_DECLARED_COMMITS)
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
return shas;
|
|
105
|
+
}
|
|
106
|
+
function asIso(value) {
|
|
107
|
+
return asDate(value)?.toISOString() ?? null;
|
|
108
|
+
}
|
|
109
|
+
function asDate(value) {
|
|
110
|
+
if (!value)
|
|
111
|
+
return null;
|
|
112
|
+
const at = new Date(value);
|
|
113
|
+
return Number.isNaN(at.getTime()) ? null : at;
|
|
114
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"declared-commits.test.d.ts","sourceRoot":"","sources":["../../../src/lib/declared-commits.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildReflogArgs, parseReflogCommits } from "./declared-commits.js";
|
|
3
|
+
const STARTED_AT = "2026-08-20T05:35:45.728Z";
|
|
4
|
+
const SHA = "418a320a048f839f2200106fbcffbb6f7683d135";
|
|
5
|
+
const reflog = (...lines) => lines.join("\n");
|
|
6
|
+
describe("buildReflogArgs", () => {
|
|
7
|
+
it("bounds the read at the session start exactly", () => {
|
|
8
|
+
expect(buildReflogArgs({ startedAt: STARTED_AT })).toContain(`--since=${STARTED_AT}`);
|
|
9
|
+
});
|
|
10
|
+
/**
|
|
11
|
+
* No backward grace. Both stamps come from one machine clock, and SessionStart fires
|
|
12
|
+
* before the agent can act, so a grace could only admit a commit made *before* the
|
|
13
|
+
* session — as `declared`, which outranks the time window and is permanent.
|
|
14
|
+
*/
|
|
15
|
+
it("admits nothing committed before the session started", () => {
|
|
16
|
+
const args = buildReflogArgs({ startedAt: STARTED_AT });
|
|
17
|
+
const since = args
|
|
18
|
+
.find((arg) => arg.startsWith("--since="))
|
|
19
|
+
.replace("--since=", "");
|
|
20
|
+
expect(new Date(since).getTime()).toBe(new Date(STARTED_AT).getTime());
|
|
21
|
+
});
|
|
22
|
+
/** The sweep runs hours late; a session that died owns nothing committed since. */
|
|
23
|
+
it("takes an upper bound, for the sweep", () => {
|
|
24
|
+
const args = buildReflogArgs({
|
|
25
|
+
startedAt: STARTED_AT,
|
|
26
|
+
until: "2026-08-20T11:56:30.103Z",
|
|
27
|
+
});
|
|
28
|
+
expect(args).toContain("--until=2026-08-20T11:56:30.103Z");
|
|
29
|
+
});
|
|
30
|
+
/**
|
|
31
|
+
* A working tree with a file called `HEAD` otherwise makes git refuse the read
|
|
32
|
+
* outright — "ambiguous argument 'HEAD': both revision and filename".
|
|
33
|
+
*/
|
|
34
|
+
it("separates the revision from any path of the same name", () => {
|
|
35
|
+
expect(buildReflogArgs({ startedAt: STARTED_AT }).at(-1)).toBe("--");
|
|
36
|
+
});
|
|
37
|
+
it("omits the upper bound for a live SessionEnd", () => {
|
|
38
|
+
const args = buildReflogArgs({ startedAt: STARTED_AT });
|
|
39
|
+
expect(args.some((arg) => arg.startsWith("--until="))).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
/**
|
|
42
|
+
* Declaring nothing is the deliberate answer. Without a lower bound the reflog is
|
|
43
|
+
* the checkout's entire history, and a `declared` link outranks the time window, so
|
|
44
|
+
* over-declaring is the one mistake that cannot be undone later.
|
|
45
|
+
*/
|
|
46
|
+
it("refuses to read at all when the session start was never recorded", () => {
|
|
47
|
+
expect(buildReflogArgs({})).toBeNull();
|
|
48
|
+
expect(buildReflogArgs({ startedAt: "not a date" })).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe("parseReflogCommits", () => {
|
|
52
|
+
it("takes the commits this checkout authored", () => {
|
|
53
|
+
const shas = parseReflogCommits(reflog(`${SHA} commit: fix(bla-597): read the reflog`, "cd72bebc2ce64099f27f28e251be57cb53189328 commit (merge): merge the release branch"));
|
|
54
|
+
expect(shas).toEqual([SHA, "cd72bebc2ce64099f27f28e251be57cb53189328"]);
|
|
55
|
+
});
|
|
56
|
+
it("takes a revert and a cherry-pick, which always author here", () => {
|
|
57
|
+
const shas = parseReflogCommits(reflog('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa revert: Revert "feat: c2"', "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cherry-pick: feat: c3"));
|
|
58
|
+
expect(shas).toHaveLength(2);
|
|
59
|
+
});
|
|
60
|
+
/**
|
|
61
|
+
* Parent count looks like it would separate a merge you made from one you
|
|
62
|
+
* fast-forwarded onto, and does not: a plain `git pull` on a repository that merges
|
|
63
|
+
* its pull requests lands on a two-parent commit somebody else wrote. So merges are
|
|
64
|
+
* excluded, and a conflicted one is still caught as `commit (merge)`.
|
|
65
|
+
*/
|
|
66
|
+
it("ignores a clean merge rather than risk claiming a pulled one", () => {
|
|
67
|
+
const shas = parseReflogCommits(reflog("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa merge side: Merge made by the 'ort' strategy.", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb pull: Fast-forward"));
|
|
68
|
+
expect(shas).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
/**
|
|
71
|
+
* The whole point. A pull or a merge is one reflog entry however much history it
|
|
72
|
+
* brings — the range that replaced this declared 118 commits for one such merge.
|
|
73
|
+
*/
|
|
74
|
+
it("ignores history that merely arrived in the checkout", () => {
|
|
75
|
+
const shas = parseReflogCommits(reflog("4257246e3860ef8d834e4e4efbb7ed0da648ad23 pull: Fast-forward", "9f0ae840a64bae586d8e816124278ee0212ad0cc merge develop: Merge made by the 'ort' strategy.", "64d934042b0568a7acded2b059148dbc8f36ac83 checkout: moving from develop to a-branch", "6f59f8ad63a21000406480c17e766ea0706dff70 reset: moving to HEAD~1"));
|
|
76
|
+
expect(shas).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
/**
|
|
79
|
+
* A rebase writes new objects, but replaying a branch would declare work authored
|
|
80
|
+
* long before the session. Excluded; the pushed SHAs fall to the time-window rule.
|
|
81
|
+
*/
|
|
82
|
+
it("ignores rebase replays", () => {
|
|
83
|
+
const shas = parseReflogCommits(reflog("64d934042b0568a7acded2b059148dbc8f36ac83 rebase (start): checkout develop", "cd72bebc2ce64099f27f28e251be57cb53189328 rebase (pick): fix(bla-595): bound the range", "cd72bebc2ce64099f27f28e251be57cb53189328 rebase (finish): returning to a-branch"));
|
|
84
|
+
expect(shas).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
it("keeps an amend, whose old and new SHAs are both reflog commits", () => {
|
|
87
|
+
const shas = parseReflogCommits(reflog("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa commit (amend): fix(bla-597): reworded", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb commit: fix(bla-597): original"));
|
|
88
|
+
// Only the amended SHA is ever pushed; the original simply joins to nothing.
|
|
89
|
+
expect(shas).toHaveLength(2);
|
|
90
|
+
});
|
|
91
|
+
it("keeps an initial commit", () => {
|
|
92
|
+
expect(parseReflogCommits(`${SHA} commit (initial): base`)).toEqual([SHA]);
|
|
93
|
+
});
|
|
94
|
+
it("survives a subject containing the field separator", () => {
|
|
95
|
+
const shas = parseReflogCommits(`${SHA} commit: fix(bla-597): read HEAD, then the reflog`);
|
|
96
|
+
expect(shas).toEqual([SHA]);
|
|
97
|
+
});
|
|
98
|
+
it("does not repeat a SHA HEAD returned to", () => {
|
|
99
|
+
const shas = parseReflogCommits(reflog(`${SHA} commit: one`, `${SHA} commit (amend): one, reworded`));
|
|
100
|
+
expect(shas).toEqual([SHA]);
|
|
101
|
+
});
|
|
102
|
+
it("returns nothing for an empty or unreadable reflog", () => {
|
|
103
|
+
expect(parseReflogCommits("")).toEqual([]);
|
|
104
|
+
expect(parseReflogCommits(undefined)).toEqual([]);
|
|
105
|
+
expect(parseReflogCommits("not a reflog line")).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
it("caps what one session may declare", () => {
|
|
108
|
+
const lines = Array.from({ length: 250 }, (_, i) => `${i.toString(16).padStart(40, "0")} commit: commit ${i}`);
|
|
109
|
+
expect(parseReflogCommits(reflog(...lines))).toHaveLength(200);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
/**
|
|
113
|
+
* The sweep's upper bound is the state file's mtime, which is only a record of "last
|
|
114
|
+
* activity" on a host that rewrites the file as the session goes. Everywhere else the
|
|
115
|
+
* file is written once at SessionStart, so `until` equals `startedAt` and the window
|
|
116
|
+
* collapses onto a point — which is why `sweptCommits` refuses to read it at all for
|
|
117
|
+
* those hosts rather than relying on this shape.
|
|
118
|
+
*/
|
|
119
|
+
describe("the window a sweep would ask for", () => {
|
|
120
|
+
it("collapses to a point when until is the session start", () => {
|
|
121
|
+
const args = buildReflogArgs({
|
|
122
|
+
startedAt: STARTED_AT,
|
|
123
|
+
until: STARTED_AT,
|
|
124
|
+
});
|
|
125
|
+
const since = args.find((a) => a.startsWith("--since=")).slice(8);
|
|
126
|
+
const until = args.find((a) => a.startsWith("--until=")).slice(8);
|
|
127
|
+
expect(new Date(since).getTime()).toBe(new Date(until).getTime());
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses `git remote -v` into a deduplicated URL list, `origin` first.
|
|
3
|
+
*
|
|
4
|
+
* Order is load-bearing in one narrow way: the first entry is what a receiver older
|
|
5
|
+
* than BLA-598 reads as the only remote, so keeping `origin` there preserves the
|
|
6
|
+
* previous behaviour exactly.
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseGitRemotes(output: string | undefined): string[];
|
|
9
|
+
//# sourceMappingURL=git-remotes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"git-remotes.d.ts","sourceRoot":"","sources":["../../../src/lib/git-remotes.ts"],"names":[],"mappings":"AAWA;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,EAAE,CAkCpE"}
|