@uchibeke/gallivant 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -60
- package/SECURITY.md +9 -0
- package/bin/gallivant.mjs +41 -14
- package/bin/promote-bitbucket.mjs +40 -0
- package/bin/task-pr.mjs +9 -0
- package/bin/task-review.mjs +15 -0
- package/docs/README.md +6 -2
- package/docs/agent-setup.md +55 -41
- package/docs/architecture.md +11 -3
- package/docs/bitbucket-promotion.md +69 -0
- package/docs/bitbucket-review.md +330 -0
- package/docs/chains.md +30 -1
- package/docs/codex-cloud.md +7 -0
- package/docs/codex-review.md +50 -0
- package/docs/commands.md +103 -14
- package/docs/config.md +45 -13
- package/docs/execution.md +1 -1
- package/docs/first-party-review.md +266 -0
- package/docs/git-hosts.md +294 -0
- package/docs/guardrails.md +56 -0
- package/docs/install.md +137 -67
- package/docs/launch.md +7 -4
- package/docs/network-recovery.md +9 -0
- package/docs/openai-agents.md +3 -2
- package/docs/prd.md +23 -2
- package/docs/preparation.md +2 -2
- package/docs/release-qualification.md +1 -1
- package/docs/releasing.md +46 -22
- package/docs/task-delivery.md +5 -5
- package/docs/task-profiles.md +537 -0
- package/docs/tasks.md +59 -197
- package/lib/attribution.mjs +7 -3
- package/lib/block.mjs +10 -1
- package/lib/chains.mjs +101 -30
- package/lib/codex_cloud.mjs +2 -1
- package/lib/codex_cloud_input.mjs +15 -6
- package/lib/codex_cloud_result.mjs +1 -1
- package/lib/commands/doctor.mjs +22 -453
- package/lib/commands/init.mjs +281 -28
- package/lib/commands/register.mjs +81 -13
- package/lib/commands/resume.mjs +5 -1
- package/lib/commands/review.mjs +66 -0
- package/lib/commands/serve.mjs +8 -0
- package/lib/commands/start.mjs +30 -11
- package/lib/commands/status.mjs +18 -2
- package/lib/commands/task.mjs +9 -1
- package/lib/commands/up.mjs +19 -11
- package/lib/commands/why.mjs +7 -3
- package/lib/config.mjs +42 -9
- package/lib/context.mjs +28 -21
- package/lib/control.mjs +21 -6
- package/lib/credential.mjs +20 -14
- package/lib/deliverable.mjs +2 -0
- package/lib/events.mjs +96 -53
- package/lib/execution.mjs +132 -11
- package/lib/execution_preparation.mjs +16 -1
- package/lib/execution_result.mjs +26 -5
- package/lib/first_party_review.mjs +618 -0
- package/lib/fs.mjs +10 -3
- package/lib/gh.mjs +144 -56
- package/lib/git.mjs +133 -32
- package/lib/git_diff.mjs +121 -0
- package/lib/harness.mjs +14 -10
- package/lib/host_contract.mjs +71 -0
- package/lib/host_definitions.mjs +22 -0
- package/lib/hosts/bitbucket.mjs +553 -0
- package/lib/hosts/bitbucket_contract.mjs +57 -0
- package/lib/hosts/bitbucket_credential.mjs +62 -0
- package/lib/hosts/bitbucket_review.mjs +44 -0
- package/lib/hosts/bitbucket_review_producer.mjs +50 -0
- package/lib/hosts/github.mjs +57 -0
- package/lib/hosts/github_contract.mjs +27 -0
- package/lib/hosts/github_doctor.mjs +482 -0
- package/lib/hosts/github_review_summary.mjs +38 -0
- package/lib/hosts/transport_doctor.mjs +35 -0
- package/lib/hosts.mjs +130 -0
- package/lib/learning_qualification.mjs +1 -1
- package/lib/notify.mjs +1 -1
- package/lib/openai_agents_input.mjs +7 -2
- package/lib/openai_agents_sources.mjs +16 -8
- package/lib/plugin_package.mjs +13 -1
- package/lib/pr_operation.mjs +41 -0
- package/lib/prd.mjs +18 -3
- package/lib/preparation_codex.mjs +6 -1
- package/lib/probe.mjs +6 -4
- package/lib/process.mjs +24 -0
- package/lib/promotion.mjs +132 -0
- package/lib/promotion_git.mjs +63 -0
- package/lib/prompt.mjs +16 -13
- package/lib/provider_health.mjs +14 -3
- package/lib/repository.mjs +68 -0
- package/lib/repository_preflight.mjs +11 -0
- package/lib/review_evaluation.mjs +25 -0
- package/lib/review_evidence.mjs +500 -0
- package/lib/review_history.mjs +73 -0
- package/lib/review_image.mjs +145 -0
- package/lib/review_link.mjs +12 -0
- package/lib/review_policy.mjs +132 -0
- package/lib/review_policy_contract.mjs +18 -0
- package/lib/review_state.mjs +13 -0
- package/lib/review_visual.mjs +94 -0
- package/lib/runtime-environment.mjs +6 -0
- package/lib/runtime_node.mjs +3 -2
- package/lib/runtime_state.mjs +39 -5
- package/lib/sources/prd.mjs +6 -3
- package/lib/state.mjs +137 -24
- package/lib/task_completion.mjs +7 -3
- package/lib/task_contract.mjs +201 -27
- package/lib/task_delivery.mjs +9 -2
- package/lib/task_delivery_contract.mjs +13 -9
- package/lib/task_delivery_view.mjs +18 -2
- package/lib/task_environment.mjs +13 -1
- package/lib/task_limits.mjs +13 -0
- package/lib/task_model_config.mjs +99 -0
- package/lib/task_model_route.mjs +243 -0
- package/lib/task_prepare.mjs +21 -8
- package/lib/task_profile.mjs +1875 -0
- package/lib/task_repository_context.mjs +65 -0
- package/lib/tasks.mjs +252 -51
- package/lib/tick.mjs +373 -98
- package/lib/time.mjs +1 -0
- package/lib/tracker/pages.mjs +19 -5
- package/lib/tracker/review.mjs +21 -0
- package/lib/tracker_process.mjs +8 -1
- package/lib/verification_sandbox.mjs +2 -1
- package/lib/work_artifacts.mjs +79 -0
- package/npm-shrinkwrap.json +12 -3
- package/package.json +9 -3
- package/templates/plugin/task-delivery.SKILL.md +17 -10
- package/templates/plugin/task-intake-contract.json +18 -0
- package/templates/plugin/task-profile-requests.mjs +1448 -0
- package/templates/tracker/tracker.css +3 -0
- package/templates/tracker/tracker.js +38 -0
package/lib/fs.mjs
CHANGED
|
@@ -14,10 +14,17 @@ export const HOME = homedir();
|
|
|
14
14
|
* so a decision can be traced to the release that asked for it. Unknown rather than fatal if the file is missing:
|
|
15
15
|
* a version string is never worth failing a run over.
|
|
16
16
|
*/
|
|
17
|
-
|
|
18
|
-
try { return JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"))
|
|
19
|
-
catch { return
|
|
17
|
+
const PACKAGE = (() => {
|
|
18
|
+
try { return JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")); }
|
|
19
|
+
catch { return {}; }
|
|
20
20
|
})();
|
|
21
|
+
export const VERSION = PACKAGE.version || "unknown";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The published npm name, read from the same package.json. `init` prints the `npx` and `npm install -g` commands
|
|
25
|
+
* that run this package again, and a name typed into a command module would drift from the one npm publishes.
|
|
26
|
+
*/
|
|
27
|
+
export const PACKAGE_NAME = PACKAGE.name || "@uchibeke/gallivant";
|
|
21
28
|
|
|
22
29
|
/**
|
|
23
30
|
* When the code a process is running was last changed, as the newest mtime across `lib/`. Paired with `VERSION`
|
package/lib/gh.mjs
CHANGED
|
@@ -3,60 +3,33 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Logins: GitHub renders bots as `name[bot]` in REST and as `login` + `is_bot` in gh's GraphQL output. Everything
|
|
5
5
|
// is normalised to the bare login plus a `bot` flag before any comparison.
|
|
6
|
-
import {
|
|
6
|
+
import { execFileAsync, transportExecutor } from "./process.mjs";
|
|
7
|
+
import { actor, normalizeLogin, MAX_DIFF_BYTES } from "./host_contract.mjs";
|
|
8
|
+
import { parseRemote, repositoryMatches, remoteMatches } from "./repository.mjs";
|
|
9
|
+
export { execFileAsync, actor, normalizeLogin, MAX_DIFF_BYTES, repositoryMatches, remoteMatches };
|
|
7
10
|
|
|
8
11
|
import { ROUND_MARKER, NUDGE_MARKER, RESET_MARKER, READY_LABEL, NEEDS_LABEL, labelAliases } from "./public-format.mjs";
|
|
9
12
|
import { assertPublicText, assertNoNul } from "./public_output.mjs";
|
|
10
|
-
import { normalizePublicBody, parseBlock, renderBlock } from "./block.mjs";
|
|
11
13
|
import { repositoryName, parseJson } from "./learning_data.mjs";
|
|
12
14
|
import { redact } from "./execution_io.mjs";
|
|
15
|
+
import { gitDiffSectionMatches } from "./git_diff.mjs";
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { inspectReviewImageSet, REVIEW_IMAGE_LIMITS } from "./review_image.mjs";
|
|
18
|
+
import { inspectReviewLink, MAX_REVIEW_LINK_BYTES } from "./review_link.mjs";
|
|
13
19
|
export { ROUND_MARKER, NUDGE_MARKER, RESET_MARKER, READY_LABEL, NEEDS_LABEL };
|
|
14
20
|
|
|
15
21
|
const GH_TIMEOUT_MS = 60_000;
|
|
22
|
+
const decodeUtf8 = buffer => new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buffer);
|
|
16
23
|
// A hundred long, JSON-escaped bodies plus metadata can exceed one MiB.
|
|
17
24
|
const LEARNING_PAGE_BYTES = 64 * 1024 * 1024;
|
|
18
|
-
/**
|
|
19
|
-
export
|
|
20
|
-
|
|
21
|
-
export const normalizeLogin = login => String(login || "").replace(/^app\//, "").replace(/\[bot\]$/, "").toLowerCase();
|
|
22
|
-
|
|
23
|
-
/** One shape for authors from either API: { login, bot }. */
|
|
24
|
-
export const actor = a => {
|
|
25
|
-
if (!a) return { login: "", bot: false };
|
|
26
|
-
const raw = a.login ?? a.user?.login ?? "";
|
|
27
|
-
const bot = a.is_bot === true || a.type === "Bot" || a.user?.type === "Bot" || /\[bot\]$/.test(raw) || /^app\//.test(raw);
|
|
28
|
-
return { login: normalizeLogin(raw), bot };
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
/** owner/name from any GitHub PR URL, as GitHub spells it (lowercased, since GitHub treats names case-insensitively). */
|
|
32
|
-
export function repoFromUrl(url) {
|
|
33
|
-
const m = String(url || "").match(/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/i);
|
|
34
|
-
return m ? { repo: `${m[1]}/${m[2]}`.toLowerCase(), number: Number(m[3]) } : null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* owner/repo from a git remote URL, lowercased, or null when the URL is not a GitHub remote we understand.
|
|
39
|
-
* The supported forms are the ones `gh repo clone` and `git clone` produce: https, scp-style ssh, and ssh://.
|
|
40
|
-
* Callers compare the result for equality, never by substring, so `acme/widget-fork` never matches `acme/widget`.
|
|
41
|
-
*/
|
|
25
|
+
/** Compatibility exports for existing callers; all parsing uses the strict identity boundary. */
|
|
26
|
+
export { parseGitHubPr as repoFromUrl } from "./hosts/github_contract.mjs";
|
|
42
27
|
export function repoFromRemote(url) {
|
|
43
|
-
const
|
|
44
|
-
const m = s.match(/^(?:https?:\/\/(?:[^@/]*@)?github\.com\/|(?:ssh:\/\/)?(?:[^@/]+@)?github\.com[:/])([\w-][\w.-]*)\/([\w-][\w.-]*?)(?:\.git)?$/i);
|
|
45
|
-
if (!m) return null;
|
|
46
|
-
return `${m[1]}/${m[2]}`.toLowerCase();
|
|
28
|
+
try { const r = parseRemote(url); return r.host === "github.com" ? r.key : null; } catch { return null; }
|
|
47
29
|
}
|
|
48
30
|
|
|
49
|
-
/** GitHub owner/repo identity ignores letter case without rewriting either spelling. */
|
|
50
|
-
export const repositoryMatches = (left, right) => typeof left === "string" && left.length > 0 && typeof right === "string" && left.toLowerCase() === right.toLowerCase();
|
|
51
|
-
|
|
52
|
-
/** Whether a remote URL points at exactly this `owner/repo`. */
|
|
53
|
-
export const remoteMatches = (url, nameWithOwner) => {
|
|
54
|
-
const parsed = repoFromRemote(url);
|
|
55
|
-
return repositoryMatches(parsed, nameWithOwner);
|
|
56
|
-
};
|
|
57
|
-
|
|
58
31
|
// Reviews and comments come from REST (numeric ids, `user.type`, `since` filters); the rest from `gh pr view`.
|
|
59
|
-
const PR_FIELDS = "number,url,title,state,isDraft,mergeable,headRefOid,headRefName,isCrossRepository,author,createdAt,updatedAt,statusCheckRollup,labels,body,baseRefName,mergeCommit,mergedAt";
|
|
32
|
+
const PR_FIELDS = "number,url,title,state,isDraft,mergeable,headRefOid,headRefName,isCrossRepository,author,createdAt,updatedAt,statusCheckRollup,labels,body,baseRefName,baseRefOid,mergeCommit,mergedAt";
|
|
60
33
|
|
|
61
34
|
/**
|
|
62
35
|
* The `gh` the daemon itself runs.
|
|
@@ -66,13 +39,17 @@ const PR_FIELDS = "number,url,title,state,isDraft,mergeable,headRefOid,headRefNa
|
|
|
66
39
|
* It defaults to resolving nothing, in which case `gh` falls back to its own stores exactly as before, which is
|
|
67
40
|
* right for the daemon (not sandboxed) and is what keeps every existing test working unchanged.
|
|
68
41
|
*/
|
|
69
|
-
export function makeGh({ credential = async () => null, exec = execFileAsync, log = null } = {}) {
|
|
42
|
+
export function makeGh({ credential = async () => null, exec: execute = execFileAsync, log = null } = {}) {
|
|
43
|
+
const exec = transportExecutor(execute);
|
|
70
44
|
const failureDetails = new WeakMap();
|
|
71
45
|
const env = async () => {
|
|
72
|
-
const
|
|
73
|
-
|
|
46
|
+
const resolved = await credential({ host: "github.com" });
|
|
47
|
+
if (resolved?.host && resolved.host !== "github.com") throw new Error("GitHub API credential host mismatch");
|
|
48
|
+
if (resolved?.sshKey && !resolved.token) throw new Error("A Git SSH key does not authorize the host API");
|
|
49
|
+
const token = resolved?.token;
|
|
50
|
+
return { ...process.env, GH_HOST: "github.com", ...(token ? { GH_TOKEN: token, GITHUB_TOKEN: "", GH_ENTERPRISE_TOKEN: "", GITHUB_ENTERPRISE_TOKEN: "" } : {}) };
|
|
74
51
|
};
|
|
75
|
-
const LABELS = [[READY_LABEL, "5319e7", "
|
|
52
|
+
const LABELS = [[READY_LABEL, "5319e7", "Ready for human review; inspect the recorded review and completion evidence"], [NEEDS_LABEL, "b60205", "Work paused; operator attention required"]];
|
|
76
53
|
const run = async (args, { cwd, timeout = GH_TIMEOUT_MS, signal } = {}) => {
|
|
77
54
|
signal?.throwIfAborted();
|
|
78
55
|
const resolvedEnv = await env();
|
|
@@ -85,12 +62,20 @@ export function makeGh({ credential = async () => null, exec = execFileAsync, lo
|
|
|
85
62
|
const secrets=Object.entries(resolvedEnv).filter(([key,value])=>/(?:TOKEN|SECRET|KEY|PASSWORD)/i.test(key) && typeof value==='string' && value.length).map(([,value])=>value);
|
|
86
63
|
failureDetails.set(error,redact(String(error.stderr || error.message || 'GitHub CLI failed'),secrets).replace(/[\x00-\x08\x0b-\x1f\x7f]/g,' ').slice(0,1200));
|
|
87
64
|
}
|
|
65
|
+
if (failureDetails.has(error)) {
|
|
66
|
+
error.message = failureDetails.get(error);
|
|
67
|
+
error.stderr = error.message;
|
|
68
|
+
error.stdout = "";
|
|
69
|
+
}
|
|
88
70
|
throw error;
|
|
89
71
|
}
|
|
90
72
|
};
|
|
91
73
|
const json = async (args, opts) => { const out = await run(args, opts); return out.trim() ? JSON.parse(out) : null; };
|
|
92
74
|
// `--paginate` alone concatenates one JSON array per page; `--slurp` wraps them so the output parses as one array.
|
|
93
|
-
const pages = path => json(["api", path, "--paginate", "--slurp"]).then(r =>
|
|
75
|
+
const pages = path => json(["api", path, "--paginate", "--slurp"]).then(r => {
|
|
76
|
+
if (!Array.isArray(r) || !r.length || r.some(page => !Array.isArray(page))) throw new Error("Incomplete host page; retry without advancing the cursor");
|
|
77
|
+
return r.flat();
|
|
78
|
+
});
|
|
94
79
|
|
|
95
80
|
// Remove every historical spelling separately: one absent alias must not hide a forbidden/network failure on
|
|
96
81
|
// another. Existing retry counters therefore cover the whole migration and never dispatch behind a stale label.
|
|
@@ -147,12 +132,13 @@ export function makeGh({ credential = async () => null, exec = execFileAsync, lo
|
|
|
147
132
|
user: options => json(["api", "user", "--jq", "{login: .login}"], options).then(u => u.login),
|
|
148
133
|
authStatus: () => run(["auth", "status"]).then(() => true).catch(() => false),
|
|
149
134
|
/** owner/name and default branch for a checkout. */
|
|
150
|
-
repoInfo: dir => json(["repo", "view", "--json", "nameWithOwner,defaultBranchRef"], { cwd: dir })
|
|
135
|
+
repoInfo: (dir, repo = null) => json(["repo", "view", ...(repo ? [repo] : []), "--json", "nameWithOwner,defaultBranchRef"], { cwd: dir })
|
|
151
136
|
.then(r => ({ nameWithOwner: r.nameWithOwner, defaultBranch: r.defaultBranchRef?.name })),
|
|
152
137
|
markReady: (repo, n) => run(["pr", "ready", String(n), "--repo", repo]),
|
|
153
138
|
/** Only generated recovery/receipt transport may migrate before prose is corrected. Read fresh, and derive
|
|
154
139
|
* the replacement here: callers cannot use this path to smuggle an arbitrary unguarded body to GitHub. */
|
|
155
140
|
setRecoveryMetadata: async (repo, n, identity, { taskId = null } = {}) => {
|
|
141
|
+
const { normalizePublicBody, parseBlock, renderBlock } = await import("./block.mjs");
|
|
156
142
|
const pr = await json(["pr", "view", String(n), "--repo", repo, "--json", PR_FIELDS]);
|
|
157
143
|
if (pr.isCrossRepository || identity.branch !== pr.headRefName) throw new Error("recovery identity branch differs from the same-repository PR head");
|
|
158
144
|
const validated = parseBlock(renderBlock(identity));
|
|
@@ -201,6 +187,117 @@ export function makeGh({ credential = async () => null, exec = execFileAsync, lo
|
|
|
201
187
|
* looked at. The cap stays, because a diff of any size in memory is its own problem; what it produces is a
|
|
202
188
|
* refusal to decide rather than a partial answer presented as a whole one.
|
|
203
189
|
*/
|
|
190
|
+
reviewEvidence: async (repo, _n, head, base, maxBytes, { imageEvidence = false, linkEvidence = false } = {}) => {
|
|
191
|
+
if (![head, base].every(v => /^[a-f0-9]{40,64}$/.test(v))) throw new Error("review requires exact commit object IDs");
|
|
192
|
+
const comparison = `repos/${repo}/compare/${base}...${head}`;
|
|
193
|
+
const metadata = await json(["api", comparison]);
|
|
194
|
+
// GitHub caps compare files at 300. At the boundary completeness is
|
|
195
|
+
// unprovable; omitted patches/binary/submodule content also fail closed.
|
|
196
|
+
if (!metadata?.merge_base_commit?.sha || !Array.isArray(metadata.files) || !metadata.files.length || metadata.files.length >= 300) throw new Error("review comparison is empty, incomplete or at the host file limit");
|
|
197
|
+
const diff = await run(["api", comparison, "-H", "Accept: application/vnd.github.diff"]);
|
|
198
|
+
let bytes = Buffer.byteLength(diff);
|
|
199
|
+
if (!diff.trim() || bytes > maxBytes) throw new Error("complete review diff exceeds input limit or is unreadable");
|
|
200
|
+
const sections = diff.split(/(?=^diff --git )/m).filter(section => section.startsWith("diff --git "));
|
|
201
|
+
if (sections.length !== metadata.files.length || !imageEvidence && /^(?:GIT binary patch|Binary files )/m.test(diff) || /^(?:(?:old|new|new file|deleted file) mode 160000$|[ +\-]?Subproject commit )/m.test(diff) || !linkEvidence && /^(?:old|new|new file|deleted file) mode 120000$/m.test(diff)) throw new Error("host diff omitted, binary, symlink or submodule changed files; review is incomplete");
|
|
202
|
+
const read = async (path, ref) => {
|
|
203
|
+
const r = await json(["api", `repos/${repo}/contents/${path.split("/").map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`]);
|
|
204
|
+
if (r?.type !== "file" || r.encoding !== "base64" || typeof r.content !== "string" || r.submodule_git_url || r.target) throw new Error("review source is unreadable or not a regular text file");
|
|
205
|
+
const buffer = Buffer.from(r.content, "base64");
|
|
206
|
+
const value = decodeUtf8(buffer);
|
|
207
|
+
bytes += buffer.length;
|
|
208
|
+
if (value.includes("\0") || bytes > maxBytes) throw new Error("review source is binary or complete context exceeds input limit");
|
|
209
|
+
return value;
|
|
210
|
+
};
|
|
211
|
+
const context = [];
|
|
212
|
+
const paths = new Set();
|
|
213
|
+
const images = [], treeCache = new Map(), commitCache = new Map();
|
|
214
|
+
let imageCount = 0, imageBytes = 0;
|
|
215
|
+
// Contents endpoints may dereference symlinks. Bind each image to a
|
|
216
|
+
// regular blob from the exact commit's tree before reading its bytes.
|
|
217
|
+
const lookupTree = async (path, ref) => {
|
|
218
|
+
if (typeof path !== "string" || !path || /[\\\0\r\n]/.test(path) || path.split("/").some(part => !part || part === "." || part === ".." || part.toLowerCase() === ".git")) throw new Error("unsafe review image path");
|
|
219
|
+
let tree = commitCache.get(ref);
|
|
220
|
+
if (!tree) {
|
|
221
|
+
const commit = await json(["api", `repos/${repo}/git/commits/${ref}`]);
|
|
222
|
+
if (!/^[a-f0-9]{40}$/.test(commit?.tree?.sha || "") || commit?.sha !== ref) throw new Error("review image commit identity differs");
|
|
223
|
+
tree = commit.tree.sha; commitCache.set(ref, tree);
|
|
224
|
+
}
|
|
225
|
+
let entry;
|
|
226
|
+
const parts = path.split("/");
|
|
227
|
+
for (const [index, part] of parts.entries()) {
|
|
228
|
+
let data = treeCache.get(tree);
|
|
229
|
+
if (!data) { data = await json(["api", `repos/${repo}/git/trees/${tree}`]); treeCache.set(tree, data); }
|
|
230
|
+
if (data?.sha !== tree || data.truncated !== false || !Array.isArray(data.tree)) throw new Error("review image tree is incomplete");
|
|
231
|
+
const matches = data.tree.filter(item => item.path === part);
|
|
232
|
+
if (matches.length !== 1) throw new Error("review image tree path is absent or duplicated");
|
|
233
|
+
entry = matches[0];
|
|
234
|
+
if (index < parts.length - 1 && (entry.type !== "tree" || entry.mode !== "040000" || !/^[a-f0-9]{40}$/.test(entry.sha || ""))) throw new Error("review image parent is not a tree");
|
|
235
|
+
tree = entry.sha;
|
|
236
|
+
}
|
|
237
|
+
return entry;
|
|
238
|
+
};
|
|
239
|
+
const readImage = async (path, ref, side) => {
|
|
240
|
+
const entry = await lookupTree(path, ref), parts = path.split("/");
|
|
241
|
+
if (entry?.type !== "blob" || !["100644", "100755"].includes(entry.mode) || !/^[a-f0-9]{40}$/.test(entry.sha || "") || !Number.isSafeInteger(entry.size) || entry.size < 1 || entry.size > REVIEW_IMAGE_LIMITS.individualBytes || ++imageCount > REVIEW_IMAGE_LIMITS.count || (imageBytes += entry.size) > REVIEW_IMAGE_LIMITS.totalBytes) throw new Error("review image is not a bounded regular blob");
|
|
242
|
+
if (bytes + entry.size > maxBytes) throw new Error("complete review context exceeds input limit");
|
|
243
|
+
const r = await json(["api", `repos/${repo}/contents/${parts.map(encodeURIComponent).join("/")}?ref=${ref}`]);
|
|
244
|
+
const encoded = typeof r?.content === "string" ? r.content.replace(/\s/g, "") : "";
|
|
245
|
+
if (r?.type !== "file" || r.encoding !== "base64" || r.sha !== entry.sha || r.size !== entry.size || encoded.length !== Math.ceil(entry.size / 3) * 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) throw new Error("review image contents are incomplete");
|
|
246
|
+
const buffer = Buffer.from(encoded, "base64");
|
|
247
|
+
const object = createHash("sha1").update(`blob ${buffer.length}\0`).update(buffer).digest("hex");
|
|
248
|
+
if (buffer.length !== entry.size || object !== entry.sha) throw new Error("review image blob hash differs");
|
|
249
|
+
bytes += buffer.length;
|
|
250
|
+
return { bytes: buffer, identity: { path, revision: ref, side } };
|
|
251
|
+
};
|
|
252
|
+
const readLink = async (path, ref, side, entry) => {
|
|
253
|
+
if (entry?.type !== "blob" || entry.mode !== "120000" || !/^[a-f0-9]{40}$/.test(entry.sha || "") || !Number.isSafeInteger(entry.size) || entry.size < 1 || entry.size > MAX_REVIEW_LINK_BYTES || bytes + entry.size > maxBytes) throw new Error("review link is not a bounded blob");
|
|
254
|
+
const r = await json(["api", `repos/${repo}/git/blobs/${entry.sha}`]);
|
|
255
|
+
const encoded = typeof r?.content === "string" ? r.content.replace(/\s/g, "") : "";
|
|
256
|
+
if (r?.encoding !== "base64" || r.sha !== entry.sha || r.size !== entry.size || encoded.length !== Math.ceil(entry.size / 3) * 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) throw new Error("review link blob is incomplete");
|
|
257
|
+
const buffer = Buffer.from(encoded, "base64");
|
|
258
|
+
if (buffer.length !== entry.size || createHash("sha1").update(`blob ${buffer.length}\0`).update(buffer).digest("hex") !== entry.sha) throw new Error("review link blob hash differs");
|
|
259
|
+
bytes += buffer.length;
|
|
260
|
+
return inspectReviewLink(buffer, { path, revision: ref, side });
|
|
261
|
+
};
|
|
262
|
+
for (const [index, f] of metadata.files.entries()) {
|
|
263
|
+
if (!["added", "modified", "removed", "renamed"].includes(f.status) || (f.patch != null && typeof f.patch !== "string") || paths.has(f.filename)) throw new Error("host omitted or duplicated a changed-file patch; review is incomplete");
|
|
264
|
+
paths.add(f.filename);
|
|
265
|
+
const section = sections[index], beforePath = f.previous_filename || f.filename;
|
|
266
|
+
const beforeEntry = linkEvidence && f.status !== "added" ? await lookupTree(beforePath, metadata.merge_base_commit.sha) : null;
|
|
267
|
+
const afterEntry = linkEvidence && f.status !== "removed" ? await lookupTree(f.filename, head) : null;
|
|
268
|
+
const linkTypes = [beforeEntry, afterEntry].filter(Boolean).map(entry => entry.mode === "120000" && entry.type === "blob");
|
|
269
|
+
if (linkTypes.some(Boolean) && !linkTypes.every(Boolean)) throw new Error("review link type transition is incomplete");
|
|
270
|
+
const isLink = linkTypes.some(Boolean);
|
|
271
|
+
if (linkEvidence && !isLink && [beforeEntry, afterEntry].filter(Boolean).some(entry => entry.type !== "blob" || !["100644", "100755"].includes(entry.mode))) throw new Error("review source is not a regular Git blob");
|
|
272
|
+
const isImage = !isLink && imageEvidence && (/^(?:GIT binary patch|Binary files )/m.test(section) || [beforePath, f.filename].some(path => /\.(?:png|jpe?g)$/i.test(path || "")));
|
|
273
|
+
if (!gitDiffSectionMatches(section, { status: f.status, oldPath: beforePath, newPath: f.filename, binary: isImage })) throw new Error("host diff omitted content or its changed-path inventory differs from comparison metadata");
|
|
274
|
+
if (isLink) {
|
|
275
|
+
if (/^(?:old|new|new file|deleted file) mode (?!120000)/m.test(section)) throw new Error("review link type transition is incomplete");
|
|
276
|
+
const beforeLink = beforeEntry ? await readLink(beforePath, metadata.merge_base_commit.sha, "before", beforeEntry) : null;
|
|
277
|
+
const afterLink = afterEntry ? await readLink(f.filename, head, "after", afterEntry) : null;
|
|
278
|
+
context.push({ path: f.filename, status: f.status, patch: section, kind: "symlink",
|
|
279
|
+
before: beforeLink?.target ?? null, after: afterLink?.target ?? null, beforeLink, afterLink });
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (/^(?:old|new|new file|deleted file) mode 120000$/m.test(section)) throw new Error("review link metadata differs from diff");
|
|
283
|
+
if (isImage) {
|
|
284
|
+
const target = { path: f.filename, status: f.status, patch: section, kind: "image", before: null, after: null };
|
|
285
|
+
if (f.status !== "added") images.push({ ...await readImage(beforePath, metadata.merge_base_commit.sha, "before"), target });
|
|
286
|
+
if (f.status !== "removed") images.push({ ...await readImage(f.filename, head, "after"), target });
|
|
287
|
+
context.push(target);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (/^(?:GIT binary patch|Binary files )/m.test(section)) throw new Error("unsupported binary review evidence");
|
|
291
|
+
if (typeof f.patch !== "string" && /^(?:--- |\+\+\+ |@@ )/m.test(section)) throw new Error("host omitted a changed-file patch; review is incomplete");
|
|
292
|
+
context.push({ path: f.filename, status: f.status, patch: section,
|
|
293
|
+
before: f.status === "added" ? null : await read(f.previous_filename || f.filename, metadata.merge_base_commit.sha),
|
|
294
|
+
after: f.status === "removed" ? null : await read(f.filename, head) });
|
|
295
|
+
}
|
|
296
|
+
if (imageEvidence) for (const [index, verified] of inspectReviewImageSet(images, { head, mergeBase: metadata.merge_base_commit.sha }).entries()) {
|
|
297
|
+
const image = images[index]; image.target[image.identity.side] = { ...verified, content: image.bytes.toString("base64") };
|
|
298
|
+
}
|
|
299
|
+
return { complete: true, head, base, mergeBase: metadata.merge_base_commit.sha, diff, context };
|
|
300
|
+
},
|
|
204
301
|
prDiff: (repo, n) => run(["pr", "diff", String(n), "--repo", repo]).then(d => {
|
|
205
302
|
// At the cap, not only over it: a diff of exactly the cap is indistinguishable from one the cap cut short.
|
|
206
303
|
const bytes = Buffer.byteLength(d);
|
|
@@ -237,6 +334,7 @@ export function makeGh({ credential = async () => null, exec = execFileAsync, lo
|
|
|
237
334
|
async publishResultPr({ repo, branch, base, title, body }) {
|
|
238
335
|
assertPublicText(title, "PR title"); assertPublicText(body, "PR body");
|
|
239
336
|
const prs = await json(["pr", "list", "--repo", repo, "--head", branch, "--state", "open", "--json", "number,url,headRefName,isCrossRepository,baseRefName"]);
|
|
337
|
+
if (!Array.isArray(prs)) throw new Error("Incomplete result PR list; reconcile the owned branch before publishing");
|
|
240
338
|
if ((prs || []).length > 1) throw new Error("ambiguous result PR ownership; reconcile the recorded branch before publishing");
|
|
241
339
|
const existing = prs?.[0];
|
|
242
340
|
if (existing) {
|
|
@@ -277,13 +375,3 @@ export function makeGh({ credential = async () => null, exec = execFileAsync, lo
|
|
|
277
375
|
repoPermissions: repo => json(["api", `repos/${repo}`, "--jq", "{push: .permissions.push, admin: .permissions.admin, archived: .archived}"]),
|
|
278
376
|
};
|
|
279
377
|
}
|
|
280
|
-
|
|
281
|
-
/** execFile as a promise, with a timeout, and an error that carries stdout, stderr and the exit code. */
|
|
282
|
-
export function execFileAsync(cmd, args, opts = {}) {
|
|
283
|
-
return new Promise((resolve, reject) => {
|
|
284
|
-
execFile(cmd, args, { maxBuffer: 16 * 1024 * 1024, encoding: "utf8", ...opts }, (err, stdout, stderr) => {
|
|
285
|
-
if (err) return reject(Object.assign(new Error(`${cmd} ${args.slice(0, 3).join(" ")}: ${String(stderr || "").trim() || err.message}`), { stdout, stderr, code: err.code, killed: err.killed }));
|
|
286
|
-
resolve({ stdout, stderr });
|
|
287
|
-
});
|
|
288
|
-
});
|
|
289
|
-
}
|
package/lib/git.mjs
CHANGED
|
@@ -6,16 +6,18 @@ import { join } from "node:path";
|
|
|
6
6
|
import { lstatSync } from "node:fs";
|
|
7
7
|
import { exists, ensureDir } from "./fs.mjs";
|
|
8
8
|
import { repositoryPath, writeRepositoryText } from "./repository_files.mjs";
|
|
9
|
-
import { execFileAsync,
|
|
10
|
-
import {
|
|
9
|
+
import { execFileAsync, transportExecutor } from "./process.mjs";
|
|
10
|
+
import { remoteMatches, parseRemote, repositoryIdentity, safeOrigin, assertBranchName } from "./repository.mjs";
|
|
11
|
+
import { hosts as defaultHosts } from "./hosts.mjs";
|
|
12
|
+
import { commitMsgHook } from "./attribution.mjs";
|
|
11
13
|
import { BRANCH_PREFIX, BRANCH_PREFIXES } from "./public-format.mjs";
|
|
12
14
|
|
|
13
15
|
const CLONE_TIMEOUT_MS = 15 * 60_000;
|
|
14
16
|
const GIT_TIMEOUT_MS = 5 * 60_000;
|
|
15
17
|
export const GIT_PATH_LIMITS = Object.freeze({ timeoutMs: 2000, bytes: 1_048_576 });
|
|
16
|
-
const cloneUrl = repo => `https://github.com/${repo}.git`;
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
|
|
20
|
+
export const worktreePath = (config, nameWithOwner, prdId) => join(config.worktrees, ...repositoryIdentity(nameWithOwner).key.split("/"), prdId);
|
|
19
21
|
|
|
20
22
|
/** The branch naming convention, in one place with its inverse. */
|
|
21
23
|
export const branchFor = prdId => `${BRANCH_PREFIX}${prdId}`;
|
|
@@ -34,7 +36,10 @@ export const prdIdFromBranch = branch => (isOurBranch(branch) ? branch.slice(bra
|
|
|
34
36
|
*/
|
|
35
37
|
export const RUN_GIT_CONFIG = [
|
|
36
38
|
["push.default", "current"],
|
|
37
|
-
["credential.helper",
|
|
39
|
+
["credential.helper", ""],
|
|
40
|
+
["credential.useHttpPath", "true"],
|
|
41
|
+
["http.followRedirects", "false"],
|
|
42
|
+
["credential.helper", `!f() { test "$1" = get || return; protocol= host= path=; while IFS='=' read -r k v; do case "$k" in protocol) protocol="$v";; host) host="$v";; path) path="$v";; esac; done; test "$protocol" = https && test "$host" = "$GIT_CREDENTIAL_HOST" || return; test -z "$GIT_CREDENTIAL_REPOSITORY" || test "$path" = "$GIT_CREDENTIAL_REPOSITORY" || test "$path" = "$GIT_CREDENTIAL_REPOSITORY.git" || return; test -n "${'$'}{GIT_HOST_TOKEN:-$GH_TOKEN}" && printf 'username=%s\\npassword=%s\\n' "${'$'}{GIT_HOST_USERNAME:-x}" "${'$'}{GIT_HOST_TOKEN:-$GH_TOKEN}"; }; f`],
|
|
38
43
|
];
|
|
39
44
|
|
|
40
45
|
/**
|
|
@@ -107,10 +112,9 @@ export const NODE_TOOLING_ENV = Object.freeze({
|
|
|
107
112
|
* every sandbox permits; the alternative of leaving it unset makes git read `$HOME/.gitconfig`, which is outside
|
|
108
113
|
* the worktree and is the class of failure this whole function exists to end.
|
|
109
114
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* control that bounds a run is the token's scope and the branch ruleset, not the transport of the config.
|
|
115
|
+
* The empty credential.helper entry resets repository-local helper lists before installing the bound helper.
|
|
116
|
+
* HTTPS redirects are disabled. Credentials are returned only for the expected host and repository path.
|
|
117
|
+
* A run can still read its own environment, so credential scope remains a separate authority boundary.
|
|
114
118
|
*
|
|
115
119
|
* The token reaches the helper through this environment and nothing else. It is never an argument, so `ps` cannot
|
|
116
120
|
* show it, and nothing here writes it to a file.
|
|
@@ -121,16 +125,45 @@ export function runEnv(_config = null, credential = null, identity = null, workt
|
|
|
121
125
|
// ones (`originOf`, `currentBranch`), or `doctor`'s probe. Anything that COMMITS must be given an identity, because
|
|
122
126
|
// this function's own `GIT_CONFIG_GLOBAL=/dev/null` is what leaves the clone without one; `makeGit` says what git
|
|
123
127
|
// does when it is not, and it is not a clean failure on every platform.
|
|
124
|
-
|
|
125
|
-
|
|
128
|
+
// The coordinator may clone a Bitbucket repository with its private SSH key,
|
|
129
|
+
// but the coding session never receives that key. Rewrite only this selected
|
|
130
|
+
// repository's SSH origin to HTTPS inside the session. The existing helper
|
|
131
|
+
// supplies its already-bound account token only for that exact repo path.
|
|
132
|
+
const bitbucketRepo = credential?.host === "bitbucket.org" && credential?.token && credential?.repository
|
|
133
|
+
? repositoryIdentity(credential.repository, "bitbucket.org").path : null;
|
|
134
|
+
const bitbucketGit = bitbucketRepo
|
|
135
|
+
? ["git@ssh.bitbucket.org:", "git@bitbucket.org:"].map(origin => [
|
|
136
|
+
`url.https://bitbucket.org/${bitbucketRepo}.git.insteadOf`, `${origin}${bitbucketRepo}.git`,
|
|
137
|
+
]) : [];
|
|
138
|
+
const pairs = [...RUN_GIT_CONFIG, ...bitbucketGit, ...(identity?.name && identity?.email ? identityConfig(identity) : [])];
|
|
139
|
+
const env = { GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_COUNT: String(pairs.length), GIT_SSH_COMMAND: "false" };
|
|
126
140
|
for (const [i, [key, value]] of pairs.entries()) { env[`GIT_CONFIG_KEY_${i}`] = key; env[`GIT_CONFIG_VALUE_${i}`] = value; }
|
|
127
|
-
|
|
141
|
+
env.GIT_CREDENTIAL_HOST = credential?.host || "github.com";
|
|
142
|
+
env.GIT_CREDENTIAL_REPOSITORY = credential?.repository || "";
|
|
143
|
+
env.GIT_HOST_USERNAME = credential?.username || "x";
|
|
144
|
+
for (const name of Object.keys(process.env)) if (/^(?:BITBUCKET_|BB_|REVIEWER_|CODE_REVIEW_|HOST_API_)/.test(name)) env[name] = "";
|
|
145
|
+
Object.assign(env, { HOST_API_HOST: "", HOST_API_REPOSITORY: "", HOST_API_TOKEN: "", HOST_API_EMAIL: "", HOST_API_BINDING: "" });
|
|
146
|
+
if (credential) {
|
|
147
|
+
// Only a host adapter may supply these explicitly bound coding credentials.
|
|
148
|
+
for (const key of ["HOST_API_HOST", "HOST_API_REPOSITORY", "HOST_API_TOKEN", "HOST_API_EMAIL", "HOST_API_BINDING"]) if (credential.environment?.[key] !== undefined) env[key] = credential.environment[key];
|
|
149
|
+
// A run must not inherit another account's API token or ambient SSH identity.
|
|
150
|
+
Object.assign(env, { GH_HOST: env.GIT_CREDENTIAL_HOST, GH_TOKEN: "", GITHUB_TOKEN: "", GH_ENTERPRISE_TOKEN: "", GITHUB_ENTERPRISE_TOKEN: "", GIT_HOST_TOKEN: "", GIT_CONFIG_PARAMETERS: "" });
|
|
151
|
+
if (credential.sshKey && !credential.sshCommand) throw new Error("The selected HTTPS Git policy requires its bound token; an SSH key does not authorize API operations");
|
|
152
|
+
if (credential.sshCommand) { env.GIT_SSH_COMMAND = credential.sshCommand; env.SSH_AUTH_SOCK = ""; }
|
|
153
|
+
if (credential.token) env[env.GIT_CREDENTIAL_HOST === "github.com" ? "GH_TOKEN" : "GIT_HOST_TOKEN"] = credential.token;
|
|
154
|
+
}
|
|
128
155
|
// gh loads config even with GH_TOKEN. A sandboxed run cannot read ~/.config/gh, so keep its CLI state inside
|
|
129
156
|
// Gallivant's private clone. The directory may be absent; gh uses defaults. No personal credentials are copied.
|
|
130
157
|
if (worktree) env.GH_CONFIG_DIR = join(worktree, ".git", "gallivant-gh");
|
|
131
158
|
return { ...env, ...NODE_TOOLING_ENV };
|
|
132
159
|
}
|
|
133
160
|
|
|
161
|
+
// Harness runtime settings may add paths and model settings, but cannot replace
|
|
162
|
+
// the selected repository's Git/API authority or session isolation values.
|
|
163
|
+
export function runSessionEnv(specEnv, config, credential, identity, worktree) {
|
|
164
|
+
return { ...specEnv, ...runEnv(config, credential, identity, worktree) };
|
|
165
|
+
}
|
|
166
|
+
|
|
134
167
|
/**
|
|
135
168
|
* Git for the daemon's own clones and fetches.
|
|
136
169
|
*
|
|
@@ -158,25 +191,58 @@ export function runEnv(_config = null, credential = null, identity = null, workt
|
|
|
158
191
|
* The refusal belongs where a commit is actually being paid for: `requireIdentity` in `startRun` and in `start`, before
|
|
159
192
|
* a session is spawned. So this resolver is allowed to answer nothing, and git decides what a commit without one means.
|
|
160
193
|
*/
|
|
161
|
-
export function makeGit({ config, exec = execFileAsync, credential = async () => null, identity = async () => null } = {}) {
|
|
162
|
-
const
|
|
163
|
-
const
|
|
194
|
+
export function makeGit({ config, exec: execute = execFileAsync, credential = async () => null, identity = async () => null, hosts = null } = {}) {
|
|
195
|
+
const exec = transportExecutor(execute);
|
|
196
|
+
const registry = hosts ?? defaultHosts;
|
|
197
|
+
const bindings = new Map();
|
|
198
|
+
const cleanEnv = () => Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(?:GIT_|GH_|GITHUB_|BITBUCKET_|BB_|SSH_)/.test(key)));
|
|
199
|
+
const env = async (repository = null) => {
|
|
200
|
+
const resolved = repository && hosts ? await hosts.gitCredential(repository) : await credential(repository ?? {});
|
|
201
|
+
if (repository && (resolved?.host && resolved.host !== repository.host || resolved?.repository && resolved.repository !== repository.path)) throw new Error("Git credential host mismatch");
|
|
202
|
+
return { ...cleanEnv(), GIT_TERMINAL_PROMPT: "0", ...runEnv(config, resolved && { ...resolved, ...(repository ? { host: repository.host, repository: repository.path } : {}) }, await identity()) };
|
|
203
|
+
};
|
|
204
|
+
const git = async (args, cwd, timeout = GIT_TIMEOUT_MS, expected = null) => {
|
|
205
|
+
if (args[0] === "fetch" && args[2]) assertBranchName(args[2]);
|
|
206
|
+
let repository = expected ? registry.inspect(expected).repository : bindings.get(cwd) ?? null;
|
|
207
|
+
if (["fetch", "push"].includes(args[0])) {
|
|
208
|
+
const origin = (await exec("git", ["remote", "get-url", "origin"], { cwd, env: { ...cleanEnv(), ...runEnv() }, timeout })).stdout.trim();
|
|
209
|
+
// Local, credential-free fixture repositories are useful to exercise native merge semantics.
|
|
210
|
+
if (repository && !remoteMatches(origin, repository)) throw new Error("Git origin identity mismatch; restore the saved repository before retrying");
|
|
211
|
+
if (hosts || /^(?:https?:|ssh:|git@)/.test(origin)) repository ??= registry.inspectRemote(origin).repository;
|
|
212
|
+
const urls = (await exec("git", ["remote", "get-url", "--push", "--all", "origin"], { cwd, env: { ...cleanEnv(), ...runEnv() }, timeout })).stdout.trim().split("\n");
|
|
213
|
+
if (repository) {
|
|
214
|
+
const policy = registry.remote(repository);
|
|
215
|
+
if (origin !== policy.url || urls.some(url => url !== policy.url)) throw new Error("Git remote transport or push identity mismatch; restore the adapter's verified origin before retrying");
|
|
216
|
+
const api = registry.inspect(repository).api;
|
|
217
|
+
if (api.inspectRepository) await api.inspectRepository(repository.path);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return (await exec("git", args, { cwd, env: await env(repository), timeout })).stdout.trim();
|
|
221
|
+
};
|
|
164
222
|
// NUL-delimited paths preserve whitespace and newlines; never trim them.
|
|
165
223
|
const gitPaths = async (args, cwd) => {
|
|
166
|
-
const { stdout } = await exec("git", args, { cwd, env: await env(), timeout: GIT_PATH_LIMITS.timeoutMs, maxBuffer: GIT_PATH_LIMITS.bytes });
|
|
224
|
+
const { stdout } = await exec("git", args, { cwd, env: await env(bindings.get(cwd)), timeout: GIT_PATH_LIMITS.timeoutMs, maxBuffer: GIT_PATH_LIMITS.bytes });
|
|
167
225
|
if (typeof stdout !== "string" || Buffer.byteLength(stdout) > GIT_PATH_LIMITS.bytes || (stdout && !stdout.endsWith("\0"))) throw new Error("incomplete or oversized Git path metadata; preserve the checkout and retrieve complete paths before publication");
|
|
168
226
|
return stdout ? stdout.slice(0, -1).split("\0") : [];
|
|
169
227
|
};
|
|
170
|
-
const
|
|
171
|
-
const
|
|
228
|
+
const clone = async (repo, path, options, beforeExec = null) => {
|
|
229
|
+
const { repository, api } = registry.inspect(repo), remote = registry.remote(repository);
|
|
230
|
+
if (api.inspectRepository) await api.inspectRepository(repository.path);
|
|
231
|
+
bindings.set(path, repository);
|
|
232
|
+
const optionsEnv = await env(repository);
|
|
233
|
+
if (remote.protocol === "ssh" && (!optionsEnv.GIT_SSH_COMMAND || optionsEnv.GIT_SSH_COMMAND === "false")) throw new Error("Host SSH transport requires its explicitly bound key");
|
|
172
234
|
beforeExec?.();
|
|
173
|
-
return
|
|
235
|
+
return exec("git", ["clone", "--filter=blob:none", ...options, "--", remote.url, path], { env: optionsEnv, timeout: CLONE_TIMEOUT_MS });
|
|
174
236
|
};
|
|
175
237
|
|
|
176
|
-
async function ensureHttpsRemote(dir) {
|
|
238
|
+
async function ensureHttpsRemote(dir, expected = null) {
|
|
177
239
|
const origin = await git(["remote", "get-url", "origin"], dir);
|
|
178
|
-
const
|
|
179
|
-
if (
|
|
240
|
+
const { repository, api } = registry.inspectRemote(origin);
|
|
241
|
+
if (expected && !remoteMatches(origin, expected)) throw new Error("Checkout origin differs from the saved repository; restore origin before retrying");
|
|
242
|
+
if (api.inspectRepository) await api.inspectRepository(repository.path);
|
|
243
|
+
bindings.set(dir, repository);
|
|
244
|
+
const remote = registry.remote(repository);
|
|
245
|
+
if (origin !== remote.url) await git(["remote", "set-url", "origin", remote.url], dir);
|
|
180
246
|
}
|
|
181
247
|
|
|
182
248
|
/**
|
|
@@ -192,14 +258,43 @@ export function makeGit({ config, exec = execFileAsync, credential = async () =>
|
|
|
192
258
|
* it needs, so the failure is returned and the caller logs it. The identity half of the guarantee (`user.name` and
|
|
193
259
|
* `user.email` in `RUN_GIT_CONFIG`) does not depend on this file at all.
|
|
194
260
|
*/
|
|
195
|
-
function installHooks(dir) {
|
|
261
|
+
function installHooks(dir, publicationPolicy = {}) {
|
|
196
262
|
try {
|
|
197
|
-
writeRepositoryText(dir, ".git/hooks/commit-msg",
|
|
263
|
+
writeRepositoryText(dir, ".git/hooks/commit-msg", commitMsgHook(publicationPolicy), { mode: 0o755, allowGit: true });
|
|
198
264
|
return { installed: true };
|
|
199
265
|
} catch (e) { return { installed: false, why: e.message }; }
|
|
200
266
|
}
|
|
201
267
|
|
|
202
268
|
return {
|
|
269
|
+
async probeAccess(repo, base, expectedSha) {
|
|
270
|
+
assertBranchName(base);
|
|
271
|
+
const { repository } = registry.inspect(repo), remote = registry.remote(repository);
|
|
272
|
+
const { stdout } = await exec("git", ["ls-remote", "--exit-code", "--heads", remote.url, `refs/heads/${base}`], { env: await env(repository), timeout: GIT_TIMEOUT_MS });
|
|
273
|
+
const rows = stdout.trim().split("\n");
|
|
274
|
+
if (rows.length !== 1 || rows[0] !== `${expectedSha}\trefs/heads/${base}`) throw new Error("Git and API base identities differ or changed during preflight; retry a complete repository inspection");
|
|
275
|
+
return { gitRead: "proved", gitPush: "unknown" };
|
|
276
|
+
},
|
|
277
|
+
/** The checkout's current commit, for pinning a read-only context snapshot. */
|
|
278
|
+
revision: dir => git(["rev-parse", "--verify", "HEAD^{commit}"], dir),
|
|
279
|
+
/**
|
|
280
|
+
* Read-only listings for the intake snapshot, NUL-delimited and bounded by
|
|
281
|
+
* GIT_PATH_LIMITS. `treePaths` enumerates the recorded commit's tree, so a
|
|
282
|
+
* listing and the revision it is pinned to describe the same state; the
|
|
283
|
+
* index listings only decide which of those paths the checkout's ignore
|
|
284
|
+
* rules exclude (ignore evaluation needs the index) and which it can vouch for.
|
|
285
|
+
*/
|
|
286
|
+
treePaths: (dir, revision) => gitPaths(["ls-tree", "-r", "-z", "--name-only", "--full-tree", `${revision}^{tree}`], dir),
|
|
287
|
+
indexPaths: dir => gitPaths(["ls-files", "-z", "--cached", "--full-name"], dir),
|
|
288
|
+
// Only in-tree .gitignore files decide ignores (no info/exclude or global
|
|
289
|
+
// excludes), and `ignoreRuleChanges` reports every .gitignore whose working
|
|
290
|
+
// copy differs from the recorded revision or is untracked, so the snapshot
|
|
291
|
+
// can refuse to evaluate rules that the recorded tree does not contain.
|
|
292
|
+
ignoredTrackedPaths: dir => gitPaths(["ls-files", "-z", "--cached", "--ignored", "--exclude-per-directory=.gitignore", "--full-name"], dir),
|
|
293
|
+
ignoreRuleChanges: async (dir, revision) => {
|
|
294
|
+
const changed = await gitPaths(["diff", "-z", "--name-only", `${revision}^{commit}`, "--", ":(glob,top)**/.gitignore", ":(top).gitignore"], dir);
|
|
295
|
+
const untracked = await gitPaths(["ls-files", "-z", "--others", "--full-name", "--", ":(glob,top)**/.gitignore", ":(top).gitignore"], dir);
|
|
296
|
+
return [...new Set([...changed, ...untracked])];
|
|
297
|
+
},
|
|
203
298
|
containsCommit: async (dir, sha) => {
|
|
204
299
|
try { await git(["merge-base", "--is-ancestor", sha, "HEAD"], dir); return true; }
|
|
205
300
|
catch (e) { if (e.code === 1) return false; throw e; }
|
|
@@ -209,9 +304,13 @@ export function makeGit({ config, exec = execFileAsync, credential = async () =>
|
|
|
209
304
|
if (!listing) return null;
|
|
210
305
|
if (!listing.startsWith("100644 blob ") && !listing.startsWith("100755 blob ")) throw new Error(`${path} at ${ref} is not a regular file`);
|
|
211
306
|
// Keep final newlines: these bytes are compared with the approved source snapshot.
|
|
212
|
-
return (await exec("git", ["show", `${ref}:${path}`], { cwd: dir, env: await env(), timeout: GIT_TIMEOUT_MS })).stdout;
|
|
307
|
+
return (await exec("git", ["show", `${ref}:${path}`], { cwd: dir, env: await env(bindings.get(dir)), timeout: GIT_TIMEOUT_MS })).stdout;
|
|
213
308
|
},
|
|
214
309
|
installHooks,
|
|
310
|
+
publicationPaths: (dir, base) => {
|
|
311
|
+
assertBranchName(base);
|
|
312
|
+
return gitPaths(["diff", "--name-only", "--diff-filter=ACMRT", "-z", `refs/remotes/origin/${base}...HEAD`], dir);
|
|
313
|
+
},
|
|
215
314
|
ensureHttpsRemote,
|
|
216
315
|
/**
|
|
217
316
|
* Fresh worktree on a new branch cut from `base` on the remote.
|
|
@@ -220,7 +319,7 @@ export function makeGit({ config, exec = execFileAsync, credential = async () =>
|
|
|
220
319
|
* branch when nothing configured one. It used to be `defaultBranch` and nothing else, which cut every branch from
|
|
221
320
|
* the repository's default even for a project whose pull requests all target `staging`.
|
|
222
321
|
*
|
|
223
|
-
* The fetch is the reason the clone is not the whole of this. `
|
|
322
|
+
* The fetch is the reason the clone is not the whole of this. `git clone --no-checkout` brings the remote's
|
|
224
323
|
* refs as they are at clone time, which for a fresh directory is current; for one whose parent directory was left
|
|
225
324
|
* behind by a previous run it is whatever git last saw. Fetching the base by name before cutting from it is what
|
|
226
325
|
* makes "cut from the current base" true rather than nearly true, and it is the whole of what a rebase would have
|
|
@@ -229,10 +328,11 @@ export function makeGit({ config, exec = execFileAsync, credential = async () =>
|
|
|
229
328
|
async create({ nameWithOwner, prdId, base }) {
|
|
230
329
|
let cloneAttempted = false;
|
|
231
330
|
try {
|
|
331
|
+
assertBranchName(base); assertBranchName(prdId);
|
|
232
332
|
const path = worktreePath(config, nameWithOwner, prdId);
|
|
233
333
|
if (exists(join(path, ".git"))) throw new Error(`worktree already exists at ${path}`);
|
|
234
334
|
ensureDir(join(path, ".."), 0o755);
|
|
235
|
-
await
|
|
335
|
+
await clone(nameWithOwner, path, ["--no-checkout"], () => { cloneAttempted = true; });
|
|
236
336
|
await git(["fetch", "origin", base], path);
|
|
237
337
|
await git(["checkout", "-b", branchFor(prdId), `origin/${base}`], path);
|
|
238
338
|
installHooks(path);
|
|
@@ -294,18 +394,19 @@ export function makeGit({ config, exec = execFileAsync, credential = async () =>
|
|
|
294
394
|
* reused.
|
|
295
395
|
*/
|
|
296
396
|
async fromBranch({ nameWithOwner, prdId, branch, path = worktreePath(config, nameWithOwner, prdId) }) {
|
|
397
|
+
assertBranchName(branch); assertBranchName(prdId);
|
|
297
398
|
if (exists(join(path, ".git"))) {
|
|
298
399
|
const origin = await git(["remote", "get-url", "origin"], path);
|
|
299
400
|
// Exact owner/repo, never a substring: `acme/widget-fork` is not `acme/widget`.
|
|
300
|
-
if (!remoteMatches(origin, nameWithOwner)) throw new Error(`${path} is a clone of ${origin}, not ${nameWithOwner}`);
|
|
301
|
-
await ensureHttpsRemote(path);
|
|
401
|
+
if (!remoteMatches(origin, nameWithOwner)) throw new Error(`${path} is a clone of ${safeOrigin(origin)}, not ${nameWithOwner}`);
|
|
402
|
+
await ensureHttpsRemote(path, nameWithOwner);
|
|
302
403
|
await git(["fetch", "origin", branch], path);
|
|
303
404
|
await git(["checkout", branch], path);
|
|
304
405
|
installHooks(path);
|
|
305
406
|
return path;
|
|
306
407
|
}
|
|
307
408
|
ensureDir(join(path, ".."), 0o755);
|
|
308
|
-
await
|
|
409
|
+
await clone(nameWithOwner, path, ["--branch", branch]);
|
|
309
410
|
installHooks(path);
|
|
310
411
|
return path;
|
|
311
412
|
},
|
|
@@ -423,8 +524,8 @@ export function makeGit({ config, exec = execFileAsync, credential = async () =>
|
|
|
423
524
|
if (pending.length) await git(["add", "--", ...pending], dir);
|
|
424
525
|
if (await git(["diff", "--cached", "--name-only"], dir)) await git(["commit", "-m", "Apply execution artifacts"], dir);
|
|
425
526
|
},
|
|
426
|
-
pushResult: (dir, branch) => git(["push", "--set-upstream", "origin", `HEAD:refs/heads/${branch}`], dir),
|
|
427
|
-
pushSnapshotRef: (dir, branch) => git(["push", "origin", `HEAD:refs/heads/${branch}`], dir),
|
|
527
|
+
pushResult: (dir, branch, repo = null) => git(["push", "--set-upstream", "origin", `HEAD:refs/heads/${branch}`], dir, GIT_TIMEOUT_MS, repo),
|
|
528
|
+
pushSnapshotRef: (dir, branch, repo = null) => git(["push", "origin", `HEAD:refs/heads/${branch}`], dir, GIT_TIMEOUT_MS, repo),
|
|
428
529
|
async resultHeadMatches(dir, inputHead, paths) {
|
|
429
530
|
try { await git(["merge-base", "--is-ancestor", inputHead, "HEAD"], dir); } catch { return false; }
|
|
430
531
|
const changed = await gitPaths(["diff", "--name-only", "-z", inputHead, "HEAD"], dir);
|