@senad-d/branchme 0.3.15 → 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.
- package/CHANGELOG.md +11 -0
- package/README.md +131 -30
- package/SECURITY.md +45 -9
- package/docs/PROJECT_DEFINITION_BRIEF.md +26 -7
- package/docs/SHELL_FREE_WORKFLOWS.md +74 -0
- package/docs/SMOKE_TEST.md +16 -7
- package/docs/STRUCTURE.md +48 -8
- package/docs/TUI_CAPTURE.md +17 -3
- package/package.json +9 -6
- package/src/blocker-guidance.ts +20 -5
- package/src/commands/branchme-command.ts +17 -3
- package/src/commit-draft.ts +461 -0
- package/src/commit-prompt.ts +409 -0
- package/src/commit-workflow.ts +233 -0
- package/src/constants.ts +138 -0
- package/src/git-commit-context.ts +783 -0
- package/src/git-commit.ts +317 -0
- package/src/git-conflicts.ts +77 -0
- package/src/git-diff.ts +274 -0
- package/src/git-history.ts +112 -0
- package/src/git-index.ts +119 -0
- package/src/git-read.ts +100 -0
- package/src/git-rebase.ts +42 -0
- package/src/git-remotes.ts +129 -0
- package/src/git-restore.ts +53 -0
- package/src/git-revert.ts +67 -0
- package/src/git-status.ts +346 -0
- package/src/git.ts +11 -2
- package/src/github-api.ts +39 -0
- package/src/github-checks.ts +88 -0
- package/src/github-lifecycle.ts +106 -0
- package/src/tools/branchme-tools.ts +159 -126
- package/src/tools/commit-tools.ts +240 -0
- package/src/tools/github-lifecycle-tools.ts +29 -0
- package/src/tools/inspection-tools.ts +39 -0
- package/src/tools/mutation-tools.ts +58 -0
- package/src/tools/workflow-tools.ts +32 -49
- package/src/tools/worktree-routing.ts +34 -0
- package/src/truncation.ts +119 -0
- package/src/types.ts +273 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { getGitDiff } from "./git-diff.ts";
|
|
2
|
+
import {
|
|
3
|
+
boundedGitText, decodeReadCursor, encodeReadCursor, fingerprint, OBJECT_ID, openGitReader,
|
|
4
|
+
readBlob, readGit, READ_PAGE_SIZE, resolveReadCommit, safeGitText, validateKeys,
|
|
5
|
+
validateReadPath, validateReadPaths, validateReadRef, type GitAPI, type GitReader,
|
|
6
|
+
} from "./git-read.ts";
|
|
7
|
+
import type { GitCommandContext } from "./git.ts";
|
|
8
|
+
|
|
9
|
+
export interface GitLogInput { ref?: string; excludeRef?: string; paths?: string[]; after?: string; worktreePath?: string }
|
|
10
|
+
export interface GitShowInput { ref?: string; output?: "summary" | "patch" | "file"; path?: string; parent?: number; worktreePath?: string }
|
|
11
|
+
export interface HistoryCommit { head: string; parents: string[]; date: string; subject: string; subjectTruncated?: boolean; message?: string; messageTruncated?: boolean }
|
|
12
|
+
const LOG_FORMAT = "%H%x00%P%x00%aI%x00%s";
|
|
13
|
+
|
|
14
|
+
function parseCommits(raw: string, reader: GitReader): HistoryCommit[] {
|
|
15
|
+
if (!raw) return [];
|
|
16
|
+
const fields = raw.endsWith("\0") ? raw.slice(0, -1).split("\0") : [];
|
|
17
|
+
if (fields.length % 4 || !fields.length) throw new Error("Malformed history output.");
|
|
18
|
+
const commits: HistoryCommit[] = [];
|
|
19
|
+
for (let i = 0; i < fields.length; i += 4) {
|
|
20
|
+
const [head, parentText, date, subject] = fields.slice(i, i + 4);
|
|
21
|
+
const parents = parentText ? parentText.split(" ") : [];
|
|
22
|
+
if (!OBJECT_ID.test(head) || !parents.every(isOid) || parents.length > 16 || date.length > 40 || !/^\d{4}-\d\d-\d\dT/u.test(date) || !Number.isFinite(Date.parse(date))) throw new Error("Malformed history identities/date.");
|
|
23
|
+
const preview = boundedGitText(subject, reader.tokens, 512, 1);
|
|
24
|
+
commits.push({ head, parents, date, subject: preview.text, subjectTruncated: preview.truncated });
|
|
25
|
+
}
|
|
26
|
+
return commits;
|
|
27
|
+
}
|
|
28
|
+
function isOid(value: string): boolean { return OBJECT_ID.test(value); }
|
|
29
|
+
async function unbornHead(reader: GitReader): Promise<boolean> {
|
|
30
|
+
const head = await readGit(reader, ["rev-parse", "--verify", "--quiet", "HEAD"], true);
|
|
31
|
+
if (head.code === 0) return false;
|
|
32
|
+
const symbolic = await readGit(reader, ["symbolic-ref", "--quiet", "HEAD"], true);
|
|
33
|
+
if (head.code !== 1 || symbolic.code !== 0 || !symbolic.stdout.startsWith("refs/heads/")) return false;
|
|
34
|
+
const ref = symbolic.stdout.trimEnd();
|
|
35
|
+
const present = await readGit(reader, ["show-ref", "--verify", "--quiet", ref], true);
|
|
36
|
+
return present.code === 1;
|
|
37
|
+
}
|
|
38
|
+
export async function getGitLog(host: GitAPI, ctx: GitCommandContext, input: GitLogInput = {}, signal?: AbortSignal, tokens: readonly string[] = []) {
|
|
39
|
+
validateKeys(input, ["ref", "excludeRef", "paths", "after", "worktreePath"]);
|
|
40
|
+
validateReadRef(input.ref ?? "HEAD");
|
|
41
|
+
if (input.excludeRef !== undefined) validateReadRef(input.excludeRef);
|
|
42
|
+
validateReadPaths(input.paths);
|
|
43
|
+
const reader = await openGitReader(host, ctx, input.worktreePath, signal, tokens);
|
|
44
|
+
const shallow = (await readGit(reader, ["rev-parse", "--is-shallow-repository"])).stdout.trim() === "true";
|
|
45
|
+
const query = fingerprint(JSON.stringify([reader.ctx.cwd, input.ref ?? "HEAD", input.excludeRef ?? null, input.paths ?? []]));
|
|
46
|
+
let start: string;
|
|
47
|
+
let exclude: string | null;
|
|
48
|
+
let offset = 0;
|
|
49
|
+
if (input.after !== undefined) {
|
|
50
|
+
const fields = decodeReadCursor(input.after);
|
|
51
|
+
if (fields.length !== 5 || fields[0] !== "log1" || fields[1] !== query || typeof fields[2] !== "string" || !OBJECT_ID.test(fields[2]) || (fields[3] !== null && (typeof fields[3] !== "string" || !OBJECT_ID.test(fields[3]))) || !Number.isSafeInteger(fields[4]) || Number(fields[4]) < 1 || Number(fields[4]) > 100000) throw new Error("History continuation belongs to another query or is invalid; restart without after.");
|
|
52
|
+
start = await resolveReadCommit(reader, fields[2]);
|
|
53
|
+
exclude = fields[3] === null ? null : await resolveReadCommit(reader, fields[3] as string);
|
|
54
|
+
offset = fields[4] as number;
|
|
55
|
+
} else {
|
|
56
|
+
if ((input.ref ?? "HEAD") === "HEAD" && await unbornHead(reader)) return { action: "git_log", repoRoot: safeGitText(reader.ctx.cwd, tokens), start: null, exclude: null, unborn: true, shallow, commits: [], complete: true, next: null };
|
|
57
|
+
start = await resolveReadCommit(reader, input.ref ?? "HEAD");
|
|
58
|
+
exclude = input.excludeRef === undefined ? null : await resolveReadCommit(reader, input.excludeRef);
|
|
59
|
+
}
|
|
60
|
+
const result = await readGit(reader, ["--literal-pathspecs", "log", "-z", `--format=${LOG_FORMAT}`, "--no-show-signature", "--no-decorate", "--no-notes", "--topo-order", `--skip=${offset}`, `--max-count=${READ_PAGE_SIZE + 1}`, start, ...(exclude ? [`^${exclude}`] : []), "--", ...(input.paths ?? [])]);
|
|
61
|
+
const all = parseCommits(result.stdout, reader);
|
|
62
|
+
const commits = all.slice(0, READ_PAGE_SIZE);
|
|
63
|
+
const complete = all.length <= READ_PAGE_SIZE;
|
|
64
|
+
return { action: "git_log", repoRoot: safeGitText(reader.ctx.cwd, tokens), start, exclude, unborn: false, shallow, commits, complete, next: complete ? null : encodeReadCursor(["log1", query, start, exclude, offset + commits.length]) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validateShowInput(input: GitShowInput): void {
|
|
68
|
+
validateKeys(input, ["ref", "output", "path", "parent", "worktreePath"]);
|
|
69
|
+
validateReadRef(input.ref ?? "HEAD");
|
|
70
|
+
const output = input.output ?? "summary";
|
|
71
|
+
if (!["summary", "patch", "file"].includes(output)) throw new TypeError("Unknown git_show output.");
|
|
72
|
+
if (input.path !== undefined) validateReadPath(input.path);
|
|
73
|
+
if (input.parent !== undefined && (!Number.isInteger(input.parent) || input.parent < 1 || input.parent > 100 || output === "file")) throw new TypeError("parent must select a 1-based commit parent for summary/patch output.");
|
|
74
|
+
if (output === "file" && input.path === undefined) throw new TypeError("git_show file output requires one literal path.");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function showFile(reader: GitReader, commit: HistoryCommit, path: string) {
|
|
78
|
+
const tree = await readGit(reader, ["--literal-pathspecs", "ls-tree", "-z", commit.head, "--", path]);
|
|
79
|
+
const match = /^([0-7]{6}) blob ([a-f0-9]{40}|[a-f0-9]{64})\t([^\0]+)\0$/u.exec(tree.stdout);
|
|
80
|
+
if (match?.[3] !== path) throw new Error("Path does not name one blob at the captured commit (directories/gitlinks are unsupported).");
|
|
81
|
+
return { action: "git_show", commit, output: "file", path: safeGitText(path, reader.tokens), mode: match[1], blob: match[2], ...await readBlob(reader, match[2]) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function validateShowParent(commit: HistoryCommit, parent: number | undefined): void {
|
|
85
|
+
if (commit.parents.length > 1 && parent === undefined) throw new Error("Merge commit inspection requires an explicit 1-based parent comparison.");
|
|
86
|
+
if (parent !== undefined && parent > commit.parents.length) throw new Error("Requested commit parent does not exist.");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function getGitShow(host: GitAPI, ctx: GitCommandContext, input: GitShowInput = {}, signal?: AbortSignal, tokens: readonly string[] = []) {
|
|
90
|
+
validateShowInput(input);
|
|
91
|
+
const output = input.output ?? "summary";
|
|
92
|
+
const reader = await openGitReader(host, ctx, input.worktreePath, signal, tokens);
|
|
93
|
+
const head = await resolveReadCommit(reader, input.ref ?? "HEAD");
|
|
94
|
+
const metadata = await readGit(reader, ["log", "-z", "-1", `--format=${LOG_FORMAT}`, "--no-show-signature", "--no-notes", head, "--"]);
|
|
95
|
+
const commit = parseCommits(metadata.stdout, reader)[0];
|
|
96
|
+
if (commit?.head !== head) throw new Error("Commit metadata did not match captured identity.");
|
|
97
|
+
const message = boundedGitText((await readGit(reader, ["log", "-1", "--format=%B", "--no-show-signature", "--no-notes", head, "--"])).stdout, tokens, 4096, 100);
|
|
98
|
+
commit.message = message.text;
|
|
99
|
+
commit.messageTruncated = message.truncated;
|
|
100
|
+
if (output === "file") return showFile(reader, commit, input.path!);
|
|
101
|
+
validateShowParent(commit, input.parent);
|
|
102
|
+
const base = commit.parents[(input.parent ?? 1) - 1] ?? null;
|
|
103
|
+
let comparison;
|
|
104
|
+
if (base) {
|
|
105
|
+
const diff = await getGitDiff(host, reader.ctx, { mode: "range", baseRef: base, targetRef: head, output: output === "patch" ? "patch" : "name-status", ...(input.path ? { paths: [input.path] } : {}) }, signal, tokens);
|
|
106
|
+
comparison = { text: diff.diff, truncated: diff.truncated };
|
|
107
|
+
} else {
|
|
108
|
+
const diff = await readGit(reader, ["--literal-pathspecs", "-c", "diff.noprefix=false", "diff-tree", "--no-relative", "--src-prefix=a/", "--dst-prefix=b/", "--root", "--no-commit-id", "-r", "--no-ext-diff", "--no-textconv", "--no-color", "--no-renames", "--ignore-submodules=dirty", output === "patch" ? "--patch" : "--name-status", head, "--", ...(input.path ? [input.path] : [])]);
|
|
109
|
+
comparison = boundedGitText(diff.stdout, tokens);
|
|
110
|
+
}
|
|
111
|
+
return { action: "git_show", commit, output, comparisonBase: base, ...comparison };
|
|
112
|
+
}
|
package/src/git-index.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { getGitOperationState, runGit, withRepositoryMutationQueue, type GitCommandContext } from "./git.ts";
|
|
3
|
+
import { checkedFilePath, fileExpectation, getGitStatus } from "./git-status.ts";
|
|
4
|
+
import { fingerprint, OBJECT_ID, openGitReader, readGit, safeGitText, validateKeys, validateReadPaths, type GitAPI, type GitReader } from "./git-read.ts";
|
|
5
|
+
|
|
6
|
+
export interface IndexInput { paths: string[]; expectedHead: string | null; expectedIndex: string; worktreePath?: string }
|
|
7
|
+
export interface IndexRecord { flag: string; mode: string; oid: string; path: string }
|
|
8
|
+
export async function inspectIndex(reader: GitReader): Promise<{ raw: string; index: string; entries: IndexRecord[] }> {
|
|
9
|
+
const raw = (await readGit(reader, ["ls-files", "--stage", "-v", "-z"])).stdout;
|
|
10
|
+
if (raw && !raw.endsWith("\0")) throw new Error("Incomplete index inspection.");
|
|
11
|
+
const entries = [];
|
|
12
|
+
for (const record of raw ? raw.slice(0, -1).split("\0") : []) {
|
|
13
|
+
const match = /^([A-Za-z]) ([0-7]{6}) ([a-f0-9]{40}|[a-f0-9]{64}) ([0-3])\t([^\0]+)$/u.exec(record);
|
|
14
|
+
if (match?.[4] !== "0") throw new Error("Unmerged or unsupported index; use git_conflicts/conclude_merge for conflicts.");
|
|
15
|
+
if (match[1] !== "H" || !["100644", "100755", "120000"].includes(match[2])) throw new Error("Index contains assume-unchanged, skip-worktree, gitlink, or unsupported entries; no mutation performed.");
|
|
16
|
+
entries.push({ flag: match[1], mode: match[2], oid: match[3], path: match[5] });
|
|
17
|
+
}
|
|
18
|
+
return { raw, index: fingerprint(raw), entries };
|
|
19
|
+
}
|
|
20
|
+
export async function requireSimpleIndex(reader: GitReader): Promise<void> {
|
|
21
|
+
for (const key of ["GIT_INDEX_FILE", "GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY"]) if (process.env[key] !== undefined) throw new Error("Git path environment overrides must be unset for guarded file/index mutations.");
|
|
22
|
+
if ((await getGitOperationState(reader.pi, reader.ctx, reader.signal)).active.length) throw new Error("An in-progress Git operation must be completed or aborted first.");
|
|
23
|
+
const visible = await readGit(reader, ["diff", "--cached", "--raw", "-z", "--no-ext-diff", "--no-textconv", "--no-renames", "--ita-visible-in-index"]);
|
|
24
|
+
const invisible = await readGit(reader, ["diff", "--cached", "--raw", "-z", "--no-ext-diff", "--no-textconv", "--no-renames", "--ita-invisible-in-index"]);
|
|
25
|
+
if (visible.stdout !== invisible.stdout) throw new Error("Intent-to-add entries are unsupported for guarded file/index mutations.");
|
|
26
|
+
const split = await readGit(reader, ["rev-parse", "--shared-index-path"]);
|
|
27
|
+
if (split.stdout.trim()) throw new Error("Split indexes are unsupported for guarded file/index mutations.");
|
|
28
|
+
const configs = await Promise.all(["core.sparsecheckout", "index.sparse"].map(readSparseConfig.bind(undefined, reader)));
|
|
29
|
+
for (const config of configs) {
|
|
30
|
+
if (config.code !== 0 && config.code !== 1) throw new Error("Cannot verify sparse-index configuration.");
|
|
31
|
+
if (config.code === 0 && config.stdout.trim() !== "false") throw new Error("Sparse checkouts/indexes are unsupported for guarded file/index mutations.");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function readSparseConfig(reader: GitReader, name: string) { return readGit(reader, ["config", "--bool", "--get", name], true); }
|
|
35
|
+
export function validateIndexInput(input: IndexInput, extraKeys: string[] = []): void {
|
|
36
|
+
validateKeys(input, ["paths", "expectedHead", "expectedIndex", "worktreePath", ...extraKeys]);
|
|
37
|
+
validateReadPaths(input.paths);
|
|
38
|
+
if (!input.paths?.length || input.paths.some((path) => path.length > 512 || path.split("/").some((part) => !part || part === ".")) || input.paths.reduce((bytes, path) => bytes + Buffer.byteLength(path), 0) > 8192 || new Set(input.paths).size !== input.paths.length) throw new TypeError("Require distinct exact file paths, at most 512 characters each / 8 KiB combined; split larger batches. Directories and normalized aliases are unsupported.");
|
|
39
|
+
if ((input.expectedHead !== null && (typeof input.expectedHead !== "string" || !OBJECT_ID.test(input.expectedHead))) || typeof input.expectedIndex !== "string" || !/^[a-f0-9]{64}$/u.test(input.expectedIndex)) throw new TypeError("Require expectedHead (or null before the first commit) and expectedIndex from git_status.");
|
|
40
|
+
}
|
|
41
|
+
export async function captureSelectedFiles(reader: GitReader, paths: string[]): Promise<Map<string, string>> {
|
|
42
|
+
return new Map(await Promise.all(paths.map(captureSelectedFile.bind(undefined, reader))));
|
|
43
|
+
}
|
|
44
|
+
async function captureSelectedFile(reader: GitReader, path: string): Promise<[string, string]> {
|
|
45
|
+
const file = await checkedFilePath(reader.ctx.cwd, path);
|
|
46
|
+
if (!file || safeGitText(path, reader.tokens) !== path) throw new Error("A selected path is not a reusable repository-local identity.");
|
|
47
|
+
try {
|
|
48
|
+
const info = await lstat(file);
|
|
49
|
+
if (!info.isFile() && !info.isSymbolicLink()) throw new Error("Selected paths must be exact regular files or symlinks, not directories/gitlinks.");
|
|
50
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
51
|
+
const expectation = await fileExpectation(reader.ctx.cwd, path, reader.signal);
|
|
52
|
+
if (!expectation.fingerprint) throw new Error("A selected file has no safe content expectation (oversized or unsupported).");
|
|
53
|
+
return [path, expectation.fingerprint];
|
|
54
|
+
}
|
|
55
|
+
export async function requireExpectedState(reader: GitReader, input: IndexInput, checkRenameSides = true) {
|
|
56
|
+
const status = await getGitStatus(reader.pi, reader.ctx, { paths: input.paths }, reader.signal, reader.tokens);
|
|
57
|
+
if (status.head !== input.expectedHead || status.index !== input.expectedIndex) throw new Error("HEAD/index changed; inspect git_status and use fresh expectations.");
|
|
58
|
+
for (const entry of status.entries) if (checkRenameSides && entry.originalPath && (input.paths.includes(entry.path) || input.paths.includes(entry.originalPath)) && (!input.paths.includes(entry.path) || !input.paths.includes(entry.originalPath))) throw new Error("Both sides of a selected rename must be explicitly approved.");
|
|
59
|
+
return status;
|
|
60
|
+
}
|
|
61
|
+
function unrelated(entries: IndexRecord[], paths: string[]): string { return JSON.stringify(entries.filter((entry) => !paths.includes(entry.path))); }
|
|
62
|
+
async function mutateIndexInside(host: GitAPI, reader: GitReader, input: IndexInput, action: "stage_changes" | "unstage_changes") {
|
|
63
|
+
await requireSimpleIndex(reader);
|
|
64
|
+
const before = await inspectIndex(reader);
|
|
65
|
+
const statusBefore = await requireExpectedState(reader, input);
|
|
66
|
+
const files = await captureSelectedFiles(reader, input.paths);
|
|
67
|
+
await Promise.all(input.paths.map(validateSelectedIndexPath.bind(undefined, reader, input, action, before.entries, statusBefore, files)));
|
|
68
|
+
await requireExpectedState(reader, input);
|
|
69
|
+
if (JSON.stringify([...files]) !== JSON.stringify([...await captureSelectedFiles(reader, input.paths)])) throw new Error("Selected files changed during preparation; inspect git_status again.");
|
|
70
|
+
const stagePaths = input.paths.filter((path) => files.get(path) !== fingerprint("missing") || before.entries.some((entry) => entry.path === path));
|
|
71
|
+
const args = indexMutationArgs(input, action, stagePaths);
|
|
72
|
+
reader.signal?.throwIfAborted();
|
|
73
|
+
try {
|
|
74
|
+
if (action !== "stage_changes" || stagePaths.length) await runGit(host, reader.ctx, ["--no-lazy-fetch", "--literal-pathspecs", "-c", "core.fsmonitor=false", ...args], { signal: reader.signal, tokens: reader.tokens });
|
|
75
|
+
const final = { ...reader, signal: undefined };
|
|
76
|
+
const after = await inspectIndex(final);
|
|
77
|
+
const status = await getGitStatus(host, reader.ctx, {}, undefined, reader.tokens);
|
|
78
|
+
if (status.head !== input.expectedHead || status.branch !== statusBefore.branch || status.operations.length || unrelated(before.entries, input.paths) !== unrelated(after.entries, input.paths) || JSON.stringify([...files]) !== JSON.stringify([...await captureSelectedFiles(final, input.paths)])) throw new Error("Unrelated index entries, HEAD, or working-file bytes changed.");
|
|
79
|
+
await verifySelectedIndex(final, input, action, files, after.entries);
|
|
80
|
+
return { action, paths: input.paths, head: status.head, index: after.index, committed: false, workingFilesPreserved: true };
|
|
81
|
+
} catch (error) { throw new Error(`${action} outcome requires inspection with git_status; index may have changed. No rollback attempted. ${safeGitText(String(error), reader.tokens).slice(0, 2000)}`); }
|
|
82
|
+
}
|
|
83
|
+
function indexMutationArgs(input: IndexInput, action: string, stagePaths: string[]): string[] {
|
|
84
|
+
if (action === "stage_changes") return ["add", "--", ...stagePaths];
|
|
85
|
+
if (input.expectedHead === null) return ["update-index", "--force-remove", "--", ...input.paths];
|
|
86
|
+
return ["restore", "--staged", `--source=${input.expectedHead}`, "--", ...input.paths];
|
|
87
|
+
}
|
|
88
|
+
async function validateSelectedIndexPath(reader: GitReader, input: IndexInput, action: string, entries: IndexRecord[], statusBefore: Awaited<ReturnType<typeof requireExpectedState>>, files: Map<string, string>, path: string) {
|
|
89
|
+
const tracked = entries.some((entry) => entry.path === path);
|
|
90
|
+
const atHead = input.expectedHead === null ? "" : (await readGit(reader, ["--literal-pathspecs", "ls-tree", "-z", input.expectedHead, "--", path])).stdout;
|
|
91
|
+
if (atHead && !atHead.endsWith(`\t${path}\0`)) throw new Error("Selected path does not exactly match its tracked identity.");
|
|
92
|
+
if (!tracked && !atHead) {
|
|
93
|
+
if (action === "unstage_changes") throw new Error("Selected path is not staged or tracked at HEAD.");
|
|
94
|
+
if (!statusBefore.entries.some((entry) => entry.kind === "untracked" && entry.path === path && entry.reusablePath)) throw new Error("Selected addition is ignored or not an exact reusable untracked path; aliases and implicit expansion are refused.");
|
|
95
|
+
const ignored = await readGit(reader, ["check-ignore", "--quiet", "--", path], true);
|
|
96
|
+
if (ignored.code !== 1) throw new Error("Selected addition is ignored or could not be verified; force-add is unsupported.");
|
|
97
|
+
if (files.get(path) === fingerprint("missing")) throw new Error("Selected path is missing and untracked.");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function verifySelectedIndex(final: GitReader, input: IndexInput, action: string, files: Map<string, string>, entries: IndexRecord[]) {
|
|
101
|
+
if (action === "stage_changes") {
|
|
102
|
+
const unstaged = await readGit(final, ["--literal-pathspecs", "diff", "--no-ext-diff", "--no-textconv", "--quiet", "--ignore-submodules=all", "--", ...input.paths], true);
|
|
103
|
+
if (unstaged.code !== 0) throw new Error("Selected content is not fully staged.");
|
|
104
|
+
for (const path of input.paths) if (files.get(path) !== fingerprint("missing") && !entries.some((entry) => entry.path === path)) throw new Error("Selected file was not added to the index.");
|
|
105
|
+
} else {
|
|
106
|
+
await Promise.all(input.paths.map(verifyUnstagedPath.bind(undefined, final, input, entries)));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function verifyUnstagedPath(final: GitReader, input: IndexInput, entries: IndexRecord[], path: string) {
|
|
110
|
+
const entry = entries.find((candidate) => candidate.path === path);
|
|
111
|
+
const atHead = input.expectedHead === null ? "" : (await readGit(final, ["--literal-pathspecs", "ls-tree", "-z", input.expectedHead, "--", path])).stdout;
|
|
112
|
+
if (!atHead && entry) throw new Error("Selected addition remained staged.");
|
|
113
|
+
if (atHead && (!entry || !atHead.startsWith(`${entry.mode} blob ${entry.oid}\t`))) throw new Error("Selected index entry does not match captured HEAD.");
|
|
114
|
+
}
|
|
115
|
+
export async function changeIndex(host: GitAPI, ctx: GitCommandContext, input: IndexInput, action: "stage_changes" | "unstage_changes", signal?: AbortSignal, tokens: readonly string[] = []) {
|
|
116
|
+
validateIndexInput(input);
|
|
117
|
+
const reader = await openGitReader(host, ctx, input.worktreePath, signal, tokens);
|
|
118
|
+
return withRepositoryMutationQueue(reader.ctx.cwd, mutateIndexInside.bind(undefined, host, reader, input, action));
|
|
119
|
+
}
|
package/src/git-read.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { isAbsolute, win32 } from "node:path";
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { getCanonicalGitWorktreeRoot, resolveRepositoryWorktreeCwd, runGit, type GitCommandContext } from "./git.ts";
|
|
5
|
+
import { redactSecrets } from "./redaction.ts";
|
|
6
|
+
|
|
7
|
+
export type GitAPI = Pick<ExtensionAPI, "exec">;
|
|
8
|
+
export interface GitReader { pi: GitAPI; ctx: GitCommandContext; signal?: AbortSignal; tokens: readonly string[] }
|
|
9
|
+
export const OBJECT_ID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u;
|
|
10
|
+
export const READ_RAW_BYTES = 4 * 1024 * 1024;
|
|
11
|
+
export const READ_PAGE_SIZE = 25;
|
|
12
|
+
export const READ_CURSOR_CHARS = 1024;
|
|
13
|
+
|
|
14
|
+
export function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error); }
|
|
15
|
+
export function fingerprint(value: string | Buffer): string { return createHash("sha256").update(value).digest("hex"); }
|
|
16
|
+
function escapeControl(character: string): string { return character === "\n" || character === "\t" ? character : String.raw`\u${character.codePointAt(0)!.toString(16).padStart(4, "0")}`; }
|
|
17
|
+
export function safeGitText(text: string, tokens: readonly string[] = []): string {
|
|
18
|
+
return redactSecrets(text, tokens).replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, escapeControl);
|
|
19
|
+
}
|
|
20
|
+
export function boundedGitText(raw: string, tokens: readonly string[] = [], maxBytes = 32 * 1024, maxLines = 1000): { text: string; truncated: boolean } {
|
|
21
|
+
const text = safeGitText(raw, tokens);
|
|
22
|
+
let result = "";
|
|
23
|
+
let bytes = 0;
|
|
24
|
+
let lines = 0;
|
|
25
|
+
for (const character of text) {
|
|
26
|
+
const size = Buffer.byteLength(character);
|
|
27
|
+
if (bytes + size > maxBytes || lines >= maxLines) break;
|
|
28
|
+
result += character;
|
|
29
|
+
bytes += size;
|
|
30
|
+
if (character === "\n") lines++;
|
|
31
|
+
}
|
|
32
|
+
return { text: result, truncated: result.length !== text.length };
|
|
33
|
+
}
|
|
34
|
+
export function validateReadRef(value: unknown): asserts value is string {
|
|
35
|
+
if (typeof value !== "string" || !value || value.length > 512 || value.startsWith("-") || /[\s:\p{Cc}\p{Cf}]/u.test(value) || value.includes("..")) throw new TypeError("Expected a bounded single local commit revision, not a range, option, or path.");
|
|
36
|
+
}
|
|
37
|
+
export function validateReadPath(value: unknown): asserts value is string {
|
|
38
|
+
if (typeof value !== "string" || !value.trim() || value.length > 4096 || isAbsolute(value) || win32.isAbsolute(value) || /[\\\p{Cc}\p{Cf}\ufffd]/u.test(value) || value.split("/").some(unsafeSegment)) throw new TypeError("Expected a literal root-relative path without traversal, .git, backslashes, or controls.");
|
|
39
|
+
}
|
|
40
|
+
function unsafeSegment(part: string): boolean { return part === ".." || part.toLowerCase() === ".git"; }
|
|
41
|
+
export function validateReadPaths(paths: unknown): asserts paths is string[] | undefined {
|
|
42
|
+
if (paths === undefined) return;
|
|
43
|
+
if (!Array.isArray(paths) || !paths.length || paths.length > 25) throw new TypeError("paths must contain 1–25 literal paths.");
|
|
44
|
+
for (const path of paths) validateReadPath(path);
|
|
45
|
+
}
|
|
46
|
+
export function validateKeys(input: unknown, keys: string[]): void {
|
|
47
|
+
if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some((key) => !keys.includes(key))) throw new TypeError("Only documented input fields are accepted.");
|
|
48
|
+
}
|
|
49
|
+
async function execRead(host: GitAPI, tokens: readonly string[], command: string, args: string[], options: Parameters<GitAPI["exec"]>[2]) {
|
|
50
|
+
let result;
|
|
51
|
+
try { result = await host.exec(command, ["--no-optional-locks", "--no-lazy-fetch", "--no-pager", "-c", "core.fsmonitor=false", ...args], options); }
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (options?.signal?.aborted) throw error;
|
|
54
|
+
throw new Error(boundedGitText(String(error), tokens, 4000).text);
|
|
55
|
+
}
|
|
56
|
+
if (result.killed) throw new Error("Local Git inspection was killed or timed out; retry once, then report the failure.");
|
|
57
|
+
if (result.code !== 0) {
|
|
58
|
+
if (/unknown option[^\n]*no-lazy-fetch/iu.test(result.stderr)) throw new Error("Git with --no-lazy-fetch support is required; update Git rather than weakening read-only safeguards.");
|
|
59
|
+
return { ...result, stdout: boundedGitText(result.stdout, tokens, 4000).text, stderr: boundedGitText(result.stderr, tokens, 4000).text };
|
|
60
|
+
}
|
|
61
|
+
if (Buffer.byteLength(result.stdout) > READ_RAW_BYTES) throw new Error("Local Git inspection exceeded the 4 MiB safety limit. Narrow the query; no completeness claim is possible.");
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
export async function openGitReader(host: GitAPI, ctx: GitCommandContext, worktreePath?: string, signal?: AbortSignal, tokens: readonly string[] = []): Promise<GitReader> {
|
|
65
|
+
signal?.throwIfAborted();
|
|
66
|
+
if (worktreePath !== undefined && (typeof worktreePath !== "string" || !isAbsolute(worktreePath) || worktreePath.length > 4096 || /[\p{Cc}\p{Cf}]/u.test(worktreePath))) throw new TypeError("worktreePath must be a bounded absolute worktree root.");
|
|
67
|
+
const pi = { exec: execRead.bind(undefined, host, tokens) };
|
|
68
|
+
const cwd = worktreePath === undefined ? ctx.cwd : await resolveRepositoryWorktreeCwd(pi, ctx, worktreePath, signal);
|
|
69
|
+
return { pi, ctx: { cwd: await getCanonicalGitWorktreeRoot(pi, { cwd }, signal) }, signal, tokens };
|
|
70
|
+
}
|
|
71
|
+
export async function readGit(reader: GitReader, args: string[], allowFailure = false) {
|
|
72
|
+
reader.signal?.throwIfAborted();
|
|
73
|
+
return runGit(reader.pi, reader.ctx, args, { signal: reader.signal, tokens: reader.tokens, allowFailure });
|
|
74
|
+
}
|
|
75
|
+
export async function resolveReadCommit(reader: GitReader, ref: string): Promise<string> {
|
|
76
|
+
validateReadRef(ref);
|
|
77
|
+
const result = await readGit(reader, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], true);
|
|
78
|
+
if (result.code !== 0 || !OBJECT_ID.test(result.stdout.trim())) throw new Error("Commit revision is not locally available. Inspect git_log/list_branches; fetch separately only when intended.");
|
|
79
|
+
return result.stdout.trim();
|
|
80
|
+
}
|
|
81
|
+
export function encodeReadCursor(fields: unknown[]): string { return Buffer.from(JSON.stringify(fields)).toString("base64url"); }
|
|
82
|
+
export function decodeReadCursor(value: unknown): unknown[] {
|
|
83
|
+
if (typeof value !== "string" || !value || value.length > READ_CURSOR_CHARS || !/^[A-Za-z0-9_-]+$/u.test(value)) throw new TypeError("Invalid continuation; use the unchanged next cursor.");
|
|
84
|
+
try {
|
|
85
|
+
const fields: unknown = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
|
|
86
|
+
if (!Array.isArray(fields) || encodeReadCursor(fields) !== value) throw new Error("Noncanonical continuation fields.");
|
|
87
|
+
return fields;
|
|
88
|
+
} catch { throw new TypeError("Invalid continuation; restart without after."); }
|
|
89
|
+
}
|
|
90
|
+
export async function readBlob(reader: GitReader, oid: string): Promise<{ size: number; binary: boolean; text: string; truncated: boolean }> {
|
|
91
|
+
if (!OBJECT_ID.test(oid)) throw new Error("Invalid blob identity.");
|
|
92
|
+
const result = await readGit(reader, ["cat-file", "-s", oid]);
|
|
93
|
+
if (!/^\d+\n?$/u.test(result.stdout)) throw new Error("Invalid blob size.");
|
|
94
|
+
const size = Number(result.stdout.trim());
|
|
95
|
+
if (!Number.isSafeInteger(size)) throw new Error("Invalid blob size.");
|
|
96
|
+
if (size > READ_RAW_BYTES) return { size, binary: false, text: "Object too large for a safe preview.", truncated: true };
|
|
97
|
+
const blob = await readGit(reader, ["cat-file", "blob", oid]);
|
|
98
|
+
const binary = blob.stdout.includes("\0") || blob.stdout.includes("\ufffd");
|
|
99
|
+
return { size, binary, ...(binary ? { text: "Binary or non-UTF-8 object; content omitted.", truncated: false } : boundedGitText(blob.stdout, reader.tokens)) };
|
|
100
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { getCurrentBranch, getGitOperationState, getLocalBranchCommit, isCommitAncestor, requireCurrentBranch, resolveWorktreeBaseCommit, runGit, withRepositoryMutationQueue, type GitCommandContext } from "./git.ts";
|
|
2
|
+
import { getGitStatus } from "./git-status.ts";
|
|
3
|
+
import { boundedGitText, errorText, openGitReader, readGit, safeGitText, validateReadRef, type GitAPI, type GitReader } from "./git-read.ts";
|
|
4
|
+
|
|
5
|
+
async function otherRefs(reader: GitReader, branch: string): Promise<string> {
|
|
6
|
+
const raw = (await readGit(reader, ["for-each-ref", "--format=%(refname) %(objectname)"])).stdout;
|
|
7
|
+
return raw.split("\n").filter((line) => !line.startsWith(`refs/heads/${branch} `)).join("\n");
|
|
8
|
+
}
|
|
9
|
+
async function rebaseInside(host: GitAPI, reader: GitReader, baseRef: string) {
|
|
10
|
+
const branch = await requireCurrentBranch(reader.pi, reader.ctx, reader.signal);
|
|
11
|
+
if (safeGitText(branch, reader.tokens) !== branch) throw new Error("Current branch is not a reusable identity.");
|
|
12
|
+
const source = await getLocalBranchCommit(reader.pi, reader.ctx, branch, reader.signal);
|
|
13
|
+
const status = await getGitStatus(reader.pi, reader.ctx, {}, reader.signal, reader.tokens);
|
|
14
|
+
if (status.hasChanges || status.operations.length) throw new Error("Explicit-base rebase requires a clean idle checkout.");
|
|
15
|
+
// Rebase has no no-overwrite-ignore switch. Refuse local-only residue rather than
|
|
16
|
+
// silently allowing Git's intermediate checkouts to overwrite ignored files.
|
|
17
|
+
if ((await readGit(reader, ["ls-files", "--others", "--ignored", "--exclude-standard", "-z"])).stdout) throw new Error("Explicit-base rebase refuses ignored local-only files; preserve them outside the checkout first.");
|
|
18
|
+
const base = await resolveWorktreeBaseCommit(reader.pi, reader.ctx, baseRef, reader.signal);
|
|
19
|
+
const refs = await otherRefs(reader, branch);
|
|
20
|
+
const config = (await readGit(reader, ["config", "--null", "--list"])).stdout;
|
|
21
|
+
reader.signal?.throwIfAborted();
|
|
22
|
+
let failure: unknown;
|
|
23
|
+
try {
|
|
24
|
+
await runGit(host, reader.ctx, ["rebase", "--no-autostash", "--no-update-refs", "--no-autosquash", "--no-rebase-merges", base], { signal: reader.signal, timeout: 120000, tokens: reader.tokens });
|
|
25
|
+
} catch (error) { failure = error; }
|
|
26
|
+
const final = { ...reader, signal: undefined };
|
|
27
|
+
if (failure) {
|
|
28
|
+
try {
|
|
29
|
+
const operation = await getGitOperationState(final.pi, final.ctx);
|
|
30
|
+
if (operation.active.includes("rebase")) await runGit(host, reader.ctx, ["rebase", "--abort"], { timeout: 30000, tokens: reader.tokens });
|
|
31
|
+
if ((await getCurrentBranch(final.pi, final.ctx)).currentBranch !== branch || await getLocalBranchCommit(final.pi, final.ctx, branch) !== source || (await getGitStatus(final.pi, final.ctx)).hasChanges || (await getGitOperationState(final.pi, final.ctx)).active.length || await otherRefs(final, branch) !== refs || (await readGit(final, ["config", "--null", "--list"])).stdout !== config) throw new Error("Restoration could not be verified.");
|
|
32
|
+
} catch { throw new Error("Explicit-base rebase recovery is uncertain. Stop mutations and inspect git_status; a person must resolve any remaining rebase state."); }
|
|
33
|
+
throw new Error(`Rebase failed; restoration was verified. ${boundedGitText(errorText(failure), reader.tokens, 2000).text}`);
|
|
34
|
+
}
|
|
35
|
+
if ((await getCurrentBranch(final.pi, final.ctx)).currentBranch !== branch || !(await isCommitAncestor(final.pi, final.ctx, base, await getLocalBranchCommit(final.pi, final.ctx, branch))) || (await getGitOperationState(final.pi, final.ctx)).active.length || await otherRefs(final, branch) !== refs || (await readGit(final, ["config", "--null", "--list"])).stdout !== config) throw new Error("Rebase postconditions are uncertain; inspect git_status before another mutation.");
|
|
36
|
+
return { action: "rebase_branch", currentBranch: branch, upstream: safeGitText(baseRef, reader.tokens), base, previousHead: source, head: await getLocalBranchCommit(final.pi, final.ctx, branch), publicationWarning: "History was rewritten locally; force-push remains unsupported. Prefer update_from_base for published feature updates." };
|
|
37
|
+
}
|
|
38
|
+
export async function rebaseOntoBase(host: GitAPI, ctx: GitCommandContext, baseRef: string, signal?: AbortSignal, tokens: readonly string[] = []) {
|
|
39
|
+
validateReadRef(baseRef);
|
|
40
|
+
const reader = await openGitReader(host, ctx, undefined, signal, tokens);
|
|
41
|
+
return withRepositoryMutationQueue(reader.ctx.cwd, rebaseInside.bind(undefined, host, reader, baseRef));
|
|
42
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { getCanonicalCommonGitDirectory, inspectDirectLocalBranchRef, inspectDirectRemoteTrackingRef, runGit, validateBranchName, withRepositoryMutationQueue } from "./git.ts";
|
|
4
|
+
import { boundedGitText, fingerprint, openGitReader, readGit, safeGitText, validateKeys, type GitAPI, type GitReader } from "./git-read.ts";
|
|
5
|
+
import type { GitCommandContext } from "./git.ts";
|
|
6
|
+
|
|
7
|
+
interface ConfigEntry { scope: string; origin: string; key: string; value: string }
|
|
8
|
+
async function configuration(reader: GitReader): Promise<ConfigEntry[]> {
|
|
9
|
+
const raw = (await readGit(reader, ["config", "--null", "--show-scope", "--show-origin", "--list"])).stdout;
|
|
10
|
+
const fields = raw ? raw.slice(0, -1).split("\0") : [];
|
|
11
|
+
if ((raw && !raw.endsWith("\0")) || fields.length % 3) throw new Error("Malformed Git configuration inspection.");
|
|
12
|
+
const entries = [];
|
|
13
|
+
for (let i = 0; i < fields.length; i += 3) {
|
|
14
|
+
const separator = fields[i + 2].indexOf("\n");
|
|
15
|
+
if (separator < 0) throw new Error("Malformed Git configuration entry.");
|
|
16
|
+
entries.push({ scope: fields[i], origin: fields[i + 1], key: fields[i + 2].slice(0, separator), value: fields[i + 2].slice(separator + 1) });
|
|
17
|
+
}
|
|
18
|
+
return entries;
|
|
19
|
+
}
|
|
20
|
+
function configIdentity(entries: ConfigEntry[]): string { return fingerprint(JSON.stringify(entries)); }
|
|
21
|
+
function remoteNames(entries: ConfigEntry[]): string[] {
|
|
22
|
+
return [...new Set(entries.map(remoteName).filter((name): name is string => name !== undefined))].sort((a, b) => a.localeCompare(b));
|
|
23
|
+
}
|
|
24
|
+
function remoteName(entry: ConfigEntry): string | undefined { return /^remote\.(.+)\.[^.]+$/u.exec(entry.key)?.[1]; }
|
|
25
|
+
function values(entries: ConfigEntry[], key: string): string[] { return entries.filter((entry) => entry.key === key).map((entry) => entry.value); }
|
|
26
|
+
function safeName(name: unknown): asserts name is string {
|
|
27
|
+
if (typeof name !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,127}$/u.test(name) || name === "." || name.includes("..") || name.endsWith("/") || name.includes("//")) throw new TypeError("Expected a safe configured remote name, not a URL or option.");
|
|
28
|
+
}
|
|
29
|
+
function safeDestination(value: unknown): asserts value is string {
|
|
30
|
+
if (typeof value !== "string" || value.length > 2048 || /[\s\p{Cc}\p{Cf}]/u.test(value) || safeGitText(value) !== value) throw new TypeError("Remote destination must be credential-free HTTPS or SSH.");
|
|
31
|
+
if (/^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+:[a-zA-Z0-9_./-]+$/u.test(value)) return;
|
|
32
|
+
let url: URL;
|
|
33
|
+
try { url = new URL(value); } catch { throw new TypeError("Remote destination must be an explicit HTTPS or SSH URL."); }
|
|
34
|
+
if (!["https:", "ssh:"].includes(url.protocol) || !url.hostname || !url.pathname || url.pathname === "/" || url.password || url.search || url.hash || (url.protocol === "https:" && url.username) || (url.username && !/^[a-zA-Z0-9._-]+$/u.test(url.username))) throw new TypeError("Unsupported remote transport or embedded credentials.");
|
|
35
|
+
}
|
|
36
|
+
export async function listRemotes(host: GitAPI, ctx: GitCommandContext, signal?: AbortSignal, tokens: readonly string[] = []) {
|
|
37
|
+
const reader = await openGitReader(host, ctx, undefined, signal, tokens);
|
|
38
|
+
const entries = await configuration(reader);
|
|
39
|
+
const names = remoteNames(entries);
|
|
40
|
+
if (names.length > 100) throw new Error("Remote inventory exceeds the 100-remote safety limit.");
|
|
41
|
+
const remotes = await Promise.all(names.slice(0, 25).map(inspectRemote.bind(undefined, reader, entries, tokens)));
|
|
42
|
+
return { action: "list_remotes", configuration: configIdentity(entries), remotes, omitted: Math.max(0, names.length - 25), complete: names.length <= 25 };
|
|
43
|
+
}
|
|
44
|
+
async function inspectRemote(reader: GitReader, entries: ConfigEntry[], tokens: readonly string[], name: string) {
|
|
45
|
+
const configured = values(entries, `remote.${name}.url`);
|
|
46
|
+
const push = values(entries, `remote.${name}.pushurl`);
|
|
47
|
+
let validName = true;
|
|
48
|
+
try { safeName(name); } catch { validName = false; }
|
|
49
|
+
let supported = validName && configured.length === 1 && push.length <= 1;
|
|
50
|
+
try { for (const url of [...configured, ...push]) safeDestination(url); } catch { supported = false; }
|
|
51
|
+
const effectiveFetch = validName ? await readGit(reader, ["remote", "get-url", "--all", "--", name], true) : { stdout: "Unsupported remote name." };
|
|
52
|
+
const effectivePush = validName ? await readGit(reader, ["remote", "get-url", "--push", "--all", "--", name], true) : { stdout: "Unsupported remote name." };
|
|
53
|
+
return { name: boundedGitText(name, tokens, 128, 1).text, reusableName: validName && safeGitText(name, tokens) === name && name.length <= 128, supported, configuredFetch: configured.slice(0, 2).map((url) => boundedGitText(url, tokens, 128, 1).text), configuredPush: push.slice(0, 2).map((url) => boundedGitText(url, tokens, 128, 1).text), omittedUrls: Math.max(0, configured.length - 2) + Math.max(0, push.length - 2), effectiveFetch: boundedGitText(effectiveFetch.stdout, tokens, 256, 2).text, effectivePush: boundedGitText(effectivePush.stdout, tokens, 256, 2).text };
|
|
54
|
+
}
|
|
55
|
+
async function requireWritableConfiguration(reader: GitReader, entries: ConfigEntry[], expected: string): Promise<string> {
|
|
56
|
+
for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_CONFIG"]) if (process.env[key] !== undefined) throw new Error("Git path/config environment overrides must be unset for guarded repository configuration writes.");
|
|
57
|
+
if (!/^[a-f0-9]{64}$/u.test(expected) || configIdentity(entries) !== expected) throw new Error("Configuration changed; call list_remotes again and use its configuration fingerprint.");
|
|
58
|
+
if (entries.some((entry) => entry.key === "extensions.worktreeconfig" && entry.value !== "false")) throw new Error("Configuration mutations do not support worktree-specific config.");
|
|
59
|
+
const common = await getCanonicalCommonGitDirectory(reader.pi, reader.ctx, reader.signal);
|
|
60
|
+
const path = join(common, "config");
|
|
61
|
+
const info = await lstat(path);
|
|
62
|
+
if (!info.isFile() || info.isSymbolicLink()) throw new Error("Repository config must be a regular non-symlink file.");
|
|
63
|
+
return `file:${path}`;
|
|
64
|
+
}
|
|
65
|
+
export interface AddRemoteInput { name: string; url: string; expectedConfig: string }
|
|
66
|
+
export async function addRemote(host: GitAPI, ctx: GitCommandContext, input: AddRemoteInput, signal?: AbortSignal, tokens: readonly string[] = []) {
|
|
67
|
+
validateKeys(input, ["name", "url", "expectedConfig"]); safeName(input.name); safeDestination(input.url);
|
|
68
|
+
if (safeGitText(input.name, tokens) !== input.name) throw new Error("Remote name is not a reusable safe identity.");
|
|
69
|
+
if (safeGitText(input.url, tokens) !== input.url) throw new Error("Credentials must not be stored in remote URLs.");
|
|
70
|
+
const reader = await openGitReader(host, ctx, undefined, signal, tokens);
|
|
71
|
+
const common = await getCanonicalCommonGitDirectory(reader.pi, reader.ctx, signal);
|
|
72
|
+
return withRepositoryMutationQueue(common, addRemoteInside.bind(undefined, host, reader, input));
|
|
73
|
+
}
|
|
74
|
+
async function addRemoteInside(host: GitAPI, reader: GitReader, input: AddRemoteInput) {
|
|
75
|
+
const { signal, tokens } = reader;
|
|
76
|
+
const before = await configuration(reader);
|
|
77
|
+
await requireWritableConfiguration(reader, before, input.expectedConfig);
|
|
78
|
+
if (before.some((entry) => entry.key.startsWith("url."))) throw new Error("URL rewriting configuration is unsupported for add_remote; no configuration changed.");
|
|
79
|
+
const names = remoteNames(before);
|
|
80
|
+
if (names.some((name) => name.toLowerCase() === input.name.toLowerCase() || name.toLowerCase().startsWith(`${input.name.toLowerCase()}/`) || input.name.toLowerCase().startsWith(`${name.toLowerCase()}/`))) throw new Error("Remote exists or overlaps another remote namespace.");
|
|
81
|
+
if (configIdentity(await configuration(reader)) !== input.expectedConfig) throw new Error("Configuration changed during preparation; retry list_remotes.");
|
|
82
|
+
try {
|
|
83
|
+
await runGit(host, reader.ctx, ["remote", "add", "--", input.name, input.url], { signal, tokens });
|
|
84
|
+
const after = await configuration({ ...reader, signal: undefined });
|
|
85
|
+
const unrelated = after.filter((entry) => !entry.key.startsWith(`remote.${input.name}.`));
|
|
86
|
+
if (JSON.stringify(unrelated) !== JSON.stringify(before) || values(after, `remote.${input.name}.url`).join() !== input.url || values(after, `remote.${input.name}.fetch`).join() !== `+refs/heads/*:refs/remotes/${input.name}/*`) throw new Error("Remote configuration did not match the requested state.");
|
|
87
|
+
return { action: "add_remote", name: input.name, configuration: configIdentity(after), fetched: false };
|
|
88
|
+
} catch (error) { throw new Error(`Remote setup outcome requires inspection with list_remotes; no rollback attempted. ${boundedGitText(String(error), tokens, 2000).text}`); }
|
|
89
|
+
}
|
|
90
|
+
export interface SetUpstreamInput { branchName: string; upstream?: string; unset?: boolean; expectedConfig: string }
|
|
91
|
+
export async function setUpstream(host: GitAPI, ctx: GitCommandContext, input: SetUpstreamInput, signal?: AbortSignal, tokens: readonly string[] = []) {
|
|
92
|
+
validateKeys(input, ["branchName", "upstream", "unset", "expectedConfig"]);
|
|
93
|
+
if ((input.upstream === undefined) === (input.unset !== true) || (input.unset !== undefined && input.unset !== true)) throw new TypeError("Supply exactly upstream or unset: true.");
|
|
94
|
+
const reader = await openGitReader(host, ctx, undefined, signal, tokens);
|
|
95
|
+
const common = await getCanonicalCommonGitDirectory(reader.pi, reader.ctx, signal);
|
|
96
|
+
return withRepositoryMutationQueue(common, setUpstreamInside.bind(undefined, host, reader, input));
|
|
97
|
+
}
|
|
98
|
+
function requireSafeTrackingIdentities(input: SetUpstreamInput, tokens: readonly string[]): void {
|
|
99
|
+
if (safeGitText(input.branchName, tokens) !== input.branchName || (input.upstream !== undefined && safeGitText(input.upstream, tokens) !== input.upstream)) throw new Error("Tracking identities cannot be safely reused.");
|
|
100
|
+
}
|
|
101
|
+
async function setUpstreamInside(host: GitAPI, reader: GitReader, input: SetUpstreamInput) {
|
|
102
|
+
const { signal, tokens } = reader;
|
|
103
|
+
await validateBranchName(reader.pi, reader.ctx, input.branchName, signal);
|
|
104
|
+
requireSafeTrackingIdentities(input, tokens);
|
|
105
|
+
if ((await inspectDirectLocalBranchRef(reader.pi, reader.ctx, input.branchName, signal)).status !== "present") throw new Error("Local branch does not exist.");
|
|
106
|
+
const before = await configuration(reader);
|
|
107
|
+
const origin = await requireWritableConfiguration(reader, before, input.expectedConfig);
|
|
108
|
+
const keys = [`branch.${input.branchName}.remote`, `branch.${input.branchName}.merge`];
|
|
109
|
+
const owned = before.filter((entry) => keys.includes(entry.key));
|
|
110
|
+
if (owned.some((entry) => entry.scope !== "local" || !entry.origin.startsWith("file:") || resolve(reader.ctx.cwd, entry.origin.slice(5)) !== origin.slice(5)) || keys.some((key) => values(before, key).length > 1)) throw new Error("Upstream settings are inherited, included, or ambiguous; no configuration changed.");
|
|
111
|
+
let remote: string | undefined;
|
|
112
|
+
let merge: string | undefined;
|
|
113
|
+
if (input.upstream !== undefined) {
|
|
114
|
+
await validateBranchName(reader.pi, reader.ctx, input.upstream, signal);
|
|
115
|
+
const candidates = remoteNames(before).filter((name) => input.upstream!.startsWith(`${name}/`));
|
|
116
|
+
if (candidates.length !== 1) throw new Error("Upstream must name one existing configured remote-tracking branch.");
|
|
117
|
+
remote = candidates[0]; safeName(remote);
|
|
118
|
+
merge = `refs/heads/${input.upstream.slice(remote.length + 1)}`;
|
|
119
|
+
if ((await inspectDirectRemoteTrackingRef(reader.pi, reader.ctx, input.upstream, signal)).status !== "present") throw new Error("Upstream tracking ref is missing or symbolic; fetch separately first.");
|
|
120
|
+
}
|
|
121
|
+
if (configIdentity(await configuration(reader)) !== input.expectedConfig) throw new Error("Configuration changed during preparation; call list_remotes again.");
|
|
122
|
+
if (!input.upstream && !owned.length) return { action: "set_upstream", branchName: input.branchName, upstream: null, configuration: input.expectedConfig, changed: false };
|
|
123
|
+
try {
|
|
124
|
+
await runGit(host, reader.ctx, ["branch", ...(input.upstream ? [`--set-upstream-to=refs/remotes/${input.upstream}`] : ["--unset-upstream"]), "--", input.branchName], { signal, tokens });
|
|
125
|
+
const after = await configuration({ ...reader, signal: undefined });
|
|
126
|
+
if (JSON.stringify(before.filter((entry) => !keys.includes(entry.key))) !== JSON.stringify(after.filter((entry) => !keys.includes(entry.key))) || values(after, keys[0]).join() !== (remote ?? "") || values(after, keys[1]).join() !== (merge ?? "")) throw new Error("Upstream postconditions were not verified.");
|
|
127
|
+
return { action: "set_upstream", branchName: safeGitText(input.branchName, tokens), upstream: input.upstream ? safeGitText(input.upstream, tokens) : null, configuration: configIdentity(after), changed: true };
|
|
128
|
+
} catch (error) { throw new Error(`Upstream outcome requires inspection with list_remotes; no rollback attempted. ${boundedGitText(String(error), tokens, 2000).text}`); }
|
|
129
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { captureSelectedFiles, inspectIndex, requireExpectedState, requireSimpleIndex, validateIndexInput, type IndexInput, type IndexRecord } from "./git-index.ts";
|
|
2
|
+
import { OBJECT_ID, openGitReader, readGit, resolveReadCommit, safeGitText, type GitAPI, type GitReader } from "./git-read.ts";
|
|
3
|
+
import { getGitStatus } from "./git-status.ts";
|
|
4
|
+
import { runGit, withRepositoryMutationQueue, type GitCommandContext } from "./git.ts";
|
|
5
|
+
|
|
6
|
+
export interface RestoreInput extends IndexInput {
|
|
7
|
+
sourceRef?: string;
|
|
8
|
+
preview?: boolean;
|
|
9
|
+
discardChanges?: boolean;
|
|
10
|
+
expectedFiles?: { path: string; fingerprint: string }[];
|
|
11
|
+
}
|
|
12
|
+
async function previewRestorePath(reader: GitReader, entries: IndexRecord[], files: Map<string, string>, source: string | null, path: string) {
|
|
13
|
+
const entry = entries.find((candidate) => candidate.path === path)!;
|
|
14
|
+
const tree = source === null ? `${entry.mode} blob ${entry.oid}\t${path}\0` : (await readGit(reader, ["--literal-pathspecs", "ls-tree", "-z", source, "--", path])).stdout;
|
|
15
|
+
if (tree && (!/^(?:100644|100755|120000) blob /u.test(tree) || !tree.endsWith(`\t${path}\0`))) throw new Error("Source path is not a supported regular file or symlink.");
|
|
16
|
+
return { path, fingerprint: files.get(path)!, operation: tree ? "replace" : "delete", source: tree ? tree.slice(0, tree.indexOf("\t")) : null };
|
|
17
|
+
}
|
|
18
|
+
function requireRestoreApproval(input: RestoreInput, source: string | null, files: Map<string, string>): void {
|
|
19
|
+
if (input.discardChanges !== true) throw new Error("Restoration discards local edits. Require explicit discardChanges: true after inspecting the preview.");
|
|
20
|
+
if (source !== null && !OBJECT_ID.test(input.sourceRef!)) throw new Error("Apply must use the full sourceCommit from the preview, not a moving ref.");
|
|
21
|
+
if (!Array.isArray(input.expectedFiles) || input.expectedFiles.length !== input.paths.length || new Set(input.expectedFiles.map((entry) => entry.path)).size !== input.paths.length || input.expectedFiles.some((entry) => !input.paths.includes(entry.path) || !/^[a-f0-9]{64}$/u.test(entry.fingerprint) || files.get(entry.path) !== entry.fingerprint)) throw new Error("Selected working files changed or preview expectations are missing; request a fresh restore_paths preview.");
|
|
22
|
+
}
|
|
23
|
+
async function restoreInside(host: GitAPI, reader: GitReader, input: RestoreInput) {
|
|
24
|
+
await requireSimpleIndex(reader);
|
|
25
|
+
const before = await inspectIndex(reader);
|
|
26
|
+
const statusBefore = await requireExpectedState(reader, input, false);
|
|
27
|
+
if (input.paths.some((path) => !before.entries.some((entry) => entry.path === path))) throw new Error("restore_paths only restores index-tracked files; untracked/ignored content is protected.");
|
|
28
|
+
const files = await captureSelectedFiles(reader, input.paths);
|
|
29
|
+
const source = input.sourceRef === undefined ? null : await resolveReadCommit(reader, input.sourceRef);
|
|
30
|
+
const preview = await Promise.all(input.paths.map(previewRestorePath.bind(undefined, reader, before.entries, files, source)));
|
|
31
|
+
if (input.preview === true) return { action: "restore_paths", applied: false, head: input.expectedHead, index: input.expectedIndex, sourceCommit: source, expectedFiles: preview.map((entry) => ({ path: entry.path, fingerprint: entry.fingerprint })), preview };
|
|
32
|
+
requireRestoreApproval(input, source, files);
|
|
33
|
+
await requireExpectedState(reader, input, false);
|
|
34
|
+
if (JSON.stringify([...files]) !== JSON.stringify([...await captureSelectedFiles(reader, input.paths)])) throw new Error("Selected files changed during preparation; request a fresh preview.");
|
|
35
|
+
reader.signal?.throwIfAborted();
|
|
36
|
+
try {
|
|
37
|
+
await runGit(host, reader.ctx, ["--no-optional-locks", "--no-lazy-fetch", "--literal-pathspecs", "-c", "core.fsmonitor=false", "restore", "--worktree", "--no-overlay", ...(source ? [`--source=${source}`] : []), "--", ...input.paths], { signal: reader.signal, tokens: reader.tokens });
|
|
38
|
+
const final = { ...reader, signal: undefined };
|
|
39
|
+
const after = await inspectIndex(final);
|
|
40
|
+
const status = await getGitStatus(host, reader.ctx, {}, undefined, reader.tokens);
|
|
41
|
+
const verify = await readGit(final, ["--literal-pathspecs", "diff", "--no-ext-diff", "--no-textconv", "--quiet", "--ignore-submodules=all", ...(source ? [source] : []), "--", ...input.paths], true);
|
|
42
|
+
if (before.raw !== after.raw || status.head !== input.expectedHead || status.branch !== statusBefore.branch || status.operations.length || verify.code !== 0) throw new Error("Index/HEAD preservation or selected restoration could not be verified.");
|
|
43
|
+
return { action: "restore_paths", applied: true, head: status.head, index: after.index, sourceCommit: source, paths: preview.map((entry) => ({ path: entry.path, operation: entry.operation })), committed: false };
|
|
44
|
+
} catch (error) { throw new Error(`Restoration may have partially changed selected paths; inspect git_status. No rollback attempted. ${safeGitText(String(error), reader.tokens).slice(0, 2000)}`); }
|
|
45
|
+
}
|
|
46
|
+
export async function restorePaths(host: GitAPI, ctx: GitCommandContext, input: RestoreInput, signal?: AbortSignal, tokens: readonly string[] = []) {
|
|
47
|
+
validateIndexInput(input, ["sourceRef", "preview", "discardChanges", "expectedFiles"]);
|
|
48
|
+
if (input.preview !== undefined && typeof input.preview !== "boolean") throw new TypeError("preview must be boolean.");
|
|
49
|
+
if (input.discardChanges !== undefined && typeof input.discardChanges !== "boolean") throw new TypeError("discardChanges must be boolean.");
|
|
50
|
+
if (input.expectedFiles !== undefined && (!Array.isArray(input.expectedFiles) || input.expectedFiles.some((entry) => !entry || typeof entry !== "object" || Object.keys(entry).some((key) => !["path", "fingerprint"].includes(key))))) throw new TypeError("Malformed selected-file expectations.");
|
|
51
|
+
const reader = await openGitReader(host, ctx, input.worktreePath, signal, tokens);
|
|
52
|
+
return withRepositoryMutationQueue(reader.ctx.cwd, restoreInside.bind(undefined, host, reader, input));
|
|
53
|
+
}
|