@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.
@@ -1,5 +1,5 @@
1
1
  export { type ContextBinding, ContextBindingError, isContextBinding, parseContextBinding, } from "./descriptor.ts";
2
2
  export { type ContextIdentity, contextIdentityKey, resolveContextIdentity, sameContext, } from "./identity.ts";
3
3
  export { type ResolvedContextHandle, type SubstrateBackend, type SubstrateResolveOptions, } from "./backend.ts";
4
- export { GitSubstrateBackend, type GitRunner, GIT_SAFETY_CONFIG, hardenedGitArgs, SubstrateResolveError, } from "./git-backend.ts";
4
+ export { GitSubstrateBackend, type GitRunner, GIT_SAFETY_CONFIG, hardenedGitArgs, redactUrlUserinfo, SubstrateResolveError, } from "./git-backend.ts";
5
5
  export { ContextResolver, type ContextResolverOptions, type ResolveOptions, defaultContextCacheRoot, resolveContextBinding, } from "./resolver.ts";
@@ -10,5 +10,5 @@
10
10
  // future PII/mutable or self-hosted backend.
11
11
  export { ContextBindingError, isContextBinding, parseContextBinding, } from "./descriptor.js";
12
12
  export { contextIdentityKey, resolveContextIdentity, sameContext, } from "./identity.js";
13
- export { GitSubstrateBackend, GIT_SAFETY_CONFIG, hardenedGitArgs, SubstrateResolveError, } from "./git-backend.js";
13
+ export { GitSubstrateBackend, GIT_SAFETY_CONFIG, hardenedGitArgs, redactUrlUserinfo, SubstrateResolveError, } from "./git-backend.js";
14
14
  export { ContextResolver, defaultContextCacheRoot, resolveContextBinding, } from "./resolver.js";
