@brainervirus/workit-core 2.1.5 → 2.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/package.json +2 -1
- package/src/core/doctor.ts +45 -0
- package/src/core/methods.ts +5 -0
- package/src/core/store-lock.ts +301 -0
- package/src/core/task-contract.ts +121 -1
- package/src/core/task-store.ts +395 -77
- package/src/core.ts +1 -1
- package/src/git/rev.ts +736 -0
- package/src/{core/session-context.ts → hooks/context.ts} +95 -8
- package/src/hooks/descriptor.ts +113 -0
- package/src/hooks/handle.ts +111 -0
- package/src/hooks/hosts/claude-code.ts +281 -0
- package/src/hooks/hosts/codex.ts +298 -0
- package/src/hooks/hosts/cursor.ts +321 -0
- package/src/hooks/hosts/fields.ts +55 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +80 -0
- package/src/hooks/index.ts +43 -0
- package/src/hooks/policy.ts +14 -0
- package/src/hooks/protocol.ts +89 -0
- package/src/hooks/run.ts +52 -0
package/src/git/rev.ts
ADDED
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
// Pure git revision and push-target helpers for the CLI verbs (design §2.0,
|
|
2
|
+
// §2.2; D16). Plain TS on top of the git binary: no task store, no config
|
|
3
|
+
// reads, no zod, so any verb can import this without paying for the engine.
|
|
4
|
+
//
|
|
5
|
+
// - headSha / worktreeTree / mergeBase / patchId / remoteTip key evidence and
|
|
6
|
+
// verdicts to code state (fresh = same tree key, carried = same patch-id).
|
|
7
|
+
// - pushRemoteName / pushUrl / deriveForge / pushForge derive the forge from
|
|
8
|
+
// the PUSH remote host, honoring ~/.ssh/config Host aliases, instead of the
|
|
9
|
+
// configured provider (design §0 #6). forgeConflict turns a disagreement
|
|
10
|
+
// with the workspace provider into a `blocked` result with an unblock hint.
|
|
11
|
+
//
|
|
12
|
+
// Every git call is bounded: local reads time out after GIT_TIMEOUTS.local,
|
|
13
|
+
// worktree hashing after GIT_TIMEOUTS.worktree, and network calls after
|
|
14
|
+
// GIT_TIMEOUTS.network. Network calls run under a watchdog that kills git's
|
|
15
|
+
// whole process tree on timeout, with prompts disabled
|
|
16
|
+
// (networkGitInvocation). Remote URLs are never echoed raw: messages go through
|
|
17
|
+
// redactRemote, which drops credentials, query and fragment.
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
|
|
24
|
+
const MAX_BUFFER = 256 * 1024 * 1024;
|
|
25
|
+
|
|
26
|
+
/** Default bounds (ms) for git subprocesses; every network helper takes an override. */
|
|
27
|
+
export const GIT_TIMEOUTS = { local: 20_000, worktree: 120_000, network: 15_000 } as const;
|
|
28
|
+
|
|
29
|
+
type GitRun = { ok: boolean; stdout: string; stderr: string; timedOut: boolean };
|
|
30
|
+
|
|
31
|
+
const git = (
|
|
32
|
+
cwd: string,
|
|
33
|
+
args: string[],
|
|
34
|
+
options: { env?: NodeJS.ProcessEnv; input?: string; timeoutMs?: number } = {},
|
|
35
|
+
): GitRun => {
|
|
36
|
+
const result = spawnSync("git", args, {
|
|
37
|
+
cwd,
|
|
38
|
+
encoding: "utf8",
|
|
39
|
+
env: options.env ?? process.env,
|
|
40
|
+
input: options.input,
|
|
41
|
+
maxBuffer: MAX_BUFFER,
|
|
42
|
+
timeout: options.timeoutMs ?? GIT_TIMEOUTS.local,
|
|
43
|
+
killSignal: "SIGKILL",
|
|
44
|
+
windowsHide: true,
|
|
45
|
+
});
|
|
46
|
+
const error: NodeJS.ErrnoException | undefined = result.error;
|
|
47
|
+
return {
|
|
48
|
+
ok: result.status === 0,
|
|
49
|
+
stdout: result.stdout ?? "",
|
|
50
|
+
stderr: result.stderr ?? "",
|
|
51
|
+
timedOut: error?.code === "ETIMEDOUT",
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const line = (run: GitRun): string | null => {
|
|
56
|
+
if (!run.ok) return null;
|
|
57
|
+
const value = run.stdout.trim();
|
|
58
|
+
return value ? value : null;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// A ref or remote argument must never be read as an option by git.
|
|
62
|
+
const safeArg = (value: string): boolean => value.length > 0 && !value.startsWith("-");
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The exact invocation for a git call that may touch the network: git args
|
|
66
|
+
* with HTTP stall limits, and an environment that can never prompt.
|
|
67
|
+
*
|
|
68
|
+
* - GIT_TERMINAL_PROMPT=0 and empty GIT_ASKPASS/SSH_ASKPASS (an empty value
|
|
69
|
+
* stops git from falling back to core.askPass) mean no credential prompt.
|
|
70
|
+
* SSH_ASKPASS_REQUIRE=never and GCM_INTERACTIVE=never cover ssh and Git
|
|
71
|
+
* Credential Manager.
|
|
72
|
+
* - ssh gets `-o BatchMode=yes -o ConnectTimeout=N`, appended to an existing
|
|
73
|
+
* GIT_SSH_COMMAND or core.sshCommand. ssh takes the first value of each
|
|
74
|
+
* option, so the user's own settings still win. A bare GIT_SSH program
|
|
75
|
+
* (e.g. plink) is left alone because it may not accept `-o`.
|
|
76
|
+
* - HTTPS aborts once the transfer stays below 1 B/s for the whole budget
|
|
77
|
+
* (http.lowSpeedLimit/lowSpeedTime).
|
|
78
|
+
*/
|
|
79
|
+
export function networkGitInvocation(
|
|
80
|
+
cwd: string,
|
|
81
|
+
args: string[],
|
|
82
|
+
timeoutMs: number,
|
|
83
|
+
): { args: string[]; env: NodeJS.ProcessEnv } {
|
|
84
|
+
const seconds = Math.max(1, Math.ceil(timeoutMs / 1000));
|
|
85
|
+
const env: NodeJS.ProcessEnv = {
|
|
86
|
+
...process.env,
|
|
87
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
88
|
+
GIT_ASKPASS: "",
|
|
89
|
+
SSH_ASKPASS: "",
|
|
90
|
+
SSH_ASKPASS_REQUIRE: "never",
|
|
91
|
+
GCM_INTERACTIVE: "never",
|
|
92
|
+
};
|
|
93
|
+
const base =
|
|
94
|
+
process.env.GIT_SSH_COMMAND ||
|
|
95
|
+
line(git(cwd, ["config", "--get", "core.sshCommand"])) ||
|
|
96
|
+
(process.env.GIT_SSH ? null : "ssh");
|
|
97
|
+
if (base)
|
|
98
|
+
env.GIT_SSH_COMMAND = `${base} -o BatchMode=yes -o ConnectTimeout=${Math.min(10, seconds)}`;
|
|
99
|
+
return {
|
|
100
|
+
args: ["-c", "http.lowSpeedLimit=1", "-c", `http.lowSpeedTime=${seconds}`, ...args],
|
|
101
|
+
env,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Runs in a child runtime (node or bun, whichever runs workit). It starts git
|
|
106
|
+
// in its own process group and, on timeout, kills the whole group: git and
|
|
107
|
+
// every helper it spawned (git-remote-https with the URL in its argv, ssh).
|
|
108
|
+
// Leftover helpers are also killed after a normal exit. Windows has no
|
|
109
|
+
// process groups, so the tree is killed with `taskkill /T`.
|
|
110
|
+
const WATCHDOG = `
|
|
111
|
+
const cp = require("node:child_process");
|
|
112
|
+
const { ms, args } = JSON.parse(process.env.WORKIT_GIT_WATCHDOG);
|
|
113
|
+
delete process.env.WORKIT_GIT_WATCHDOG;
|
|
114
|
+
const win = process.platform === "win32";
|
|
115
|
+
const child = cp.spawn("git", args, { stdio: "inherit", detached: !win, windowsHide: true });
|
|
116
|
+
const killTree = () => {
|
|
117
|
+
if (!child.pid) return;
|
|
118
|
+
if (win) cp.spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
119
|
+
else try { process.kill(-child.pid, "SIGKILL"); } catch {}
|
|
120
|
+
};
|
|
121
|
+
let timedOut = false;
|
|
122
|
+
const timer = setTimeout(() => { timedOut = true; killTree(); }, ms);
|
|
123
|
+
child.on("error", () => { clearTimeout(timer); process.exit(127); });
|
|
124
|
+
child.on("exit", (code) => {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
if (!win) killTree();
|
|
127
|
+
process.exit(timedOut ? 124 : code ?? 1);
|
|
128
|
+
});
|
|
129
|
+
`;
|
|
130
|
+
|
|
131
|
+
const WATCHDOG_TIMEOUT_EXIT = 124;
|
|
132
|
+
|
|
133
|
+
/** A network git call under the process-group watchdog (see WATCHDOG). */
|
|
134
|
+
const gitNetwork = (cwd: string, args: string[], timeoutMs: number): GitRun => {
|
|
135
|
+
const invocation = networkGitInvocation(cwd, args, timeoutMs);
|
|
136
|
+
const result = spawnSync(process.execPath, ["-e", WATCHDOG], {
|
|
137
|
+
cwd,
|
|
138
|
+
encoding: "utf8",
|
|
139
|
+
env: {
|
|
140
|
+
...invocation.env,
|
|
141
|
+
WORKIT_GIT_WATCHDOG: JSON.stringify({ ms: timeoutMs, args: invocation.args }),
|
|
142
|
+
},
|
|
143
|
+
maxBuffer: MAX_BUFFER,
|
|
144
|
+
// Backstop only: the watchdog itself enforces timeoutMs.
|
|
145
|
+
timeout: timeoutMs + 10_000,
|
|
146
|
+
killSignal: "SIGKILL",
|
|
147
|
+
windowsHide: true,
|
|
148
|
+
});
|
|
149
|
+
const error: NodeJS.ErrnoException | undefined = result.error;
|
|
150
|
+
return {
|
|
151
|
+
ok: result.status === 0,
|
|
152
|
+
stdout: result.stdout ?? "",
|
|
153
|
+
stderr: result.stderr ?? "",
|
|
154
|
+
timedOut: result.status === WATCHDOG_TIMEOUT_EXIT || error?.code === "ETIMEDOUT",
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const UNPARSEABLE = "<unparseable remote>";
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A remote (URL or name) that is safe to print: userinfo (except an SSH login
|
|
162
|
+
* name), query and fragment removed. Anything that does not parse as a URL,
|
|
163
|
+
* an scp-like `user@host:path`, a remote name or a local path prints as
|
|
164
|
+
* `<unparseable remote>`.
|
|
165
|
+
*/
|
|
166
|
+
export function redactRemote(raw: string): string {
|
|
167
|
+
const value = raw.trim();
|
|
168
|
+
if (!value) return UNPARSEABLE;
|
|
169
|
+
// git's `<transport>::<address>` syntax (ext::<command>) can embed anything.
|
|
170
|
+
if (/^[A-Za-z][A-Za-z0-9+.-]*::/u.test(value)) return UNPARSEABLE;
|
|
171
|
+
if (/^[A-Za-z]:[\\/]/u.test(value) || value.startsWith("/") || value.startsWith("."))
|
|
172
|
+
return value;
|
|
173
|
+
// A remote name (origin) or a relative path carries no userinfo.
|
|
174
|
+
if (!/[:@]/u.test(value)) return value;
|
|
175
|
+
if (!value.includes("://")) {
|
|
176
|
+
// `user:secret@host:path` is not scp syntax; refuse rather than guess.
|
|
177
|
+
if (/^[^/@]*:[^/]*@/u.test(value)) return UNPARSEABLE;
|
|
178
|
+
const scp = /^(?:([^@\s/:]+)@)?([^:/\s]+):([^?#]+)/u.exec(value);
|
|
179
|
+
return scp ? `${scp[1] ? `${scp[1]}@` : ""}${scp[2]}:${scp[3]}` : UNPARSEABLE;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const url = new URL(value);
|
|
183
|
+
const ssh = /^(?:ssh|git\+ssh|ssh\+git):$/u.test(url.protocol);
|
|
184
|
+
url.password = "";
|
|
185
|
+
if (!ssh) url.username = "";
|
|
186
|
+
url.search = "";
|
|
187
|
+
url.hash = "";
|
|
188
|
+
return url.toString();
|
|
189
|
+
} catch {
|
|
190
|
+
return UNPARSEABLE;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The commit HEAD points at, or null (unborn HEAD, not a repository). */
|
|
195
|
+
export function headSha(cwd: string): string | null {
|
|
196
|
+
return line(git(cwd, ["rev-parse", "--verify", "-q", "HEAD^{commit}"]));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const emptyTree = (cwd: string): string | null => line(git(cwd, ["mktree"], { input: "" }));
|
|
200
|
+
|
|
201
|
+
/** Default cap for hashing an untracked file into the worktree key. */
|
|
202
|
+
export const MAX_UNTRACKED_BYTES = 5 * 1024 * 1024;
|
|
203
|
+
|
|
204
|
+
export type WorktreeTree = {
|
|
205
|
+
/** The git tree of the worktree (tracked + small untracked files). */
|
|
206
|
+
tree: string;
|
|
207
|
+
/**
|
|
208
|
+
* The freshness key: `tree` itself, or `sha256:<hex>` over `tree` plus the
|
|
209
|
+
* skipped files' path/size/mtime when any untracked file was over the cap.
|
|
210
|
+
*/
|
|
211
|
+
key: string;
|
|
212
|
+
dirty: boolean;
|
|
213
|
+
/** Untracked files over the cap, recorded by stat instead of content. */
|
|
214
|
+
skipped: Array<{ path: string; size: number; mtimeMs: number }>;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The tree the worktree would commit right now, including unstaged and
|
|
219
|
+
* untracked (non-ignored) files. Built in a throwaway index
|
|
220
|
+
* (GIT_INDEX_FILE + `git add -A` + `git write-tree`), so the real index, HEAD
|
|
221
|
+
* and every ref stay untouched. `dirty` is true when that tree differs from
|
|
222
|
+
* HEAD's tree (or from the empty tree on an unborn branch), or when an
|
|
223
|
+
* oversized untracked file exists.
|
|
224
|
+
*
|
|
225
|
+
* Untracked files larger than `maxUntrackedBytes` (default 5 MB: build
|
|
226
|
+
* output, dumps, media) are not hashed into the object store; they enter the
|
|
227
|
+
* key by path + size + mtime instead. Touching one changes the key, but an
|
|
228
|
+
* edit that keeps size and mtime is not seen. Tracked files are always hashed.
|
|
229
|
+
*/
|
|
230
|
+
export function worktreeTree(
|
|
231
|
+
cwd: string,
|
|
232
|
+
options: { maxUntrackedBytes?: number; timeoutMs?: number } = {},
|
|
233
|
+
): WorktreeTree | null {
|
|
234
|
+
const top = line(git(cwd, ["rev-parse", "--show-toplevel"]));
|
|
235
|
+
const indexPath = line(git(cwd, ["rev-parse", "--path-format=absolute", "--git-path", "index"]));
|
|
236
|
+
if (!top || !indexPath) return null;
|
|
237
|
+
const cap = options.maxUntrackedBytes ?? MAX_UNTRACKED_BYTES;
|
|
238
|
+
const timeoutMs = options.timeoutMs ?? GIT_TIMEOUTS.worktree;
|
|
239
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "workit-tree-"));
|
|
240
|
+
try {
|
|
241
|
+
const tempIndex = path.join(scratch, "index");
|
|
242
|
+
// Seeding from the real index keeps git's stat cache, so `add -A` only
|
|
243
|
+
// rehashes files that actually changed.
|
|
244
|
+
if (fs.existsSync(indexPath)) fs.copyFileSync(indexPath, tempIndex);
|
|
245
|
+
const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
|
|
246
|
+
const untracked = git(top, ["ls-files", "--others", "--exclude-standard", "-z"], {
|
|
247
|
+
env,
|
|
248
|
+
timeoutMs,
|
|
249
|
+
});
|
|
250
|
+
if (!untracked.ok) return null;
|
|
251
|
+
const skipped: WorktreeTree["skipped"] = [];
|
|
252
|
+
for (const file of untracked.stdout.split("\0").filter(Boolean)) {
|
|
253
|
+
try {
|
|
254
|
+
const stat = fs.statSync(path.join(top, file));
|
|
255
|
+
if (stat.isFile() && stat.size > cap)
|
|
256
|
+
skipped.push({ path: file, size: stat.size, mtimeMs: Math.trunc(stat.mtimeMs) });
|
|
257
|
+
} catch {
|
|
258
|
+
// Vanished between listing and stat: `add -A` decides.
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
skipped.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
262
|
+
const added = skipped.length
|
|
263
|
+
? git(top, ["add", "-A", "--pathspec-from-file=-", "--pathspec-file-nul"], {
|
|
264
|
+
env,
|
|
265
|
+
timeoutMs,
|
|
266
|
+
input: [".", ...skipped.map((file) => `:(exclude,literal)${file.path}`)].join("\0"),
|
|
267
|
+
})
|
|
268
|
+
: git(top, ["add", "-A"], { env, timeoutMs });
|
|
269
|
+
if (!added.ok) return null;
|
|
270
|
+
const tree = line(git(top, ["write-tree"], { env, timeoutMs }));
|
|
271
|
+
if (!tree) return null;
|
|
272
|
+
const base = line(git(top, ["rev-parse", "--verify", "-q", "HEAD^{tree}"])) ?? emptyTree(top);
|
|
273
|
+
const key = skipped.length
|
|
274
|
+
? `sha256:${createHash("sha256").update(JSON.stringify({ tree, skipped })).digest("hex")}`
|
|
275
|
+
: tree;
|
|
276
|
+
return { tree, key, dirty: tree !== base || skipped.length > 0, skipped };
|
|
277
|
+
} finally {
|
|
278
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The best common ancestor of two revisions, or null. */
|
|
283
|
+
export function mergeBase(cwd: string, a: string, b: string): string | null {
|
|
284
|
+
if (!safeArg(a) || !safeArg(b)) return null;
|
|
285
|
+
return line(git(cwd, ["merge-base", a, b]));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The stable patch-id of `merge-base(base, head)..head`: equal before and
|
|
290
|
+
* after a rebase that only moved the base (and while the base moves without a
|
|
291
|
+
* rebase), different once the change itself differs. Null when the range is
|
|
292
|
+
* empty or a revision does not resolve.
|
|
293
|
+
*
|
|
294
|
+
* Diff options are pinned (3 context lines, myers + indent heuristic, no
|
|
295
|
+
* renames, no color, external diff or textconv, fixed prefixes) so user
|
|
296
|
+
* config cannot change the id between machines. Caveat: patch-id hashes the
|
|
297
|
+
* context lines, so a base change landing within 3 lines of a hunk changes
|
|
298
|
+
* the id. That reads as stale, the safe direction.
|
|
299
|
+
*/
|
|
300
|
+
export function patchId(cwd: string, base: string, head: string): string | null {
|
|
301
|
+
if (!safeArg(base) || !safeArg(head)) return null;
|
|
302
|
+
const diff = git(cwd, [
|
|
303
|
+
"diff",
|
|
304
|
+
"-U3",
|
|
305
|
+
"--diff-algorithm=myers",
|
|
306
|
+
"--indent-heuristic",
|
|
307
|
+
"--no-color",
|
|
308
|
+
"--no-ext-diff",
|
|
309
|
+
"--no-textconv",
|
|
310
|
+
"--no-renames",
|
|
311
|
+
"--full-index",
|
|
312
|
+
"--src-prefix=a/",
|
|
313
|
+
"--dst-prefix=b/",
|
|
314
|
+
`${base}...${head}`,
|
|
315
|
+
"--",
|
|
316
|
+
]);
|
|
317
|
+
if (!diff.ok || !diff.stdout.trim()) return null;
|
|
318
|
+
const id = git(cwd, ["patch-id", "--stable"], { input: diff.stdout });
|
|
319
|
+
return line(id)?.split(/\s+/u)[0] ?? null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export type RemoteTipResult =
|
|
323
|
+
| { ok: true; sha: string | null }
|
|
324
|
+
| { ok: false; code: "invalid_input" | "unavailable"; error: string };
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The commit a remote branch points at (`git ls-remote`). `sha: null` means
|
|
328
|
+
* the remote answered and the branch does not exist; an unreachable remote,
|
|
329
|
+
* an auth prompt or a timeout is `unavailable`, never a hang.
|
|
330
|
+
*/
|
|
331
|
+
export function remoteTip(
|
|
332
|
+
cwd: string,
|
|
333
|
+
remote: string,
|
|
334
|
+
branch: string,
|
|
335
|
+
options: { timeoutMs?: number } = {},
|
|
336
|
+
): RemoteTipResult {
|
|
337
|
+
if (!safeArg(remote) || !safeArg(branch))
|
|
338
|
+
return { ok: false, code: "invalid_input", error: "remote and branch must not start with -" };
|
|
339
|
+
const timeoutMs = options.timeoutMs ?? GIT_TIMEOUTS.network;
|
|
340
|
+
const ref = `refs/heads/${branch}`;
|
|
341
|
+
const listed = gitNetwork(cwd, ["ls-remote", remote, ref], timeoutMs);
|
|
342
|
+
if (!listed.ok)
|
|
343
|
+
return {
|
|
344
|
+
ok: false,
|
|
345
|
+
code: "unavailable",
|
|
346
|
+
error: listed.timedOut
|
|
347
|
+
? `git ls-remote ${redactRemote(remote)} timed out after ${timeoutMs} ms`
|
|
348
|
+
: `git ls-remote ${redactRemote(remote)} failed (unreachable or not authorized)`,
|
|
349
|
+
};
|
|
350
|
+
for (const row of listed.stdout.split(/\r?\n/u)) {
|
|
351
|
+
const [sha, name] = row.split(/\s+/u);
|
|
352
|
+
if (name === ref && sha) return { ok: true, sha };
|
|
353
|
+
}
|
|
354
|
+
return { ok: true, sha: null };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const config = (cwd: string, key: string): string | null =>
|
|
358
|
+
line(git(cwd, ["config", "--get", key]));
|
|
359
|
+
|
|
360
|
+
/** The current branch name (also on an unborn branch), or null when detached. */
|
|
361
|
+
export function currentBranch(cwd: string): string | null {
|
|
362
|
+
return line(git(cwd, ["symbolic-ref", "--short", "-q", "HEAD"]));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The remote `git push` would use for `branch` (default: the current branch),
|
|
367
|
+
* in git's own precedence: branch.<b>.pushRemote, remote.pushDefault,
|
|
368
|
+
* branch.<b>.remote, then `origin`, then the only remote. Null when none.
|
|
369
|
+
*/
|
|
370
|
+
export function pushRemoteName(cwd: string, branch?: string | null): string | null {
|
|
371
|
+
const name = branch ?? currentBranch(cwd);
|
|
372
|
+
const remotes = (git(cwd, ["remote"]).stdout ?? "")
|
|
373
|
+
.split(/\r?\n/u)
|
|
374
|
+
.map((value) => value.trim())
|
|
375
|
+
.filter(Boolean);
|
|
376
|
+
const candidates = [
|
|
377
|
+
name ? config(cwd, `branch.${name}.pushRemote`) : null,
|
|
378
|
+
config(cwd, "remote.pushDefault"),
|
|
379
|
+
name ? config(cwd, `branch.${name}.remote`) : null,
|
|
380
|
+
];
|
|
381
|
+
for (const candidate of candidates)
|
|
382
|
+
if (candidate && candidate !== "." && remotes.includes(candidate)) return candidate;
|
|
383
|
+
if (remotes.includes("origin")) return "origin";
|
|
384
|
+
return remotes.length === 1 ? remotes[0] : null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The single push URL of `remote` after git's insteadOf/pushInsteadOf
|
|
389
|
+
* rewrites. Null when the remote is missing or pushes to several URLs (the
|
|
390
|
+
* target would be ambiguous).
|
|
391
|
+
*/
|
|
392
|
+
export function pushUrl(cwd: string, remote: string): string | null {
|
|
393
|
+
if (!safeArg(remote)) return null;
|
|
394
|
+
const urls = git(cwd, ["remote", "get-url", "--push", "--all", remote])
|
|
395
|
+
.stdout.split(/\r?\n/u)
|
|
396
|
+
.map((value) => value.trim())
|
|
397
|
+
.filter(Boolean);
|
|
398
|
+
return urls.length === 1 ? urls[0] : null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export type RemoteUrl = {
|
|
402
|
+
protocol: "ssh" | "https" | "http" | "git" | "file";
|
|
403
|
+
user: string | null;
|
|
404
|
+
/** Host as written in the URL (an SSH alias stays unresolved, case kept). */
|
|
405
|
+
host: string;
|
|
406
|
+
port: string | null;
|
|
407
|
+
/** Repository path without leading slash, trailing slash or `.git`. */
|
|
408
|
+
path: string;
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const cleanPath = (value: string): string =>
|
|
412
|
+
value
|
|
413
|
+
.replace(/^\/+/u, "")
|
|
414
|
+
.replace(/\/+$/u, "")
|
|
415
|
+
.replace(/\.git$/u, "");
|
|
416
|
+
|
|
417
|
+
/** Parse a git remote URL (scp-like `user@host:path` or URL syntax). */
|
|
418
|
+
export function parseRemoteUrl(raw: string): RemoteUrl | null {
|
|
419
|
+
const value = raw.trim();
|
|
420
|
+
if (!value || /^[A-Za-z][A-Za-z0-9+.-]*::/u.test(value)) return null;
|
|
421
|
+
// Local paths, including Windows drive paths that look scp-like (C:\repo).
|
|
422
|
+
if (/^[A-Za-z]:[\\/]/u.test(value) || value.startsWith("/") || value.startsWith(".")) {
|
|
423
|
+
return { protocol: "file", user: null, host: "", port: null, path: value };
|
|
424
|
+
}
|
|
425
|
+
if (!value.includes("://")) {
|
|
426
|
+
// `user:secret@host:path` is not scp syntax (see redactRemote).
|
|
427
|
+
if (/^[^/@]*:[^/]*@/u.test(value)) return null;
|
|
428
|
+
const scp = /^(?:([^@\s/:]+)@)?([^:/\s]+):(.+)$/u.exec(value);
|
|
429
|
+
if (!scp) return null;
|
|
430
|
+
return {
|
|
431
|
+
protocol: "ssh",
|
|
432
|
+
user: scp[1] ?? null,
|
|
433
|
+
// As written: ssh matches Host patterns case-insensitively and expands
|
|
434
|
+
// %h with the original spelling.
|
|
435
|
+
host: scp[2],
|
|
436
|
+
port: null,
|
|
437
|
+
path: cleanPath(scp[3]),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
let url: URL;
|
|
441
|
+
try {
|
|
442
|
+
url = new URL(value);
|
|
443
|
+
} catch {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
const scheme = url.protocol.replace(/:$/u, "");
|
|
447
|
+
const protocol =
|
|
448
|
+
scheme === "ssh" || scheme === "git+ssh" || scheme === "ssh+git"
|
|
449
|
+
? "ssh"
|
|
450
|
+
: scheme === "https" || scheme === "http" || scheme === "git" || scheme === "file"
|
|
451
|
+
? scheme
|
|
452
|
+
: null;
|
|
453
|
+
if (!protocol) return null;
|
|
454
|
+
return {
|
|
455
|
+
protocol,
|
|
456
|
+
// Never keep an HTTPS credential; an SSH user is part of the identity.
|
|
457
|
+
user: protocol === "ssh" && url.username ? decodeURIComponent(url.username) : null,
|
|
458
|
+
host: url.hostname.replace(/\.$/u, ""),
|
|
459
|
+
port: url.port || null,
|
|
460
|
+
path: cleanPath(decodeURIComponent(url.pathname)),
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
// ~/.ssh/config Host alias resolution: enough of ssh_config(5) for HostName
|
|
466
|
+
// and Port, with OpenSSH's semantics.
|
|
467
|
+
// - The first value obtained for each option wins.
|
|
468
|
+
// - Host patterns support `*`, `?` and `!negation`. They match the alias
|
|
469
|
+
// case-insensitively, and %h expands to the alias as written.
|
|
470
|
+
// - Include is read where it appears. Its directives apply only when the
|
|
471
|
+
// enclosing block matches (or at top level). Inside an inactive block,
|
|
472
|
+
// Host lines in the included file never match. After the Include, the
|
|
473
|
+
// enclosing block's state is restored, so its later directives still apply.
|
|
474
|
+
// - `Match` blocks are treated as non-matching: they depend on runtime state
|
|
475
|
+
// that a static read cannot evaluate.
|
|
476
|
+
|
|
477
|
+
const globToRegExp = (pattern: string): RegExp =>
|
|
478
|
+
new RegExp(
|
|
479
|
+
`^${pattern
|
|
480
|
+
.split("")
|
|
481
|
+
.map((char) =>
|
|
482
|
+
char === "*" ? ".*" : char === "?" ? "." : char.replace(/[.+^${}()|[\]\\]/gu, "\\$&"),
|
|
483
|
+
)
|
|
484
|
+
.join("")}$`,
|
|
485
|
+
"iu",
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
const hostMatches = (patterns: string[], host: string): boolean => {
|
|
489
|
+
let matched = false;
|
|
490
|
+
for (const pattern of patterns) {
|
|
491
|
+
const negated = pattern.startsWith("!");
|
|
492
|
+
if (globToRegExp(negated ? pattern.slice(1) : pattern).test(host)) {
|
|
493
|
+
if (negated) return false;
|
|
494
|
+
matched = true;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return matched;
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const tokens = (rest: string): string[] =>
|
|
501
|
+
[...rest.matchAll(/"([^"]*)"|(\S+)/gu)].map((match) => match[1] ?? match[2]);
|
|
502
|
+
|
|
503
|
+
type SshDirective = { key: string; args: string[] };
|
|
504
|
+
|
|
505
|
+
const directives = (text: string): SshDirective[] =>
|
|
506
|
+
text.split(/\r?\n/u).flatMap((raw) => {
|
|
507
|
+
const trimmed = raw.trim();
|
|
508
|
+
if (!trimmed || trimmed.startsWith("#")) return [];
|
|
509
|
+
const match = /^(\S+?)(?:\s*=\s*|\s+)(.*)$/u.exec(trimmed);
|
|
510
|
+
if (!match) return [];
|
|
511
|
+
return [{ key: match[1].toLowerCase(), args: tokens(match[2]) }];
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
/** An ssh_config text plus the home its relative `Include`s resolve against (~/.ssh). */
|
|
515
|
+
export type SshConfig = { text: string; home: string };
|
|
516
|
+
|
|
517
|
+
/** The user's ~/.ssh/config (empty when missing); Includes resolve lazily. */
|
|
518
|
+
export function readSshConfig(home: string = os.homedir()): SshConfig {
|
|
519
|
+
try {
|
|
520
|
+
return { text: fs.readFileSync(path.join(home, ".ssh", "config"), "utf8"), home };
|
|
521
|
+
} catch {
|
|
522
|
+
return { text: "", home };
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const includeFiles = (spec: string, home: string): string[] => {
|
|
527
|
+
const expanded = spec.startsWith("~") ? path.join(home, spec.slice(1)) : spec;
|
|
528
|
+
const absolute = path.isAbsolute(expanded) ? expanded : path.join(home, ".ssh", expanded);
|
|
529
|
+
const dir = path.dirname(absolute);
|
|
530
|
+
const base = path.basename(absolute);
|
|
531
|
+
if (!/[*?]/u.test(base)) return [absolute];
|
|
532
|
+
try {
|
|
533
|
+
const pattern = globToRegExp(base);
|
|
534
|
+
return fs
|
|
535
|
+
.readdirSync(dir)
|
|
536
|
+
.filter((name) => pattern.test(name))
|
|
537
|
+
.toSorted()
|
|
538
|
+
.map((name) => path.join(dir, name));
|
|
539
|
+
} catch {
|
|
540
|
+
return [];
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Resolve an SSH host alias: the effective HostName (with %h / %% expanded)
|
|
546
|
+
* and Port. An alias with no HostName resolves to itself. A plain string is
|
|
547
|
+
* a config text whose relative Includes resolve against the real home.
|
|
548
|
+
*/
|
|
549
|
+
export function resolveSshHost(
|
|
550
|
+
alias: string,
|
|
551
|
+
sshConfig: string | SshConfig,
|
|
552
|
+
): { hostname: string; port: string | null } {
|
|
553
|
+
const source =
|
|
554
|
+
typeof sshConfig === "string" ? { text: sshConfig, home: os.homedir() } : sshConfig;
|
|
555
|
+
let hostname: string | null = null;
|
|
556
|
+
let port: string | null = null;
|
|
557
|
+
const evaluate = (text: string, initiallyActive: boolean, neverMatch: boolean, depth: number) => {
|
|
558
|
+
let active = initiallyActive;
|
|
559
|
+
for (const { key, args } of directives(text)) {
|
|
560
|
+
if (key === "host") {
|
|
561
|
+
active = !neverMatch && hostMatches(args, alias);
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
if (key === "match") {
|
|
565
|
+
active = false;
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
if (key === "include") {
|
|
569
|
+
if (depth >= 16) continue;
|
|
570
|
+
for (const spec of args)
|
|
571
|
+
for (const file of includeFiles(spec, source.home)) {
|
|
572
|
+
let included: string;
|
|
573
|
+
try {
|
|
574
|
+
included = fs.readFileSync(file, "utf8");
|
|
575
|
+
} catch {
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
// OpenSSH: an Include inside an inactive block can never match,
|
|
579
|
+
// and the enclosing block's state is restored afterwards.
|
|
580
|
+
evaluate(included, active, neverMatch || !active, depth + 1);
|
|
581
|
+
}
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (!active || args.length === 0) continue;
|
|
585
|
+
if (key === "hostname" && hostname === null)
|
|
586
|
+
hostname = args[0].replace(/%(%|h)/gu, (_, token: string) => (token === "h" ? alias : "%"));
|
|
587
|
+
if (key === "port" && port === null) port = args[0];
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
evaluate(source.text, true, false, 0);
|
|
591
|
+
return { hostname: (hostname ?? alias).replace(/\.$/u, ""), port };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ---------------------------------------------------------------------------
|
|
595
|
+
// Forge derivation (D16): the push remote host decides github vs gitlab.
|
|
596
|
+
|
|
597
|
+
export type ForgeKind = "github" | "gitlab";
|
|
598
|
+
|
|
599
|
+
/** Self-hosted / enterprise hosts known from vcs config (`github.host`, `gitlab.host`). */
|
|
600
|
+
export type ForgeHosts = { github?: readonly string[]; gitlab?: readonly string[] };
|
|
601
|
+
|
|
602
|
+
export type DerivedForge = {
|
|
603
|
+
kind: ForgeKind;
|
|
604
|
+
/** Host as written in the remote URL (may be an SSH alias). */
|
|
605
|
+
host: string;
|
|
606
|
+
/** Host after SSH alias resolution. */
|
|
607
|
+
hostname: string;
|
|
608
|
+
/** Host the gh/glab API calls must target (GH_HOST / GITLAB_HOST). */
|
|
609
|
+
apiHost: string;
|
|
610
|
+
/** owner/name (GitHub) or group[/subgroup]/project (GitLab). */
|
|
611
|
+
repo: string;
|
|
612
|
+
/** How the kind was decided. */
|
|
613
|
+
via: "known_host" | "configured_host" | "host_name";
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
// Public forges and their SSH-over-443 endpoints.
|
|
617
|
+
const KNOWN_HOSTS: Record<string, { kind: ForgeKind; apiHost: string }> = {
|
|
618
|
+
"github.com": { kind: "github", apiHost: "github.com" },
|
|
619
|
+
"ssh.github.com": { kind: "github", apiHost: "github.com" },
|
|
620
|
+
"gitlab.com": { kind: "gitlab", apiHost: "gitlab.com" },
|
|
621
|
+
"altssh.gitlab.com": { kind: "gitlab", apiHost: "gitlab.com" },
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
const normalizeHost = (value: string): string =>
|
|
625
|
+
value
|
|
626
|
+
.trim()
|
|
627
|
+
.toLowerCase()
|
|
628
|
+
.replace(/^https?:\/\//u, "")
|
|
629
|
+
.replace(/\/.*$/u, "")
|
|
630
|
+
.replace(/\.$/u, "");
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Derive the forge from a remote URL. SSH aliases resolve through `sshConfig`
|
|
634
|
+
* (default: the user's ~/.ssh/config), so `git@github-work.com:org/repo` with
|
|
635
|
+
* `Host github-work.com / HostName github.com` is GitHub at github.com.
|
|
636
|
+
* Configured enterprise hosts win next; finally a host whose first label is
|
|
637
|
+
* exactly `github` or `gitlab` (gitlab.example.com) is taken at face value.
|
|
638
|
+
* Null when the host is unknown or the path has no owner/name.
|
|
639
|
+
*/
|
|
640
|
+
export function deriveForge(
|
|
641
|
+
url: string,
|
|
642
|
+
options: { hosts?: ForgeHosts; sshConfig?: string | SshConfig } = {},
|
|
643
|
+
): DerivedForge | null {
|
|
644
|
+
const parsed = parseRemoteUrl(url);
|
|
645
|
+
if (!parsed || parsed.protocol === "file" || !parsed.host) return null;
|
|
646
|
+
const segments = parsed.path.split("/").filter(Boolean);
|
|
647
|
+
if (segments.length < 2) return null;
|
|
648
|
+
const hostname = (
|
|
649
|
+
parsed.protocol === "ssh"
|
|
650
|
+
? resolveSshHost(parsed.host, options.sshConfig ?? readSshConfig()).hostname
|
|
651
|
+
: parsed.host
|
|
652
|
+
).toLowerCase();
|
|
653
|
+
const withPort =
|
|
654
|
+
parsed.protocol !== "ssh" && parsed.port ? `${hostname}:${parsed.port}` : hostname;
|
|
655
|
+
const base = { host: parsed.host, hostname, repo: segments.join("/") };
|
|
656
|
+
const known = KNOWN_HOSTS[hostname];
|
|
657
|
+
if (known) {
|
|
658
|
+
if (known.kind === "github" && segments.length !== 2) return null;
|
|
659
|
+
return { ...base, kind: known.kind, apiHost: known.apiHost, via: "known_host" };
|
|
660
|
+
}
|
|
661
|
+
for (const kind of ["github", "gitlab"] as const) {
|
|
662
|
+
const hosts = new Set((options.hosts?.[kind] ?? []).map(normalizeHost));
|
|
663
|
+
if (hosts.has(withPort) || hosts.has(hostname)) {
|
|
664
|
+
const apiHost = hosts.has(withPort) ? withPort : hostname;
|
|
665
|
+
return { ...base, kind, apiHost, via: "configured_host" };
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
const label = hostname.split(".")[0];
|
|
669
|
+
if ((label === "github" || label === "gitlab") && hostname.includes("."))
|
|
670
|
+
return { ...base, kind: label, apiHost: withPort, via: "host_name" };
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
export type PushForgeResult =
|
|
675
|
+
| { ok: true; remote: string; /** Redacted push URL. */ url: string; forge: DerivedForge }
|
|
676
|
+
| { ok: false; code: "not_found" | "unavailable"; error: string; unblock: string };
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* The forge behind the current branch's push remote. Fails closed with an
|
|
680
|
+
* unblock hint when there is no push remote, the push URL is ambiguous, or
|
|
681
|
+
* the host is not a recognizable GitHub/GitLab.
|
|
682
|
+
*/
|
|
683
|
+
export function pushForge(
|
|
684
|
+
cwd: string,
|
|
685
|
+
options: { branch?: string | null; hosts?: ForgeHosts; sshConfig?: string | SshConfig } = {},
|
|
686
|
+
): PushForgeResult {
|
|
687
|
+
const remote = pushRemoteName(cwd, options.branch);
|
|
688
|
+
if (!remote)
|
|
689
|
+
return {
|
|
690
|
+
ok: false,
|
|
691
|
+
code: "not_found",
|
|
692
|
+
error: "no push remote is configured for this branch",
|
|
693
|
+
unblock: "git remote add origin <url>",
|
|
694
|
+
};
|
|
695
|
+
const url = pushUrl(cwd, remote);
|
|
696
|
+
if (!url)
|
|
697
|
+
return {
|
|
698
|
+
ok: false,
|
|
699
|
+
code: "unavailable",
|
|
700
|
+
error: `remote "${remote}" has no single push URL`,
|
|
701
|
+
unblock: `git remote get-url --push --all ${remote} # keep exactly one push URL`,
|
|
702
|
+
};
|
|
703
|
+
const forge = deriveForge(url, options);
|
|
704
|
+
if (!forge) {
|
|
705
|
+
// Never echo the raw URL: it may carry a token (https://user:tok@…).
|
|
706
|
+
const host = parseRemoteUrl(url)?.host || null;
|
|
707
|
+
return {
|
|
708
|
+
ok: false,
|
|
709
|
+
code: "unavailable",
|
|
710
|
+
error: host
|
|
711
|
+
? `cannot tell whether push host "${host}" is GitHub or GitLab`
|
|
712
|
+
: `push URL ${redactRemote(url)} of remote "${remote}" is not a GitHub/GitLab URL`,
|
|
713
|
+
unblock: host
|
|
714
|
+
? `map the alias in ~/.ssh/config (Host ${host} / HostName github.com|gitlab.com) or set github.host / gitlab.host in ~/.config/workit/vcs.json`
|
|
715
|
+
: `git remote set-url --push ${remote} <ssh-or-https-url>`,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
return { ok: true, remote, url: redactRemote(url), forge };
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* D16: the push remote decides. A configured provider that disagrees is not
|
|
723
|
+
* silently overridden; it is `blocked` with the exact fix.
|
|
724
|
+
*/
|
|
725
|
+
export function forgeConflict(
|
|
726
|
+
derived: DerivedForge,
|
|
727
|
+
configuredProvider: string | null | undefined,
|
|
728
|
+
): { code: "blocked"; error: string; unblock: string } | null {
|
|
729
|
+
const configured = configuredProvider?.trim().toLowerCase();
|
|
730
|
+
if (!configured || configured === derived.kind) return null;
|
|
731
|
+
return {
|
|
732
|
+
code: "blocked",
|
|
733
|
+
error: `forge_mismatch: push remote ${derived.host} is ${derived.kind} but the workspace vcs.provider is ${configured}`,
|
|
734
|
+
unblock: `set vcs.provider to "${derived.kind}" for this repo in ~/.config/workit/workspaces.json (workit-github-override), or push to a ${configured} remote`,
|
|
735
|
+
};
|
|
736
|
+
}
|