@opum-ai/lore 0.1.0 → 0.1.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/README.md +33 -22
- package/bin/lore.cjs +35 -7
- package/package.json +17 -17
- package/src/adapters/backlog.ts +0 -1084
- package/src/adapters/git.ts +0 -221
- package/src/cli.ts +0 -667
- package/src/commands/agent.ts +0 -301
- package/src/commands/agents.ts +0 -302
- package/src/commands/args.ts +0 -209
- package/src/commands/changed.ts +0 -70
- package/src/commands/check.ts +0 -1031
- package/src/commands/codex-bridge.ts +0 -49
- package/src/commands/concurrency.ts +0 -48
- package/src/commands/context.ts +0 -292
- package/src/commands/discover.ts +0 -89
- package/src/commands/explorer.ts +0 -253
- package/src/commands/export.ts +0 -93
- package/src/commands/fswrite.ts +0 -928
- package/src/commands/graph.ts +0 -291
- package/src/commands/help.ts +0 -151
- package/src/commands/impact.ts +0 -59
- package/src/commands/init.ts +0 -583
- package/src/commands/instructions.ts +0 -91
- package/src/commands/link.ts +0 -929
- package/src/commands/new.ts +0 -476
- package/src/commands/orphans.ts +0 -457
- package/src/commands/path.ts +0 -67
- package/src/commands/provenance.ts +0 -68
- package/src/commands/query.ts +0 -312
- package/src/commands/reconcile-shared.ts +0 -280
- package/src/commands/rename.ts +0 -585
- package/src/commands/replace.ts +0 -320
- package/src/commands/scaffold.ts +0 -346
- package/src/commands/schema.ts +0 -293
- package/src/commands/snapshot.ts +0 -130
- package/src/commands/supersede.ts +0 -400
- package/src/commands/sync.ts +0 -371
- package/src/commands/tasks.ts +0 -271
- package/src/commands/traversal.ts +0 -151
- package/src/commands/validate.ts +0 -226
- package/src/config.ts +0 -598
- package/src/core/agent-bridge.ts +0 -287
- package/src/core/agent-context.ts +0 -498
- package/src/core/agent-profile.ts +0 -447
- package/src/core/bundle.ts +0 -893
- package/src/core/check.ts +0 -853
- package/src/core/codex-bridge.ts +0 -100
- package/src/core/concept.ts +0 -597
- package/src/core/consumer-scaffold.ts +0 -433
- package/src/core/context.ts +0 -271
- package/src/core/explorer-contract.ts +0 -441
- package/src/core/explorer-qualification.ts +0 -58
- package/src/core/explorer.ts +0 -518
- package/src/core/finding.ts +0 -31
- package/src/core/graph.ts +0 -201
- package/src/core/indexes.ts +0 -436
- package/src/core/instructions.ts +0 -209
- package/src/core/ladybug-driver.ts +0 -1795
- package/src/core/ladybug-lifecycle.ts +0 -1178
- package/src/core/ladybug-native.ts +0 -95
- package/src/core/ladybug-source.ts +0 -667
- package/src/core/links.ts +0 -681
- package/src/core/log.ts +0 -253
- package/src/core/managed-block.ts +0 -540
- package/src/core/manifest.ts +0 -718
- package/src/core/order.ts +0 -13
- package/src/core/profile.ts +0 -1007
- package/src/core/projection.ts +0 -195
- package/src/core/query.ts +0 -542
- package/src/core/reconcile.ts +0 -236
- package/src/core/replace.ts +0 -419
- package/src/core/retrieval.ts +0 -213
- package/src/core/rewrite.ts +0 -940
- package/src/core/scaffold.ts +0 -255
- package/src/core/schema.ts +0 -366
- package/src/core/snapshot-runtime.ts +0 -52
- package/src/core/snapshot-store.ts +0 -287
- package/src/core/snapshot.ts +0 -711
- package/src/core/template.ts +0 -429
- package/src/core/traversal.ts +0 -487
- package/src/core/validate.ts +0 -517
- package/src/core/workspace-contract.ts +0 -473
- package/src/core/workspace-projection.ts +0 -365
- package/src/core/workspace-retrieval.ts +0 -196
- package/src/core/workspace-source.ts +0 -174
- package/src/errors.ts +0 -697
- package/src/meta.ts +0 -7
- package/src/output.ts +0 -589
- package/src/scripts/upstream-backlog-watch.ts +0 -288
- package/src/state.ts +0 -390
package/src/state.ts
DELETED
|
@@ -1,390 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* state.ts — `.lore/` and git ownership of `backlog/` (design spec §2.4, LORE-26).
|
|
3
|
-
*
|
|
4
|
-
* lore is the **sole committer** of `backlog/` (ADR-0012): Backlog.md itself never runs `git
|
|
5
|
-
* add`/`git commit` (`auto_commit: false`), so whichever `lore` command wrote to a task through the
|
|
6
|
-
* Backlog CLI (`task create`/`task edit`) has to be committed by `lore` itself. Two shapes:
|
|
7
|
-
* {@link commitBacklogFiles} is the **per-write** commit — `link`/`unlink`/`rename` commit exactly the
|
|
8
|
-
* task file(s) they just edited, immediately, staging **only** those paths (ADR-0012 §1: never sweep an
|
|
9
|
-
* unrelated in-flight edit into the commit). {@link commitBacklogIfDirty} is the **catch-all** sweep —
|
|
10
|
-
* `lore sync` (whose own writes are entirely to `docs/`) commits whatever is *still* uncommitted under
|
|
11
|
-
* `backlog/` (a human's direct `backlog task edit`, or anything a prior command missed), leaving
|
|
12
|
-
* anything outside `backlog/` untouched. Both are one `lore`-authored commit.
|
|
13
|
-
*
|
|
14
|
-
* This is the **fourth** injectable determinism seam (after the clock, the Backlog subprocess, and
|
|
15
|
-
* the git-history `GitAdapter` in `core/log.ts`; lore-design §8): {@link GitSpawn} mirrors
|
|
16
|
-
* `adapters/backlog.ts`'s `BacklogSpawn` pattern exactly — the real implementation shells `git`, and
|
|
17
|
-
* tests inject a fake that returns canned output instead of driving a real subprocess.
|
|
18
|
-
*
|
|
19
|
-
* **Scope (LORE-26).** The design spec's §2.4 envisions `state.ts` more broadly — also reading
|
|
20
|
-
* `.lore/config.toml`/`cache/`/`schemas/`/`templates/`. This file ships only the git-ownership half:
|
|
21
|
-
* `commands/sync.ts` calls `config.ts`'s `loadConfig` directly rather than through this module, since
|
|
22
|
-
* config-loading has nothing to do with git. Widening this file's scope, if ever warranted, is a
|
|
23
|
-
* separate concern from the git-write seam it owns today.
|
|
24
|
-
*
|
|
25
|
-
* Per lore-design §2.1 this module is impure command-layer wiring (like `config.ts`), not `core/`:
|
|
26
|
-
* it exists specifically to shell `git`.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
import { posix } from "node:path";
|
|
30
|
-
import { escapesRoot } from "./core/rewrite";
|
|
31
|
-
import { LoreError, stderrHint } from "./errors";
|
|
32
|
-
|
|
33
|
-
/** The result of one `git` invocation, mirroring `adapters/backlog.ts`'s `SpawnResult` shape. */
|
|
34
|
-
export interface GitSpawnResult {
|
|
35
|
-
/** The process exit code (`0` on success). */
|
|
36
|
-
readonly exitCode: number;
|
|
37
|
-
/** Everything the process wrote to stdout. */
|
|
38
|
-
readonly stdout: string;
|
|
39
|
-
/** Everything the process wrote to stderr (human diagnostics; never parsed as data). */
|
|
40
|
-
readonly stderr: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* The injectable git-write seam. `args` are the arguments after `git` (e.g.
|
|
45
|
-
* `["status", "--porcelain", "--", "backlog/"]`); the binary and `cwd` are bound inside the real
|
|
46
|
-
* implementation. Mirrors `adapters/backlog.ts`'s `BacklogSpawn` exactly, including its "rejects
|
|
47
|
-
* only on a failed spawn, resolves with a non-zero `exitCode` on a failed run" contract.
|
|
48
|
-
*/
|
|
49
|
-
export type GitSpawn = (args: readonly string[]) => Promise<GitSpawnResult>;
|
|
50
|
-
|
|
51
|
-
/** The real {@link GitSpawn}: shells `git` via `Bun.spawn`, scoped to `cwd`. */
|
|
52
|
-
export function bunGitSpawn(cwd: string): GitSpawn {
|
|
53
|
-
return async (args: readonly string[]): Promise<GitSpawnResult> => {
|
|
54
|
-
const proc = Bun.spawn(["git", ...args], { stdout: "pipe", stderr: "pipe", cwd });
|
|
55
|
-
const [stdout, stderr, exitCode] = await Promise.all([
|
|
56
|
-
new Response(proc.stdout).text(),
|
|
57
|
-
new Response(proc.stderr).text(),
|
|
58
|
-
proc.exited,
|
|
59
|
-
]);
|
|
60
|
-
return { exitCode, stdout, stderr };
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** The outcome of {@link commitBacklogIfDirty} / {@link commitBacklogFiles}. */
|
|
65
|
-
export interface BacklogCommitResult {
|
|
66
|
-
/** Whether a commit was made (`false` when there was nothing to commit, or a per-write commit failed). */
|
|
67
|
-
readonly committed: boolean;
|
|
68
|
-
/** The repo-relative paths committed, empty when {@link committed} is `false`. */
|
|
69
|
-
readonly files: readonly string[];
|
|
70
|
-
/**
|
|
71
|
-
* Set only by {@link commitBacklogFiles} when a per-write commit was *attempted but failed* (a
|
|
72
|
-
* `drift` git error, e.g. a rejected pre-commit hook): the failure message, captured rather than
|
|
73
|
-
* thrown so the calling command can still emit its per-task report (naming every write it made)
|
|
74
|
-
* before it exits `drift`. Never set on a success or a nothing-to-commit no-op, and never set by
|
|
75
|
-
* {@link commitBacklogIfDirty} (`sync`'s catch-all sweep still throws on a git failure).
|
|
76
|
-
*/
|
|
77
|
-
readonly error?: string;
|
|
78
|
-
/**
|
|
79
|
-
* Set alongside {@link error} when the underlying `drift` {@link LoreError} carried a `hint` — the
|
|
80
|
-
* actual git/hook stderr reason `run()` captured via {@link stderrHint} (e.g. a rejected pre-commit
|
|
81
|
-
* hook's real output), or its generic fallback when the failing invocation produced no stderr.
|
|
82
|
-
* {@link renderBacklogCommitLine} folds this into its one output line so a caller sees the real
|
|
83
|
-
* cause rather than only a bare "`git commit` exited N". Never set without {@link error}.
|
|
84
|
-
*/
|
|
85
|
-
readonly hint?: string;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Where Backlog.md's own working state lives, relative to the repo root — the one directory lore commits on its behalf. */
|
|
89
|
-
const BACKLOG_DIR = "backlog/";
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Wrap a path in git's `:(literal)` pathspec magic so git matches it byte-for-byte: a filename
|
|
93
|
-
* containing a wildcard (`*`, `?`, `[`…) then matches ONLY itself, never a glob-expanded unrelated
|
|
94
|
-
* sibling. Without this the "scoped" commit could sweep in an unrelated `backlog/` file whose name
|
|
95
|
-
* happens to satisfy the glob — defeating the very ADR-0012 §1 scoping guarantee. A plain directory
|
|
96
|
-
* pathspec (`backlog/`) still recurses into everything under it: `:(literal)` disables only wildcard
|
|
97
|
-
* interpretation, not directory-prefix matching (verified against real git).
|
|
98
|
-
*/
|
|
99
|
-
function literalPathspec(path: string): string {
|
|
100
|
-
return `:(literal)${path}`;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* The single line a command prints for its `backlog/`-commit outcome: `committed backlog/: N files`
|
|
105
|
-
* on success, `backlog/ commit failed: … (hint)` when {@link BacklogCommitResult.error} is set (the
|
|
106
|
-
* trailing `(hint)` present only when {@link BacklogCommitResult.hint} is too — the real git/hook
|
|
107
|
-
* stderr reason, not just the generic "exited N" message), or `undefined` when nothing was
|
|
108
|
-
* committed and nothing failed (a no-op the report omits entirely). Shared by `link`/`unlink`/
|
|
109
|
-
* `rename`/`sync` so the one line they all emit for the same event — its label, count, and
|
|
110
|
-
* pluralization — stays byte-identical in exactly one place.
|
|
111
|
-
*/
|
|
112
|
-
export function renderBacklogCommitLine(commit: BacklogCommitResult): string | undefined {
|
|
113
|
-
if (commit.error !== undefined) {
|
|
114
|
-
const hintSuffix = commit.hint !== undefined ? ` (${commit.hint})` : "";
|
|
115
|
-
return `backlog/ commit failed: ${commit.error}${hintSuffix}`;
|
|
116
|
-
}
|
|
117
|
-
if (commit.committed) {
|
|
118
|
-
const noun = commit.files.length === 1 ? "file" : "files";
|
|
119
|
-
return `committed backlog/: ${commit.files.length} ${noun}`;
|
|
120
|
-
}
|
|
121
|
-
return undefined;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* If `backlog/` has any uncommitted changes (tracked modifications, or new/untracked files),
|
|
126
|
-
* stage exactly those paths and commit them in one `lore`-authored commit; otherwise a no-op.
|
|
127
|
-
*
|
|
128
|
-
* This is the concrete mechanism behind ADR-0012's "lore is the sole committer of `backlog/`": the
|
|
129
|
-
* write itself (a Backlog CLI `task create`/`edit`) already happened elsewhere (`link`, `unlink`,
|
|
130
|
-
* `rename`, or a human editing directly); this function only ever discovers and commits whatever is
|
|
131
|
-
* sitting dirty under `backlog/` at the moment it runs. Nothing outside `backlog/` is staged, so an
|
|
132
|
-
* in-flight, not-yet-committed change elsewhere in the tree (e.g. `sync`'s own `docs/` writes) is
|
|
133
|
-
* never swept in.
|
|
134
|
-
*
|
|
135
|
-
* @throws LoreError `drift` (exit 6) when `git status`, `git add`, or `git commit` fails — surfaced
|
|
136
|
-
* clearly rather than left silent (ADR-0012's own documented tradeoff: a `lore` write can succeed
|
|
137
|
-
* while its commit fails, e.g. a pre-commit hook rejection).
|
|
138
|
-
*/
|
|
139
|
-
export async function commitBacklogIfDirty(
|
|
140
|
-
spawn: GitSpawn,
|
|
141
|
-
message: string = DEFAULT_COMMIT_MESSAGE,
|
|
142
|
-
pathspecs: readonly string[] = [BACKLOG_DIR],
|
|
143
|
-
): Promise<BacklogCommitResult> {
|
|
144
|
-
// An empty pathspec set means "commit nothing" — never a bare `git status` (which would report the
|
|
145
|
-
// WHOLE repo, not just backlog/). A caller with no files to commit ({@link commitBacklogFiles}) is
|
|
146
|
-
// a no-op here rather than a repo-wide sweep.
|
|
147
|
-
if (pathspecs.length === 0) {
|
|
148
|
-
return { committed: false, files: [] };
|
|
149
|
-
}
|
|
150
|
-
const { addPaths, allPaths } = await porcelainPaths(spawn, pathspecs);
|
|
151
|
-
if (allPaths.length === 0) {
|
|
152
|
-
return { committed: false, files: [] };
|
|
153
|
-
}
|
|
154
|
-
// addPaths is never empty here: every entry porcelainPaths parses contributes at least one path to
|
|
155
|
-
// BOTH addPaths and allPaths (a rename/copy's old path is the only thing added to allPaths alone),
|
|
156
|
-
// so allPaths being non-empty (checked above) guarantees addPaths is too.
|
|
157
|
-
await run(spawn, ["add", "--", ...addPaths.map(literalPathspec)], "git add");
|
|
158
|
-
// Scoped with every touched path (including a staged rename/copy's old path, never passed to
|
|
159
|
-
// `add` — see porcelainPaths): `git commit -- <paths>` commits ONLY those paths' content, leaving
|
|
160
|
-
// any OTHER already-staged change (e.g. in-progress work a developer staged separately) untouched
|
|
161
|
-
// in the index rather than swept into lore's commit — a bare, unscoped `git commit` would instead
|
|
162
|
-
// commit the entire index.
|
|
163
|
-
await run(spawn, ["commit", "-m", message, "--", ...allPaths.map(literalPathspec)], "git commit");
|
|
164
|
-
return { committed: true, files: allPaths };
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** The default commit message when the caller does not supply one. */
|
|
168
|
-
const DEFAULT_COMMIT_MESSAGE = "chore(backlog): sync task changes";
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Commit exactly the `backlog/` `files` a `lore` command just wrote (each repo-relative, e.g.
|
|
172
|
-
* `backlog/tasks/lore-1 - x.md`), in one `lore`-authored commit — the **per-write** commit
|
|
173
|
-
* `link`/`unlink`/`rename` use so their `doc:<conceptId>` back-reference edit is committed
|
|
174
|
-
* immediately rather than left for the next `lore sync`. Unlike {@link commitBacklogIfDirty}'s
|
|
175
|
-
* bundle-wide `backlog/` sweep (which `sync` uses as the catch-all), this stages **only** the given
|
|
176
|
-
* files' pathspecs (each `:(literal)`-quoted so a wildcard in a filename cannot glob-match a
|
|
177
|
-
* sibling), so an unrelated uncommitted edit to a **different** `backlog/` file is never swept in —
|
|
178
|
-
* [ADR-0012](../docs/adr/0012-backlog-coexistence-git-ownership.md) §1's "stage only the specific
|
|
179
|
-
* task file(s) it intended to change".
|
|
180
|
-
*
|
|
181
|
-
* Isolation is at **file** granularity — all a `git commit -- <pathspec>` can give. An unrelated,
|
|
182
|
-
* uncommitted edit already sitting in the working tree of one of the *same* files being committed is
|
|
183
|
-
* part of that file's content and IS included (the only way to hit this is a developer hand-editing
|
|
184
|
-
* the very task file lore is about to write). Every path must be under `backlog/`: lore is the sole
|
|
185
|
-
* committer of `backlog/` and of nothing else (ADR-0012), so a path escaping it is a caller bug and
|
|
186
|
-
* throws rather than committing outside lore's domain.
|
|
187
|
-
*
|
|
188
|
-
* An empty `files` (the command wrote nothing — `--no-back-ref`, an idempotent no-op, a read-phase
|
|
189
|
-
* failure) no-ops inside {@link commitBacklogIfDirty}'s own empty-pathspec guard, never reaching
|
|
190
|
-
* `git`. `gitSpawn` defaults to the real `git` binary scoped to `root`; injected in tests. A failed
|
|
191
|
-
* `git add`/`commit` (a `drift` git error, e.g. a rejected pre-commit hook) is captured into the
|
|
192
|
-
* result's {@link BacklogCommitResult.error} (with its {@link LoreError.hint} riding along in
|
|
193
|
-
* {@link BacklogCommitResult.hint}) rather than thrown, so the caller can still emit its per-task
|
|
194
|
-
* report before it exits `drift`; any non-`drift` error still propagates.
|
|
195
|
-
*/
|
|
196
|
-
export async function commitBacklogFiles(
|
|
197
|
-
files: readonly string[],
|
|
198
|
-
opts: { readonly root: string; readonly gitSpawn?: GitSpawn },
|
|
199
|
-
message: string,
|
|
200
|
-
): Promise<BacklogCommitResult> {
|
|
201
|
-
// Structural scope guard: lore commits `backlog/` and nothing else (ADR-0012). Each path is
|
|
202
|
-
// normalized (collapsing `.`/`..` segments) BEFORE the prefix check, and the NORMALIZED form —
|
|
203
|
-
// not the raw one — is what gets passed to git from here on. A raw `startsWith` check alone is
|
|
204
|
-
// fooled by a pathspec like `backlog/../docs/secret.md`: it textually starts with `backlog/` but
|
|
205
|
-
// resolves outside it once git interprets the `..` segment — confirmed live that git honors `..`
|
|
206
|
-
// even inside a `:(literal)`-quoted pathspec, so quoting alone does not neutralize it. Validating
|
|
207
|
-
// only a normalized copy while still shelling the raw string out would leave the traversal
|
|
208
|
-
// exploitable, hence normalizing in place. A NUL byte is rejected outright, BEFORE normalizing:
|
|
209
|
-
// `posix.normalize` treats a segment like `"..\0"` as an ordinary (non-`..`) path component and
|
|
210
|
-
// leaves it untouched, so it still passes the prefix check — but `Bun.spawn`'s argv is a C string,
|
|
211
|
-
// silently truncated at that same NUL when it crosses into the actual `git` process. The two layers
|
|
212
|
-
// disagree about where the path ends: normalize validates the full JS string, git only ever sees
|
|
213
|
-
// the bytes before the NUL (confirmed live: a payload like `"backlog/.." + "\0" + "/x"` normalizes
|
|
214
|
-
// to itself, starts with `backlog/`, and passes — but git receives only `:(literal)backlog/..`,
|
|
215
|
-
// which resolves to the repo root). The paths come from Backlog's own `filePathRelative`, always
|
|
216
|
-
// under `backlog/` today — this defends the invariant against a future Backlog layout change (or
|
|
217
|
-
// an absolute/`../`/NUL-embedded path) rather than silently committing outside lore's domain, which
|
|
218
|
-
// the removed bundle-wide sweep made structurally impossible. An empty `files` skips the loop
|
|
219
|
-
// entirely (a no-op run).
|
|
220
|
-
//
|
|
221
|
-
// `posix.normalize` only ever collapses `/`-delimited segments, so a BACKSLASH-delimited `..`
|
|
222
|
-
// climb (e.g. `backlog/x\..\..\outside.md` — the shape a platform-native `path.join` would resolve
|
|
223
|
-
// outside `backlog/` under win32) survives it as one opaque, unchanged segment and still passes
|
|
224
|
-
// the `startsWith` check above. `escapesRoot` (this codebase's shared, already-review-tested
|
|
225
|
-
// traversal check — see LORE-69/72/80's convention of validating against the deployment platform,
|
|
226
|
-
// not just the POSIX host running the fix) walks segments split on EITHER `/` or `\`, so it catches
|
|
227
|
-
// this shape too. It must run on the path with the `backlog/` PREFIX ALREADY STRIPPED: running it
|
|
228
|
-
// on the full, prefixed string would let the `backlog` segment itself absorb one level of the `..`
|
|
229
|
-
// climb and miss the real payload — `escapesRoot` answers "does this climb above where it starts",
|
|
230
|
-
// and the boundary that matters here is `backlog/`, not the string's own first character. Note this
|
|
231
|
-
// guard is defense-in-depth rather than a fix for an active break today: downstream, git's own
|
|
232
|
-
// `:(literal)` pathspec matching never treats `\` as a separator either, so this exact input shape
|
|
233
|
-
// was already a silent no-op (nothing committed, in or out of `backlog/`) rather than an actual
|
|
234
|
-
// escape — but the guard's own contract is a hard boundary regardless of what any current caller or
|
|
235
|
-
// downstream consumer happens to do with the string, so it is rejected outright here too.
|
|
236
|
-
const normalizedFiles = files.map((file) => {
|
|
237
|
-
if (file.includes("\0")) {
|
|
238
|
-
throw new LoreError(
|
|
239
|
-
"drift",
|
|
240
|
-
`refusing to commit "${JSON.stringify(file)}": embedded NUL byte`,
|
|
241
|
-
"this is a bug — a task path with a NUL byte reached the per-write commit",
|
|
242
|
-
{ file },
|
|
243
|
-
);
|
|
244
|
-
}
|
|
245
|
-
const normalized = posix.normalize(file);
|
|
246
|
-
if (
|
|
247
|
-
posix.isAbsolute(normalized) ||
|
|
248
|
-
!normalized.startsWith(BACKLOG_DIR) ||
|
|
249
|
-
escapesRoot(normalized.slice(BACKLOG_DIR.length))
|
|
250
|
-
) {
|
|
251
|
-
throw new LoreError(
|
|
252
|
-
"drift",
|
|
253
|
-
`refusing to commit "${file}": lore only commits ${BACKLOG_DIR}`,
|
|
254
|
-
"this is a bug — a task path outside backlog/ reached the per-write commit",
|
|
255
|
-
{ file },
|
|
256
|
-
);
|
|
257
|
-
}
|
|
258
|
-
return normalized;
|
|
259
|
-
});
|
|
260
|
-
const gitSpawn = opts.gitSpawn ?? bunGitSpawn(opts.root);
|
|
261
|
-
// Empty `files` is a no-op via commitBacklogIfDirty's own empty-pathspec guard — the single source
|
|
262
|
-
// of truth for "nothing to commit", not re-implemented here.
|
|
263
|
-
try {
|
|
264
|
-
return await commitBacklogIfDirty(gitSpawn, message, normalizedFiles);
|
|
265
|
-
} catch (err) {
|
|
266
|
-
// Capture a git-side `drift` failure into the result so the caller still emits its report before
|
|
267
|
-
// exiting `drift`; anything else is unexpected and propagates unchanged. `err.hint` rides along
|
|
268
|
-
// (the actual git/hook stderr `run()` captured, or its generic fallback) so the rendered line
|
|
269
|
-
// below isn't left with only a bare "exited N".
|
|
270
|
-
if (err instanceof LoreError && err.type === "drift") {
|
|
271
|
-
return { committed: false, files: [], error: err.message, hint: err.hint };
|
|
272
|
-
}
|
|
273
|
-
throw err;
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/** {@link porcelainPaths}'s result: the paths to `git add`, and the full set to scope the commit to (and report). */
|
|
278
|
-
interface PorcelainPaths {
|
|
279
|
-
/** Paths to `git add`. Excludes a staged rename/copy's OLD path — see the field below for why. */
|
|
280
|
-
readonly addPaths: string[];
|
|
281
|
-
/**
|
|
282
|
-
* Every touched path, including a staged rename/copy's OLD path. `git commit -- <pathspec>` fills
|
|
283
|
-
* in any path it does NOT see in the pathspec from `HEAD`'s tree rather than treating it as
|
|
284
|
-
* absent, so a commit scoped to only the new path would resurrect the old file (its staged
|
|
285
|
-
* deletion silently discarded, left stranded — staged and uncommitted — after the commit;
|
|
286
|
-
* verified against real git). The old path is deliberately excluded from {@link addPaths}: a
|
|
287
|
-
* `git mv`-staged rename has already fully removed the old path from the index (it is not a
|
|
288
|
-
* pending change `add` can re-apply), so re-adding it fails outright with "did not match any
|
|
289
|
-
* files" — it only ever needs to appear in the *commit's* pathspec, never `add`'s.
|
|
290
|
-
*/
|
|
291
|
-
readonly allPaths: string[];
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* The **cwd-relative** paths `git status --porcelain -z` reports as changed under `pathspecs`
|
|
296
|
-
* (staged, unstaged, or untracked — every porcelain status code). Parses the NUL-delimited machine
|
|
297
|
-
* format (`-z`), not the human `->`-separated text format: `-z` disables git's C-style quoting
|
|
298
|
-
* entirely (every path is raw bytes, even one containing a space, a literal arrow, or non-ASCII
|
|
299
|
-
* characters — no unescaping needed), and a rename/copy entry is two independent NUL-terminated
|
|
300
|
-
* fields (`new-path\0old-path\0`, no `" -> "` text token at all) rather than one line joined by a
|
|
301
|
-
* literal `" -> "` — which a text-format parser could otherwise mis-split when the path itself
|
|
302
|
-
* contains that exact substring. An *unstaged* rename is reported by git as two ordinary
|
|
303
|
-
* independent entries (a deletion, an untracked add) rather than one `R` entry at all — both come
|
|
304
|
-
* through as ordinary single-field entries either way, so this needs no special casing (and both
|
|
305
|
-
* belong in `addPaths` too, since neither is already-staged).
|
|
306
|
-
*
|
|
307
|
-
* Unlike `adapters/git.ts`'s `git log --relative` fix for the identical concern, `git status` has
|
|
308
|
-
* **no** `--relative` flag at all — it always reports paths relative to the repository's top level,
|
|
309
|
-
* never `cwd`. Since `git add`/`git commit` (the very next calls, run in the same `cwd`) resolve
|
|
310
|
-
* their own pathspecs relative to `cwd`, a nested-bundle checkout (`cwd` below the repo's top level)
|
|
311
|
-
* would otherwise feed them a path they can never match (verified against real git: a project nested
|
|
312
|
-
* one level down reports `project/backlog/tasks/x.md`, but `git add` from `cwd=.../project` looks
|
|
313
|
-
* for `project/project/backlog/tasks/x.md`). `git rev-parse --show-prefix` gives exactly the
|
|
314
|
-
* translation needed — the path from the repo's top level down to `cwd` (e.g. `"project/"`, or `""`
|
|
315
|
-
* when `cwd` already is the top level) — stripped from every reported path before it is used.
|
|
316
|
-
*/
|
|
317
|
-
async function porcelainPaths(spawn: GitSpawn, pathspecs: readonly string[]): Promise<PorcelainPaths> {
|
|
318
|
-
const prefixResult = await run(spawn, ["rev-parse", "--show-prefix"], "git rev-parse --show-prefix");
|
|
319
|
-
// `.trim()` would also strip LEADING whitespace, corrupting the prefix when the bundle sits under
|
|
320
|
-
// a directory whose name begins with whitespace (e.g. " proj/\n" -> "proj/", losing the leading
|
|
321
|
-
// space that `git status` still reports). Strip only the single trailing newline git terminates
|
|
322
|
-
// `--show-prefix` output with.
|
|
323
|
-
const prefix = prefixResult.stdout.replace(/\r?\n$/, "");
|
|
324
|
-
const cwdRelative = (path: string): string =>
|
|
325
|
-
prefix !== "" && path.startsWith(prefix) ? path.slice(prefix.length) : path;
|
|
326
|
-
|
|
327
|
-
// `--untracked-files=all` expands a brand-new untracked directory into its individual file paths
|
|
328
|
-
// (plain `--porcelain` reports only the directory itself, e.g. `?? backlog/`) — scoped to `pathspecs`
|
|
329
|
-
// so, unlike a bare repo-wide `-uall`, this never walks more of the tree than lore is committing.
|
|
330
|
-
const result = await run(
|
|
331
|
-
spawn,
|
|
332
|
-
["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ...pathspecs.map(literalPathspec)],
|
|
333
|
-
"git status",
|
|
334
|
-
);
|
|
335
|
-
const tokens = result.stdout.split("\0");
|
|
336
|
-
const addPaths: string[] = [];
|
|
337
|
-
const allPaths: string[] = [];
|
|
338
|
-
for (let i = 0; i < tokens.length; i++) {
|
|
339
|
-
const entry = tokens[i];
|
|
340
|
-
if (entry === undefined || entry === "") {
|
|
341
|
-
continue;
|
|
342
|
-
}
|
|
343
|
-
const status = entry.slice(0, 2);
|
|
344
|
-
const path = cwdRelative(entry.slice(3)); // "XY " is always exactly 3 chars, even in -z mode
|
|
345
|
-
addPaths.push(path);
|
|
346
|
-
allPaths.push(path);
|
|
347
|
-
if (status.includes("R") || status.includes("C")) {
|
|
348
|
-
// The OLD path is the next NUL-terminated field — needed in the commit's pathspec (see
|
|
349
|
-
// PorcelainPaths.allPaths) but NOT `add`'s (see PorcelainPaths.addPaths).
|
|
350
|
-
const oldPath = tokens[++i];
|
|
351
|
-
if (oldPath !== undefined && oldPath !== "") {
|
|
352
|
-
allPaths.push(cwdRelative(oldPath));
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
// Last-line defense-in-depth (ADR-0012): every path `git status` reports back for a `backlog/`-
|
|
357
|
-
// scoped pathspec must genuinely be under `backlog/` — both this function's callers (the catch-all
|
|
358
|
-
// sweep's hardcoded `[BACKLOG_DIR]` default, and the per-write commit's caller-validated files)
|
|
359
|
-
// only ever pass pathspecs already confined to `backlog/`, so this should never fire in practice.
|
|
360
|
-
// It exists because the per-write caller's OWN validation (commitBacklogFiles) and what git
|
|
361
|
-
// actually resolves/reports can diverge in ways that validation alone cannot rule out (LORE-69's
|
|
362
|
-
// NUL-byte finding: a value can pass a JS-level check yet resolve differently once it crosses the
|
|
363
|
-
// exec boundary into git) — re-checking git's own reported paths here, right before they are used
|
|
364
|
-
// to `add`/`commit`, closes that whole class rather than just the one instance found.
|
|
365
|
-
for (const path of allPaths) {
|
|
366
|
-
if (!path.startsWith(BACKLOG_DIR)) {
|
|
367
|
-
throw new LoreError(
|
|
368
|
-
"drift",
|
|
369
|
-
`refusing to commit "${path}": git reported a path outside ${BACKLOG_DIR} for a backlog/-scoped status`,
|
|
370
|
-
"this is a bug — report it; lore's own scope guard did not prevent this",
|
|
371
|
-
{ path },
|
|
372
|
-
);
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
return { addPaths, allPaths };
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
/** Run one `git` invocation through {@link GitSpawn}, mapping a non-zero exit to a `drift` {@link LoreError}. */
|
|
379
|
-
async function run(spawn: GitSpawn, args: readonly string[], label: string): Promise<GitSpawnResult> {
|
|
380
|
-
const result = await spawn(args);
|
|
381
|
-
if (result.exitCode !== 0) {
|
|
382
|
-
throw new LoreError(
|
|
383
|
-
"drift",
|
|
384
|
-
`\`${label}\` exited ${result.exitCode}: lore could not commit backlog/ changes`,
|
|
385
|
-
stderrHint(result.stderr) ?? "check the repository's git state (a dirty index, a rejected pre-commit hook, …)",
|
|
386
|
-
{ exitCode: result.exitCode },
|
|
387
|
-
);
|
|
388
|
-
}
|
|
389
|
-
return result;
|
|
390
|
-
}
|