@senad-d/branchme 0.3.14 → 0.3.16

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 (41) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +135 -34
  3. package/SECURITY.md +45 -9
  4. package/docs/PROJECT_DEFINITION_BRIEF.md +28 -8
  5. package/docs/SHELL_FREE_WORKFLOWS.md +74 -0
  6. package/docs/SMOKE_TEST.md +16 -7
  7. package/docs/STRUCTURE.md +49 -9
  8. package/docs/TUI_CAPTURE.md +17 -3
  9. package/package.json +9 -6
  10. package/src/blocker-guidance.ts +20 -5
  11. package/src/commands/branchme-command.ts +17 -3
  12. package/src/commit-draft.ts +461 -0
  13. package/src/commit-prompt.ts +409 -0
  14. package/src/commit-workflow.ts +233 -0
  15. package/src/constants.ts +138 -0
  16. package/src/git-commit-context.ts +783 -0
  17. package/src/git-commit.ts +317 -0
  18. package/src/git-conflicts.ts +77 -0
  19. package/src/git-diff.ts +274 -0
  20. package/src/git-history.ts +112 -0
  21. package/src/git-index.ts +119 -0
  22. package/src/git-integration.ts +1 -0
  23. package/src/git-read.ts +100 -0
  24. package/src/git-rebase.ts +42 -0
  25. package/src/git-remotes.ts +129 -0
  26. package/src/git-restore.ts +53 -0
  27. package/src/git-revert.ts +67 -0
  28. package/src/git-status.ts +346 -0
  29. package/src/git.ts +30 -2
  30. package/src/github-api.ts +39 -0
  31. package/src/github-checks.ts +88 -0
  32. package/src/github-lifecycle.ts +106 -0
  33. package/src/tools/branchme-tools.ts +167 -127
  34. package/src/tools/commit-tools.ts +240 -0
  35. package/src/tools/github-lifecycle-tools.ts +29 -0
  36. package/src/tools/inspection-tools.ts +39 -0
  37. package/src/tools/mutation-tools.ts +58 -0
  38. package/src/tools/workflow-tools.ts +36 -50
  39. package/src/tools/worktree-routing.ts +34 -0
  40. package/src/truncation.ts +119 -0
  41. package/src/types.ts +279 -0