@@ -0,0 +1,26 @@
1
+ import type { PiiCandidate, PiiGuard } from "../pii/index.ts";
2
+ /**
3
+ * An append-only registry of pre-commit guards. The mandatory S6 PII guard is
4
+ * pre-registered as the default and cannot be removed; callers may register
5
+ * ADDITIONAL guards, which run in addition to (never instead of) the default.
6
+ */
7
+ export declare class PreCommitGuardRegistry {
8
+ #private;
9
+ /**
10
+ * Build a registry. It is seeded with the mandatory {@link preCommitPiiGuard}
11
+ * first; any `additional` guards are appended after it. Even with no arguments
12
+ * the resulting registry enforces PII by construction.
13
+ */
14
+ constructor(additional?: readonly PiiGuard[]);
15
+ /** Register an ADDITIONAL guard. Cannot displace the mandatory default guard. */
16
+ register(guard: PiiGuard): void;
17
+ /** A snapshot of the registered guards, in run order (mandatory guard first). */
18
+ get guards(): readonly PiiGuard[];
19
+ /**
20
+ * Run EVERY registered guard against `candidate`. The mandatory PII guard runs
21
+ * first. Each guard is default-DENY: the first guard to detect a violation
22
+ * throws (a {@link import("../pii/index.ts").PiiGuardError} for the PII guard),
23
+ * so a candidate carrying PII never reaches the commit that follows this call.
24
+ */
25
+ assertAll(candidate: PiiCandidate): void;
26
+ }
@@ -0,0 +1,52 @@
1
+ // Slice S3 (git/governance) — the mandatory pre-commit guard registry.
2
+ //
3
+ // This is the seam that makes PII enforcement NON-OPTIONAL and default-DENY BY
4
+ // CONSTRUCTION on every write path. A registry is ALWAYS seeded with the S6
5
+ // mandatory guard (`preCommitPiiGuard`) as its first, non-removable member:
6
+ //
7
+ // - there is no method to remove or replace the default guard, and
8
+ // - a freshly-constructed registry (the default the writer uses when a caller
9
+ // supplies no wiring at all) already contains it,
10
+ //
11
+ // so a caller cannot silently bypass PII enforcement. Additional guards can only
12
+ // be ADDED (making the policy stricter), never subtracted. The writer calls
13
+ // {@link PreCommitGuardRegistry.assertAll} immediately before it stages/commits
14
+ // any record, so a write carrying PII is rejected before it is ever committed.
15
+ import { preCommitPiiGuard } from "../pii/index.js";
16
+ /**
17
+ * An append-only registry of pre-commit guards. The mandatory S6 PII guard is
18
+ * pre-registered as the default and cannot be removed; callers may register
19
+ * ADDITIONAL guards, which run in addition to (never instead of) the default.
20
+ */
21
+ export class PreCommitGuardRegistry {
22
+ // Index 0 is ALWAYS the mandatory S6 PII guard. There is no removal API, so it
23
+ // is impossible to end up with a registry that does not enforce it.
24
+ #guards;
25
+ /**
26
+ * Build a registry. It is seeded with the mandatory {@link preCommitPiiGuard}
27
+ * first; any `additional` guards are appended after it. Even with no arguments
28
+ * the resulting registry enforces PII by construction.
29
+ */
30
+ constructor(additional = []) {
31
+ this.#guards = [preCommitPiiGuard, ...additional];
32
+ }
33
+ /** Register an ADDITIONAL guard. Cannot displace the mandatory default guard. */
34
+ register(guard) {
35
+ this.#guards.push(guard);
36
+ }
37
+ /** A snapshot of the registered guards, in run order (mandatory guard first). */
38
+ get guards() {
39
+ return [...this.#guards];
40
+ }
41
+ /**
42
+ * Run EVERY registered guard against `candidate`. The mandatory PII guard runs
43
+ * first. Each guard is default-DENY: the first guard to detect a violation
44
+ * throws (a {@link import("../pii/index.ts").PiiGuardError} for the PII guard),
45
+ * so a candidate carrying PII never reaches the commit that follows this call.
46
+ */
47
+ assertAll(candidate) {
48
+ for (const guard of this.#guards) {
49
+ guard.assert(candidate);
50
+ }
51
+ }
52
+ }
@@ -1 +1,4 @@
1
- export {};
1
+ export { LAYOUT_ROOT, RECORD_FILE_EXTENSION, UNSCOPED_BUCKET, isRecordPath, recordDir, recordRelativePath, sanitizeSegment, scopeDir, } from "./layout.ts";
2
+ export { PreCommitGuardRegistry } from "./guard-registry.ts";
3
+ export { type CommitAuthor, GitWriteSubstrate, SubstrateWriteError, substratePath, type WriteSubstrate, } from "./substrate.ts";
4
+ export { type AppendResult, ContextWriter, type ContextWriterOptions, DEFAULT_BOT_AUTHOR, DEFAULT_WRITE_AUTHOR, GovernanceError, type ProposalResult, type RatifyResult, type ResolvedSubstrateHandle, serialiseRecord, } from "./writer.ts";
@@ -1 +1,21 @@
1
- export {};
1
+ // @nanobpm/urban/context/git — slice S3 (git substrate + PR-governance).
2
+ //
3
+ // The git-as-system-of-record WRITE + governance layer, built on top of a
4
+ // resolved substrate handle (S1), the validated record schema (S2), and the
5
+ // mandatory PII pre-commit guard (S6). It publishes three things downstream:
6
+ //
7
+ // 1. APPEND / GOVERNANCE — ContextWriter: append-via-commit for
8
+ // human/measured/instance facts, and PR-governance (propose → ratify) for
9
+ // agent-retro hypotheses, with the mandatory PII guard wired into EVERY write
10
+ // path so PII is rejected before any commit, by construction.
11
+ // 2. PATH / NAMESPACE PARTITIONING — the layout helper (recordRelativePath,
12
+ // recordDir, scopeDir, isRecordPath, LAYOUT_ROOT). S4 retrieval and the S6 CI
13
+ // slice READ this exact layout, so it is exported as a stable seam.
14
+ // 3. THE WRITE SUBSTRATE SEAM — WriteSubstrate / GitWriteSubstrate: the write
15
+ // path is abstracted over the substrate (not hard-wired to public git),
16
+ // leaving room for a future PII/mutable backend, and the
17
+ // PreCommitGuardRegistry that makes the PII guard non-bypassable.
18
+ export { LAYOUT_ROOT, RECORD_FILE_EXTENSION, UNSCOPED_BUCKET, isRecordPath, recordDir, recordRelativePath, sanitizeSegment, scopeDir, } from "./layout.js";
19
+ export { PreCommitGuardRegistry } from "./guard-registry.js";
20
+ export { GitWriteSubstrate, SubstrateWriteError, substratePath, } from "./substrate.js";
21
+ export { ContextWriter, DEFAULT_BOT_AUTHOR, DEFAULT_WRITE_AUTHOR, GovernanceError, serialiseRecord, } from "./writer.js";
@@ -0,0 +1,46 @@
1
+ import type { MemoryRecord, MemoryScope } from "../schema/index.ts";
2
+ /** The directory (relative to the substrate root) all records live under. */
3
+ export declare const LAYOUT_ROOT: "records";
4
+ /** The on-disk extension every serialised record file carries. */
5
+ export declare const RECORD_FILE_EXTENSION: ".json";
6
+ /**
7
+ * The bucket directory used for records that carry no `scopeRef` (e.g. a
8
+ * `corpus`-scoped record). `sanitizeSegment` never returns exactly `"_"` — an
9
+ * input that would sanitise to `"_"` is treated as reserved and gets a `_<hash>`
10
+ * form instead — so a sanitised `scopeRef` can never collide with this bucket.
11
+ */
12
+ export declare const UNSCOPED_BUCKET: "_";
13
+ /**
14
+ * Reduce an untrusted string to a single filesystem-safe path segment.
15
+ *
16
+ * The result contains only `[A-Za-z0-9._-]`, never a path separator, never a
17
+ * `..` sequence, and is never `""`, `.`, `..`, or exactly `"_"` — so it can
18
+ * never traverse out of its parent directory nor collide with
19
+ * {@link UNSCOPED_BUCKET}. When sanitisation is LOSSY (the cleaned form differs
20
+ * from the input, or collapses to a reserved value) a short hash of the ORIGINAL
21
+ * value is appended, so two distinct raw values can never silently collide onto
22
+ * one segment while a clean value is preserved verbatim.
23
+ */
24
+ export declare function sanitizeSegment(value: string): string;
25
+ /** The directory (relative to the substrate root) that holds a whole scope. */
26
+ export declare function scopeDir(scope: MemoryScope): string;
27
+ /**
28
+ * The directory a record with the given `scope`/`scopeRef` is partitioned into.
29
+ * Records sharing a `(scope, scopeRef)` namespace land in the same directory, so
30
+ * a retrieval slice can list one namespace cheaply.
31
+ */
32
+ export declare function recordDir(scope: MemoryScope, scopeRef?: string): string;
33
+ /**
34
+ * The full path (relative to the substrate root, POSIX separators) a record is
35
+ * persisted at. Deterministic: the same record id/scope always maps to the same
36
+ * path, so re-appending an updated record with the same id overwrites its file
37
+ * in place while git retains the full append-only history.
38
+ */
39
+ export declare function recordRelativePath(record: MemoryRecord): string;
40
+ /**
41
+ * `true` iff `relPath` looks like a record file under the layout root (used by
42
+ * S4 retrieval and the S6 CI scan to select record files while walking the
43
+ * substrate). Accepts POSIX or native separators and rejects anything that is
44
+ * not a `.json` file beneath `records/`.
45
+ */
46
+ export declare function isRecordPath(relPath: string): boolean;
@@ -0,0 +1,96 @@
1
+ // Slice S3 (git/governance) — the on-disk path / namespace partitioning.
2
+ //
3
+ // Records are persisted into the substrate repo under a stable, deterministic
4
+ // layout keyed by the record's scope (and, where present, its `scopeRef`). This
5
+ // helper is the SINGLE SOURCE OF TRUTH for that layout: the write path (this
6
+ // slice) writes to it, and BOTH S4 (retrieval) and the S6 CI slice READ it, so
7
+ // it is exported as a small, stable surface rather than duplicated per-consumer.
8
+ //
9
+ // The scheme is:
10
+ //
11
+ // records/<scope>/<scopeRef-bucket>/<id>.json
12
+ //
13
+ // where `<scope>` is a controlled-vocabulary ladder level (element … corpus),
14
+ // `<scopeRef-bucket>` is the sanitised `scopeRef` (or the shared `_` bucket when
15
+ // a record carries no `scopeRef`), and `<id>` is the sanitised record id. Every
16
+ // path component is sanitised to a filesystem-safe segment that can never
17
+ // contain a path separator or `..`, so an untrusted `id`/`scopeRef` can never
18
+ // escape the layout root (path-traversal safe by construction).
19
+ import { createHash } from "node:crypto";
20
+ /** The directory (relative to the substrate root) all records live under. */
21
+ export const LAYOUT_ROOT = "records";
22
+ /** The on-disk extension every serialised record file carries. */
23
+ export const RECORD_FILE_EXTENSION = ".json";
24
+ /**
25
+ * The bucket directory used for records that carry no `scopeRef` (e.g. a
26
+ * `corpus`-scoped record). `sanitizeSegment` never returns exactly `"_"` — an
27
+ * input that would sanitise to `"_"` is treated as reserved and gets a `_<hash>`
28
+ * form instead — so a sanitised `scopeRef` can never collide with this bucket.
29
+ */
30
+ export const UNSCOPED_BUCKET = "_";
31
+ /**
32
+ * Reduce an untrusted string to a single filesystem-safe path segment.
33
+ *
34
+ * The result contains only `[A-Za-z0-9._-]`, never a path separator, never a
35
+ * `..` sequence, and is never `""`, `.`, `..`, or exactly `"_"` — so it can
36
+ * never traverse out of its parent directory nor collide with
37
+ * {@link UNSCOPED_BUCKET}. When sanitisation is LOSSY (the cleaned form differs
38
+ * from the input, or collapses to a reserved value) a short hash of the ORIGINAL
39
+ * value is appended, so two distinct raw values can never silently collide onto
40
+ * one segment while a clean value is preserved verbatim.
41
+ */
42
+ export function sanitizeSegment(value) {
43
+ const cleaned = value
44
+ .trim()
45
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
46
+ .replace(/\.{2,}/g, ".")
47
+ .replace(/^[-.]+|[-.]+$/g, "")
48
+ .slice(0, 100);
49
+ const reserved = cleaned === "" ||
50
+ cleaned === "." ||
51
+ cleaned === ".." ||
52
+ cleaned === UNSCOPED_BUCKET;
53
+ if (reserved) {
54
+ return `_${shortHash(value)}`;
55
+ }
56
+ // Preserve a clean, reversible value verbatim; disambiguate a lossy one so
57
+ // distinct inputs never map to the same segment.
58
+ return cleaned === value ? cleaned : `${cleaned}-${shortHash(value)}`;
59
+ }
60
+ function shortHash(value) {
61
+ return createHash("sha256").update(value, "utf8").digest("hex").slice(0, 12);
62
+ }
63
+ /** The directory (relative to the substrate root) that holds a whole scope. */
64
+ export function scopeDir(scope) {
65
+ return `${LAYOUT_ROOT}/${scope}`;
66
+ }
67
+ /**
68
+ * The directory a record with the given `scope`/`scopeRef` is partitioned into.
69
+ * Records sharing a `(scope, scopeRef)` namespace land in the same directory, so
70
+ * a retrieval slice can list one namespace cheaply.
71
+ */
72
+ export function recordDir(scope, scopeRef) {
73
+ const bucket = scopeRef === undefined ? UNSCOPED_BUCKET : sanitizeSegment(scopeRef);
74
+ return `${scopeDir(scope)}/${bucket}`;
75
+ }
76
+ /**
77
+ * The full path (relative to the substrate root, POSIX separators) a record is
78
+ * persisted at. Deterministic: the same record id/scope always maps to the same
79
+ * path, so re-appending an updated record with the same id overwrites its file
80
+ * in place while git retains the full append-only history.
81
+ */
82
+ export function recordRelativePath(record) {
83
+ return `${recordDir(record.scope, record.scopeRef)}/${sanitizeSegment(record.id)}${RECORD_FILE_EXTENSION}`;
84
+ }
85
+ /**
86
+ * `true` iff `relPath` looks like a record file under the layout root (used by
87
+ * S4 retrieval and the S6 CI scan to select record files while walking the
88
+ * substrate). Accepts POSIX or native separators and rejects anything that is
89
+ * not a `.json` file beneath `records/`.
90
+ */
91
+ export function isRecordPath(relPath) {
92
+ const normalised = relPath.replace(/\\/g, "/").replace(/^\.\//, "");
93
+ return (normalised.startsWith(`${LAYOUT_ROOT}/`) &&
94
+ normalised.endsWith(RECORD_FILE_EXTENSION) &&
95
+ !normalised.includes(".."));
96
+ }
@@ -0,0 +1,95 @@
1
+ import { type GitRunner } from "../binding/index.ts";
2
+ /** Error thrown when a substrate write/governance git operation fails. */
3
+ export declare class SubstrateWriteError extends Error {
4
+ constructor(message: string, options?: {
5
+ cause?: unknown;
6
+ });
7
+ }
8
+ /** The identity a commit/merge is attributed to. */
9
+ export interface CommitAuthor {
10
+ readonly name: string;
11
+ readonly email: string;
12
+ }
13
+ /**
14
+ * The write seam the governance layer composes. A future mutable/PII backend can
15
+ * implement this interface differently (e.g. against a database with real
16
+ * erasure) without the writer or its callers changing. Every method operates on
17
+ * a single logical substrate working copy rooted at {@link rootPath}.
18
+ */
19
+ export interface WriteSubstrate {
20
+ /** Absolute path to the substrate working copy. */
21
+ readonly rootPath: string;
22
+ /** Write `content` to `relPath` (relative to {@link rootPath}), creating dirs. */
23
+ writeRecordFile(relPath: string, content: string): Promise<void>;
24
+ /** Read the file at `relPath` as it exists on `ref`, without touching the tree. */
25
+ readFileAtRef(ref: string, relPath: string): Promise<string>;
26
+ /**
27
+ * The substrate-relative paths changed on `branch` relative to its merge-base
28
+ * with `baseRef` — i.e. exactly the set of files a `baseRef`-into-`branch` merge
29
+ * would introduce/modify. Lets a caller assert a merge is limited to expected
30
+ * files before performing it.
31
+ */
32
+ diffPaths(baseRef: string, branch: string): Promise<readonly string[]>;
33
+ /** The name of the currently checked-out branch. */
34
+ currentBranch(): Promise<string>;
35
+ /** Create (or reset) branch `name` at `from` and check it out. */
36
+ createBranch(name: string, from: string): Promise<void>;
37
+ /** Check out an existing branch/ref. */
38
+ checkout(ref: string): Promise<void>;
39
+ /**
40
+ * Recover the shared working copy to a pristine checkout of `ref`: force-checks
41
+ * out `ref` — moving HEAD and discarding ALL tracked modifications across the
42
+ * whole working copy, not only those under `pathspec` — then removes untracked
43
+ * files/dirs UNDER `pathspec`, leaving untracked files OUTSIDE `pathspec`
44
+ * untouched. The broad tracked-file reset is intended: the substrate working
45
+ * copy is a single-writer, serialised recovery seam, so callers must not keep
46
+ * unrelated tracked edits in it across a write that might fail.
47
+ */
48
+ restoreClean(ref: string, pathspec: string): Promise<void>;
49
+ /**
50
+ * Stage the changes UNDER `pathspec` (only) and commit them as `author`;
51
+ * returns the new commit sha. Staging is deliberately scoped to the caller's
52
+ * record path(s) — never a blanket `git add -A` — so an unrelated change
53
+ * elsewhere in the shared working copy can never be swept into a commit that
54
+ * the per-record PII guard only inspected the record content for.
55
+ */
56
+ stageAndCommit(pathspec: string, message: string, author: CommitAuthor): Promise<string>;
57
+ /** Merge `branch` into the current branch as `author`; returns the merge sha. */
58
+ mergeBranch(branch: string, message: string, author: CommitAuthor): Promise<string>;
59
+ /** `true` iff a local branch named `name` exists. */
60
+ branchExists(name: string): Promise<boolean>;
61
+ /**
62
+ * `true` iff `commitish` is an ancestor of (or equal to) `ref` — i.e. landed
63
+ * on it. Both operands are git commit-ish inputs: a raw sha, a branch name, or
64
+ * any other ref resolvable to a commit. `ContextWriter.isRatified` deliberately
65
+ * passes a branch ref (the proposal branch tip) here to bind the check to the
66
+ * real tip rather than a caller-supplied sha, so implementations MUST resolve
67
+ * `commitish` as a general ref, not assume a sha-only input.
68
+ */
69
+ isMerged(commitish: string, ref: string): Promise<boolean>;
70
+ }
71
+ /**
72
+ * The default git-only write substrate. Composes commit/branch/merge primitives
73
+ * over a local working copy. Commit author identity is passed per-commit via
74
+ * `-c user.name=… -c user.email=…`, so a bare temp repo with no configured user
75
+ * still commits cleanly (and every write path can attribute a bot vs. human
76
+ * author independently).
77
+ */
78
+ export declare class GitWriteSubstrate implements WriteSubstrate {
79
+ #private;
80
+ readonly rootPath: string;
81
+ constructor(rootPath: string, gitRunner?: GitRunner);
82
+ writeRecordFile(relPath: string, content: string): Promise<void>;
83
+ readFileAtRef(ref: string, relPath: string): Promise<string>;
84
+ diffPaths(baseRef: string, branch: string): Promise<readonly string[]>;
85
+ currentBranch(): Promise<string>;
86
+ createBranch(name: string, from: string): Promise<void>;
87
+ checkout(ref: string): Promise<void>;
88
+ restoreClean(ref: string, pathspec: string): Promise<void>;
89
+ stageAndCommit(pathspec: string, message: string, author: CommitAuthor): Promise<string>;
90
+ mergeBranch(branch: string, message: string, author: CommitAuthor): Promise<string>;
91
+ branchExists(name: string): Promise<boolean>;
92
+ isMerged(commitish: string, ref: string): Promise<boolean>;
93
+ }
94
+ /** Join a substrate-relative path onto an absolute root (POSIX-in, native-out). */
95
+ export declare function substratePath(root: string, relPath: string): string;
@@ -0,0 +1,190 @@
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
+ import { execFile } from "node:child_process";
11
+ import { mkdir, writeFile } from "node:fs/promises";
12
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
13
+ import { promisify } from "node:util";
14
+ import { hardenedGitArgs, redactUrlUserinfo } from "../binding/index.js";
15
+ const execFileAsync = promisify(execFile);
16
+ /** Error thrown when a substrate write/governance git operation fails. */
17
+ export class SubstrateWriteError extends Error {
18
+ constructor(message, options) {
19
+ super(message, options);
20
+ this.name = "SubstrateWriteError";
21
+ }
22
+ }
23
+ const defaultWriteGitRunner = async (args, cwd) => {
24
+ const hardened = hardenedGitArgs(args);
25
+ try {
26
+ const { stdout } = await execFileAsync("git", hardened, {
27
+ cwd,
28
+ maxBuffer: 64 * 1024 * 1024,
29
+ });
30
+ return stdout;
31
+ }
32
+ catch (cause) {
33
+ const label = `git ${hardened.map(redactUrlUserinfo).join(" ")}`;
34
+ throw new SubstrateWriteError(`substrate git command failed: ${label}`, { cause });
35
+ }
36
+ };
37
+ /**
38
+ * The default git-only write substrate. Composes commit/branch/merge primitives
39
+ * over a local working copy. Commit author identity is passed per-commit via
40
+ * `-c user.name=… -c user.email=…`, so a bare temp repo with no configured user
41
+ * still commits cleanly (and every write path can attribute a bot vs. human
42
+ * author independently).
43
+ */
44
+ export class GitWriteSubstrate {
45
+ rootPath;
46
+ #git;
47
+ constructor(rootPath, gitRunner = defaultWriteGitRunner) {
48
+ if (!isAbsolute(rootPath)) {
49
+ throw new SubstrateWriteError(`substrate rootPath must be absolute: ${rootPath}`);
50
+ }
51
+ this.rootPath = rootPath;
52
+ this.#git = gitRunner;
53
+ }
54
+ async writeRecordFile(relPath, content) {
55
+ const target = this.#resolveWithinRoot(relPath);
56
+ await mkdir(dirname(target), { recursive: true });
57
+ await writeFile(target, content, "utf8");
58
+ }
59
+ async readFileAtRef(ref, relPath) {
60
+ // Read the blob at `ref:relPath` via `git show` — a pure read that never
61
+ // mutates the working tree (no checkout), so a failed read/guard cannot leave
62
+ // the shared working copy on the wrong branch. `#resolveWithinRoot` still
63
+ // rejects any traversal path before it reaches git.
64
+ this.#resolveWithinRoot(relPath);
65
+ return this.#git(["show", "--end-of-options", `${ref}:${relPath}`], this.rootPath);
66
+ }
67
+ async diffPaths(baseRef, branch) {
68
+ // `base...branch` (symmetric-difference range) lists files changed on `branch`
69
+ // since it forked from `baseRef` — precisely what a merge of `branch` would
70
+ // bring onto `baseRef`. `-z` NUL-delimits paths so unusual names never split
71
+ // wrong; `--end-of-options` stops a ref that looks like a flag being parsed.
72
+ const out = await this.#git(["diff", "--name-only", "-z", "--end-of-options", `${baseRef}...${branch}`], this.rootPath);
73
+ return out.split("\0").filter((p) => p.length > 0);
74
+ }
75
+ // Resolve a caller-supplied substrate-relative path to an absolute path,
76
+ // refusing anything that escapes the substrate root. Defence in depth: the
77
+ // layout helper already produces traversal-safe paths, but no read/write may
78
+ // ever leave the substrate root regardless of the relPath handed in.
79
+ #resolveWithinRoot(relPath) {
80
+ const target = resolve(this.rootPath, relPath);
81
+ const rel = relative(this.rootPath, target);
82
+ if (rel === "" || rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) {
83
+ throw new SubstrateWriteError(`record path escapes the substrate root: ${relPath}`);
84
+ }
85
+ return target;
86
+ }
87
+ async currentBranch() {
88
+ const out = await this.#git(["rev-parse", "--abbrev-ref", "HEAD"], this.rootPath);
89
+ return out.trim();
90
+ }
91
+ async createBranch(name, from) {
92
+ // `checkout -B` creates or resets the branch to `from` and checks it out.
93
+ // `--end-of-options` stops an option-style ref from being parsed as a flag.
94
+ await this.#git(["checkout", "-B", name, "--end-of-options", from], this.rootPath);
95
+ }
96
+ async checkout(ref) {
97
+ await this.#git(["checkout", "--end-of-options", ref], this.rootPath);
98
+ }
99
+ async restoreClean(ref, pathspec) {
100
+ // `checkout --force <ref>` moves HEAD to `ref` and discards modifications to ALL
101
+ // tracked files across the working copy (not just under `pathspec`) — the full
102
+ // reset a post-failure recovery of this single-writer working copy wants. `clean
103
+ // -fd` then removes leftover UNTRACKED files/dirs, scoped to `pathspec` (the
104
+ // record layout root) so it never touches untracked files elsewhere. `--` ends
105
+ // option parsing before the pathspec so a path that looks like a flag is never
106
+ // misread as one.
107
+ await this.#git(["checkout", "--force", "--end-of-options", ref], this.rootPath);
108
+ await this.#git(["clean", "-fd", "--", pathspec], this.rootPath);
109
+ }
110
+ async stageAndCommit(pathspec, message, author) {
111
+ // Stage ONLY the caller's record path(s) — `add -A -- <pathspec>` — never a
112
+ // blanket `git add -A`. A blanket stage would sweep ANY unrelated tracked or
113
+ // untracked change in the shared single-writer working copy into this commit,
114
+ // bypassing the per-record PII guard that inspected the record content alone
115
+ // (and risking committing stray residue outside the record layout). `-A` still
116
+ // captures adds/modifies/deletes, but confined to `pathspec`; `--` ends option
117
+ // parsing so a path that looks like a flag is never misread.
118
+ await this.#git(["add", "-A", "--", pathspec], this.rootPath);
119
+ await this.#git([
120
+ "-c",
121
+ `user.name=${author.name}`,
122
+ "-c",
123
+ `user.email=${author.email}`,
124
+ "commit",
125
+ "--no-gpg-sign",
126
+ "--allow-empty",
127
+ "-m",
128
+ message,
129
+ ], this.rootPath);
130
+ return this.#revParse("HEAD");
131
+ }
132
+ async mergeBranch(branch, message, author) {
133
+ // `--no-ff` always records a merge commit, so ratification (a merge) is a
134
+ // distinct, auditable event even when the branch could fast-forward.
135
+ try {
136
+ await this.#git([
137
+ "-c",
138
+ `user.name=${author.name}`,
139
+ "-c",
140
+ `user.email=${author.email}`,
141
+ "merge",
142
+ "--no-ff",
143
+ "--no-gpg-sign",
144
+ "-m",
145
+ message,
146
+ "--end-of-options",
147
+ branch,
148
+ ], this.rootPath);
149
+ }
150
+ catch (cause) {
151
+ // A failed merge (e.g. a content conflict) leaves the working tree in a
152
+ // conflicted MERGE_HEAD state. Abort it best-effort so the shared working
153
+ // copy is clean for the next serialised operation, then surface the cause.
154
+ await this.#git(["merge", "--abort"], this.rootPath).catch(() => { });
155
+ throw cause;
156
+ }
157
+ return this.#revParse("HEAD");
158
+ }
159
+ async branchExists(name) {
160
+ try {
161
+ await this.#git(["show-ref", "--verify", "--quiet", `refs/heads/${name}`], this.rootPath);
162
+ return true;
163
+ }
164
+ catch {
165
+ return false;
166
+ }
167
+ }
168
+ async isMerged(commitish, ref) {
169
+ try {
170
+ // `--end-of-options` (as every other ref-passing call in this file) stops an
171
+ // option-style `commitish`/`ref` (e.g. a caller-supplied baseBranch override)
172
+ // from being parsed as a `git merge-base` flag rather than a revision.
173
+ await this.#git(["merge-base", "--is-ancestor", "--end-of-options", commitish, ref], this.rootPath);
174
+ return true;
175
+ }
176
+ catch {
177
+ return false;
178
+ }
179
+ }
180
+ async #revParse(ref) {
181
+ // `git rev-parse` echoes `--end-of-options` back as output, so it is omitted
182
+ // here; `ref` on this path is always an internally-produced value (`HEAD`).
183
+ const out = await this.#git(["rev-parse", ref], this.rootPath);
184
+ return out.trim();
185
+ }
186
+ }
187
+ /** Join a substrate-relative path onto an absolute root (POSIX-in, native-out). */
188
+ export function substratePath(root, relPath) {
189
+ return join(root, relPath);
190
+ }