@workit-dev/core 0.0.1 → 1.0.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 +22 -43
- package/package.json +56 -6
- package/schemas/config.json +74 -0
- package/schemas/vcs.json +79 -0
- package/schemas/workspaces.json +1029 -0
- package/schemas/youtrack.json +84 -0
- package/scripts/analyze-release-scope.ts +215 -0
- package/scripts/bundled-deps.json +131 -0
- package/scripts/fixtures/sample-plan.md +15 -0
- package/scripts/fixtures/sample-sdd/progress.md +2 -0
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +146 -0
- package/scripts/install-opencode-plugin.sh +79 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/lib/config-dir.sh +28 -0
- package/scripts/publish-changed-packages.ts +395 -0
- package/scripts/rewrite-workspace-deps.ts +53 -0
- package/scripts/run-cursor-mcp.sh +6 -0
- package/scripts/sync-release-manifests.ts +82 -0
- package/scripts/sync-runtime.sh +142 -0
- package/scripts/validate-cursor-marketplace.ts +171 -0
- package/scripts/verify-bundle-sources.ts +134 -0
- package/scripts/write-schemas.ts +16 -0
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- package/skills/workit-verify-app/references/template.md +51 -0
- package/src/autonomy.ts +910 -0
- package/src/check-config.ts +613 -0
- package/src/checks.ts +534 -0
- package/src/ci-waiter.ts +159 -0
- package/src/core/boundary.ts +55 -0
- package/src/core/branch-policy.ts +112 -0
- package/src/core/branch.ts +1192 -0
- package/src/core/changelog-gate.ts +349 -0
- package/src/core/changelog-merge.ts +214 -0
- package/src/core/changelog.ts +392 -0
- package/src/core/cleanup.ts +638 -0
- package/src/core/cli-locate.ts +173 -0
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-schemas.ts +653 -0
- package/src/core/config.ts +351 -0
- package/src/core/context-read.ts +144 -0
- package/src/core/docs-gate.ts +69 -0
- package/src/core/finding-evidence.ts +89 -0
- package/src/core/finding-queue.ts +36 -0
- package/src/core/git.ts +73 -0
- package/src/core/gitignore.ts +52 -0
- package/src/core/heavy-eta.ts +170 -0
- package/src/core/heavy-lease.ts +290 -0
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +83 -0
- package/src/core/heavy.ts +535 -0
- package/src/core/hygiene.ts +149 -0
- package/src/core/init.ts +218 -0
- package/src/core/live-session.ts +333 -0
- package/src/core/logger.ts +310 -0
- package/src/core/methods.ts +185 -0
- package/src/core/model-fallback.ts +208 -0
- package/src/core/node-resolve.mjs +249 -0
- package/src/core/operation-input.ts +632 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/bookkeeping.ts +119 -0
- package/src/core/policy/carry.ts +122 -0
- package/src/core/policy/check-evidence.ts +115 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +387 -0
- package/src/core/policy/judgment.ts +246 -0
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/policy-resolver.ts +94 -0
- package/src/core/pr-create.ts +448 -0
- package/src/core/pr-template.ts +162 -0
- package/src/core/release-notes.ts +197 -0
- package/src/core/release-tracks.ts +779 -0
- package/src/core/remotes.ts +436 -0
- package/src/core/repo-context.ts +542 -0
- package/src/core/repo-paths.ts +29 -0
- package/src/core/resources.ts +1299 -0
- package/src/core/role-models.ts +603 -0
- package/src/core/route-intent.ts +305 -0
- package/src/core/rules.ts +65 -0
- package/src/core/runtime-identity.ts +63 -0
- package/src/core/safe-write.ts +87 -0
- package/src/core/scope-glob.ts +56 -0
- package/src/core/scripts.ts +6 -0
- package/src/core/skill-manifests.ts +173 -0
- package/src/core/store-lock.ts +374 -0
- package/src/core/support-matrix.ts +24 -0
- package/src/core/task-context.ts +247 -0
- package/src/core/task-contract.ts +1279 -0
- package/src/core/task-delivery.ts +109 -0
- package/src/core/task-engine.ts +2248 -0
- package/src/core/task-evaluation.ts +1147 -0
- package/src/core/task-store.ts +2649 -0
- package/src/core/task-view.ts +180 -0
- package/src/core/templates.ts +22 -0
- package/src/core/tracker-issues.ts +174 -0
- package/src/core/vcs-config.ts +540 -0
- package/src/core/workers.ts +62 -0
- package/src/core/workit-paths.mjs +242 -0
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/core/workspaces.ts +1745 -0
- package/src/core/youtrack-backend.ts +288 -0
- package/src/core/youtrack-tools.ts +260 -0
- package/src/core/youtrack-writes.ts +73 -0
- package/src/core/youtrack-ytrack.ts +217 -0
- package/src/core/youtrack.ts +454 -0
- package/src/core/ytrack-skill.ts +297 -0
- package/src/core.ts +171 -0
- package/src/deslop/lex.ts +271 -0
- package/src/deslop/scan.ts +232 -0
- package/src/duration.ts +27 -0
- package/src/fanout-brief.ts +486 -0
- package/src/fanout-check.ts +455 -0
- package/src/fanout-landed.ts +370 -0
- package/src/fanout-stack.ts +108 -0
- package/src/fanout-status.ts +362 -0
- package/src/fanout-worktree.ts +893 -0
- package/src/fanout.ts +1473 -0
- package/src/forge/exec.ts +385 -0
- package/src/forge/github.ts +831 -0
- package/src/forge/gitlab.ts +906 -0
- package/src/forge/issue-forge.ts +209 -0
- package/src/forge/issue-link.ts +468 -0
- package/src/forge/issue-ops.ts +314 -0
- package/src/forge/merge-gate-base-red.ts +72 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-edit.ts +292 -0
- package/src/forge/pr-ops.ts +1109 -0
- package/src/forge/pr-status.ts +10 -0
- package/src/forge/redact.ts +195 -0
- package/src/forge/report.ts +994 -0
- package/src/forge/reruns.ts +132 -0
- package/src/forge/resolve.ts +509 -0
- package/src/forge/types.ts +242 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/default-base.ts +130 -0
- package/src/git/env-redirect.ts +28 -0
- package/src/git/facts.ts +122 -0
- package/src/git/ops.ts +1823 -0
- package/src/git/refs-read.ts +332 -0
- package/src/git/release.ts +1020 -0
- package/src/git/rev.ts +1175 -0
- package/src/git/session-hook.ts +597 -0
- package/src/git/stage.ts +63 -0
- package/src/hooks/agent-model.ts +149 -0
- package/src/hooks/budget-signal.ts +284 -0
- package/src/hooks/context.ts +461 -0
- package/src/hooks/cwd-normalize.ts +79 -0
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/delivery-verbs.ts +182 -0
- package/src/hooks/descriptor.ts +124 -0
- package/src/hooks/handle.ts +433 -0
- package/src/hooks/heavy-gate.ts +99 -0
- package/src/hooks/hook-runs.ts +130 -0
- package/src/hooks/hosts/claude-code.ts +380 -0
- package/src/hooks/hosts/codex.ts +372 -0
- package/src/hooks/hosts/cursor.ts +419 -0
- package/src/hooks/hosts/fields.ts +98 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +78 -0
- package/src/hooks/index.ts +60 -0
- package/src/hooks/model-limit.ts +378 -0
- package/src/hooks/model-switch.ts +35 -0
- package/src/hooks/policy.ts +29 -0
- package/src/hooks/process.ts +48 -0
- package/src/hooks/protocol.ts +135 -0
- package/src/hooks/raw-git.ts +2289 -0
- package/src/hooks/repeat-failure.ts +115 -0
- package/src/hooks/run.ts +54 -0
- package/src/hooks/session-marker.ts +151 -0
- package/src/hooks/shell-words.ts +532 -0
- package/src/hooks/skill-nudge.ts +295 -0
- package/src/hooks/spawn-gate.ts +72 -0
- package/src/hooks/standing-signal.ts +99 -0
- package/src/hooks/standing.ts +225 -0
- package/src/hooks/steering.ts +74 -0
- package/src/hooks/stop.ts +408 -0
- package/src/hooks/worktree-confine.ts +415 -0
- package/src/hooks/write-gate.ts +1174 -0
- package/src/host-session.ts +459 -0
- package/src/knowledge.ts +368 -0
- package/src/ledger.ts +2913 -0
- package/src/stack.ts +2570 -0
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/src/test-audit-mocks.ts +309 -0
- package/src/verdict-cover.ts +151 -0
- package/src/worktree-deps.ts +142 -0
- package/templates/headers.md +3 -0
- package/templates/hygiene/.editorconfig +8 -0
- package/templates/hygiene/.gitattributes +3 -0
- package/templates/hygiene/CHANGELOG.md +14 -0
- package/templates/hygiene/CONTRIBUTING.md +3 -0
- package/templates/hygiene/LICENSE +21 -0
- package/templates/hygiene/README.md +3 -0
- package/templates/issue-update.md +5 -0
- package/templates/plan-template.md +26 -0
- package/templates/spec-template.md +64 -0
- package/templates/workit-contract.md +13 -0
package/src/git/rev.ts
ADDED
|
@@ -0,0 +1,1175 @@
|
|
|
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
|
+
import { configFilePath } from "../core/config";
|
|
24
|
+
import { gitDirs } from "./facts";
|
|
25
|
+
import { readHeadBranch, readHeadCommit } from "./refs-read";
|
|
26
|
+
|
|
27
|
+
const MAX_BUFFER = 256 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
/** Default bounds (ms) for git subprocesses; every network helper takes an override. */
|
|
30
|
+
export const GIT_TIMEOUTS = { local: 20_000, worktree: 120_000, network: 15_000 } as const;
|
|
31
|
+
|
|
32
|
+
type GitRun = { ok: boolean; stdout: string; stderr: string; timedOut: boolean };
|
|
33
|
+
|
|
34
|
+
const git = (
|
|
35
|
+
cwd: string,
|
|
36
|
+
args: string[],
|
|
37
|
+
options: { env?: NodeJS.ProcessEnv; input?: string; timeoutMs?: number } = {},
|
|
38
|
+
): GitRun => {
|
|
39
|
+
const result = spawnSync("git", args, {
|
|
40
|
+
cwd,
|
|
41
|
+
encoding: "utf8",
|
|
42
|
+
env: options.env ?? process.env,
|
|
43
|
+
input: options.input,
|
|
44
|
+
maxBuffer: MAX_BUFFER,
|
|
45
|
+
timeout: options.timeoutMs ?? GIT_TIMEOUTS.local,
|
|
46
|
+
killSignal: "SIGKILL",
|
|
47
|
+
windowsHide: true,
|
|
48
|
+
});
|
|
49
|
+
const error: NodeJS.ErrnoException | undefined = result.error;
|
|
50
|
+
return {
|
|
51
|
+
ok: result.status === 0,
|
|
52
|
+
stdout: result.stdout ?? "",
|
|
53
|
+
stderr: result.stderr ?? "",
|
|
54
|
+
timedOut: error?.code === "ETIMEDOUT",
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const line = (run: GitRun): string | null => {
|
|
59
|
+
if (!run.ok) return null;
|
|
60
|
+
const value = run.stdout.trim();
|
|
61
|
+
return value ? value : null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// A ref or remote argument must never be read as an option by git.
|
|
65
|
+
const safeArg = (value: string): boolean => value.length > 0 && !value.startsWith("-");
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The exact invocation for a git call that may touch the network: git args
|
|
69
|
+
* with HTTP stall limits, and an environment that can never prompt.
|
|
70
|
+
*
|
|
71
|
+
* - GIT_TERMINAL_PROMPT=0 and empty GIT_ASKPASS/SSH_ASKPASS (an empty value
|
|
72
|
+
* stops git from falling back to core.askPass) mean no credential prompt.
|
|
73
|
+
* SSH_ASKPASS_REQUIRE=never and GCM_INTERACTIVE=never cover ssh and Git
|
|
74
|
+
* Credential Manager.
|
|
75
|
+
* - ssh gets `-o BatchMode=yes -o ConnectTimeout=N`, appended to an existing
|
|
76
|
+
* GIT_SSH_COMMAND or core.sshCommand. ssh takes the first value of each
|
|
77
|
+
* option, so the user's own settings still win. A bare GIT_SSH program
|
|
78
|
+
* (e.g. plink) is left alone because it may not accept `-o`.
|
|
79
|
+
* - HTTPS aborts once the transfer stays below 1 B/s for the whole budget
|
|
80
|
+
* (http.lowSpeedLimit/lowSpeedTime).
|
|
81
|
+
*/
|
|
82
|
+
export function networkGitInvocation(
|
|
83
|
+
cwd: string,
|
|
84
|
+
args: string[],
|
|
85
|
+
timeoutMs: number,
|
|
86
|
+
): { args: string[]; env: NodeJS.ProcessEnv } {
|
|
87
|
+
const seconds = Math.max(1, Math.ceil(timeoutMs / 1000));
|
|
88
|
+
const env: NodeJS.ProcessEnv = {
|
|
89
|
+
...process.env,
|
|
90
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
91
|
+
GIT_ASKPASS: "",
|
|
92
|
+
SSH_ASKPASS: "",
|
|
93
|
+
SSH_ASKPASS_REQUIRE: "never",
|
|
94
|
+
GCM_INTERACTIVE: "never",
|
|
95
|
+
};
|
|
96
|
+
const base =
|
|
97
|
+
process.env.GIT_SSH_COMMAND ||
|
|
98
|
+
line(git(cwd, ["config", "--get", "core.sshCommand"])) ||
|
|
99
|
+
(process.env.GIT_SSH ? null : "ssh");
|
|
100
|
+
if (base)
|
|
101
|
+
env.GIT_SSH_COMMAND = `${base} -o BatchMode=yes -o ConnectTimeout=${Math.min(10, seconds)}`;
|
|
102
|
+
return {
|
|
103
|
+
args: ["-c", "http.lowSpeedLimit=1", "-c", `http.lowSpeedTime=${seconds}`, ...args],
|
|
104
|
+
env,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Runs in a child runtime (node or bun, whichever runs workit). It starts git
|
|
109
|
+
// in its own process group and, on timeout, kills the whole group: git and
|
|
110
|
+
// every helper it spawned (git-remote-https with the URL in its argv, ssh).
|
|
111
|
+
// Leftover helpers are also killed after a normal exit. Windows has no
|
|
112
|
+
// process groups, so the tree is killed with `taskkill /T`.
|
|
113
|
+
const WATCHDOG = `
|
|
114
|
+
const cp = require("node:child_process");
|
|
115
|
+
const { ms, args } = JSON.parse(process.env.WORKIT_GIT_WATCHDOG);
|
|
116
|
+
delete process.env.WORKIT_GIT_WATCHDOG;
|
|
117
|
+
const win = process.platform === "win32";
|
|
118
|
+
const child = cp.spawn("git", args, { stdio: "inherit", detached: !win, windowsHide: true });
|
|
119
|
+
const killTree = () => {
|
|
120
|
+
if (!child.pid) return;
|
|
121
|
+
if (win) cp.spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
122
|
+
else try { process.kill(-child.pid, "SIGKILL"); } catch {}
|
|
123
|
+
};
|
|
124
|
+
let timedOut = false;
|
|
125
|
+
const timer = setTimeout(() => { timedOut = true; killTree(); }, ms);
|
|
126
|
+
child.on("error", () => { clearTimeout(timer); process.exit(127); });
|
|
127
|
+
child.on("exit", (code) => {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
if (!win) killTree();
|
|
130
|
+
process.exit(timedOut ? 124 : code ?? 1);
|
|
131
|
+
});
|
|
132
|
+
`;
|
|
133
|
+
|
|
134
|
+
const WATCHDOG_TIMEOUT_EXIT = 124;
|
|
135
|
+
|
|
136
|
+
/** A network git call (push, fetch, ls-remote) under the process-group watchdog (see WATCHDOG). */
|
|
137
|
+
export const gitNetwork = (cwd: string, args: string[], timeoutMs: number): GitRun => {
|
|
138
|
+
const invocation = networkGitInvocation(cwd, args, timeoutMs);
|
|
139
|
+
const result = spawnSync(process.execPath, ["-e", WATCHDOG], {
|
|
140
|
+
cwd,
|
|
141
|
+
encoding: "utf8",
|
|
142
|
+
env: {
|
|
143
|
+
...invocation.env,
|
|
144
|
+
WORKIT_GIT_WATCHDOG: JSON.stringify({ ms: timeoutMs, args: invocation.args }),
|
|
145
|
+
},
|
|
146
|
+
maxBuffer: MAX_BUFFER,
|
|
147
|
+
// Backstop only: the watchdog itself enforces timeoutMs.
|
|
148
|
+
timeout: timeoutMs + 10_000,
|
|
149
|
+
killSignal: "SIGKILL",
|
|
150
|
+
windowsHide: true,
|
|
151
|
+
});
|
|
152
|
+
const error: NodeJS.ErrnoException | undefined = result.error;
|
|
153
|
+
return {
|
|
154
|
+
ok: result.status === 0,
|
|
155
|
+
stdout: result.stdout ?? "",
|
|
156
|
+
stderr: result.stderr ?? "",
|
|
157
|
+
timedOut: result.status === WATCHDOG_TIMEOUT_EXIT || error?.code === "ETIMEDOUT",
|
|
158
|
+
};
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const UNPARSEABLE = "<unparseable remote>";
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* A remote (URL or name) that is safe to print: userinfo (except an SSH login
|
|
165
|
+
* name), query and fragment removed. Anything that does not parse as a URL,
|
|
166
|
+
* an scp-like `user@host:path`, a remote name or a local path prints as
|
|
167
|
+
* `<unparseable remote>`.
|
|
168
|
+
*/
|
|
169
|
+
export function redactRemote(raw: string): string {
|
|
170
|
+
const value = raw.trim();
|
|
171
|
+
if (!value) return UNPARSEABLE;
|
|
172
|
+
// git's `<transport>::<address>` syntax (ext::<command>) can embed anything.
|
|
173
|
+
if (/^[A-Za-z][A-Za-z0-9+.-]*::/u.test(value)) return UNPARSEABLE;
|
|
174
|
+
if (/^[A-Za-z]:[\\/]/u.test(value) || value.startsWith("/") || value.startsWith("."))
|
|
175
|
+
return value;
|
|
176
|
+
// A remote name (origin) or a relative path carries no userinfo.
|
|
177
|
+
if (!/[:@]/u.test(value)) return value;
|
|
178
|
+
if (!value.includes("://")) {
|
|
179
|
+
// `user:secret@host:path` is not scp syntax; refuse rather than guess.
|
|
180
|
+
if (/^[^/@]*:[^/]*@/u.test(value)) return UNPARSEABLE;
|
|
181
|
+
const scp = /^(?:([^@\s/:]+)@)?([^:/\s]+):([^?#]+)/u.exec(value);
|
|
182
|
+
return scp ? `${scp[1] ? `${scp[1]}@` : ""}${scp[2]}:${scp[3]}` : UNPARSEABLE;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const url = new URL(value);
|
|
186
|
+
const ssh = /^(?:ssh|git\+ssh|ssh\+git):$/u.test(url.protocol);
|
|
187
|
+
url.password = "";
|
|
188
|
+
if (!ssh) url.username = "";
|
|
189
|
+
url.search = "";
|
|
190
|
+
url.hash = "";
|
|
191
|
+
return url.toString();
|
|
192
|
+
} catch {
|
|
193
|
+
return UNPARSEABLE;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The commit HEAD points at, or null (unborn HEAD, not a repository). */
|
|
198
|
+
export function headSha(cwd: string): string | null {
|
|
199
|
+
return readHeadCommit(cwd) ?? line(git(cwd, ["rev-parse", "--verify", "-q", "HEAD^{commit}"]));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const emptyTree = (cwd: string): string | null => line(git(cwd, ["mktree"], { input: "" }));
|
|
203
|
+
|
|
204
|
+
/** Default cap for hashing an untracked file into the worktree key. */
|
|
205
|
+
export const MAX_UNTRACKED_BYTES = 5 * 1024 * 1024;
|
|
206
|
+
|
|
207
|
+
export type WorktreeTree = {
|
|
208
|
+
/** The git tree of the worktree (tracked + small untracked files). */
|
|
209
|
+
tree: string;
|
|
210
|
+
/**
|
|
211
|
+
* The freshness key: `tree` itself, or `sha256:<hex>` over `tree` plus the
|
|
212
|
+
* skipped files' path/size/mtime when any untracked file was over the cap.
|
|
213
|
+
*/
|
|
214
|
+
key: string;
|
|
215
|
+
dirty: boolean;
|
|
216
|
+
/** Untracked files over the cap, recorded by stat instead of content. */
|
|
217
|
+
skipped: Array<{ path: string; size: number; mtimeMs: number }>;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Copy the index and keep its mtime. Git trusts an entry's cached stat only
|
|
222
|
+
* when the entry is older than the index file ("racy git"). An entry written
|
|
223
|
+
* in the same timestamp tick as the index is re-hashed instead. A plain copy
|
|
224
|
+
* stamps the index with the current time, so a same-size rewrite in that
|
|
225
|
+
* tick would read as clean. That is common on git builds that compare whole
|
|
226
|
+
* seconds (macOS). The copy's mtime is floored one microsecond below the
|
|
227
|
+
* original: an older index only makes more entries racy, which is the safe
|
|
228
|
+
* direction.
|
|
229
|
+
*/
|
|
230
|
+
const seedIndex = (from: string, to: string): void => {
|
|
231
|
+
fs.copyFileSync(from, to);
|
|
232
|
+
const { atimeNs, mtimeNs } = fs.statSync(from, { bigint: true });
|
|
233
|
+
const seconds = (ns: bigint): number => (Number(ns / 1000n) - 1) / 1e6;
|
|
234
|
+
fs.utimesSync(to, seconds(atimeNs), seconds(mtimeNs));
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The tree the worktree would commit right now, including unstaged and
|
|
239
|
+
* untracked (non-ignored) files. Built in a throwaway index
|
|
240
|
+
* (GIT_INDEX_FILE + `git add -A` + `git write-tree`), so the real index, HEAD
|
|
241
|
+
* and every ref stay untouched. `dirty` is true when that tree differs from
|
|
242
|
+
* HEAD's tree (or from the empty tree on an unborn branch), or when an
|
|
243
|
+
* oversized untracked file exists.
|
|
244
|
+
*
|
|
245
|
+
* Untracked files larger than `maxUntrackedBytes` (default 5 MB: build
|
|
246
|
+
* output, dumps, media) are not hashed into the object store; they enter the
|
|
247
|
+
* key by path + size + mtime instead. Touching one changes the key, but an
|
|
248
|
+
* edit that keeps size and mtime is not seen. Tracked files are always hashed.
|
|
249
|
+
*/
|
|
250
|
+
export function worktreeTree(
|
|
251
|
+
cwd: string,
|
|
252
|
+
options: { maxUntrackedBytes?: number; timeoutMs?: number } = {},
|
|
253
|
+
): WorktreeTree | null {
|
|
254
|
+
const dirs = gitDirs(cwd);
|
|
255
|
+
const top = dirs?.top ?? null;
|
|
256
|
+
// `--git-path index` is the git dir's index unless GIT_INDEX_FILE moves it.
|
|
257
|
+
const indexPath =
|
|
258
|
+
dirs && process.env.GIT_INDEX_FILE === undefined
|
|
259
|
+
? `${dirs.gitDir}/index` // git's own form (forward slashes on Windows too)
|
|
260
|
+
: line(git(cwd, ["rev-parse", "--path-format=absolute", "--git-path", "index"]));
|
|
261
|
+
if (!top || !indexPath) return null;
|
|
262
|
+
const cap = options.maxUntrackedBytes ?? MAX_UNTRACKED_BYTES;
|
|
263
|
+
const timeoutMs = options.timeoutMs ?? GIT_TIMEOUTS.worktree;
|
|
264
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "workit-tree-"));
|
|
265
|
+
try {
|
|
266
|
+
const tempIndex = path.join(scratch, "index");
|
|
267
|
+
// Seeding from the real index keeps git's stat cache, so `add -A` only
|
|
268
|
+
// rehashes files that actually changed.
|
|
269
|
+
if (fs.existsSync(indexPath)) seedIndex(indexPath, tempIndex);
|
|
270
|
+
const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
|
|
271
|
+
const untracked = git(top, ["ls-files", "--others", "--exclude-standard", "-z"], {
|
|
272
|
+
env,
|
|
273
|
+
timeoutMs,
|
|
274
|
+
});
|
|
275
|
+
if (!untracked.ok) return null;
|
|
276
|
+
const skipped: WorktreeTree["skipped"] = [];
|
|
277
|
+
for (const file of untracked.stdout.split("\0").filter(Boolean)) {
|
|
278
|
+
try {
|
|
279
|
+
const stat = fs.statSync(path.join(top, file));
|
|
280
|
+
if (stat.isFile() && stat.size > cap)
|
|
281
|
+
skipped.push({ path: file, size: stat.size, mtimeMs: Math.trunc(stat.mtimeMs) });
|
|
282
|
+
} catch {
|
|
283
|
+
// Vanished between listing and stat: `add -A` decides.
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
skipped.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
287
|
+
const added = skipped.length
|
|
288
|
+
? git(top, ["add", "-A", "--pathspec-from-file=-", "--pathspec-file-nul"], {
|
|
289
|
+
env,
|
|
290
|
+
timeoutMs,
|
|
291
|
+
input: [".", ...skipped.map((file) => `:(exclude,literal)${file.path}`)].join("\0"),
|
|
292
|
+
})
|
|
293
|
+
: git(top, ["add", "-A"], { env, timeoutMs });
|
|
294
|
+
if (!added.ok) return null;
|
|
295
|
+
const tree = line(git(top, ["write-tree"], { env, timeoutMs }));
|
|
296
|
+
if (!tree) return null;
|
|
297
|
+
const base = line(git(top, ["rev-parse", "--verify", "-q", "HEAD^{tree}"])) ?? emptyTree(top);
|
|
298
|
+
const key = skipped.length
|
|
299
|
+
? `sha256:${createHash("sha256").update(JSON.stringify({ tree, skipped })).digest("hex")}`
|
|
300
|
+
: tree;
|
|
301
|
+
return { tree, key, dirty: tree !== base || skipped.length > 0, skipped };
|
|
302
|
+
} finally {
|
|
303
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* A cheap worktree freshness signal for per-turn paths: sha256 over HEAD and,
|
|
309
|
+
* for every entry `git status` reports (untracked files listed one by one),
|
|
310
|
+
* its path, lstat mode, size and mtime. A directory entry (a submodule or an
|
|
311
|
+
* untracked nested repository) also contributes its own HEAD, so moving a
|
|
312
|
+
* submodule or committing in a nested repo changes the signal. Git answers
|
|
313
|
+
* from its stat cache and, with GIT_OPTIONAL_LOCKS=0, never refreshes the
|
|
314
|
+
* index, and nothing is written to the object store (unlike worktreeTree).
|
|
315
|
+
* Equal signals mean "probably unchanged": an edit to an already-dirty file
|
|
316
|
+
* that keeps its mode, size and mtime is not seen, so gates that decide
|
|
317
|
+
* (close, inspect) use worktreeTree. Null outside a repository.
|
|
318
|
+
*/
|
|
319
|
+
export function worktreeSignal(cwd: string, options: { timeoutMs?: number } = {}): string | null {
|
|
320
|
+
const top = gitDirs(cwd)?.top ?? null;
|
|
321
|
+
if (!top) return null;
|
|
322
|
+
const status = git(
|
|
323
|
+
top,
|
|
324
|
+
[
|
|
325
|
+
"status",
|
|
326
|
+
"--porcelain=v1",
|
|
327
|
+
"-z",
|
|
328
|
+
"--untracked-files=all",
|
|
329
|
+
"--no-renames",
|
|
330
|
+
"--ignore-submodules=dirty",
|
|
331
|
+
],
|
|
332
|
+
{
|
|
333
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
|
|
334
|
+
timeoutMs: options.timeoutMs ?? GIT_TIMEOUTS.worktree,
|
|
335
|
+
},
|
|
336
|
+
);
|
|
337
|
+
if (!status.ok) return null;
|
|
338
|
+
const hash = createHash("sha256");
|
|
339
|
+
hash.update(`${headSha(top) ?? "unborn"}\0`);
|
|
340
|
+
for (const entry of status.stdout.split("\0")) {
|
|
341
|
+
if (entry.length < 4) continue;
|
|
342
|
+
const file = entry.slice(3);
|
|
343
|
+
const absolute = path.join(top, file);
|
|
344
|
+
let stat = "-";
|
|
345
|
+
try {
|
|
346
|
+
const info = fs.lstatSync(absolute, { bigint: true });
|
|
347
|
+
stat = `${info.mode.toString(8)}:${info.size}:${info.mtimeNs}`;
|
|
348
|
+
if (info.isDirectory()) stat += `:${headSha(absolute) ?? "-"}`;
|
|
349
|
+
} catch {
|
|
350
|
+
// Deleted: the status code alone records it.
|
|
351
|
+
}
|
|
352
|
+
hash.update(`${entry.slice(0, 2)}\0${file}\0${stat}\0`);
|
|
353
|
+
}
|
|
354
|
+
return `sig:${hash.digest("hex")}`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** The best common ancestor of two revisions, or null. */
|
|
358
|
+
export function mergeBase(cwd: string, a: string, b: string): string | null {
|
|
359
|
+
if (!safeArg(a) || !safeArg(b)) return null;
|
|
360
|
+
return line(git(cwd, ["merge-base", a, b]));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* The stable patch-id of `merge-base(base, head)..head`: equal before and
|
|
365
|
+
* after a rebase that only moved the base (and while the base moves without a
|
|
366
|
+
* rebase), different once the change itself differs. Null when the range is
|
|
367
|
+
* empty or a revision does not resolve.
|
|
368
|
+
*
|
|
369
|
+
* Diff options are pinned (3 context lines, myers + indent heuristic, no
|
|
370
|
+
* renames, no color, external diff or textconv, fixed prefixes) so user
|
|
371
|
+
* config cannot change the id between machines. Caveat: patch-id hashes the
|
|
372
|
+
* context lines, so a base change landing within 3 lines of a hunk changes
|
|
373
|
+
* the id. That reads as stale, the safe direction.
|
|
374
|
+
*/
|
|
375
|
+
export function patchId(cwd: string, base: string, head: string): string | null {
|
|
376
|
+
if (!safeArg(base) || !safeArg(head)) return null;
|
|
377
|
+
const diff = git(cwd, ["diff", ...PINNED_DIFF, `${base}...${head}`, "--"]);
|
|
378
|
+
return diff.ok ? patchIdOfDiff(cwd, diff.stdout) : null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The pinned `git diff` options patch-ids (and ledger diff hashes) are computed with. */
|
|
382
|
+
export const PINNED_DIFF: readonly string[] = [
|
|
383
|
+
"-U3",
|
|
384
|
+
"--diff-algorithm=myers",
|
|
385
|
+
"--indent-heuristic",
|
|
386
|
+
"--no-color",
|
|
387
|
+
"--no-ext-diff",
|
|
388
|
+
"--no-textconv",
|
|
389
|
+
"--no-renames",
|
|
390
|
+
"--full-index",
|
|
391
|
+
"--src-prefix=a/",
|
|
392
|
+
"--dst-prefix=b/",
|
|
393
|
+
];
|
|
394
|
+
|
|
395
|
+
/** The stable patch-id of a diff made with PINNED_DIFF; null for an empty one. */
|
|
396
|
+
export function patchIdOfDiff(cwd: string, diff: string): string | null {
|
|
397
|
+
if (!diff.trim()) return null;
|
|
398
|
+
const id = git(cwd, ["patch-id", "--stable"], { input: diff });
|
|
399
|
+
return line(id)?.split(/\s+/u)[0] ?? null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export type RemoteTipResult =
|
|
403
|
+
| { ok: true; sha: string | null }
|
|
404
|
+
| { ok: false; code: "invalid_input" | "unavailable"; error: string };
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* The commit a remote branch points at (`git ls-remote`). `sha: null` means
|
|
408
|
+
* the remote answered and the branch does not exist; an unreachable remote,
|
|
409
|
+
* an auth prompt or a timeout is `unavailable`, never a hang.
|
|
410
|
+
*/
|
|
411
|
+
export function remoteTip(
|
|
412
|
+
cwd: string,
|
|
413
|
+
remote: string,
|
|
414
|
+
branch: string,
|
|
415
|
+
options: { timeoutMs?: number } = {},
|
|
416
|
+
): RemoteTipResult {
|
|
417
|
+
if (!safeArg(branch))
|
|
418
|
+
return { ok: false, code: "invalid_input", error: "remote and branch must not start with -" };
|
|
419
|
+
return remoteRefTip(cwd, remote, `refs/heads/${branch}`, options);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The commit a full remote ref points at (`refs/heads/x`, `refs/tags/v1`); an
|
|
424
|
+
* annotated tag resolves to the commit it peels to. Same contract as remoteTip.
|
|
425
|
+
*/
|
|
426
|
+
export function remoteRefTip(
|
|
427
|
+
cwd: string,
|
|
428
|
+
remote: string,
|
|
429
|
+
ref: string,
|
|
430
|
+
options: { timeoutMs?: number } = {},
|
|
431
|
+
): RemoteTipResult {
|
|
432
|
+
if (!safeArg(remote) || !safeArg(ref))
|
|
433
|
+
return { ok: false, code: "invalid_input", error: "remote and ref must not start with -" };
|
|
434
|
+
const timeoutMs = options.timeoutMs ?? GIT_TIMEOUTS.network;
|
|
435
|
+
const listed = gitNetwork(cwd, ["ls-remote", remote, ref, `${ref}^{}`], timeoutMs);
|
|
436
|
+
if (!listed.ok)
|
|
437
|
+
return {
|
|
438
|
+
ok: false,
|
|
439
|
+
code: "unavailable",
|
|
440
|
+
error: listed.timedOut
|
|
441
|
+
? `git ls-remote ${redactRemote(remote)} timed out after ${timeoutMs} ms`
|
|
442
|
+
: `git ls-remote ${redactRemote(remote)} failed (unreachable or not authorized)`,
|
|
443
|
+
};
|
|
444
|
+
let direct: string | null = null;
|
|
445
|
+
for (const row of listed.stdout.split(/\r?\n/u)) {
|
|
446
|
+
const [sha, name] = row.split(/\s+/u);
|
|
447
|
+
if (!sha) continue;
|
|
448
|
+
if (name === `${ref}^{}`) return { ok: true, sha };
|
|
449
|
+
if (name === ref) direct = sha;
|
|
450
|
+
}
|
|
451
|
+
return { ok: true, sha: direct };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export type FetchResult =
|
|
455
|
+
| { ok: true }
|
|
456
|
+
| { ok: false; code: "invalid_input" | "unavailable" | "busy"; error: string };
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* `git fetch --no-tags --no-recurse-submodules --no-write-fetch-head
|
|
460
|
+
* <remote> <refspec…>` under the watchdog (no prompts, bounded). Pass bare
|
|
461
|
+
* object ids (no `:dst`) for a pure read: the objects arrive, no ref moves.
|
|
462
|
+
* Contention on a repository lock is `busy`.
|
|
463
|
+
*/
|
|
464
|
+
export function fetchRefs(
|
|
465
|
+
cwd: string,
|
|
466
|
+
remote: string,
|
|
467
|
+
refspecs: readonly string[],
|
|
468
|
+
options: { timeoutMs?: number } = {},
|
|
469
|
+
): FetchResult {
|
|
470
|
+
if (
|
|
471
|
+
!safeArg(remote) ||
|
|
472
|
+
refspecs.length === 0 ||
|
|
473
|
+
!refspecs.every((spec) => safeArg(spec.replace(/^\+/u, "")))
|
|
474
|
+
)
|
|
475
|
+
return { ok: false, code: "invalid_input", error: "remote and refspecs must not start with -" };
|
|
476
|
+
const timeoutMs = options.timeoutMs ?? GIT_TIMEOUTS.network;
|
|
477
|
+
const fetched = gitNetwork(
|
|
478
|
+
cwd,
|
|
479
|
+
[
|
|
480
|
+
"fetch",
|
|
481
|
+
"--no-tags",
|
|
482
|
+
"--no-recurse-submodules",
|
|
483
|
+
"--no-write-fetch-head",
|
|
484
|
+
"--quiet",
|
|
485
|
+
remote,
|
|
486
|
+
...refspecs,
|
|
487
|
+
],
|
|
488
|
+
timeoutMs,
|
|
489
|
+
);
|
|
490
|
+
if (fetched.ok) return { ok: true };
|
|
491
|
+
if (/\.lock'?:? File exists|unable to lock|another git process/iu.test(fetched.stderr))
|
|
492
|
+
return {
|
|
493
|
+
ok: false,
|
|
494
|
+
code: "busy",
|
|
495
|
+
error: `git fetch ${redactRemote(remote)}: the repository is locked by another git process`,
|
|
496
|
+
};
|
|
497
|
+
return {
|
|
498
|
+
ok: false,
|
|
499
|
+
code: "unavailable",
|
|
500
|
+
error: fetched.timedOut
|
|
501
|
+
? `git fetch ${redactRemote(remote)} timed out after ${timeoutMs} ms`
|
|
502
|
+
: `git fetch ${redactRemote(remote)} failed (unreachable, not authorized, or ref missing)`,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** The configured remote names. */
|
|
507
|
+
export function remoteNames(cwd: string): string[] {
|
|
508
|
+
return (git(cwd, ["remote"]).stdout ?? "")
|
|
509
|
+
.split(/\r?\n/u)
|
|
510
|
+
.map((value) => value.trim())
|
|
511
|
+
.filter(Boolean);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** The absolute git common dir (shared by every worktree), or null outside a repository. */
|
|
515
|
+
export function gitCommonDir(cwd: string): string | null {
|
|
516
|
+
return gitDirs(cwd)?.commonDir ?? null;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** True when `rev` names a commit present in the local object store. */
|
|
520
|
+
export function hasCommit(cwd: string, rev: string): boolean {
|
|
521
|
+
if (!safeArg(rev)) return false;
|
|
522
|
+
return git(cwd, ["cat-file", "-e", `${rev}^{commit}`]).ok;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** The commit a local ref (e.g. refs/remotes/origin/main) points at, or null. */
|
|
526
|
+
export function resolveRef(cwd: string, ref: string): string | null {
|
|
527
|
+
if (!safeArg(ref)) return null;
|
|
528
|
+
return line(git(cwd, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** The branch origin's HEAD names (`origin/main`), or null. */
|
|
532
|
+
export function originHeadRef(cwd: string): string | null {
|
|
533
|
+
return line(git(cwd, ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"]));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** The top of the working tree, or null outside one. */
|
|
537
|
+
export function worktreeTop(cwd: string): string | null {
|
|
538
|
+
return gitDirs(cwd)?.top ?? null;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Whether git tracks `file` (repo-relative) in the index of the checkout at `top`. */
|
|
542
|
+
export function isTracked(top: string, file: string): boolean {
|
|
543
|
+
return git(top, ["ls-files", "--error-unmatch", "--", file]).ok;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* The files whose own change differs between two versions of a branch: the
|
|
548
|
+
* judged `from` (forked from `fromFork`) and the current `to` (forked from
|
|
549
|
+
* `toFork`), compared by exact blob and mode, never by diff text (#334). A
|
|
550
|
+
* file the branch touches on one side only differs. A file it touches on
|
|
551
|
+
* both sides is unchanged only when the base left it alone between the forks
|
|
552
|
+
* and the branch's resulting blob is identical, so a hunk moved within a
|
|
553
|
+
* file, a re-indent or a base edit in a file the branch touches always
|
|
554
|
+
* counts. Null when git cannot read either side: the caller fails closed.
|
|
555
|
+
*/
|
|
556
|
+
export function ownChangeDrift(
|
|
557
|
+
cwd: string,
|
|
558
|
+
range: { from: string; to: string; fromFork: string; toFork: string },
|
|
559
|
+
): string[] | null {
|
|
560
|
+
const before = diffRaw(cwd, range.fromFork, range.from);
|
|
561
|
+
const after = diffRaw(cwd, range.toFork, range.to);
|
|
562
|
+
if (!before || !after) return null;
|
|
563
|
+
const judged = new Map(before.map((entry) => [entry.path, entry]));
|
|
564
|
+
const current = new Map(after.map((entry) => [entry.path, entry]));
|
|
565
|
+
const same = (a: RawEntry | undefined, b: RawEntry | undefined): boolean =>
|
|
566
|
+
a !== undefined &&
|
|
567
|
+
b !== undefined &&
|
|
568
|
+
a.oldMode === b.oldMode &&
|
|
569
|
+
a.oldBlob === b.oldBlob &&
|
|
570
|
+
a.newMode === b.newMode &&
|
|
571
|
+
a.newBlob === b.newBlob;
|
|
572
|
+
return [...new Set([...judged.keys(), ...current.keys()])]
|
|
573
|
+
.filter((file) => !same(judged.get(file), current.get(file)))
|
|
574
|
+
.toSorted();
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Whether the branch's own files are identical at `from` (on `fromBase`) and
|
|
579
|
+
* `to` (on `toBase`): ownChangeDrift from each head's fork is empty. The
|
|
580
|
+
* exactness behind every patch-equal carry; false when git cannot tell.
|
|
581
|
+
*/
|
|
582
|
+
export function sameOwnChange(
|
|
583
|
+
cwd: string,
|
|
584
|
+
range: { from: string; to: string; fromBase: string; toBase: string },
|
|
585
|
+
): boolean {
|
|
586
|
+
const fromFork = mergeBase(cwd, range.fromBase, range.from);
|
|
587
|
+
const toFork = mergeBase(cwd, range.toBase, range.to);
|
|
588
|
+
if (!fromFork || !toFork) return false;
|
|
589
|
+
return ownChangeDrift(cwd, { from: range.from, to: range.to, fromFork, toFork })?.length === 0;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* The verbatim patch-ids of the non-merge commits of `fork..head` (empty
|
|
594
|
+
* commits have none) and how many merge commits the range holds: unlike
|
|
595
|
+
* `--stable`, whitespace counts, so a re-indent is another patch. Diff
|
|
596
|
+
* options are pinned as in patchId. Null when git fails, or has no
|
|
597
|
+
* `patch-id --verbatim`: the caller fails closed.
|
|
598
|
+
*/
|
|
599
|
+
export function commitPatchIds(
|
|
600
|
+
cwd: string,
|
|
601
|
+
fork: string,
|
|
602
|
+
head: string,
|
|
603
|
+
): { patchIds: string[]; merges: number } | null {
|
|
604
|
+
if (!safeArg(fork) || !safeArg(head)) return null;
|
|
605
|
+
const range = `${fork}..${head}`;
|
|
606
|
+
const merges = line(git(cwd, ["rev-list", "--count", "--merges", range]));
|
|
607
|
+
const log = git(
|
|
608
|
+
cwd,
|
|
609
|
+
[
|
|
610
|
+
"log",
|
|
611
|
+
"--no-merges",
|
|
612
|
+
"-p",
|
|
613
|
+
"-U3",
|
|
614
|
+
"--diff-algorithm=myers",
|
|
615
|
+
"--indent-heuristic",
|
|
616
|
+
"--no-color",
|
|
617
|
+
"--no-ext-diff",
|
|
618
|
+
"--no-textconv",
|
|
619
|
+
"--no-renames",
|
|
620
|
+
"--full-index",
|
|
621
|
+
"--src-prefix=a/",
|
|
622
|
+
"--dst-prefix=b/",
|
|
623
|
+
"--format=commit %H",
|
|
624
|
+
range,
|
|
625
|
+
"--",
|
|
626
|
+
],
|
|
627
|
+
{ timeoutMs: GIT_TIMEOUTS.worktree },
|
|
628
|
+
);
|
|
629
|
+
if (merges === null || !log.ok) return null;
|
|
630
|
+
if (!log.stdout.trim()) return { patchIds: [], merges: Number(merges) };
|
|
631
|
+
const ids = git(cwd, ["patch-id", "--verbatim"], { input: log.stdout });
|
|
632
|
+
if (!ids.ok) return null;
|
|
633
|
+
return {
|
|
634
|
+
patchIds: ids.stdout
|
|
635
|
+
.split("\n")
|
|
636
|
+
.map((entry) => entry.trim().split(/\s+/u)[0])
|
|
637
|
+
.filter(Boolean),
|
|
638
|
+
merges: Number(merges),
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Pinned diff options for the review-lane reads: user config never changes them. */
|
|
643
|
+
const LANE_DIFF = [
|
|
644
|
+
"-c",
|
|
645
|
+
"core.quotePath=false",
|
|
646
|
+
"diff",
|
|
647
|
+
"--no-color",
|
|
648
|
+
"--no-ext-diff",
|
|
649
|
+
"--no-textconv",
|
|
650
|
+
"--no-renames",
|
|
651
|
+
];
|
|
652
|
+
|
|
653
|
+
/** `fork..head`, or with `head` null the working tree's tracked changes since `fork`. */
|
|
654
|
+
const laneRange = (fork: string, head: string | null): string[] | null => {
|
|
655
|
+
if (!safeArg(fork) || (head !== null && !safeArg(head))) return null;
|
|
656
|
+
return head === null ? [fork] : [fork, head];
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
export type NumstatEntry = { path: string; added: number; deleted: number; binary: boolean };
|
|
660
|
+
|
|
661
|
+
/** Added and deleted lines per file of `fork..head` (no renames); null when git fails. */
|
|
662
|
+
export function diffNumstat(cwd: string, fork: string, head: string | null): NumstatEntry[] | null {
|
|
663
|
+
const range = laneRange(fork, head);
|
|
664
|
+
if (!range) return null;
|
|
665
|
+
const run = git(cwd, [...LANE_DIFF, "--numstat", "-z", ...range, "--"]);
|
|
666
|
+
if (!run.ok) return null;
|
|
667
|
+
const out: NumstatEntry[] = [];
|
|
668
|
+
for (const record of run.stdout.split("\0")) {
|
|
669
|
+
const match = /^(-|\d+)\t(-|\d+)\t(.+)$/su.exec(record.replace(/^\n/u, ""));
|
|
670
|
+
if (!match) continue;
|
|
671
|
+
const binary = match[1] === "-";
|
|
672
|
+
out.push({
|
|
673
|
+
path: match[3],
|
|
674
|
+
added: binary ? 0 : Number(match[1]),
|
|
675
|
+
deleted: binary ? 0 : Number(match[2]),
|
|
676
|
+
binary,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
return out;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** One file of `git diff --raw`: modes and blob ids on each side (zeros: absent or the working tree). */
|
|
683
|
+
export type RawEntry = {
|
|
684
|
+
path: string;
|
|
685
|
+
oldMode: string;
|
|
686
|
+
newMode: string;
|
|
687
|
+
oldBlob: string;
|
|
688
|
+
newBlob: string;
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* The changed files of `fork..head` (or the working tree) with their modes
|
|
693
|
+
* and blob ids, read NUL-separated so no path is ever quoted or split.
|
|
694
|
+
* Null when git fails.
|
|
695
|
+
*/
|
|
696
|
+
export function diffRaw(cwd: string, fork: string, head: string | null): RawEntry[] | null {
|
|
697
|
+
const range = laneRange(fork, head);
|
|
698
|
+
if (!range) return null;
|
|
699
|
+
const run = git(cwd, [...LANE_DIFF, "--raw", "-z", "--no-abbrev", ...range, "--"]);
|
|
700
|
+
if (!run.ok) return null;
|
|
701
|
+
const fields = run.stdout.split("\0");
|
|
702
|
+
const out: RawEntry[] = [];
|
|
703
|
+
for (let index = 0; index + 1 < fields.length; index += 2) {
|
|
704
|
+
const meta = /^:(\d{6}) (\d{6}) ([0-9a-f]+) ([0-9a-f]+) [A-Z]\d*$/u.exec(
|
|
705
|
+
fields[index].replace(/^\n/u, ""),
|
|
706
|
+
);
|
|
707
|
+
if (!meta) return null;
|
|
708
|
+
out.push({
|
|
709
|
+
path: fields[index + 1],
|
|
710
|
+
oldMode: meta[1],
|
|
711
|
+
newMode: meta[2],
|
|
712
|
+
oldBlob: meta[3],
|
|
713
|
+
newBlob: meta[4],
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
return out;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** The contents of `ids` (blob ids), by id; null when git fails. Missing objects are left out. */
|
|
720
|
+
export function readBlobs(cwd: string, ids: readonly string[]): Map<string, Buffer> | null {
|
|
721
|
+
const out = new Map<string, Buffer>();
|
|
722
|
+
const wanted = [...new Set(ids)].filter((id) => /^[0-9a-f]{40,64}$/u.test(id));
|
|
723
|
+
if (!wanted.length) return out;
|
|
724
|
+
const run = spawnSync("git", ["cat-file", "--batch"], {
|
|
725
|
+
cwd,
|
|
726
|
+
input: `${wanted.join("\n")}\n`,
|
|
727
|
+
maxBuffer: MAX_BUFFER,
|
|
728
|
+
timeout: GIT_TIMEOUTS.local,
|
|
729
|
+
killSignal: "SIGKILL",
|
|
730
|
+
windowsHide: true,
|
|
731
|
+
});
|
|
732
|
+
if (run.status !== 0 || !run.stdout) return null;
|
|
733
|
+
const bytes = run.stdout;
|
|
734
|
+
let at = 0;
|
|
735
|
+
for (const id of wanted) {
|
|
736
|
+
const end = bytes.indexOf(0x0a, at);
|
|
737
|
+
if (end < 0) return null;
|
|
738
|
+
const header = bytes.subarray(at, end).toString("utf8").split(" ");
|
|
739
|
+
at = end + 1;
|
|
740
|
+
if (header[1] === "missing") continue;
|
|
741
|
+
const size = Number(header[2]);
|
|
742
|
+
if (header[0] !== id || !Number.isSafeInteger(size)) return null;
|
|
743
|
+
out.set(id, bytes.subarray(at, at + size));
|
|
744
|
+
at += size + 1;
|
|
745
|
+
}
|
|
746
|
+
return out;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Blob ids of the files named `name` anywhere in the tree of `rev`, by path; null when git fails. */
|
|
750
|
+
export function treeFilesNamed(cwd: string, rev: string, name: string): Map<string, string> | null {
|
|
751
|
+
if (!safeArg(rev)) return null;
|
|
752
|
+
const run = git(cwd, ["ls-tree", "-r", "-z", "--full-tree", rev]);
|
|
753
|
+
if (!run.ok) return null;
|
|
754
|
+
const out = new Map<string, string>();
|
|
755
|
+
for (const record of run.stdout.split("\0")) {
|
|
756
|
+
const match = /^\d{6} blob ([0-9a-f]+)\t(.+)$/su.exec(record);
|
|
757
|
+
if (match && (match[2] === name || match[2].endsWith(`/${name}`))) out.set(match[2], match[1]);
|
|
758
|
+
}
|
|
759
|
+
return out;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** The text of `file` (a fixed repo path) at `rev`; null when it is absent or git fails. */
|
|
763
|
+
export function fileAt(cwd: string, rev: string, file: string): string | null {
|
|
764
|
+
if (!safeArg(rev)) return null;
|
|
765
|
+
const run = git(cwd, ["cat-file", "blob", `${rev}:${file}`]);
|
|
766
|
+
return run.ok ? run.stdout : null;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** Untracked, not ignored files of the working tree (repo-relative). */
|
|
770
|
+
export function untrackedFiles(cwd: string): string[] {
|
|
771
|
+
const run = git(cwd, ["ls-files", "-z", "--others", "--exclude-standard"]);
|
|
772
|
+
return run.ok ? run.stdout.split("\0").filter(Boolean) : [];
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Commits `head` lacks from `base` (behind) and has on top of it (ahead), and
|
|
777
|
+
* whether `base` is already an ancestor of `head`. Null when either side
|
|
778
|
+
* does not resolve locally.
|
|
779
|
+
*/
|
|
780
|
+
export function aheadBehind(
|
|
781
|
+
cwd: string,
|
|
782
|
+
base: string,
|
|
783
|
+
head: string,
|
|
784
|
+
): { ahead: number; behind: number; upToDate: boolean } | null {
|
|
785
|
+
if (!safeArg(base) || !safeArg(head)) return null;
|
|
786
|
+
const counted = line(git(cwd, ["rev-list", "--left-right", "--count", `${base}...${head}`]));
|
|
787
|
+
const match = counted ? /^(\d+)\s+(\d+)$/u.exec(counted) : null;
|
|
788
|
+
if (!match) return null;
|
|
789
|
+
const upToDate = git(cwd, ["merge-base", "--is-ancestor", base, head]).ok;
|
|
790
|
+
return { behind: Number(match[1]), ahead: Number(match[2]), upToDate };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const config = (cwd: string, key: string): string | null =>
|
|
794
|
+
line(git(cwd, ["config", "--get", key]));
|
|
795
|
+
|
|
796
|
+
/** The current branch name (also on an unborn branch), or null when detached. */
|
|
797
|
+
export function currentBranch(cwd: string): string | null {
|
|
798
|
+
const read = readHeadBranch(cwd);
|
|
799
|
+
return read ? read.branch : line(git(cwd, ["symbolic-ref", "--short", "-q", "HEAD"]));
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* The remote `git push` would use for `branch` (default: the current branch),
|
|
804
|
+
* in git's own precedence: branch.<b>.pushRemote, remote.pushDefault,
|
|
805
|
+
* branch.<b>.remote, then `origin`, then the only remote. Null when none.
|
|
806
|
+
*/
|
|
807
|
+
export function pushRemoteName(cwd: string, branch?: string | null): string | null {
|
|
808
|
+
const name = branch ?? currentBranch(cwd);
|
|
809
|
+
const remotes = remoteNames(cwd);
|
|
810
|
+
const candidates = [
|
|
811
|
+
name ? config(cwd, `branch.${name}.pushRemote`) : null,
|
|
812
|
+
config(cwd, "remote.pushDefault"),
|
|
813
|
+
name ? config(cwd, `branch.${name}.remote`) : null,
|
|
814
|
+
];
|
|
815
|
+
for (const candidate of candidates)
|
|
816
|
+
if (candidate && candidate !== "." && remotes.includes(candidate)) return candidate;
|
|
817
|
+
if (remotes.includes("origin")) return "origin";
|
|
818
|
+
return remotes.length === 1 ? remotes[0] : null;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* The single push URL of `remote` after git's insteadOf/pushInsteadOf
|
|
823
|
+
* rewrites. Null when the remote is missing or pushes to several URLs (the
|
|
824
|
+
* target would be ambiguous).
|
|
825
|
+
*/
|
|
826
|
+
export function pushUrl(cwd: string, remote: string): string | null {
|
|
827
|
+
if (!safeArg(remote)) return null;
|
|
828
|
+
const urls = git(cwd, ["remote", "get-url", "--push", "--all", remote])
|
|
829
|
+
.stdout.split(/\r?\n/u)
|
|
830
|
+
.map((value) => value.trim())
|
|
831
|
+
.filter(Boolean);
|
|
832
|
+
return urls.length === 1 ? urls[0] : null;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
export type RemoteUrl = {
|
|
836
|
+
protocol: "ssh" | "https" | "http" | "git" | "file";
|
|
837
|
+
user: string | null;
|
|
838
|
+
/** Host as written in the URL (an SSH alias stays unresolved, case kept). */
|
|
839
|
+
host: string;
|
|
840
|
+
port: string | null;
|
|
841
|
+
/** Repository path without leading slash, trailing slash or `.git`. */
|
|
842
|
+
path: string;
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
const cleanPath = (value: string): string =>
|
|
846
|
+
value
|
|
847
|
+
.replace(/^\/+/u, "")
|
|
848
|
+
.replace(/\/+$/u, "")
|
|
849
|
+
.replace(/\.git$/u, "");
|
|
850
|
+
|
|
851
|
+
/** Parse a git remote URL (scp-like `user@host:path` or URL syntax). */
|
|
852
|
+
export function parseRemoteUrl(raw: string): RemoteUrl | null {
|
|
853
|
+
const value = raw.trim();
|
|
854
|
+
if (!value || /^[A-Za-z][A-Za-z0-9+.-]*::/u.test(value)) return null;
|
|
855
|
+
// Local paths, including Windows drive paths that look scp-like (C:\repo).
|
|
856
|
+
if (/^[A-Za-z]:[\\/]/u.test(value) || value.startsWith("/") || value.startsWith(".")) {
|
|
857
|
+
return { protocol: "file", user: null, host: "", port: null, path: value };
|
|
858
|
+
}
|
|
859
|
+
if (!value.includes("://")) {
|
|
860
|
+
// `user:secret@host:path` is not scp syntax (see redactRemote).
|
|
861
|
+
if (/^[^/@]*:[^/]*@/u.test(value)) return null;
|
|
862
|
+
const scp = /^(?:([^@\s/:]+)@)?([^:/\s]+):(.+)$/u.exec(value);
|
|
863
|
+
if (!scp) return null;
|
|
864
|
+
return {
|
|
865
|
+
protocol: "ssh",
|
|
866
|
+
user: scp[1] ?? null,
|
|
867
|
+
// As written: ssh matches Host patterns case-insensitively and expands
|
|
868
|
+
// %h with the original spelling.
|
|
869
|
+
host: scp[2],
|
|
870
|
+
port: null,
|
|
871
|
+
path: cleanPath(scp[3]),
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
let url: URL;
|
|
875
|
+
try {
|
|
876
|
+
url = new URL(value);
|
|
877
|
+
} catch {
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
const scheme = url.protocol.replace(/:$/u, "");
|
|
881
|
+
const protocol =
|
|
882
|
+
scheme === "ssh" || scheme === "git+ssh" || scheme === "ssh+git"
|
|
883
|
+
? "ssh"
|
|
884
|
+
: scheme === "https" || scheme === "http" || scheme === "git" || scheme === "file"
|
|
885
|
+
? scheme
|
|
886
|
+
: null;
|
|
887
|
+
if (!protocol) return null;
|
|
888
|
+
return {
|
|
889
|
+
protocol,
|
|
890
|
+
// Never keep an HTTPS credential; an SSH user is part of the identity.
|
|
891
|
+
user: protocol === "ssh" && url.username ? decodeURIComponent(url.username) : null,
|
|
892
|
+
host: url.hostname.replace(/\.$/u, ""),
|
|
893
|
+
port: url.port || null,
|
|
894
|
+
path: cleanPath(decodeURIComponent(url.pathname)),
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// ---------------------------------------------------------------------------
|
|
899
|
+
// ~/.ssh/config Host alias resolution: enough of ssh_config(5) for HostName
|
|
900
|
+
// and Port, with OpenSSH's semantics.
|
|
901
|
+
// - The first value obtained for each option wins.
|
|
902
|
+
// - Host patterns support `*`, `?` and `!negation`. They match the alias
|
|
903
|
+
// case-insensitively, and %h expands to the alias as written.
|
|
904
|
+
// - Include is read where it appears. Its directives apply only when the
|
|
905
|
+
// enclosing block matches (or at top level). Inside an inactive block,
|
|
906
|
+
// Host lines in the included file never match. After the Include, the
|
|
907
|
+
// enclosing block's state is restored, so its later directives still apply.
|
|
908
|
+
// - `Match` blocks are treated as non-matching: they depend on runtime state
|
|
909
|
+
// that a static read cannot evaluate.
|
|
910
|
+
|
|
911
|
+
const globToRegExp = (pattern: string): RegExp =>
|
|
912
|
+
new RegExp(
|
|
913
|
+
`^${pattern
|
|
914
|
+
.split("")
|
|
915
|
+
.map((char) =>
|
|
916
|
+
char === "*" ? ".*" : char === "?" ? "." : char.replace(/[.+^${}()|[\]\\]/gu, "\\$&"),
|
|
917
|
+
)
|
|
918
|
+
.join("")}$`,
|
|
919
|
+
"iu",
|
|
920
|
+
);
|
|
921
|
+
|
|
922
|
+
const hostMatches = (patterns: string[], host: string): boolean => {
|
|
923
|
+
let matched = false;
|
|
924
|
+
for (const pattern of patterns) {
|
|
925
|
+
const negated = pattern.startsWith("!");
|
|
926
|
+
if (globToRegExp(negated ? pattern.slice(1) : pattern).test(host)) {
|
|
927
|
+
if (negated) return false;
|
|
928
|
+
matched = true;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
return matched;
|
|
932
|
+
};
|
|
933
|
+
|
|
934
|
+
const tokens = (rest: string): string[] =>
|
|
935
|
+
[...rest.matchAll(/"([^"]*)"|(\S+)/gu)].map((match) => match[1] ?? match[2]);
|
|
936
|
+
|
|
937
|
+
type SshDirective = { key: string; args: string[] };
|
|
938
|
+
|
|
939
|
+
const directives = (text: string): SshDirective[] =>
|
|
940
|
+
text.split(/\r?\n/u).flatMap((raw) => {
|
|
941
|
+
const trimmed = raw.trim();
|
|
942
|
+
if (!trimmed || trimmed.startsWith("#")) return [];
|
|
943
|
+
const match = /^(\S+?)(?:\s*=\s*|\s+)(.*)$/u.exec(trimmed);
|
|
944
|
+
if (!match) return [];
|
|
945
|
+
return [{ key: match[1].toLowerCase(), args: tokens(match[2]) }];
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
/** An ssh_config text plus the home its relative `Include`s resolve against (~/.ssh). */
|
|
949
|
+
export type SshConfig = { text: string; home: string };
|
|
950
|
+
|
|
951
|
+
/** The user's ~/.ssh/config (empty when missing); Includes resolve lazily. */
|
|
952
|
+
export function readSshConfig(home: string = os.homedir()): SshConfig {
|
|
953
|
+
try {
|
|
954
|
+
return { text: fs.readFileSync(path.join(home, ".ssh", "config"), "utf8"), home };
|
|
955
|
+
} catch {
|
|
956
|
+
return { text: "", home };
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const includeFiles = (spec: string, home: string): string[] => {
|
|
961
|
+
const expanded = spec.startsWith("~") ? path.join(home, spec.slice(1)) : spec;
|
|
962
|
+
const absolute = path.isAbsolute(expanded) ? expanded : path.join(home, ".ssh", expanded);
|
|
963
|
+
const dir = path.dirname(absolute);
|
|
964
|
+
const base = path.basename(absolute);
|
|
965
|
+
if (!/[*?]/u.test(base)) return [absolute];
|
|
966
|
+
try {
|
|
967
|
+
const pattern = globToRegExp(base);
|
|
968
|
+
return fs
|
|
969
|
+
.readdirSync(dir)
|
|
970
|
+
.filter((name) => pattern.test(name))
|
|
971
|
+
.toSorted()
|
|
972
|
+
.map((name) => path.join(dir, name));
|
|
973
|
+
} catch {
|
|
974
|
+
return [];
|
|
975
|
+
}
|
|
976
|
+
};
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Resolve an SSH host alias: the effective HostName (with %h / %% expanded)
|
|
980
|
+
* and Port. An alias with no HostName resolves to itself. A plain string is
|
|
981
|
+
* a config text whose relative Includes resolve against the real home.
|
|
982
|
+
*/
|
|
983
|
+
export function resolveSshHost(
|
|
984
|
+
alias: string,
|
|
985
|
+
sshConfig: string | SshConfig,
|
|
986
|
+
): { hostname: string; port: string | null } {
|
|
987
|
+
const source =
|
|
988
|
+
typeof sshConfig === "string" ? { text: sshConfig, home: os.homedir() } : sshConfig;
|
|
989
|
+
let hostname: string | null = null;
|
|
990
|
+
let port: string | null = null;
|
|
991
|
+
const evaluate = (text: string, initiallyActive: boolean, neverMatch: boolean, depth: number) => {
|
|
992
|
+
let active = initiallyActive;
|
|
993
|
+
for (const { key, args } of directives(text)) {
|
|
994
|
+
if (key === "host") {
|
|
995
|
+
active = !neverMatch && hostMatches(args, alias);
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
if (key === "match") {
|
|
999
|
+
active = false;
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
if (key === "include") {
|
|
1003
|
+
if (depth >= 16) continue;
|
|
1004
|
+
for (const spec of args)
|
|
1005
|
+
for (const file of includeFiles(spec, source.home)) {
|
|
1006
|
+
let included: string;
|
|
1007
|
+
try {
|
|
1008
|
+
included = fs.readFileSync(file, "utf8");
|
|
1009
|
+
} catch {
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
// OpenSSH: an Include inside an inactive block can never match,
|
|
1013
|
+
// and the enclosing block's state is restored afterwards.
|
|
1014
|
+
evaluate(included, active, neverMatch || !active, depth + 1);
|
|
1015
|
+
}
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
if (!active || args.length === 0) continue;
|
|
1019
|
+
if (key === "hostname" && hostname === null)
|
|
1020
|
+
hostname = args[0].replace(/%(%|h)/gu, (_, token: string) => (token === "h" ? alias : "%"));
|
|
1021
|
+
if (key === "port" && port === null) port = args[0];
|
|
1022
|
+
}
|
|
1023
|
+
};
|
|
1024
|
+
evaluate(source.text, true, false, 0);
|
|
1025
|
+
return { hostname: (hostname ?? alias).replace(/\.$/u, ""), port };
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// ---------------------------------------------------------------------------
|
|
1029
|
+
// Forge derivation (D16): the push remote host decides github vs gitlab.
|
|
1030
|
+
|
|
1031
|
+
export type ForgeKind = "github" | "gitlab";
|
|
1032
|
+
|
|
1033
|
+
/** Self-hosted / enterprise hosts known from vcs config (`github.host`, `gitlab.host`). */
|
|
1034
|
+
export type ForgeHosts = { github?: readonly string[]; gitlab?: readonly string[] };
|
|
1035
|
+
|
|
1036
|
+
export type DerivedForge = {
|
|
1037
|
+
kind: ForgeKind;
|
|
1038
|
+
/** Host as written in the remote URL (may be an SSH alias). */
|
|
1039
|
+
host: string;
|
|
1040
|
+
/** Host after SSH alias resolution. */
|
|
1041
|
+
hostname: string;
|
|
1042
|
+
/** Host the gh/glab API calls must target (GH_HOST / GITLAB_HOST). */
|
|
1043
|
+
apiHost: string;
|
|
1044
|
+
/** owner/name (GitHub) or group[/subgroup]/project (GitLab). */
|
|
1045
|
+
repo: string;
|
|
1046
|
+
/** How the kind was decided. */
|
|
1047
|
+
via: "known_host" | "configured_host" | "host_name";
|
|
1048
|
+
};
|
|
1049
|
+
|
|
1050
|
+
// Public forges and their SSH-over-443 endpoints.
|
|
1051
|
+
const KNOWN_HOSTS: Record<string, { kind: ForgeKind; apiHost: string }> = {
|
|
1052
|
+
"github.com": { kind: "github", apiHost: "github.com" },
|
|
1053
|
+
"ssh.github.com": { kind: "github", apiHost: "github.com" },
|
|
1054
|
+
"gitlab.com": { kind: "gitlab", apiHost: "gitlab.com" },
|
|
1055
|
+
"altssh.gitlab.com": { kind: "gitlab", apiHost: "gitlab.com" },
|
|
1056
|
+
};
|
|
1057
|
+
|
|
1058
|
+
const normalizeHost = (value: string): string =>
|
|
1059
|
+
value
|
|
1060
|
+
.trim()
|
|
1061
|
+
.toLowerCase()
|
|
1062
|
+
.replace(/^https?:\/\//u, "")
|
|
1063
|
+
.replace(/\/.*$/u, "")
|
|
1064
|
+
.replace(/\.$/u, "");
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Derive the forge from a remote URL. SSH aliases resolve through `sshConfig`
|
|
1068
|
+
* (default: the user's ~/.ssh/config), so `git@github-work.com:org/repo` with
|
|
1069
|
+
* `Host github-work.com / HostName github.com` is GitHub at github.com.
|
|
1070
|
+
* Configured enterprise hosts win next; finally a host whose first label is
|
|
1071
|
+
* exactly `github` or `gitlab` (gitlab.example.com) is taken at face value.
|
|
1072
|
+
* Null when the host is unknown or the path has no owner/name.
|
|
1073
|
+
*/
|
|
1074
|
+
export function deriveForge(
|
|
1075
|
+
url: string,
|
|
1076
|
+
options: { hosts?: ForgeHosts; sshConfig?: string | SshConfig } = {},
|
|
1077
|
+
): DerivedForge | null {
|
|
1078
|
+
const parsed = parseRemoteUrl(url);
|
|
1079
|
+
if (!parsed || parsed.protocol === "file" || !parsed.host) return null;
|
|
1080
|
+
const segments = parsed.path.split("/").filter(Boolean);
|
|
1081
|
+
if (segments.length < 2) return null;
|
|
1082
|
+
const hostname = (
|
|
1083
|
+
parsed.protocol === "ssh"
|
|
1084
|
+
? resolveSshHost(parsed.host, options.sshConfig ?? readSshConfig()).hostname
|
|
1085
|
+
: parsed.host
|
|
1086
|
+
).toLowerCase();
|
|
1087
|
+
const withPort =
|
|
1088
|
+
parsed.protocol !== "ssh" && parsed.port ? `${hostname}:${parsed.port}` : hostname;
|
|
1089
|
+
const base = { host: parsed.host, hostname, repo: segments.join("/") };
|
|
1090
|
+
const known = KNOWN_HOSTS[hostname];
|
|
1091
|
+
if (known) {
|
|
1092
|
+
if (known.kind === "github" && segments.length !== 2) return null;
|
|
1093
|
+
return { ...base, kind: known.kind, apiHost: known.apiHost, via: "known_host" };
|
|
1094
|
+
}
|
|
1095
|
+
for (const kind of ["github", "gitlab"] as const) {
|
|
1096
|
+
const hosts = new Set((options.hosts?.[kind] ?? []).map(normalizeHost));
|
|
1097
|
+
if (hosts.has(withPort) || hosts.has(hostname)) {
|
|
1098
|
+
const apiHost = hosts.has(withPort) ? withPort : hostname;
|
|
1099
|
+
return { ...base, kind, apiHost, via: "configured_host" };
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
const label = hostname.split(".")[0];
|
|
1103
|
+
if ((label === "github" || label === "gitlab") && hostname.includes("."))
|
|
1104
|
+
return { ...base, kind: label, apiHost: withPort, via: "host_name" };
|
|
1105
|
+
return null;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
export type PushForgeResult =
|
|
1109
|
+
| { ok: true; remote: string; /** Redacted push URL. */ url: string; forge: DerivedForge }
|
|
1110
|
+
| { ok: false; code: "not_found" | "unavailable"; error: string; unblock: string };
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* The forge behind the current branch's push remote. Fails closed with an
|
|
1114
|
+
* unblock hint when there is no push remote, the push URL is ambiguous, or
|
|
1115
|
+
* the host is not a recognizable GitHub/GitLab.
|
|
1116
|
+
*/
|
|
1117
|
+
export function pushForge(
|
|
1118
|
+
cwd: string,
|
|
1119
|
+
options: { branch?: string | null; hosts?: ForgeHosts; sshConfig?: string | SshConfig } = {},
|
|
1120
|
+
): PushForgeResult {
|
|
1121
|
+
const remote = pushRemoteName(cwd, options.branch);
|
|
1122
|
+
if (!remote)
|
|
1123
|
+
return {
|
|
1124
|
+
ok: false,
|
|
1125
|
+
code: "not_found",
|
|
1126
|
+
error: "no push remote is configured for this branch",
|
|
1127
|
+
unblock: "git remote add origin <url>",
|
|
1128
|
+
};
|
|
1129
|
+
const url = pushUrl(cwd, remote);
|
|
1130
|
+
if (!url)
|
|
1131
|
+
return {
|
|
1132
|
+
ok: false,
|
|
1133
|
+
code: "unavailable",
|
|
1134
|
+
error: `remote "${remote}" has no single push URL`,
|
|
1135
|
+
unblock: `git remote get-url --push --all ${remote} # keep exactly one push URL`,
|
|
1136
|
+
};
|
|
1137
|
+
const forge = deriveForge(url, options);
|
|
1138
|
+
if (!forge) {
|
|
1139
|
+
// Never echo the raw URL: it may carry a token (https://user:tok@…).
|
|
1140
|
+
const parsed = parseRemoteUrl(url);
|
|
1141
|
+
const host = parsed?.host || null;
|
|
1142
|
+
return {
|
|
1143
|
+
ok: false,
|
|
1144
|
+
code: "unavailable",
|
|
1145
|
+
error: host
|
|
1146
|
+
? parsed?.protocol === "ssh" && !host.includes(".")
|
|
1147
|
+
? `unsupported_forge: could not resolve ssh alias "${host}" to GitHub or GitLab; PR and CI verbs need one (workit git push still pushes with git)`
|
|
1148
|
+
: parsed?.protocol === "ssh"
|
|
1149
|
+
? `unsupported_forge: ssh host "${host}" is not a GitHub/GitLab host or an alias workit can resolve; PR and CI verbs need one (workit git push still pushes with git)`
|
|
1150
|
+
: `unsupported_forge: cannot tell whether push host "${host}" is GitHub or GitLab; PR and CI verbs need one (workit git push still pushes with git)`
|
|
1151
|
+
: `push URL ${redactRemote(url)} of remote "${remote}" is not a GitHub/GitLab URL`,
|
|
1152
|
+
unblock: host
|
|
1153
|
+
? `if ${host} is an ssh alias of GitHub/GitLab, add "Host ${host}" with "HostName github.com" (or gitlab.com) to ~/.ssh/config; if it is a self-hosted GitHub/GitLab, set github.host or gitlab.host in ${configFilePath("vcs.json")}; other forges (Bitbucket, Gitea…) have no PR/CI verbs`
|
|
1154
|
+
: `git remote set-url --push ${remote} <ssh-or-https-url>`,
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
return { ok: true, remote, url: redactRemote(url), forge };
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* D16: the push remote decides. A configured provider that disagrees is not
|
|
1162
|
+
* silently overridden; it is `blocked` with the exact fix.
|
|
1163
|
+
*/
|
|
1164
|
+
export function forgeConflict(
|
|
1165
|
+
derived: DerivedForge,
|
|
1166
|
+
configuredProvider: string | null | undefined,
|
|
1167
|
+
): { code: "blocked"; error: string; unblock: string } | null {
|
|
1168
|
+
const configured = configuredProvider?.trim().toLowerCase();
|
|
1169
|
+
if (!configured || configured === derived.kind) return null;
|
|
1170
|
+
return {
|
|
1171
|
+
code: "blocked",
|
|
1172
|
+
error: `forge_mismatch: push remote ${derived.host} is ${derived.kind} but the workspace vcs.provider is ${configured}, so its account and grants do not apply; PR and CI verbs stop here (workit git push still pushes with git)`,
|
|
1173
|
+
unblock: `add a workspace whose glob matches only this repo, with "vcs": {"provider": "${derived.kind}"} (and its account), to ${configFilePath("workspaces.json")}; the most specific glob wins`,
|
|
1174
|
+
};
|
|
1175
|
+
}
|