@@ -0,0 +1,67 @@
1
+ import { inspectIndex, requireSimpleIndex } from "./git-index.ts";
2
+ import { getGitStatus } from "./git-status.ts";
3
+ import { boundedGitText, OBJECT_ID, openGitReader, readGit, errorText, validateKeys, type GitAPI, type GitReader } from "./git-read.ts";
4
+ import { getCurrentBranch, getGitOperationState, isCommitAncestor, runGit, withRepositoryMutationQueue, type GitCommandContext } from "./git.ts";
5
+
6
+ export interface RevertInput { commit: string; expectedHead: string; expectedIndex: string; worktreePath?: string }
7
+ async function revertInside(host: GitAPI, reader: GitReader, input: RevertInput) {
8
+ await requireSimpleIndex(reader);
9
+ const before = await getGitStatus(reader.pi, reader.ctx, {}, reader.signal, reader.tokens);
10
+ if (before.head !== input.expectedHead || before.index !== input.expectedIndex || before.hasChanges) throw new Error("No-commit revert requires fresh HEAD/index expectations and a clean idle checkout.");
11
+ if ((await readGit(reader, ["ls-files", "--others", "--ignored", "--exclude-standard", "-z"])).stdout) throw new Error("No-commit revert refuses ignored local-only files; preserve them outside the checkout first.");
12
+ const metadata = (await readGit(reader, ["rev-list", "--parents", "-n", "1", input.commit, "--"])).stdout.trim().split(" ");
13
+ if (metadata[0] !== input.commit || metadata.length > 2 || !metadata.every(validOid) || !(await isCommitAncestor(reader.pi, reader.ctx, input.commit, input.expectedHead, reader.signal))) throw new Error("Revert requires one non-merge commit already contained in current HEAD.");
14
+ if (metadata[1]) {
15
+ const tree = await readGit(reader, ["ls-tree", "-r", "--format=%(objectmode)", metadata[1]]);
16
+ if (tree.stdout.split("\n").includes("160000")) throw new Error("Reverts introducing gitlinks are unsupported.");
17
+ }
18
+ const branch = (await getCurrentBranch(reader.pi, reader.ctx, reader.signal)).currentBranch;
19
+ const refs = (await readGit(reader, ["show-ref"])).stdout;
20
+ const config = (await readGit(reader, ["config", "--null", "--list"])).stdout;
21
+ await inspectIndex(reader);
22
+ const immediate = await getGitStatus(reader.pi, reader.ctx, {}, reader.signal, reader.tokens);
23
+ if (immediate.head !== before.head || immediate.index !== before.index || immediate.hasChanges) throw new Error("Checkout changed during revert preparation; inspect git_status again.");
24
+ reader.signal?.throwIfAborted();
25
+ let failure: unknown;
26
+ try { await runGit(host, reader.ctx, ["--no-lazy-fetch", "-c", "rerere.enabled=false", "revert", "--no-commit", input.commit], { signal: reader.signal, timeout: 120000, tokens: reader.tokens }); }
27
+ catch (error) { failure = error; }
28
+ const final = { ...reader, signal: undefined };
29
+ if (failure) {
30
+ await recoverRevert(host, final, input, before, branch, refs, config);
31
+ throw new Error(`Revert failed; automatic abort/restoration was verified. ${boundedGitText(errorText(failure), reader.tokens, 2000).text}`);
32
+ }
33
+ return verifyRevert(host, final, input, before, branch, refs, config);
34
+ }
35
+ type RevertStatus = Awaited<ReturnType<typeof getGitStatus>>;
36
+ async function recoverRevert(host: GitAPI, final: GitReader, input: RevertInput, before: RevertStatus, branch: string | null, refs: string, config: string) {
37
+ try {
38
+ const operation = await getGitOperationState(final.pi, final.ctx);
39
+ if (operation.active.includes("revert")) {
40
+ const reverting = (await readGit(final, ["rev-parse", "--verify", "REVERT_HEAD"])).stdout.trim();
41
+ if (reverting !== input.commit || (await getGitStatus(final.pi, final.ctx)).head !== input.expectedHead) throw new Error("Revert state is not owned by this request.");
42
+ await runGit(host, final.ctx, ["revert", "--abort"], { timeout: 30000, tokens: final.tokens });
43
+ }
44
+ const restored = await getGitStatus(final.pi, final.ctx);
45
+ if (restored.head !== before.head || restored.index !== before.index || restored.hasChanges || restored.operations.length || (await getCurrentBranch(final.pi, final.ctx)).currentBranch !== branch || (await readGit(final, ["show-ref"])).stdout !== refs || (await readGit(final, ["config", "--null", "--list"])).stdout !== config) throw new Error("Restoration was not verified.");
46
+ } catch { throw new Error("Revert recovery is uncertain; stop mutations and inspect git_status/git_conflicts. A person must reconcile the checkout; no reset/stash rollback was attempted."); }
47
+ }
48
+ async function verifyRevert(host: GitAPI, final: GitReader, input: RevertInput, before: RevertStatus, branch: string | null, refs: string, config: string) {
49
+ const applied = await getGitStatus(final.pi, final.ctx, {}, undefined, final.tokens);
50
+ if (applied.operations.includes("revert")) {
51
+ const reverting = (await readGit(final, ["rev-parse", "--verify", "REVERT_HEAD"])).stdout.trim();
52
+ if (reverting !== input.commit || applied.head !== before.head) throw new Error("Successful inverse has uncertain operation ownership; inspect git_status before continuing.");
53
+ // --no-commit may leave REVERT_HEAD even after a successful application.
54
+ // Quit only the owned operation; unlike abort this preserves the staged inverse.
55
+ await runGit(host, final.ctx, ["revert", "--quit"], { timeout: 30000, tokens: final.tokens });
56
+ }
57
+ const after = await getGitStatus(final.pi, final.ctx, {}, undefined, final.tokens);
58
+ if (after.index !== applied.index || after.head !== before.head || after.operations.length || (await getCurrentBranch(final.pi, final.ctx)).currentBranch !== branch || (await readGit(final, ["show-ref"])).stdout !== refs || (await readGit(final, ["config", "--null", "--list"])).stdout !== config) throw new Error("Revert postconditions are uncertain; inspect git_status before another mutation.");
59
+ return { action: "revert_changes", commit: input.commit, head: after.head, index: after.index, committed: false, stagedPaths: after.entries.map((entry) => entry.path), pathsComplete: after.complete, note: "Inverse changes are staged, not committed. Enumerate git_status for all paths; ordinary commit creation is a separate explicit workflow." };
60
+ }
61
+ function validOid(value: string): boolean { return OBJECT_ID.test(value); }
62
+ export async function revertChanges(host: GitAPI, ctx: GitCommandContext, input: RevertInput, signal?: AbortSignal, tokens: readonly string[] = []) {
63
+ validateKeys(input, ["commit", "expectedHead", "expectedIndex", "worktreePath"]);
64
+ if (typeof input.commit !== "string" || !OBJECT_ID.test(input.commit) || typeof input.expectedHead !== "string" || !OBJECT_ID.test(input.expectedHead) || !/^[a-f0-9]{64}$/u.test(input.expectedIndex)) throw new TypeError("Require a full commit identity and fresh HEAD/index expectations from git_status.");
65
+ const reader = await openGitReader(host, ctx, input.worktreePath, signal, tokens);
66
+ return withRepositoryMutationQueue(reader.ctx.cwd, revertInside.bind(undefined, host, reader, input));
67
+ }
@@ -0,0 +1,346 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, open, readlink } from "node:fs/promises";
4
+ import { isAbsolute, join, win32 } from "node:path";
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ GIT_CONTEXT_VALUE_LIMIT_CHARS, GIT_DIFF_PATH_LIMIT, GIT_STATUS_CURSOR_LIMIT_CHARS,
8
+ GIT_STATUS_FILE_LIMIT_BYTES, GIT_STATUS_PAGE_BYTES, GIT_STATUS_PAGE_LIMIT,
9
+ GIT_STATUS_RAW_LIMIT_BYTES, GIT_WORKTREE_PATH_LIMIT_CHARS,
10
+ } from "./constants.ts";
11
+ import { getCanonicalGitWorktreeRoot, getGitOperationState, resolveRepositoryWorktreeCwd, runGit, type GitCommandContext } from "./git.ts";
12
+ import { redactSecrets } from "./redaction.ts";
13
+ import type { GitStatusDetails, GitStatusEntry, GitStatusToolInput } from "./types.ts";
14
+
15
+ type GitAPI = Pick<ExtensionAPI, "exec">;
16
+ interface StatusRecord {
17
+ kind: GitStatusEntry["kind"];
18
+ path: string;
19
+ originalPath?: string;
20
+ indexStatus: string;
21
+ worktreeStatus: string;
22
+ submodule: string;
23
+ }
24
+ interface Observation {
25
+ head: string | null;
26
+ branch: string | null;
27
+ detached: boolean;
28
+ records: StatusRecord[];
29
+ index: string;
30
+ snapshot: string;
31
+ operations: string[];
32
+ }
33
+ const CONTROL = /[\p{Cc}\p{Cf}\u2028\u2029\ufffd]/u;
34
+ const OID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u;
35
+
36
+ function digest(value: string | Buffer): string {
37
+ return createHash("sha256").update(value).digest("hex");
38
+ }
39
+
40
+ function safeText(value: string, tokens: readonly string[]): string {
41
+ return redactSecrets(value, tokens).replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, escapeControl);
42
+ }
43
+
44
+ function escapeControl(character: string): string {
45
+ return String.raw`\u${character.codePointAt(0)!.toString(16).padStart(4, "0")}`;
46
+ }
47
+
48
+ function display(value: string, tokens: readonly string[]): string {
49
+ const safe = safeText(value, tokens);
50
+ if (safe.length <= GIT_CONTEXT_VALUE_LIMIT_CHARS) return safe;
51
+ const end = GIT_CONTEXT_VALUE_LIMIT_CHARS - ((safe.codePointAt(GIT_CONTEXT_VALUE_LIMIT_CHARS - 1) ?? 0) > 0xffff ? 1 : 0);
52
+ return `${safe.slice(0, end)}…`;
53
+ }
54
+
55
+ function escapeErrorControl(character: string): string {
56
+ return character === "\n" ? character : escapeControl(character);
57
+ }
58
+
59
+ function validPath(value: unknown): value is string {
60
+ return typeof value === "string" && Boolean(value.trim()) && value.length <= GIT_WORKTREE_PATH_LIMIT_CHARS &&
61
+ !isAbsolute(value) && !win32.isAbsolute(value) && !value.includes("\\") && !CONTROL.test(value) &&
62
+ !value.split("/").some((part) => part === ".." || part.toLowerCase() === ".git");
63
+ }
64
+
65
+ function validateInput(input: GitStatusToolInput): void {
66
+ if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some((key) => !["paths", "worktreePath", "after"].includes(key))) {
67
+ throw new TypeError("git_status accepts only paths, worktreePath, and after.");
68
+ }
69
+ if (input.paths !== undefined && (!Array.isArray(input.paths) || !input.paths.length || input.paths.length > GIT_DIFF_PATH_LIMIT || !input.paths.every(validPath))) {
70
+ throw new TypeError("git_status paths must be 1–25 literal root-relative paths without traversal, .git, backslashes, or controls.");
71
+ }
72
+ if (input.worktreePath !== undefined && (typeof input.worktreePath !== "string" || !isAbsolute(input.worktreePath) || input.worktreePath.length > GIT_WORKTREE_PATH_LIMIT_CHARS || CONTROL.test(input.worktreePath))) {
73
+ throw new TypeError("git_status worktreePath must be a bounded absolute worktree root.");
74
+ }
75
+ if (input.after !== undefined && (typeof input.after !== "string" || input.after.length > GIT_STATUS_CURSOR_LIMIT_CHARS || !/^v1\.[a-f0-9]{64}\.[1-9]\d{0,8}$/u.test(input.after))) {
76
+ throw new TypeError("git_status after must be an unchanged continuation returned by git_status.");
77
+ }
78
+ }
79
+
80
+ async function execStatusRead(host: GitAPI, tokens: readonly string[], command: string, args: string[], options: Parameters<GitAPI["exec"]>[2]) {
81
+ const result = await host.exec(command, ["--no-optional-locks", "--no-lazy-fetch", "--no-pager", "-c", "core.fsmonitor=false", ...args], options);
82
+ if (result.killed) throw new Error("git_status local read was killed or timed out. Retry once; if repeated, report a local Git inspection failure.");
83
+ if (result.code !== 0) {
84
+ if (/unknown option[^\n]*no-lazy-fetch/iu.test(result.stderr)) throw new Error("git_status requires Git with --no-lazy-fetch support; update Git rather than disabling safeguards.");
85
+ return { ...result, stdout: display(result.stdout, tokens), stderr: display(result.stderr, tokens) };
86
+ }
87
+ if (Buffer.byteLength(result.stdout, "utf8") > GIT_STATUS_RAW_LIMIT_BYTES) {
88
+ throw new Error("git_status raw inspection exceeded the 4 MiB safety limit; complete checkout inspection is unavailable. No partial cleanliness claim is possible.");
89
+ }
90
+ return result;
91
+ }
92
+
93
+ function parseTracked(record: string): StatusRecord {
94
+ const fields = record.split(" ");
95
+ const type = fields[0];
96
+ const count = ({ "1": 8, "2": 9 } as Record<string, number>)[type] ?? 10;
97
+ const path = fields.slice(count).join(" ");
98
+ const modeStart = 3;
99
+ const modeCount = type === "u" ? 4 : 3;
100
+ const ids = fields.slice(modeStart + modeCount, type === "u" ? 10 : 8);
101
+ if (!path || !/^[.MADRCUT]{2}$/u.test(fields[1] ?? "") || !/^(?:N\.\.\.|S[.C][.M][.U])$/u.test(fields[2] ?? "") ||
102
+ !fields.slice(modeStart, modeStart + modeCount).every((mode) => /^[0-7]{6}$/u.test(mode)) ||
103
+ ids.length !== (type === "u" ? 3 : 2) || !ids.every((id) => OID.test(id)) ||
104
+ (type === "2" && !/^[RC]\d{1,3}$/u.test(fields[8] ?? ""))) {
105
+ throw new Error("git_status received malformed tracked-file metadata.");
106
+ }
107
+ return { kind: type === "u" ? "conflict" : "tracked", path, indexStatus: fields[1][0], worktreeStatus: fields[1][1], submodule: fields[2] };
108
+ }
109
+
110
+ function appendStatusRecord(parts: string[], i: number, records: StatusRecord[]): number {
111
+ const part = parts[i];
112
+ if (part.startsWith("? ")) {
113
+ if (part.length === 2) throw new Error("git_status received an empty untracked path.");
114
+ records.push({ kind: "untracked", path: part.slice(2), indexStatus: "?", worktreeStatus: "?", submodule: "N..." });
115
+ return i;
116
+ }
117
+ if (!/^[12u] /u.test(part)) throw new Error("git_status received unknown porcelain metadata.");
118
+ const entry = parseTracked(part);
119
+ if (part.startsWith("2 ")) {
120
+ const original = parts[++i];
121
+ if (!original) throw new Error("git_status received an incomplete rename record.");
122
+ entry.originalPath = original;
123
+ }
124
+ records.push(entry);
125
+ return i;
126
+ }
127
+
128
+ function parseStatus(raw: string): Pick<Observation, "records" | "head" | "branch" | "detached"> {
129
+ if (!raw.endsWith("\0")) throw new Error("git_status received incomplete porcelain output.");
130
+ const parts = raw.slice(0, -1).split("\0");
131
+ const records: StatusRecord[] = [];
132
+ let head: string | null | undefined;
133
+ let branch: string | null | undefined;
134
+ let i = 0;
135
+ while (i < parts.length) {
136
+ const part = parts[i];
137
+ if (part.startsWith("# branch.oid ")) {
138
+ head = parseHeadMetadata(part, head);
139
+ } else if (part.startsWith("# branch.head ")) {
140
+ branch = parseBranchMetadata(part, branch);
141
+ } else if (part.startsWith("# branch.upstream ") || part.startsWith("# branch.ab ")) {
142
+ // Tracking metadata is owned by branch_status, not this file inventory.
143
+ } else i = appendStatusRecord(parts, i, records);
144
+ i++;
145
+ }
146
+ if (head === undefined || branch === undefined || branch === "") throw new Error("git_status did not receive branch/HEAD metadata.");
147
+ if (new Set(records.map(recordIdentity)).size !== records.length) throw new Error("git_status received duplicate file records.");
148
+ records.sort(compareRecords);
149
+ return { records, head, branch, detached: branch === null };
150
+ }
151
+
152
+ function parseBranchMetadata(part: string, previous: string | null | undefined): string | null {
153
+ if (previous !== undefined) throw new Error("git_status received duplicate branch metadata.");
154
+ return part.slice(14) === "(detached)" ? null : part.slice(14);
155
+ }
156
+ function parseHeadMetadata(part: string, previous: string | null | undefined): string | null {
157
+ const value = part.slice(13);
158
+ if (previous !== undefined || (value !== "(initial)" && !OID.test(value))) throw new Error("git_status received invalid HEAD metadata.");
159
+ return value === "(initial)" ? null : value;
160
+ }
161
+ function recordIdentity(record: StatusRecord): string { return `${record.kind}:${record.path}`; }
162
+ function compareRecords(a: StatusRecord, b: StatusRecord): number {
163
+ if (a.path < b.path) return -1;
164
+ return a.path > b.path ? 1 : 0;
165
+ }
166
+ function missing(error: unknown): boolean { return (error as NodeJS.ErrnoException).code === "ENOENT" || (error as NodeJS.ErrnoException).code === "ENOTDIR"; }
167
+
168
+ /** Never traverse an existing symlink ancestor while fingerprinting a Git path. */
169
+ export async function checkedFilePath(root: string, path: string): Promise<string | null> {
170
+ if (!validPath(path)) return null;
171
+ const segments = path.split("/").filter((segment) => segment && segment !== ".");
172
+ let parent = root;
173
+ for (const segment of segments.slice(0, -1)) {
174
+ parent = join(parent, segment);
175
+ try {
176
+ const info = await lstat(parent);
177
+ if (!info.isDirectory() || info.isSymbolicLink()) return null;
178
+ } catch (error) {
179
+ if (missing(error)) return join(root, ...segments);
180
+ throw new Error("git_status could not inspect a working-file ancestor.");
181
+ }
182
+ }
183
+ return join(root, ...segments);
184
+ }
185
+
186
+ async function metadata(root: string, path: string): Promise<string> {
187
+ const file = await checkedFilePath(root, path);
188
+ if (file === null) return "unsupported-path";
189
+ try {
190
+ const info = await lstat(file, { bigint: true });
191
+ return [info.dev, info.ino, info.mode, info.size, info.mtimeNs, info.ctimeNs].join(":");
192
+ } catch (error) {
193
+ if (missing(error)) return "missing";
194
+ throw new Error("git_status could not inspect working-file metadata; checkout state is unknown.");
195
+ }
196
+ }
197
+
198
+ async function observe(pi: GitAPI, ctx: GitCommandContext, signal?: AbortSignal): Promise<Observation> {
199
+ signal?.throwIfAborted();
200
+ const status = await runGit(pi, ctx, ["-c", "status.relativePaths=false", "status", "--porcelain=v2", "-z", "--branch", "--no-ahead-behind", "--untracked-files=all", "--ignore-submodules=dirty", "--find-renames=50%"], { signal });
201
+ const parsed = parseStatus(status.stdout);
202
+ const index = await runGit(pi, ctx, ["ls-files", "--stage", "-v", "-z"], { signal });
203
+ const operations = await getGitOperationState(pi, ctx, signal);
204
+ const hash = createHash("sha256").update(JSON.stringify([ctx.cwd, status.stdout, index.stdout, operations]));
205
+ // Bound filesystem concurrency even for an inventory near the 4 MiB limit.
206
+ for await (const observations of metadataBatches(ctx.cwd, signal, parsed.records)) {
207
+ for (const observation of observations) for (const value of observation) hash.update(value);
208
+ }
209
+ return { ...parsed, index: digest(index.stdout), snapshot: hash.digest("hex"), operations: operations.active };
210
+ }
211
+
212
+ async function* metadataBatches(root: string, signal: AbortSignal | undefined, records: StatusRecord[]) {
213
+ for (let offset = 0; offset < records.length; offset += 25) {
214
+ yield Promise.all(records.slice(offset, offset + 25).map(recordMetadata.bind(undefined, root, signal)));
215
+ }
216
+ }
217
+ async function recordMetadata(root: string, signal: AbortSignal | undefined, entry: StatusRecord): Promise<string[]> {
218
+ signal?.throwIfAborted();
219
+ const paths = entry.originalPath ? [entry.path, entry.originalPath] : [entry.path];
220
+ return Promise.all(paths.map(pathMetadata.bind(undefined, root)));
221
+ }
222
+ async function pathMetadata(root: string, path: string): Promise<string> { return JSON.stringify([path, await metadata(root, path)]); }
223
+
224
+ function normalizeFilter(path: string): string {
225
+ return path.split("/").filter((part) => part && part !== ".").join("/");
226
+ }
227
+ function matchesPath(path: string, filter: string): boolean { return filter === "" || path === filter || path.startsWith(`${filter}/`); }
228
+ function matchesRecord(filters: string[], entry: StatusRecord): boolean {
229
+ return filters.length === 0 || filters.some((filter) => matchesPath(entry.path, filter) || (entry.originalPath !== undefined && matchesPath(entry.originalPath, filter)));
230
+ }
231
+
232
+ export async function fileExpectation(root: string, path: string, signal?: AbortSignal): Promise<NonNullable<GitStatusEntry["worktreeExpectation"]>> {
233
+ const file = await checkedFilePath(root, path);
234
+ if (file === null) return { fingerprint: null, reason: "Unsafe path or symlink ancestor; no reusable content expectation." };
235
+ const before = await metadata(root, path);
236
+ if (before === "missing") return { fingerprint: digest("missing") };
237
+ const info = await lstat(file);
238
+ signal?.throwIfAborted();
239
+ let hash: string;
240
+ if (info.isSymbolicLink()) hash = digest(Buffer.concat([Buffer.from("symlink:"), await readlink(file, { encoding: "buffer" })]));
241
+ else if (!info.isFile()) return { fingerprint: null, reason: "Not a regular file or symlink; no content expectation." };
242
+ else if (info.size > GIT_STATUS_FILE_LIMIT_BYTES) return { fingerprint: null, reason: "File exceeds the 8 MiB content-expectation limit." };
243
+ else hash = await regularFileFingerprint(file, info, signal);
244
+ signal?.throwIfAborted();
245
+ if (before !== await metadata(root, path)) throw new Error("git_status working file changed during inspection.\nResolve: Restart git_status without after.");
246
+ return { fingerprint: hash };
247
+ }
248
+
249
+ async function regularFileFingerprint(file: string, info: Awaited<ReturnType<typeof lstat>>, signal?: AbortSignal): Promise<string> {
250
+ const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
251
+ try {
252
+ const opened = await handle.stat();
253
+ if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino || opened.size > GIT_STATUS_FILE_LIMIT_BYTES) throw new Error("git_status working file changed during inspection.\nResolve: Restart git_status without after.");
254
+ const buffer = Buffer.alloc(opened.size + 1);
255
+ let bytesRead = 0;
256
+ while (bytesRead < buffer.length) {
257
+ signal?.throwIfAborted();
258
+ const chunk = await handle.read(buffer, bytesRead, buffer.length - bytesRead, bytesRead);
259
+ if (!chunk.bytesRead) break;
260
+ bytesRead += chunk.bytesRead;
261
+ }
262
+ if (bytesRead !== opened.size) throw new Error("git_status working file changed during inspection.\nResolve: Restart git_status without after.");
263
+ return digest(Buffer.concat([Buffer.from(`file:${opened.mode}:`), buffer.subarray(0, bytesRead)]));
264
+ } finally { await handle.close(); }
265
+ }
266
+
267
+ function displayedEntry(record: StatusRecord, tokens: readonly string[]): GitStatusEntry {
268
+ const path = display(record.path, tokens);
269
+ const originalPath = record.originalPath === undefined ? undefined : display(record.originalPath, tokens);
270
+ return {
271
+ ...record, path, ...(originalPath === undefined ? {} : { originalPath }),
272
+ reusablePath: path === record.path && validPath(path) && (record.originalPath === undefined || (originalPath === record.originalPath && validPath(originalPath))),
273
+ };
274
+ }
275
+
276
+ async function inspectDisplayedEntry(root: string, record: StatusRecord, includeExpectation: boolean, signal: AbortSignal | undefined, tokens: readonly string[]) {
277
+ const entry = displayedEntry(record, tokens);
278
+ entry.reusablePath &&= await checkedFilePath(root, record.path) !== null;
279
+ if (entry.reusablePath && record.originalPath) entry.reusablePath = await checkedFilePath(root, record.originalPath) !== null;
280
+ if (includeExpectation && entry.reusablePath) entry.worktreeExpectation = await fileExpectation(root, record.path, signal);
281
+ return entry;
282
+ }
283
+
284
+ async function collectStatus(host: GitAPI, ctx: GitCommandContext, input: GitStatusToolInput, signal: AbortSignal | undefined, tokens: readonly string[]): Promise<GitStatusDetails> {
285
+ const pi = { exec: execStatusRead.bind(undefined, host, tokens) };
286
+ const cwd = input.worktreePath === undefined ? ctx.cwd : await resolveRepositoryWorktreeCwd(pi, ctx, input.worktreePath, signal);
287
+ const root = await getCanonicalGitWorktreeRoot(pi, { cwd }, signal);
288
+ const rootCtx = { cwd: root };
289
+ const before = await observe(pi, rootCtx, signal);
290
+ const filters = [...new Set((input.paths ?? []).map(normalizeFilter))].sort((a, b) => a.localeCompare(b));
291
+ const binding = digest(JSON.stringify([root, filters, before.snapshot]));
292
+ const offset = input.after === undefined ? 0 : Number(input.after.split(".")[2]);
293
+ const records = before.records.filter(matchesRecord.bind(undefined, filters));
294
+ if (input.after !== undefined && (input.after.split(".")[1] !== binding || offset >= records.length)) {
295
+ throw new Error("git_status continuation is stale or belongs to a different worktree/filter.\nResolve: Restart git_status without after and enumerate the new snapshot.");
296
+ }
297
+ const entries: GitStatusEntry[] = [];
298
+ let bytes = 0;
299
+ for (const record of records.slice(offset, offset + GIT_STATUS_PAGE_LIMIT)) {
300
+ signal?.throwIfAborted();
301
+ const entry = await inspectDisplayedEntry(root, record, input.paths !== undefined, signal, tokens);
302
+ const size = Buffer.byteLength(JSON.stringify(entry));
303
+ if (entries.length && bytes + size > GIT_STATUS_PAGE_BYTES) break;
304
+ entries.push(entry);
305
+ bytes += size;
306
+ }
307
+ const after = await observe(pi, rootCtx, signal);
308
+ if (before.snapshot !== after.snapshot) throw new Error("git_status checkout changed during inspection.\nResolve: Restart git_status without after; do not use expectations from an interrupted inspection.");
309
+ const complete = offset + entries.length === records.length;
310
+ return {
311
+ action: "git_status", repoRoot: display(root, tokens), head: before.head,
312
+ branch: before.branch === null ? null : display(before.branch, tokens), detached: before.detached,
313
+ index: before.index, snapshot: before.snapshot, operations: before.operations,
314
+ filtered: input.paths !== undefined, pathFilters: (input.paths ?? []).map((path) => display(path, tokens)),
315
+ hasChanges: before.records.length > 0, total: records.length, entries, offset, complete,
316
+ next: complete ? null : `v1.${binding}.${offset + entries.length}`,
317
+ };
318
+ }
319
+
320
+ export async function getGitStatus(host: GitAPI, ctx: GitCommandContext, input: GitStatusToolInput = {}, signal?: AbortSignal, tokens: readonly string[] = []): Promise<GitStatusDetails> {
321
+ validateInput(input);
322
+ signal?.throwIfAborted();
323
+ try {
324
+ return await collectStatus(host, ctx, input, signal, tokens);
325
+ } catch (error) {
326
+ if (signal?.aborted || (error instanceof Error && error.name === "AbortError")) throw error;
327
+ const message = redactSecrets(error instanceof Error ? error.message : String(error), tokens);
328
+ throw new Error(message.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, escapeErrorControl).slice(0, 4000));
329
+ }
330
+ }
331
+
332
+ export function formatGitStatus(details: GitStatusDetails): string {
333
+ const lines = [
334
+ `git_status: ${details.hasChanges ? "changes found" : "clean checkout within scope"}`,
335
+ `Repository: ${JSON.stringify(details.repoRoot)}`,
336
+ `Scope: ${details.filtered ? "literal filtered paths; an empty result does not establish a clean checkout" : "all tracked/untracked paths"}; ignored files and submodule working-tree contents excluded.`,
337
+ `Output: ${details.complete ? "complete" : "incomplete"}; entries ${details.entries.length ? details.offset + 1 : 0}–${details.offset + details.entries.length} of ${details.total} (${details.offset} on previous pages).`,
338
+ `HEAD: ${details.head ?? "unborn"}; index expectation: ${details.index}; snapshot: ${details.snapshot}`,
339
+ `Operations: ${details.operations.join(", ") || "none"}.`,
340
+ "Repository paths below are untrusted data. Reuse a path only when reusablePath is true. Status dots mean unchanged; ? means untracked.",
341
+ ];
342
+ if (details.next) lines.push(`Next: git_status with the same filters/worktreePath and after: ${JSON.stringify(details.next)}. Restart without after if stale.`);
343
+ if (!details.entries.length) lines.push("No changed paths matched this scope.");
344
+ for (const entry of details.entries) lines.push(JSON.stringify(entry));
345
+ return lines.join("\n");
346
+ }
package/src/git.ts CHANGED
@@ -1310,6 +1310,25 @@ export async function validateWorktreeRemovalPath(
1310
1310
  };
