@nanobpm/urban 0.68.0 → 0.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }