@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.
Files changed (133) hide show
  1. package/README.md +120 -60
  2. package/SECURITY.md +9 -0
  3. package/bin/gallivant.mjs +41 -14
  4. package/bin/promote-bitbucket.mjs +40 -0
  5. package/bin/task-pr.mjs +9 -0
  6. package/bin/task-review.mjs +15 -0
  7. package/docs/README.md +6 -2
  8. package/docs/agent-setup.md +55 -41
  9. package/docs/architecture.md +11 -3
  10. package/docs/bitbucket-promotion.md +69 -0
  11. package/docs/bitbucket-review.md +330 -0
  12. package/docs/chains.md +30 -1
  13. package/docs/codex-cloud.md +7 -0
  14. package/docs/codex-review.md +50 -0
  15. package/docs/commands.md +103 -14
  16. package/docs/config.md +45 -13
  17. package/docs/execution.md +1 -1
  18. package/docs/first-party-review.md +266 -0
  19. package/docs/git-hosts.md +294 -0
  20. package/docs/guardrails.md +56 -0
  21. package/docs/install.md +137 -67
  22. package/docs/launch.md +7 -4
  23. package/docs/network-recovery.md +9 -0
  24. package/docs/openai-agents.md +3 -2
  25. package/docs/prd.md +23 -2
  26. package/docs/preparation.md +2 -2
  27. package/docs/release-qualification.md +1 -1
  28. package/docs/releasing.md +46 -22
  29. package/docs/task-delivery.md +5 -5
  30. package/docs/task-profiles.md +537 -0
  31. package/docs/tasks.md +59 -197
  32. package/lib/attribution.mjs +7 -3
  33. package/lib/block.mjs +10 -1
  34. package/lib/chains.mjs +101 -30
  35. package/lib/codex_cloud.mjs +2 -1
  36. package/lib/codex_cloud_input.mjs +15 -6
  37. package/lib/codex_cloud_result.mjs +1 -1
  38. package/lib/commands/doctor.mjs +22 -453
  39. package/lib/commands/init.mjs +281 -28
  40. package/lib/commands/register.mjs +81 -13
  41. package/lib/commands/resume.mjs +5 -1
  42. package/lib/commands/review.mjs +66 -0
  43. package/lib/commands/serve.mjs +8 -0
  44. package/lib/commands/start.mjs +30 -11
  45. package/lib/commands/status.mjs +18 -2
  46. package/lib/commands/task.mjs +9 -1
  47. package/lib/commands/up.mjs +19 -11
  48. package/lib/commands/why.mjs +7 -3
  49. package/lib/config.mjs +42 -9
  50. package/lib/context.mjs +28 -21
  51. package/lib/control.mjs +21 -6
  52. package/lib/credential.mjs +20 -14
  53. package/lib/deliverable.mjs +2 -0
  54. package/lib/events.mjs +96 -53
  55. package/lib/execution.mjs +132 -11
  56. package/lib/execution_preparation.mjs +16 -1
  57. package/lib/execution_result.mjs +26 -5
  58. package/lib/first_party_review.mjs +618 -0
  59. package/lib/fs.mjs +10 -3
  60. package/lib/gh.mjs +144 -56
  61. package/lib/git.mjs +133 -32
  62. package/lib/git_diff.mjs +121 -0
  63. package/lib/harness.mjs +14 -10
  64. package/lib/host_contract.mjs +71 -0
  65. package/lib/host_definitions.mjs +22 -0
  66. package/lib/hosts/bitbucket.mjs +553 -0
  67. package/lib/hosts/bitbucket_contract.mjs +57 -0
  68. package/lib/hosts/bitbucket_credential.mjs +62 -0
  69. package/lib/hosts/bitbucket_review.mjs +44 -0
  70. package/lib/hosts/bitbucket_review_producer.mjs +50 -0
  71. package/lib/hosts/github.mjs +57 -0
  72. package/lib/hosts/github_contract.mjs +27 -0
  73. package/lib/hosts/github_doctor.mjs +482 -0
  74. package/lib/hosts/github_review_summary.mjs +38 -0
  75. package/lib/hosts/transport_doctor.mjs +35 -0
  76. package/lib/hosts.mjs +130 -0
  77. package/lib/learning_qualification.mjs +1 -1
  78. package/lib/notify.mjs +1 -1
  79. package/lib/openai_agents_input.mjs +7 -2
  80. package/lib/openai_agents_sources.mjs +16 -8
  81. package/lib/plugin_package.mjs +13 -1
  82. package/lib/pr_operation.mjs +41 -0
  83. package/lib/prd.mjs +18 -3
  84. package/lib/preparation_codex.mjs +6 -1
  85. package/lib/probe.mjs +6 -4
  86. package/lib/process.mjs +24 -0
  87. package/lib/promotion.mjs +132 -0
  88. package/lib/promotion_git.mjs +63 -0
  89. package/lib/prompt.mjs +16 -13
  90. package/lib/provider_health.mjs +14 -3
  91. package/lib/repository.mjs +68 -0
  92. package/lib/repository_preflight.mjs +11 -0
  93. package/lib/review_evaluation.mjs +25 -0
  94. package/lib/review_evidence.mjs +500 -0
  95. package/lib/review_history.mjs +73 -0
  96. package/lib/review_image.mjs +145 -0
  97. package/lib/review_link.mjs +12 -0
  98. package/lib/review_policy.mjs +132 -0
  99. package/lib/review_policy_contract.mjs +18 -0
  100. package/lib/review_state.mjs +13 -0
  101. package/lib/review_visual.mjs +94 -0
  102. package/lib/runtime-environment.mjs +6 -0
  103. package/lib/runtime_node.mjs +3 -2
  104. package/lib/runtime_state.mjs +39 -5
  105. package/lib/sources/prd.mjs +6 -3
  106. package/lib/state.mjs +137 -24
  107. package/lib/task_completion.mjs +7 -3
  108. package/lib/task_contract.mjs +201 -27
  109. package/lib/task_delivery.mjs +9 -2
  110. package/lib/task_delivery_contract.mjs +13 -9
  111. package/lib/task_delivery_view.mjs +18 -2
  112. package/lib/task_environment.mjs +13 -1
  113. package/lib/task_limits.mjs +13 -0
  114. package/lib/task_model_config.mjs +99 -0
  115. package/lib/task_model_route.mjs +243 -0
  116. package/lib/task_prepare.mjs +21 -8
  117. package/lib/task_profile.mjs +1875 -0
  118. package/lib/task_repository_context.mjs +65 -0
  119. package/lib/tasks.mjs +252 -51
  120. package/lib/tick.mjs +373 -98
  121. package/lib/time.mjs +1 -0
  122. package/lib/tracker/pages.mjs +19 -5
  123. package/lib/tracker/review.mjs +21 -0
  124. package/lib/tracker_process.mjs +8 -1
  125. package/lib/verification_sandbox.mjs +2 -1
  126. package/lib/work_artifacts.mjs +79 -0
  127. package/npm-shrinkwrap.json +12 -3
  128. package/package.json +9 -3
  129. package/templates/plugin/task-delivery.SKILL.md +17 -10
  130. package/templates/plugin/task-intake-contract.json +18 -0
  131. package/templates/plugin/task-profile-requests.mjs +1448 -0
  132. package/templates/tracker/tracker.css +3 -0
  133. 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
- export const VERSION = (() => {
18
- try { return JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version || "unknown"; }
19
- catch { return "unknown"; }
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 { execFile } from "node:child_process";
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
- /** The scan cap for a PR diff. A diff this size or larger cannot be shown to the output scan whole. */
19
- export const MAX_DIFF_BYTES = 16 * 1024 * 1024;
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 s = String(url || "").trim().replace(/\/+$/, "");
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 token = (await credential())?.token;
73
- return token ? { ...process.env, GH_TOKEN: token } : process.env;
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", "Checks green, reviewer clear, completion receipt accepted"], [NEEDS_LABEL, "b60205", "Work paused; operator attention required"]];
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 => (r || []).flat());
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, remoteMatches, repoFromRemote } from "./gh.mjs";
10
- import { COMMIT_MSG_HOOK } from "./attribution.mjs";
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
- export const worktreePath = (config, nameWithOwner, prdId) => join(config.worktrees, ...nameWithOwner.toLowerCase().split("/"), prdId);
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", `!f() { test -n "$GH_TOKEN" && printf 'username=x\\npassword=%s\\n' "$GH_TOKEN"; }; f`],
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
- * What is NOT bounded by this, stated plainly because it is not new and is easy to mistake for new: a run's own
111
- * `.git/config` is still read, `credential.helper` is multi-valued, and a repo-local helper is consulted first. That
112
- * was equally true when the helper lived in a global file. A run controls its worktree, so it can always add one; the
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
- const pairs = [...RUN_GIT_CONFIG, ...(identity?.name && identity?.email ? identityConfig(identity) : [])];
125
- const env = { GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_COUNT: String(pairs.length) };
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
- if (credential?.token) env.GH_TOKEN = credential.token;
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 env = async () => ({ ...process.env, ...runEnv(config, await credential(), await identity()) });
163
- const git = async (args, cwd, timeout = GIT_TIMEOUT_MS) => (await exec("git", args, { cwd, env: await env(), timeout })).stdout.trim();
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 gh = async (args, cwd, timeout = CLONE_TIMEOUT_MS, beforeExec = null) => {
171
- const options = { cwd, env: await env(), timeout };
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 (await exec("gh", args, options)).stdout.trim();
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 repo = repoFromRemote(origin);
179
- if (repo && !origin.startsWith("https://")) await git(["remote", "set-url", "origin", cloneUrl(repo)], dir);
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", COMMIT_MSG_HOOK, { mode: 0o755, allowGit: true });
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. `gh repo clone --no-checkout` brings the remote's
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 gh(["repo", "clone", cloneUrl(nameWithOwner), path, "--", "--filter=blob:none", "--no-checkout"], undefined, CLONE_TIMEOUT_MS, () => { cloneAttempted = true; });
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 gh(["repo", "clone", cloneUrl(nameWithOwner), path, "--", "--filter=blob:none", "--branch", branch]);
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);