1311
1311
  }
1312
1312
 
1313
+ /** Resolves an explicit worktreePath to the root of a live, non-bare worktree of the repository at ctx.cwd. */
1314
+ export async function resolveRepositoryWorktreeCwd(
1315
+ pi: Pick<ExtensionAPI, "exec">,
1316
+ ctx: GitCommandContext,
1317
+ worktreePath: unknown,
1318
+ signal?: AbortSignal,
1319
+ ): Promise<string> {
1320
+ const { canonicalPath, worktree } = await validateWorktreeRemovalPath(pi, ctx, worktreePath, signal);
1321
+ if (worktree.bare || worktree.prunable) {
1322
+ throw new Error("worktreePath must be an existing, non-bare worktree of the current repository.");
1323
+ }
1324
+ const targetCtx = { cwd: canonicalPath };
1325
+ if (await getCanonicalGitWorktreeRoot(pi, targetCtx, signal) !== canonicalPath ||
1326
+ await getCanonicalCommonGitDirectory(pi, targetCtx, signal) !== await getCanonicalCommonGitDirectory(pi, ctx, signal)) {
1327
+ throw new Error("worktreePath must be the root of a worktree of the current repository.");
1328
+ }
1329
+ return requireLosslessWorktreeIdentity(canonicalPath, "cwd");
1330
+ }
1331
+
1313
1332
  const GIT_BRANCH_FORMAT = "%(refname)%00%(objectname)%00%(HEAD)%00%(upstream)%00%(symref)%00%(upstream:track)";
