@nanobpm/urban 0.68.1 → 0.69.1
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/dist/context/binding/index.d.ts +1 -1
- package/dist/context/binding/index.js +1 -1
- package/dist/context/git/guard-registry.d.ts +26 -0
- package/dist/context/git/guard-registry.js +52 -0
- package/dist/context/git/index.d.ts +4 -1
- package/dist/context/git/index.js +21 -1
- package/dist/context/git/layout.d.ts +46 -0
- package/dist/context/git/layout.js +96 -0
- package/dist/context/git/substrate.d.ts +95 -0
- package/dist/context/git/substrate.js +190 -0
- package/dist/context/git/writer.d.ts +154 -0
- package/dist/context/git/writer.js +395 -0
- package/package.json +2 -2
- package/src/context/binding/index.ts +1 -0
- package/src/context/git/NOTES.md +98 -0
- package/src/context/git/guard-registry.ts +59 -0
- package/src/context/git/index.ts +51 -2
- package/src/context/git/layout.test.ts +112 -0
- package/src/context/git/layout.ts +110 -0
- package/src/context/git/substrate.ts +287 -0
- package/src/context/git/writer.test.ts +898 -0
- package/src/context/git/writer.ts +543 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// Slice S3 (git/governance) — the WRITE substrate seam.
|
|
2
|
+
//
|
|
3
|
+
// The write path is deliberately abstracted over a {@link WriteSubstrate}
|
|
4
|
+
// interface rather than hard-wired to public git, leaving a clean seam for a
|
|
5
|
+
// future PII/mutable or self-hosted backend (the same seam S1's read side keeps
|
|
6
|
+
// via `SubstrateBackend`). {@link GitWriteSubstrate} is the default, git-only
|
|
7
|
+
// MVP implementation: it composes append-via-commit, branch creation, checkout,
|
|
8
|
+
// and merge primitives over a local working copy using an injectable
|
|
9
|
+
// {@link GitRunner} (so tests drive a local temp repo without any network).
|
|
10
|
+
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
13
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
14
|
+
import { promisify } from "node:util";
|
|
15
|
+
import { hardenedGitArgs, redactUrlUserinfo, type GitRunner } from "../binding/index.ts";
|
|
16
|
+
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
18
|
+
|
|
19
|
+
/** Error thrown when a substrate write/governance git operation fails. */
|
|
20
|
+
export class SubstrateWriteError extends Error {
|
|
21
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
22
|
+
super(message, options);
|
|
23
|
+
this.name = "SubstrateWriteError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const defaultWriteGitRunner: GitRunner = async (args, cwd) => {
|
|
28
|
+
const hardened = hardenedGitArgs(args);
|
|
29
|
+
try {
|
|
30
|
+
const { stdout } = await execFileAsync("git", hardened, {
|
|
31
|
+
cwd,
|
|
32
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
33
|
+
});
|
|
34
|
+
return stdout;
|
|
35
|
+
} catch (cause) {
|
|
36
|
+
const label = `git ${hardened.map(redactUrlUserinfo).join(" ")}`;
|
|
37
|
+
throw new SubstrateWriteError(`substrate git command failed: ${label}`, { cause });
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** The identity a commit/merge is attributed to. */
|
|
42
|
+
export interface CommitAuthor {
|
|
43
|
+
readonly name: string;
|
|
44
|
+
readonly email: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The write seam the governance layer composes. A future mutable/PII backend can
|
|
49
|
+
* implement this interface differently (e.g. against a database with real
|
|
50
|
+
* erasure) without the writer or its callers changing. Every method operates on
|
|
51
|
+
* a single logical substrate working copy rooted at {@link rootPath}.
|
|
52
|
+
*/
|
|
53
|
+
export interface WriteSubstrate {
|
|
54
|
+
/** Absolute path to the substrate working copy. */
|
|
55
|
+
readonly rootPath: string;
|
|
56
|
+
/** Write `content` to `relPath` (relative to {@link rootPath}), creating dirs. */
|
|
57
|
+
writeRecordFile(relPath: string, content: string): Promise<void>;
|
|
58
|
+
/** Read the file at `relPath` as it exists on `ref`, without touching the tree. */
|
|
59
|
+
readFileAtRef(ref: string, relPath: string): Promise<string>;
|
|
60
|
+
/**
|
|
61
|
+
* The substrate-relative paths changed on `branch` relative to its merge-base
|
|
62
|
+
* with `baseRef` — i.e. exactly the set of files a `baseRef`-into-`branch` merge
|
|
63
|
+
* would introduce/modify. Lets a caller assert a merge is limited to expected
|
|
64
|
+
* files before performing it.
|
|
65
|
+
*/
|
|
66
|
+
diffPaths(baseRef: string, branch: string): Promise<readonly string[]>;
|
|
67
|
+
/** The name of the currently checked-out branch. */
|
|
68
|
+
currentBranch(): Promise<string>;
|
|
69
|
+
/** Create (or reset) branch `name` at `from` and check it out. */
|
|
70
|
+
createBranch(name: string, from: string): Promise<void>;
|
|
71
|
+
/** Check out an existing branch/ref. */
|
|
72
|
+
checkout(ref: string): Promise<void>;
|
|
73
|
+
/**
|
|
74
|
+
* Recover the shared working copy to a pristine checkout of `ref`: force-checks
|
|
75
|
+
* out `ref` — moving HEAD and discarding ALL tracked modifications across the
|
|
76
|
+
* whole working copy, not only those under `pathspec` — then removes untracked
|
|
77
|
+
* files/dirs UNDER `pathspec`, leaving untracked files OUTSIDE `pathspec`
|
|
78
|
+
* untouched. The broad tracked-file reset is intended: the substrate working
|
|
79
|
+
* copy is a single-writer, serialised recovery seam, so callers must not keep
|
|
80
|
+
* unrelated tracked edits in it across a write that might fail.
|
|
81
|
+
*/
|
|
82
|
+
restoreClean(ref: string, pathspec: string): Promise<void>;
|
|
83
|
+
/**
|
|
84
|
+
* Stage the changes UNDER `pathspec` (only) and commit them as `author`;
|
|
85
|
+
* returns the new commit sha. Staging is deliberately scoped to the caller's
|
|
86
|
+
* record path(s) — never a blanket `git add -A` — so an unrelated change
|
|
87
|
+
* elsewhere in the shared working copy can never be swept into a commit that
|
|
88
|
+
* the per-record PII guard only inspected the record content for.
|
|
89
|
+
*/
|
|
90
|
+
stageAndCommit(pathspec: string, message: string, author: CommitAuthor): Promise<string>;
|
|
91
|
+
/** Merge `branch` into the current branch as `author`; returns the merge sha. */
|
|
92
|
+
mergeBranch(branch: string, message: string, author: CommitAuthor): Promise<string>;
|
|
93
|
+
/** `true` iff a local branch named `name` exists. */
|
|
94
|
+
branchExists(name: string): Promise<boolean>;
|
|
95
|
+
/**
|
|
96
|
+
* `true` iff `commitish` is an ancestor of (or equal to) `ref` — i.e. landed
|
|
97
|
+
* on it. Both operands are git commit-ish inputs: a raw sha, a branch name, or
|
|
98
|
+
* any other ref resolvable to a commit. `ContextWriter.isRatified` deliberately
|
|
99
|
+
* passes a branch ref (the proposal branch tip) here to bind the check to the
|
|
100
|
+
* real tip rather than a caller-supplied sha, so implementations MUST resolve
|
|
101
|
+
* `commitish` as a general ref, not assume a sha-only input.
|
|
102
|
+
*/
|
|
103
|
+
isMerged(commitish: string, ref: string): Promise<boolean>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The default git-only write substrate. Composes commit/branch/merge primitives
|
|
108
|
+
* over a local working copy. Commit author identity is passed per-commit via
|
|
109
|
+
* `-c user.name=… -c user.email=…`, so a bare temp repo with no configured user
|
|
110
|
+
* still commits cleanly (and every write path can attribute a bot vs. human
|
|
111
|
+
* author independently).
|
|
112
|
+
*/
|
|
113
|
+
export class GitWriteSubstrate implements WriteSubstrate {
|
|
114
|
+
readonly rootPath: string;
|
|
115
|
+
readonly #git: GitRunner;
|
|
116
|
+
|
|
117
|
+
constructor(rootPath: string, gitRunner: GitRunner = defaultWriteGitRunner) {
|
|
118
|
+
if (!isAbsolute(rootPath)) {
|
|
119
|
+
throw new SubstrateWriteError(`substrate rootPath must be absolute: ${rootPath}`);
|
|
120
|
+
}
|
|
121
|
+
this.rootPath = rootPath;
|
|
122
|
+
this.#git = gitRunner;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async writeRecordFile(relPath: string, content: string): Promise<void> {
|
|
126
|
+
const target = this.#resolveWithinRoot(relPath);
|
|
127
|
+
await mkdir(dirname(target), { recursive: true });
|
|
128
|
+
await writeFile(target, content, "utf8");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async readFileAtRef(ref: string, relPath: string): Promise<string> {
|
|
132
|
+
// Read the blob at `ref:relPath` via `git show` — a pure read that never
|
|
133
|
+
// mutates the working tree (no checkout), so a failed read/guard cannot leave
|
|
134
|
+
// the shared working copy on the wrong branch. `#resolveWithinRoot` still
|
|
135
|
+
// rejects any traversal path before it reaches git.
|
|
136
|
+
this.#resolveWithinRoot(relPath);
|
|
137
|
+
return this.#git(["show", "--end-of-options", `${ref}:${relPath}`], this.rootPath);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async diffPaths(baseRef: string, branch: string): Promise<readonly string[]> {
|
|
141
|
+
// `base...branch` (symmetric-difference range) lists files changed on `branch`
|
|
142
|
+
// since it forked from `baseRef` — precisely what a merge of `branch` would
|
|
143
|
+
// bring onto `baseRef`. `-z` NUL-delimits paths so unusual names never split
|
|
144
|
+
// wrong; `--end-of-options` stops a ref that looks like a flag being parsed.
|
|
145
|
+
const out = await this.#git(
|
|
146
|
+
["diff", "--name-only", "-z", "--end-of-options", `${baseRef}...${branch}`],
|
|
147
|
+
this.rootPath,
|
|
148
|
+
);
|
|
149
|
+
return out.split("\0").filter((p) => p.length > 0);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Resolve a caller-supplied substrate-relative path to an absolute path,
|
|
153
|
+
// refusing anything that escapes the substrate root. Defence in depth: the
|
|
154
|
+
// layout helper already produces traversal-safe paths, but no read/write may
|
|
155
|
+
// ever leave the substrate root regardless of the relPath handed in.
|
|
156
|
+
#resolveWithinRoot(relPath: string): string {
|
|
157
|
+
const target = resolve(this.rootPath, relPath);
|
|
158
|
+
const rel = relative(this.rootPath, target);
|
|
159
|
+
if (rel === "" || rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) {
|
|
160
|
+
throw new SubstrateWriteError(`record path escapes the substrate root: ${relPath}`);
|
|
161
|
+
}
|
|
162
|
+
return target;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async currentBranch(): Promise<string> {
|
|
166
|
+
const out = await this.#git(["rev-parse", "--abbrev-ref", "HEAD"], this.rootPath);
|
|
167
|
+
return out.trim();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async createBranch(name: string, from: string): Promise<void> {
|
|
171
|
+
// `checkout -B` creates or resets the branch to `from` and checks it out.
|
|
172
|
+
// `--end-of-options` stops an option-style ref from being parsed as a flag.
|
|
173
|
+
await this.#git(["checkout", "-B", name, "--end-of-options", from], this.rootPath);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async checkout(ref: string): Promise<void> {
|
|
177
|
+
await this.#git(["checkout", "--end-of-options", ref], this.rootPath);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async restoreClean(ref: string, pathspec: string): Promise<void> {
|
|
181
|
+
// `checkout --force <ref>` moves HEAD to `ref` and discards modifications to ALL
|
|
182
|
+
// tracked files across the working copy (not just under `pathspec`) — the full
|
|
183
|
+
// reset a post-failure recovery of this single-writer working copy wants. `clean
|
|
184
|
+
// -fd` then removes leftover UNTRACKED files/dirs, scoped to `pathspec` (the
|
|
185
|
+
// record layout root) so it never touches untracked files elsewhere. `--` ends
|
|
186
|
+
// option parsing before the pathspec so a path that looks like a flag is never
|
|
187
|
+
// misread as one.
|
|
188
|
+
await this.#git(["checkout", "--force", "--end-of-options", ref], this.rootPath);
|
|
189
|
+
await this.#git(["clean", "-fd", "--", pathspec], this.rootPath);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async stageAndCommit(
|
|
193
|
+
pathspec: string,
|
|
194
|
+
message: string,
|
|
195
|
+
author: CommitAuthor,
|
|
196
|
+
): Promise<string> {
|
|
197
|
+
// Stage ONLY the caller's record path(s) — `add -A -- <pathspec>` — never a
|
|
198
|
+
// blanket `git add -A`. A blanket stage would sweep ANY unrelated tracked or
|
|
199
|
+
// untracked change in the shared single-writer working copy into this commit,
|
|
200
|
+
// bypassing the per-record PII guard that inspected the record content alone
|
|
201
|
+
// (and risking committing stray residue outside the record layout). `-A` still
|
|
202
|
+
// captures adds/modifies/deletes, but confined to `pathspec`; `--` ends option
|
|
203
|
+
// parsing so a path that looks like a flag is never misread.
|
|
204
|
+
await this.#git(["add", "-A", "--", pathspec], this.rootPath);
|
|
205
|
+
await this.#git(
|
|
206
|
+
[
|
|
207
|
+
"-c",
|
|
208
|
+
`user.name=${author.name}`,
|
|
209
|
+
"-c",
|
|
210
|
+
`user.email=${author.email}`,
|
|
211
|
+
"commit",
|
|
212
|
+
"--no-gpg-sign",
|
|
213
|
+
"--allow-empty",
|
|
214
|
+
"-m",
|
|
215
|
+
message,
|
|
216
|
+
],
|
|
217
|
+
this.rootPath,
|
|
218
|
+
);
|
|
219
|
+
return this.#revParse("HEAD");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async mergeBranch(branch: string, message: string, author: CommitAuthor): Promise<string> {
|
|
223
|
+
// `--no-ff` always records a merge commit, so ratification (a merge) is a
|
|
224
|
+
// distinct, auditable event even when the branch could fast-forward.
|
|
225
|
+
try {
|
|
226
|
+
await this.#git(
|
|
227
|
+
[
|
|
228
|
+
"-c",
|
|
229
|
+
`user.name=${author.name}`,
|
|
230
|
+
"-c",
|
|
231
|
+
`user.email=${author.email}`,
|
|
232
|
+
"merge",
|
|
233
|
+
"--no-ff",
|
|
234
|
+
"--no-gpg-sign",
|
|
235
|
+
"-m",
|
|
236
|
+
message,
|
|
237
|
+
"--end-of-options",
|
|
238
|
+
branch,
|
|
239
|
+
],
|
|
240
|
+
this.rootPath,
|
|
241
|
+
);
|
|
242
|
+
} catch (cause) {
|
|
243
|
+
// A failed merge (e.g. a content conflict) leaves the working tree in a
|
|
244
|
+
// conflicted MERGE_HEAD state. Abort it best-effort so the shared working
|
|
245
|
+
// copy is clean for the next serialised operation, then surface the cause.
|
|
246
|
+
await this.#git(["merge", "--abort"], this.rootPath).catch(() => {});
|
|
247
|
+
throw cause;
|
|
248
|
+
}
|
|
249
|
+
return this.#revParse("HEAD");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async branchExists(name: string): Promise<boolean> {
|
|
253
|
+
try {
|
|
254
|
+
await this.#git(["show-ref", "--verify", "--quiet", `refs/heads/${name}`], this.rootPath);
|
|
255
|
+
return true;
|
|
256
|
+
} catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async isMerged(commitish: string, ref: string): Promise<boolean> {
|
|
262
|
+
try {
|
|
263
|
+
// `--end-of-options` (as every other ref-passing call in this file) stops an
|
|
264
|
+
// option-style `commitish`/`ref` (e.g. a caller-supplied baseBranch override)
|
|
265
|
+
// from being parsed as a `git merge-base` flag rather than a revision.
|
|
266
|
+
await this.#git(
|
|
267
|
+
["merge-base", "--is-ancestor", "--end-of-options", commitish, ref],
|
|
268
|
+
this.rootPath,
|
|
269
|
+
);
|
|
270
|
+
return true;
|
|
271
|
+
} catch {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async #revParse(ref: string): Promise<string> {
|
|
277
|
+
// `git rev-parse` echoes `--end-of-options` back as output, so it is omitted
|
|
278
|
+
// here; `ref` on this path is always an internally-produced value (`HEAD`).
|
|
279
|
+
const out = await this.#git(["rev-parse", ref], this.rootPath);
|
|
280
|
+
return out.trim();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Join a substrate-relative path onto an absolute root (POSIX-in, native-out). */
|
|
285
|
+
export function substratePath(root: string, relPath: string): string {
|
|
286
|
+
return join(root, relPath);
|
|
287
|
+
}
|