@senad-d/branchme 0.3.2 → 0.3.4
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 +8 -2
- package/README.md +28 -12
- package/SECURITY.md +8 -5
- package/docs/PROJECT_DEFINITION_BRIEF.md +11 -8
- package/docs/SMOKE_TEST.md +2 -2
- package/docs/STRUCTURE.md +12 -7
- package/docs/TUI_CAPTURE.md +7 -3
- package/package.json +1 -1
- package/src/commands/branchme-command.ts +7 -3
- package/src/constants.ts +5 -0
- package/src/git-discovery.ts +231 -0
- package/src/git-integration.ts +389 -7
- package/src/git-workflow.ts +14 -4
- package/src/git.ts +98 -7
- package/src/tools/branchme-tools.ts +59 -37
- package/src/tools/workflow-tools.ts +78 -9
- package/src/types.ts +82 -3
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import type { Dirent } from "node:fs";
|
|
2
|
+
import { lstat, readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import {
|
|
6
|
+
GIT_BRANCH_RAW_OUTPUT_LIMIT_BYTES,
|
|
7
|
+
GIT_CONTEXT_VALUE_LIMIT_CHARS,
|
|
8
|
+
GIT_FETCH_TIMEOUT_MS,
|
|
9
|
+
GIT_WORKTREE_PATH_LIMIT_CHARS,
|
|
10
|
+
MAX_SUMMARY_OUTPUT_CHARS,
|
|
11
|
+
} from "./constants.ts";
|
|
12
|
+
import {
|
|
13
|
+
getCanonicalCommonGitDirectory,
|
|
14
|
+
getGitRoot,
|
|
15
|
+
isLosslessGitMetadata,
|
|
16
|
+
runGit,
|
|
17
|
+
safeWorktreeValue,
|
|
18
|
+
withRepositoryMutationQueue,
|
|
19
|
+
type GitCommandContext,
|
|
20
|
+
} from "./git.ts";
|
|
21
|
+
import type { FetchRemoteDetails } from "./types.ts";
|
|
22
|
+
|
|
23
|
+
function validateRemoteDiscoveryInput(remote: unknown, prune: unknown): asserts remote is string {
|
|
24
|
+
if (typeof remote !== "string" || !remote || remote === "." || remote.startsWith("-") ||
|
|
25
|
+
/[\s:@\p{Cc}\p{Cf}]/u.test(remote) ||
|
|
26
|
+
!isLosslessGitMetadata(remote, GIT_CONTEXT_VALUE_LIMIT_CHARS)) {
|
|
27
|
+
throw new TypeError("fetch_remote remote must be a safe configured remote name, not a URL, option, or local repository.");
|
|
28
|
+
}
|
|
29
|
+
if (typeof prune !== "boolean") throw new TypeError("fetch_remote prune must be a boolean.");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function requireSafeSymbolicDestination(ref: string, target: string, prefix: string): void {
|
|
33
|
+
// Clones commonly have origin/HEAD -> origin/main. All other symbolic
|
|
34
|
+
// destinations are refused to prevent wildcard fetch/prune following aliases.
|
|
35
|
+
if (target && (ref !== `${prefix}HEAD` || !target.startsWith(prefix) ||
|
|
36
|
+
target.length === prefix.length || target === `${prefix}HEAD`)) {
|
|
37
|
+
throw new Error("fetch_remote refuses unsafe symbolic remote-tracking destinations; no fetch ran.");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function findLooseRemoteDirectory(commonGitDir: string, remote: string): Promise<string | undefined> {
|
|
42
|
+
return inspectRemoteAncestors(commonGitDir, ["refs", "remotes", ...remote.split("/")]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function inspectRemoteAncestors(parent: string, segments: string[]): Promise<string | undefined> {
|
|
46
|
+
if (segments.length === 0) return parent;
|
|
47
|
+
const directory = join(parent, segments[0]);
|
|
48
|
+
let info;
|
|
49
|
+
try {
|
|
50
|
+
info = await lstat(directory);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
53
|
+
throw new Error("fetch_remote could not inspect its loose ref namespace; no fetch ran.");
|
|
54
|
+
}
|
|
55
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
56
|
+
throw new Error("fetch_remote refuses non-directory or symlinked ref namespaces; no fetch ran.");
|
|
57
|
+
}
|
|
58
|
+
return inspectRemoteAncestors(directory, segments.slice(1));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function inspectLooseRemoteFile(path: string, ref: string, prefix: string): Promise<void> {
|
|
62
|
+
if ((await lstat(path)).size > GIT_CONTEXT_VALUE_LIMIT_CHARS + 64) {
|
|
63
|
+
throw new Error("fetch_remote refuses unsafe loose ref files; no fetch ran.");
|
|
64
|
+
}
|
|
65
|
+
const content = (await readFile(path, "utf8")).trimEnd();
|
|
66
|
+
if (content.startsWith("ref: ")) {
|
|
67
|
+
requireSafeSymbolicDestination(ref, content.slice(5), prefix);
|
|
68
|
+
} else if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu.test(content)) {
|
|
69
|
+
throw new Error("fetch_remote received malformed loose ref metadata; no fetch ran.");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function* looseRemoteEntries(
|
|
74
|
+
directory: string,
|
|
75
|
+
prefix: string,
|
|
76
|
+
): AsyncGenerator<{ entry: Dirent; ref: string; path: string }> {
|
|
77
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
const ref = `${prefix}${entry.name}`;
|
|
80
|
+
const path = join(directory, entry.name);
|
|
81
|
+
yield { entry, ref, path };
|
|
82
|
+
// Traverse only after the caller has checked the inventory budget.
|
|
83
|
+
if (entry.isDirectory()) yield* looseRemoteEntries(path, `${ref}/`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function inspectLooseRemoteRefs(commonGitDir: string, remote: string): Promise<void> {
|
|
88
|
+
const prefix = `refs/remotes/${remote}/`;
|
|
89
|
+
const directory = await findLooseRemoteDirectory(commonGitDir, remote);
|
|
90
|
+
if (directory === undefined) return;
|
|
91
|
+
// for-each-ref silently omits dangling symrefs. Inspect the files backend too,
|
|
92
|
+
// so even an alias to an absent local branch cannot become a fetch destination.
|
|
93
|
+
let inventoryBytes = 0;
|
|
94
|
+
for await (const { entry, ref, path } of looseRemoteEntries(directory, prefix)) {
|
|
95
|
+
inventoryBytes += Buffer.byteLength(ref, "utf8");
|
|
96
|
+
if (inventoryBytes > GIT_BRANCH_RAW_OUTPUT_LIMIT_BYTES) {
|
|
97
|
+
throw new Error("fetch_remote loose ref inventory exceeded the safety limit; no fetch ran.");
|
|
98
|
+
}
|
|
99
|
+
if (entry.isDirectory()) continue;
|
|
100
|
+
if (!entry.isFile()) {
|
|
101
|
+
throw new Error("fetch_remote refuses unsafe loose ref files; no fetch ran.");
|
|
102
|
+
}
|
|
103
|
+
await inspectLooseRemoteFile(path, ref, prefix);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function requireUnambiguousRemoteNamespace(
|
|
108
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
109
|
+
ctx: GitCommandContext,
|
|
110
|
+
remote: string,
|
|
111
|
+
signal?: AbortSignal,
|
|
112
|
+
): Promise<void> {
|
|
113
|
+
const result = await runGit(pi, ctx, ["remote"], { signal });
|
|
114
|
+
if (Buffer.byteLength(result.stdout, "utf8") > GIT_BRANCH_RAW_OUTPUT_LIMIT_BYTES) {
|
|
115
|
+
throw new Error("fetch_remote configured remote inventory exceeded the safety limit; no fetch ran.");
|
|
116
|
+
}
|
|
117
|
+
for (const other of result.stdout.trimEnd().split("\n")) {
|
|
118
|
+
if (other !== remote && (other.startsWith(`${remote}/`) || remote.startsWith(`${other}/`))) {
|
|
119
|
+
throw new Error("fetch_remote refuses overlapping configured remote namespaces; no fetch ran.");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function requireSafeRemoteDestinations(
|
|
125
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
126
|
+
ctx: GitCommandContext,
|
|
127
|
+
remote: string,
|
|
128
|
+
commonGitDir: string,
|
|
129
|
+
signal?: AbortSignal,
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
const prefix = `refs/remotes/${remote}/`;
|
|
132
|
+
await runGit(pi, ctx, ["check-ref-format", `${prefix}branchme-validation`], { signal });
|
|
133
|
+
const configured = await runGit(pi, ctx, ["remote", "get-url", remote], { signal, allowFailure: true });
|
|
134
|
+
if (configured.code !== 0) throw new Error("fetch_remote remote is not a configured Git remote.");
|
|
135
|
+
await requireUnambiguousRemoteNamespace(pi, ctx, remote, signal);
|
|
136
|
+
const storage = await runGit(pi, ctx, ["config", "--get", "extensions.refStorage"], { signal, allowFailure: true });
|
|
137
|
+
if ((storage.code !== 0 && storage.code !== 1) ||
|
|
138
|
+
(storage.code === 0 && storage.stdout.trimEnd() !== "files")) {
|
|
139
|
+
throw new Error("fetch_remote requires Git's files ref backend to verify dangling symbolic destinations; no fetch ran.");
|
|
140
|
+
}
|
|
141
|
+
await inspectLooseRemoteRefs(commonGitDir, remote);
|
|
142
|
+
|
|
143
|
+
const result = await runGit(pi, ctx, [
|
|
144
|
+
"for-each-ref", "--format=%(refname)%00%(symref)", prefix,
|
|
145
|
+
], { signal });
|
|
146
|
+
if (Buffer.byteLength(result.stdout, "utf8") > GIT_BRANCH_RAW_OUTPUT_LIMIT_BYTES) {
|
|
147
|
+
throw new Error("fetch_remote destination inventory exceeded the safety limit; no fetch ran.");
|
|
148
|
+
}
|
|
149
|
+
const records = result.stdout === "" ? [] : result.stdout.trimEnd().split("\n");
|
|
150
|
+
for (const record of records) {
|
|
151
|
+
const fields = record.split("\0");
|
|
152
|
+
const [ref, target] = fields;
|
|
153
|
+
if (fields.length !== 2 || !ref.startsWith(prefix) || ref.length === prefix.length) {
|
|
154
|
+
throw new Error("fetch_remote received malformed destination metadata; no fetch ran.");
|
|
155
|
+
}
|
|
156
|
+
requireSafeSymbolicDestination(ref, target, prefix);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function fetchRemoteWithinQueue(
|
|
161
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
162
|
+
ctx: GitCommandContext,
|
|
163
|
+
remote: string,
|
|
164
|
+
prune: boolean,
|
|
165
|
+
commonGitDir: string,
|
|
166
|
+
signal?: AbortSignal,
|
|
167
|
+
): Promise<FetchRemoteDetails> {
|
|
168
|
+
try {
|
|
169
|
+
await requireSafeRemoteDestinations(pi, ctx, remote, commonGitDir, signal);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
172
|
+
throw new Error(safeWorktreeValue(reason, MAX_SUMMARY_OUTPUT_CHARS));
|
|
173
|
+
}
|
|
174
|
+
const refspec = `+refs/heads/*:refs/remotes/${remote}/*`;
|
|
175
|
+
let result;
|
|
176
|
+
try {
|
|
177
|
+
result = await runGit(pi, ctx, [
|
|
178
|
+
"fetch", "--atomic", "--no-tags", "--no-prune-tags", "--no-recurse-submodules",
|
|
179
|
+
"--no-auto-maintenance", "--refmap=", prune ? "--prune" : "--no-prune",
|
|
180
|
+
"--", remote, refspec, "^refs/heads/HEAD",
|
|
181
|
+
], { signal, timeout: GIT_FETCH_TIMEOUT_MS });
|
|
182
|
+
} catch (error) {
|
|
183
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
184
|
+
const diagnostic = safeWorktreeValue(reason, MAX_SUMMARY_OUTPUT_CHARS - 200);
|
|
185
|
+
throw new Error(
|
|
186
|
+
`fetch_remote did not complete: ${diagnostic}. Remote-tracking refs may have changed; inspect before retrying. No rollback was attempted.`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
action: "fetch_remote",
|
|
191
|
+
repoRoot: safeWorktreeValue(ctx.cwd, GIT_WORKTREE_PATH_LIMIT_CHARS),
|
|
192
|
+
remote,
|
|
193
|
+
prune,
|
|
194
|
+
refspec,
|
|
195
|
+
output: safeWorktreeValue(result.stdout || result.stderr, MAX_SUMMARY_OUTPUT_CHARS),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function queueCommonRemoteFetch(
|
|
200
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
201
|
+
ctx: GitCommandContext,
|
|
202
|
+
remote: string,
|
|
203
|
+
prune: boolean,
|
|
204
|
+
commonGitDir: string,
|
|
205
|
+
signal?: AbortSignal,
|
|
206
|
+
): Promise<FetchRemoteDetails> {
|
|
207
|
+
if (commonGitDir === ctx.cwd) return fetchRemoteWithinQueue(pi, ctx, remote, prune, commonGitDir, signal);
|
|
208
|
+
return withRepositoryMutationQueue(
|
|
209
|
+
commonGitDir,
|
|
210
|
+
fetchRemoteWithinQueue.bind(undefined, pi, ctx, remote, prune, commonGitDir, signal),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function fetchRemote(
|
|
215
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
216
|
+
ctx: GitCommandContext,
|
|
217
|
+
remote: string = "origin",
|
|
218
|
+
prune: boolean = false,
|
|
219
|
+
signal?: AbortSignal,
|
|
220
|
+
): Promise<FetchRemoteDetails> {
|
|
221
|
+
validateRemoteDiscoveryInput(remote, prune);
|
|
222
|
+
const repoRoot = await getGitRoot(pi, ctx, signal);
|
|
223
|
+
const rootCtx = { cwd: repoRoot };
|
|
224
|
+
const commonGitDir = await getCanonicalCommonGitDirectory(pi, rootCtx, signal);
|
|
225
|
+
// Retain the existing active-root lock for sibling BranchMe operations as well
|
|
226
|
+
// as a shared ref-cache lock for concurrent fetch_remote calls in linked trees.
|
|
227
|
+
return withRepositoryMutationQueue(
|
|
228
|
+
repoRoot,
|
|
229
|
+
queueCommonRemoteFetch.bind(undefined, pi, rootCtx, remote, prune, commonGitDir, signal),
|
|
230
|
+
);
|
|
231
|
+
}
|