1314
1333
 
1315
1334
  function branchTrackingCounts(upstream: string, track: string): AheadBehindCount {
@@ -1637,7 +1656,7 @@ function worktreeCreationFailure(
1637
1656
  );
1638
1657
  }
1639
1658
 
1640
- async function resolveWorktreeBaseCommit(
1659
+ export async function resolveWorktreeBaseCommit(
1641
1660
  pi: Pick<ExtensionAPI, "exec">,
1642
1661
  ctx: GitCommandContext,
1643
1662
  baseRef: string,
@@ -2726,6 +2745,7 @@ export async function createLocalBranch(
2726
2745
  ctx: GitCommandContext,
2727
2746
  branchName: string,
2728
2747
  signal?: AbortSignal,
2748
+ baseRef?: string,
2729
2749
  ): Promise<CreateBranchDetails> {
2730
2750
  const repoRoot = await getGitRoot(pi, ctx, signal);
2731
2751
  const rootCtx = { cwd: repoRoot };
@@ -2738,11 +2758,19 @@ export async function createLocalBranch(
2738
2758
  throw new Error(`Local branch '${redactSecrets(branchName)}' already exists.`);
2739
2759
  }
2740
2760
 
2741
- await runGit(pi, rootCtx, ["switch", "-c", branchName], {
2761
+ let base: string | undefined;
2762
+ if (baseRef !== undefined) {
2763
+ validateBranchNameInput(baseRef, "baseRef");
2764
+ if ((await getGitOperationState(pi, rootCtx, signal)).active.length) throw new Error("An in-progress Git operation must be completed or aborted first.");
2765
+ await requireNoTrackedChanges(pi, rootCtx, "create_branch", signal);
2766
+ base = await resolveWorktreeBaseCommit(pi, rootCtx, baseRef, signal);
2767
+ }
2768
+ await runGit(pi, rootCtx, ["switch", ...(base ? ["--no-overwrite-ignore"] : []), "-c", branchName, ...(base ? [base] : [])], {
2742
2769
  signal,
2743
2770
  timeout: GIT_MUTATION_TIMEOUT_MS,
2744
2771
  });
2745
2772
  const head = await getVerifiedRefCommit(pi, rootCtx, "HEAD", "HEAD", signal);
2773
+ if (base && (head !== base || (await getCurrentBranch(pi, rootCtx, signal)).currentBranch !== branchName)) throw new Error("Branch creation postconditions are uncertain; inspect git_status before continuing.");
2746
2774
 
2747
2775
  return { repoRoot, previousBranch: safeDetail(previousBranch), newBranch: safeDetail(branchName), head };
2748
2776
  });
@@ -0,0 +1,39 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { getGitRoot } from "./git.ts";
3
+ import { gitHubJsonHeaders, readBoundedResponseText, resolveGitHubRepository, resolveGitHubToken, type PullRequestFetchOptions } from "./github.ts";
4
+ import { boundedGitText } from "./git-read.ts";
5
+ import type { GitHubRepository } from "./types.ts";
6
+ import type { BranchMeToolOptions } from "./tools/branchme-tools.ts";
7
+ export interface GitHubClient { repository: GitHubRepository; token: string; fetchImpl: typeof fetch; signal?: AbortSignal }
8
+ export async function openGitHubClient(pi: Pick<ExtensionAPI, "exec">, ctx: { cwd: string }, options: BranchMeToolOptions, signal?: AbortSignal): Promise<GitHubClient> {
9
+ signal?.throwIfAborted();
10
+ const cwd = await getGitRoot(pi, ctx, signal);
11
+ const repository = await resolveGitHubRepository(pi, { cwd }, signal, options.env);
12
+ const token = (await resolveGitHubToken(options.env, { cwd, signal })).token;
13
+ return { repository, token, signal, fetchImpl: options.fetchImpl ?? globalThis.fetch };
14
+ }
15
+ export function clientOptions(client: GitHubClient): PullRequestFetchOptions { return { fetchImpl: client.fetchImpl, signal: client.signal }; }
16
+ export function apiPath(client: GitHubClient, suffix: string): string { return `/repos/${encodeURIComponent(client.repository.owner)}/${encodeURIComponent(client.repository.repo)}${suffix}`; }
17
+ export async function githubRequest(client: GitHubClient, path: string, method = "GET", body?: unknown): Promise<unknown> {
18
+ client.signal?.throwIfAborted();
19
+ const deadline = AbortSignal.timeout(10000);
20
+ const signal = client.signal ? AbortSignal.any([client.signal, deadline]) : deadline;
21
+ try {
22
+ const response = await client.fetchImpl(`https://api.github.com${path}`, { method, headers: gitHubJsonHeaders(client.token), redirect: "error", signal, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
23
+ const result = await readBoundedResponseText(response, signal);
24
+ if (result.truncated) throw new Error("GitHub response exceeded the 64 KiB safety limit.");
25
+ if (!response.ok) throw new Error(`GitHub HTTP ${response.status}; request refused or unavailable.`);
26
+ return JSON.parse(result.text) as unknown;
27
+ } catch (error) {
28
+ if (client.signal?.aborted) throw error;
29
+ throw new Error(boundedGitText(String(error), [client.token], 2000).text);
30
+ }
31
+ }
32
+ export function record(value: unknown): Record<string, unknown> {
33
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Malformed GitHub object.");
34
+ return value as Record<string, unknown>;
35
+ }
36
+ export function positiveNumber(value: unknown): number {
37
+ if (!Number.isSafeInteger(value) || Number(value) < 1) throw new Error("Invalid GitHub numeric identity.");
38
+ return Number(value);
39
+ }
@@ -0,0 +1,88 @@
1
+ import { getGitHubPullRequest } from "./github.ts";
2
+ import { apiPath, clientOptions, githubRequest, positiveNumber, record, type GitHubClient } from "./github-api.ts";
3
+ import { boundedGitText, decodeReadCursor, encodeReadCursor, fingerprint, OBJECT_ID } from "./git-read.ts";
4
+
5
+ export interface PullRequestChecksInput { number: number; after?: string }
6
+ export interface PullRequestPolicy { available: boolean; head: string | null; base: string | null; mergeable: string | null; mergeState: string | null; reviewDecision: string | null; rollup: string | null; reason?: string }
7
+ interface CheckEntry { source: string; key: string; id: number; name: string; state: string; conclusion: string | null; url: string }
8
+ function safe(value: unknown, client: GitHubClient, max = 256): string { return boundedGitText(typeof value === "string" ? value : "", [client.token], max, 1).text; }
9
+ const POLICY_QUERY = `query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$number){headRefOid baseRefOid mergeable mergeStateStatus reviewDecision commits(last:1){nodes{commit{statusCheckRollup{state}}}}}}}`;
10
+ export async function getPullRequestPolicy(client: GitHubClient, number: number): Promise<PullRequestPolicy> {
11
+ try {
12
+ const response = record(await githubRequest(client, "/graphql", "POST", { query: POLICY_QUERY, variables: { owner: client.repository.owner, repo: client.repository.repo, number } }));
13
+ if (response.errors) throw new Error("GitHub did not expose complete PR readiness information.");
14
+ const pr = record(record(record(response.data).repository).pullRequest);
15
+ if (typeof pr.headRefOid !== "string" || typeof pr.baseRefOid !== "string" || !OBJECT_ID.test(pr.headRefOid) || !OBJECT_ID.test(pr.baseRefOid)) throw new Error("Missing PR readiness identities.");
16
+ const nodes = record(pr.commits).nodes;
17
+ if (!Array.isArray(nodes) || nodes.length !== 1) throw new Error("Missing head check rollup.");
18
+ const rollup = record(nodes[0]).commit;
19
+ const status = record(rollup).statusCheckRollup;
20
+ const review = pr.reviewDecision;
21
+ if (!([null, "APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"] as unknown[]).includes(review)) throw new Error("Unknown review decision.");
22
+ if (!["MERGEABLE", "CONFLICTING", "UNKNOWN"].includes(String(pr.mergeable)) || !["CLEAN", "BLOCKED", "BEHIND", "DIRTY", "DRAFT", "HAS_HOOKS", "UNKNOWN", "UNSTABLE"].includes(String(pr.mergeStateStatus))) throw new Error("Unknown merge readiness state.");
23
+ const state = status === null ? null : record(status).state;
24
+ if (state !== null && (typeof state !== "string" || !["SUCCESS", "FAILURE", "PENDING", "ERROR", "EXPECTED"].includes(state))) throw new Error("Unknown head rollup state.");
25
+ return { available: true, head: pr.headRefOid, base: pr.baseRefOid, mergeable: String(pr.mergeable), mergeState: String(pr.mergeStateStatus), reviewDecision: review as string | null, rollup: state as string | null };
26
+ } catch (error) {
27
+ if (client.signal?.aborted) throw error;
28
+ return { available: false, head: null, base: null, mergeable: null, mergeState: null, reviewDecision: null, rollup: null, reason: safe(String(error), client) };
29
+ }
30
+ }
31
+ function count(value: unknown): number {
32
+ if (!Number.isSafeInteger(value) || Number(value) < 0) throw new Error("Missing check completeness count.");
33
+ return Number(value);
34
+ }
35
+ async function checkPage(client: GitHubClient, head: string, page: number, source: "runs" | "statuses") {
36
+ try {
37
+ const suffix = source === "runs" ? `/commits/${head}/check-runs?filter=latest&per_page=25&page=${page}` : `/commits/${head}/status?per_page=25&page=${page}`;
38
+ const result = record(await githubRequest(client, apiPath(client, suffix)));
39
+ const rows = source === "runs" ? result.check_runs : result.statuses;
40
+ if (!Array.isArray(rows) || rows.length > 25) throw new Error("Malformed check page.");
41
+ const entries = new Map<string, CheckEntry>();
42
+ for (const row of rows) {
43
+ const entry = parseCheckEntry(client, source, row);
44
+ if (!entries.has(entry.key) || entries.get(entry.key)!.id < entry.id) entries.set(entry.key, entry);
45
+ }
46
+ return { available: true, total: count(result.total_count), entries: [...entries.values()], error: null };
47
+ } catch (error) {
48
+ if (client.signal?.aborted) throw error;
49
+ return { available: false, total: null, entries: [], error: safe(String(error), client) };
50
+ }
51
+ }
52
+ function parseCheckEntry(client: GitHubClient, source: "runs" | "statuses", row: unknown): CheckEntry {
53
+ const item = record(row);
54
+ const id = positiveNumber(item.id);
55
+ const rawName = source === "runs" ? item.name : item.context;
56
+ if (typeof rawName !== "string" || !rawName) throw new Error("Check context missing.");
57
+ const app = source === "runs" ? positiveNumber(record(item.app).id) : "legacy";
58
+ const key = fingerprint(JSON.stringify([source, app, rawName]));
59
+ const state = source === "runs" ? item.status : item.state;
60
+ const conclusion = source === "runs" ? item.conclusion : item.state;
61
+ if (typeof state !== "string" || (conclusion !== null && typeof conclusion !== "string")) throw new Error("Check state missing.");
62
+ return { source, key, id, name: safe(rawName, client, 128), state: safe(state, client, 64), conclusion: conclusion === null ? null : safe(conclusion, client, 64), url: safe(item.html_url ?? item.target_url, client) };
63
+ }
64
+ function checkVerdict(unavailable: boolean, entryCount: number, page: number, complete: boolean, passed: boolean): string {
65
+ if (unavailable) return "unknown";
66
+ if (!entryCount && page === 1 && complete) return "no-checks";
67
+ if (complete && page === 1 && passed) return "passed";
68
+ return "inspect-states";
69
+ }
70
+ export async function getPullRequestChecks(client: GitHubClient, input: PullRequestChecksInput) {
71
+ positiveNumber(input.number);
72
+ const pr = await getGitHubPullRequest(client.repository, input.number, client.token, clientOptions(client));
73
+ const binding = fingerprint(JSON.stringify([client.repository, input.number, pr.headSha]));
74
+ let page = 1;
75
+ if (input.after !== undefined) {
76
+ const cursor = decodeReadCursor(input.after);
77
+ if (cursor.length !== 3 || cursor[0] !== "checks1" || cursor[1] !== binding || !Number.isSafeInteger(cursor[2]) || Number(cursor[2]) < 2 || Number(cursor[2]) > 1000) throw new Error("PR checks continuation is stale or invalid; restart without after.");
78
+ page = Number(cursor[2]);
79
+ }
80
+ const [runs, statuses, policy] = await Promise.all([checkPage(client, pr.headSha, page, "runs"), checkPage(client, pr.headSha, page, "statuses"), getPullRequestPolicy(client, input.number)]);
81
+ const current = await getGitHubPullRequest(client.repository, input.number, client.token, clientOptions(client));
82
+ const headChanged = current.headSha !== pr.headSha || current.baseSha !== pr.baseSha || (policy.available && (policy.head !== pr.headSha || policy.base !== pr.baseSha));
83
+ const complete = !headChanged && runs.available && statuses.available && page * 25 >= Math.max(runs.total ?? Infinity, statuses.total ?? Infinity);
84
+ const entries = [...runs.entries, ...statuses.entries];
85
+ const allKnownGood = entries.every((entry) => entry.source === "runs" ? entry.state === "completed" && ["success", "neutral", "skipped"].includes(entry.conclusion ?? "") : entry.state === "success");
86
+ const verdict = checkVerdict(headChanged || !runs.available || !statuses.available, entries.length, page, complete, allKnownGood && policy.available && policy.rollup === "SUCCESS");
87
+ return { action: "pull_request_checks", number: pr.number, head: pr.headSha, base: pr.baseSha, headChanged, page, runs, statuses, policy, complete, verdict, policyScope: "Host readiness snapshot, not merge authorization. Required-context/ruleset details may be unavailable; no failing-check list is proof of approval.", next: !headChanged && runs.available && statuses.available && !complete && page < 1000 ? encodeReadCursor(["checks1", binding, page + 1]) : null };
88
+ }