@omgbase/git-adapter 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +38 -2
- package/dist/src/config.d.ts +62 -0
- package/dist/src/config.js +91 -0
- package/dist/src/cursor.d.ts +24 -0
- package/dist/src/cursor.js +47 -0
- package/dist/src/exclude.d.ts +5 -0
- package/dist/src/exclude.js +41 -0
- package/dist/src/git.d.ts +157 -0
- package/dist/src/git.js +356 -0
- package/dist/src/ids.d.ts +48 -0
- package/dist/src/ids.js +84 -0
- package/dist/src/index.d.ts +11 -0
- package/dist/src/index.js +17 -0
- package/dist/src/provider.d.ts +63 -0
- package/dist/src/provider.js +113 -0
- package/dist/src/reader.d.ts +44 -0
- package/dist/src/reader.js +190 -0
- package/dist/src/remote.d.ts +113 -0
- package/dist/src/remote.js +631 -0
- package/dist/src/scheduler.d.ts +20 -0
- package/dist/src/scheduler.js +37 -0
- package/dist/src/trailers.d.ts +41 -0
- package/dist/src/trailers.js +124 -0
- package/dist/src/worker.d.ts +50 -0
- package/dist/src/worker.js +202 -0
- package/package.json +51 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Brendan Baldwin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,39 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @omgbase/git-adapter
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The **Git-backed history provider** for [omgbase](https://github.com/omgbase/omgbase) — modular-core Phase 6 (6a the engine-owned repository, writer and reader; 6b the remote half), the plan's "one alternative history strategy through the documented contract" (`spec/store` §5.12, `spec/surface` §3 "The git provider", `spec/cli` `history`, `spec/sync` §4.3/§6). A repository's history is kept by **one provider**: the engine's native recorder (the store's own history tables, captured inside every commit) or this one — a **bare Git repository at `<workspace>/.omgbase/git`** whose commits are built from the database rows by a **checkpoint worker** that consumes the change feed, and which may be bound to a **remote** the engine pushes to and fetches from. The core knows the provider only by name: `@omgbase/core` imports nothing from this package; the composition layer (the `omg` CLI / `omg mcp` host, a library embedding, the fixture runners) registers it before any store opens:
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { registerHistoryProvider } from "@omgbase/core";
|
|
7
|
+
import { gitHistoryProvider } from "@omgbase/git-adapter";
|
|
8
|
+
|
|
9
|
+
registerHistoryProvider("git", gitHistoryProvider());
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Private to the monorepo (like `@omgbase/elements`): the `omgbase` CLI depends on it and is what ships it.
|
|
13
|
+
|
|
14
|
+
## What it does
|
|
15
|
+
|
|
16
|
+
- **`omg history enable --provider git [--branch <b>] [--cadence every|<ms>]`** initialises the bare repository, ensures `.omgbase/` is listed in the user's `.git/info/exclude` (never `.gitignore` — a tracked file is never edited), writes the manifest row `{ provider: "git", git_dir: ".omgbase/git", branch, cadence, ids_ref: "refs/omgbase/ids", remote: null }` and commits a **baseline** of the full current tree. One provider per repository: switching is `history disable` then `enable --provider <other>`.
|
|
17
|
+
- **The checkpoint worker** (`omg history checkpoint`; the freshness sweep runs it before every CLI verb; the MCP host's mutation hook runs it after every write — below): reads `changes_since` from the branch tip's `omgbase-seq` trailer, hashes each touched document's accepted bytes (`reconstruct` — the rows, never the vault's files) into a blob, builds the tree from the previous commit's tree plus the adds and removes (deleted documents leave the tree; a move is a remove + add, git detects the rename), writes the commit with trailers and moves the ref by compare-and-swap. **The no-op rule** (6b): a checkpoint whose tree equals its parent's — the bytes were already in the tree, a fetch brought them in — writes no commit; the `$git` cursor row alone advances. **The resume rule**: the tip's trailer is the position unless the cursor row names the same tip and stands further on.
|
|
18
|
+
- **Trailers** are the replay and identity record: `omgbase-seq: <epoch>:<seq>` (`<from>-<to>` folded), `omgbase-ts`, one `omgbase-commit: <c_id>` per row, `omgbase-origin`, `omgbase-actor`, `omgbase-plan` (when present), `omgbase-rescan: <epoch>:<head>` on a snapshot, `omgbase-merge: <remote tip>` on a merge commit, one `omgbase-doc: <doc_id> <path> <head>` per touched document (`-` for a deletion). The worker is idempotent across the gap between the database commit and the git commit: a crash before `update-ref` (the `git.commit` fault site) leaves dangling objects the next run re-creates byte for byte.
|
|
19
|
+
- **The ids ref** (6b): beside the content branch — a plain notes repository anyone can clone — the worker keeps a parallel commit chain on `refs/omgbase/ids` whose tree holds one blob per live document at `.omgbase/ids/<doc_id>`: canonical JSON `{ "blocks": [{ "hash", "id", "ordinal", "parent" }], "doc", "head" }` — the block identities in pre-order, parents, sibling ordinals and `raw_hash`. Each ids commit carries the checkpoint's `omgbase-seq` and `omgbase-content: <content commit sha>`. Push and fetch carry the ref; `git show refs/omgbase/ids:.omgbase/ids/<doc_id>` on a clone lists a document's id map. Restoration from it — an engine adopting a clone's identities — is 6c.
|
|
20
|
+
- **Cadence.** `every` is one commit per feed row; `{ debounce_ms }` folds the rows pending when the worker runs into one commit. The MCP host (`omg mcp`) hands its `onMutation` hook to `createCheckpointScheduler`: under `every` the worker runs synchronously after each successful write tool (the git history is complete without `omg sync`), under a debounce the hook re-arms one timer so a burst of writes folds into one commit `debounce_ms` after the last; a pending timer runs at shutdown.
|
|
21
|
+
- **The remote binding** (6b) — `omg history remote set --url <url> [--name origin] [--branch <b>] [--push off|on-checkpoint|periodic] [--fetch off|periodic|webhook]`: `config.remote = { name, url, branch, push, fetch }` and `git remote add` in the bare repository. Nothing is automatic until configured (both policies default `off`). `push: on-checkpoint` pushes after every checkpoint that committed (its outcome on the receipt), `periodic` on every freshness sweep (step 7 — the sweep is the timer, the adapter has none); `fetch: periodic` fetches on every sweep (step 8), `webhook` is **reserved** — an external caller (a webhook receiver, a cron) runs `omg history fetch`, the engine itself fetches nothing. `history remote status` (local refs only, no network): `{ remote, branch, local_tip, remote_tip, pushed_through, state: in-sync | ahead | behind | diverged | unknown }`; the same summary rides on `version.capabilities.history.remote`, so a client tells apart the current state, the locally checkpointed and the remotely synchronised.
|
|
22
|
+
- **Push** (`omg history push`): the branch and the ids ref, **never `--force`**. A rejected push (non-fast-forward) raises the `git_remote_diverged` alert (`{ remote, branch, local_tip, remote_tip }`) and fails with that code — `history fetch` merges the remote's commits and the push lands after. The ids ref's rejection alone (another engine's identity record on the remote) is reported as `ids: "rejected"`, never forced — reconciling two engines' identities is 6c.
|
|
23
|
+
- **Fetch** (`omg history fetch`): `git fetch` into the bare repository, then — the remote ahead of us: its tree changes are written into the vault **through the source boundary** (the files under the repository's root as external edits, ingested by the ordinary observe path so identities are matched by the reconcile and the feed records them — the provider never writes a row) and the branch fast-forwards; both sides moved: a **three-way merge at the tree level** with the common ancestor git supplies — a path one side changed is taken, both to the same blob is nothing, both to different blobs is a **conflict** — a clean merge is one merge commit on our branch (`omgbase-merge: <remote tip>`), conflicts keep **both versions**: ours at the path, the remote's beside it as `<stem>.remote-<short sha>.md`, the `git_merge_conflict` alert raised and no merge commit until `omg history remote resolve <path> --ours|--theirs` settles each (the last resolution writes the merge commit). The worker then finds the inbound rows' bytes already in the tree and commits nothing for them.
|
|
24
|
+
- **Divergence** — the remote **rewritten** (rebased, force-pushed: its tip no longer descends from the tip we last knew): detect, stop, alert. `git_remote_diverged` carries `old_remote_tip`, push and fetch pause, checkpointing continues locally, and the operator chooses `omg history remote reconcile --rebase` (replay our unpushed checkpoints onto the new head, trailers preserved, the ids ref rebased alongside, our old tip on `refs/omgbase/backup/<ts>`), `--merge` (one merge commit, both histories kept), `--adopt` (the remote's head becomes truth: its tree through the source boundary, our local edits kept beside as `<stem>.local-<short sha>.md`, our branch reset to it, the old tip backed up) or `--fork` (our branch pushed as `<branch>-omgbase-<ts>`, the human merges with git, then `--adopt`). Never a force-push, never an unpushed commit dropped without a backup ref, never a silent last-write-wins.
|
|
25
|
+
- **Reads** through the core's `HistoryReader` seam: `docs_history` (versions with `_commit` the git sha, `_hash` the blob's sha256, `_rev` the head token the trailer recorded), `docs_read { at: <sha> | <head token> }`, `docs_diff_unified`, the `revisions` query root; the current head is listed unretained until a commit names it. `version.capabilities.history` reports `provider: "git"`, `completeness: "every" | "checkpoints"` and `remote`.
|
|
26
|
+
- **Determinism**: every spawn runs `git` with `GIT_DIR`, `GIT_CONFIG_NOSYSTEM=1`, `GIT_CONFIG_GLOBAL=/dev/null`, `GIT_ATTR_NOSYSTEM=1`, no `HOME`, `LC_ALL=C`, `TZ=UTC`; the committer is `omgbase <omgbase@localhost>` (the author's name the write's actor), the dates the feed row's `ts` as `@<seconds> +0000`. Under the conformance seams the identity is `omgbase <omgbase@fixture>` (the fixtures' remote commits `remote <remote@fixture>`), so the shas the fixtures pin follow from the inputs alone. `OMGBASE_GIT_BIN` names the executable (default `git`).
|
|
27
|
+
|
|
28
|
+
## What it does not do
|
|
29
|
+
|
|
30
|
+
- **No block lineage.** Git keeps whole documents: `blocks_history` and `docs_diff` (block-grain) answer `capability_unavailable { history, unsupported, provider: "git", fix }` naming the tool that works (`docs_history`, `docs_diff_unified`). The native recorder's dispositions, retention ladder, archive segments and `history plan|export|attach|detach|list` do not apply — a git history is managed with git.
|
|
31
|
+
- **Checkpoint granularity.** Capture is asynchronous: a document changed twice before the worker runs is committed at its later bytes (the rows hold current state only); with cadence `every` and the mutation hook every write is one commit.
|
|
32
|
+
- **Credentials never enter omgbase.** A URL carrying a user or password is refused anywhere one is accepted (`history remote set --url`); `git` resolves access through the host (ssh keys, a credential helper, `GIT_ASKPASS`, a deploy key). For a hosted customer the account layer supplies a credential helper minting short-lived tokens — product work outside this package; the adapter is identical.
|
|
33
|
+
- **No restoration from the ids ref yet.** The ref is written, pushed and fetched; an engine adopting a clone's identities (and two engines reconciling their ids refs on one remote) is 6c.
|
|
34
|
+
- **No network of its own.** Push and fetch run when a verb, the sweep's `periodic` policies or the mutation hook run them; `fetch: webhook` names the external caller.
|
|
35
|
+
- **Unavailability is loud, writes continue.** When `git` is not on PATH (or the provider is not attached), every history read of a git-provider repository fails `capability_unavailable { history, unavailable, provider, reason }` and the `history_provider_unavailable` alert is raised; the engine keeps writing — the next checkpoint that runs catches up.
|
|
36
|
+
|
|
37
|
+
## Fixtures and tests
|
|
38
|
+
|
|
39
|
+
`spec/store/cases/history/git.json` pins the worker's commits as text (`git log --format=%H%n%s%n%(trailers)`, `git ls-tree -r`), the fault and its replay, the fold, purge then rescan, re-enable, the provider conflict; `spec/store/cases/history/git-remote.json` the remote half against a second bare repository under the case's temp dir (a local path, no network): the binding, push and the remote's log equal to ours, `on-checkpoint`, the rejected push, the fast-forward through the source boundary and the no-op checkpoint, the clean merge, the conflict with its sidecar, alert and resolve, the rewritten remote and each remediation, the ids ref carried; `spec/surface/cases/reads.json` `git-history` and `git-remote` the reads, `version` and the alerts; `spec/cli/cases/history.json` the verbs. `OMGBASE_CAPABILITIES=git` runs every suite with this provider and no native recorder (`pnpm conformance`). `pnpm --filter @omgbase/git-adapter test` is the unit suite: `git-adapter.test.ts` (the trailer codec, the config, the exclude file, the worker end to end) and `remote.test.ts` (the remote half end to end, the scheduler under fake timers).
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { HistoryProviderConfig } from "@omgbase/core";
|
|
2
|
+
export declare const GIT_PROVIDER = "git";
|
|
3
|
+
export declare const DEFAULT_GIT_DIR = ".omgbase/git";
|
|
4
|
+
export declare const DEFAULT_BRANCH = "omgbase";
|
|
5
|
+
export declare const IDS_REF = "refs/omgbase/ids";
|
|
6
|
+
export type GitCadence = "every" | {
|
|
7
|
+
debounce_ms: number;
|
|
8
|
+
};
|
|
9
|
+
export type GitPushPolicy = "off" | "on-checkpoint" | "periodic";
|
|
10
|
+
export type GitFetchPolicy = "off" | "periodic" | "webhook";
|
|
11
|
+
export declare const PUSH_POLICIES: readonly GitPushPolicy[];
|
|
12
|
+
export declare const FETCH_POLICIES: readonly GitFetchPolicy[];
|
|
13
|
+
export declare const DEFAULT_REMOTE_NAME = "origin";
|
|
14
|
+
/**
|
|
15
|
+
* The remote binding (spec/store §5.12 "The remote binding", 17.6): `name` the
|
|
16
|
+
* git remote's name in the bare repository (`origin`), `url` what `git` is
|
|
17
|
+
* given — never with credentials (`refuseCredentials`) — `branch` the remote
|
|
18
|
+
* branch (the local branch's name by default), `push` when the engine pushes
|
|
19
|
+
* (`off`; `on-checkpoint` after every checkpoint that committed; `periodic` on
|
|
20
|
+
* every freshness sweep), `fetch` when it fetches (`off`; `periodic` on every
|
|
21
|
+
* sweep; `webhook` RESERVED — an external caller runs `history fetch`).
|
|
22
|
+
*/
|
|
23
|
+
export interface GitRemoteConfig {
|
|
24
|
+
name: string;
|
|
25
|
+
url: string;
|
|
26
|
+
branch: string;
|
|
27
|
+
push: GitPushPolicy;
|
|
28
|
+
fetch: GitFetchPolicy;
|
|
29
|
+
}
|
|
30
|
+
export interface GitRemoteOptions {
|
|
31
|
+
url: string;
|
|
32
|
+
name?: string;
|
|
33
|
+
branch?: string;
|
|
34
|
+
push?: string;
|
|
35
|
+
fetch?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface GitHistoryConfig extends HistoryProviderConfig {
|
|
38
|
+
provider: "git";
|
|
39
|
+
git_dir: string;
|
|
40
|
+
branch: string;
|
|
41
|
+
cadence: GitCadence;
|
|
42
|
+
ids_ref: string;
|
|
43
|
+
remote: GitRemoteConfig | null;
|
|
44
|
+
}
|
|
45
|
+
export interface GitEnableOptions {
|
|
46
|
+
branch?: string;
|
|
47
|
+
cadence?: GitCadence;
|
|
48
|
+
}
|
|
49
|
+
/** A URL (or anything that looks like one) with embedded credentials is refused everywhere: `git` resolves access through the host. */
|
|
50
|
+
export declare function refuseCredentials(value: string, what: string): void;
|
|
51
|
+
/** The config `history enable --provider git` writes, from the verb's options. */
|
|
52
|
+
export declare function gitConfigFor(opts: GitEnableOptions): GitHistoryConfig;
|
|
53
|
+
/** A git remote name (`git check-ref-format`'s rules, roughly): no spaces, no `..`, no leading `-`, no `/` at either end. */
|
|
54
|
+
export declare function validRemoteName(name: string): boolean;
|
|
55
|
+
/** The remote binding `history remote set` writes, from the verb's options (defaults: `origin`, the local branch, `off`, `off`); the URL refused when it carries credentials. */
|
|
56
|
+
export declare function remoteConfigFor(opts: GitRemoteOptions, localBranch: string): GitRemoteConfig;
|
|
57
|
+
/** A row's `remote` member read back: the binding when it is well-formed, else null (a row without one, or one an older engine wrote). */
|
|
58
|
+
export declare function readRemoteConfig(v: unknown): GitRemoteConfig | null;
|
|
59
|
+
/** Parse the `--cadence every|<ms>` flag. */
|
|
60
|
+
export declare function parseCadence(raw: string): GitCadence;
|
|
61
|
+
/** Read a manifest row's config as the git config, defaults filled (a row written by a later 6.x may carry more keys). */
|
|
62
|
+
export declare function readGitConfig(config: HistoryProviderConfig): GitHistoryConfig;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// The git provider's manifest config (spec/store §3.1 `capabilities.config`,
|
|
2
|
+
// §5.12): what `history enable --provider git` writes and every later call
|
|
3
|
+
// reads. Pinned keys and defaults. `remote` (17.6, Phase 6b) is the remote
|
|
4
|
+
// binding `history remote set` writes — `{ name, url, branch, push, fetch }`,
|
|
5
|
+
// null until one is bound; nothing is automatic until it is (`push`/`fetch`
|
|
6
|
+
// default `off`). The per-document id-map ref `ids_ref` is written by the
|
|
7
|
+
// worker since 6b (one commit chain beside the content branch).
|
|
8
|
+
export const GIT_PROVIDER = "git";
|
|
9
|
+
export const DEFAULT_GIT_DIR = ".omgbase/git";
|
|
10
|
+
export const DEFAULT_BRANCH = "omgbase";
|
|
11
|
+
export const IDS_REF = "refs/omgbase/ids";
|
|
12
|
+
export const PUSH_POLICIES = ["off", "on-checkpoint", "periodic"];
|
|
13
|
+
export const FETCH_POLICIES = ["off", "periodic", "webhook"];
|
|
14
|
+
export const DEFAULT_REMOTE_NAME = "origin";
|
|
15
|
+
/** A URL (or anything that looks like one) with embedded credentials is refused everywhere: `git` resolves access through the host. */
|
|
16
|
+
export function refuseCredentials(value, what) {
|
|
17
|
+
if (/^[a-z][a-z0-9+.-]*:\/\/[^/@\s]*:[^/@\s]*@/i.test(value) || /^[a-z][a-z0-9+.-]*:\/\/[^/@\s]+@/i.test(value)) {
|
|
18
|
+
throw new Error(`${what} carries credentials (a user or password in the URL) — omgbase never stores credentials; let git resolve access through the host (ssh keys, a credential helper)`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** The config `history enable --provider git` writes, from the verb's options. */
|
|
22
|
+
export function gitConfigFor(opts) {
|
|
23
|
+
const branch = opts.branch ?? DEFAULT_BRANCH;
|
|
24
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(branch) || branch.startsWith("-") || branch.includes("..") || branch.endsWith("/") || branch.endsWith(".lock")) {
|
|
25
|
+
throw new Error(`not a branch name: ${JSON.stringify(branch)}`);
|
|
26
|
+
}
|
|
27
|
+
const cadence = opts.cadence ?? "every";
|
|
28
|
+
if (cadence !== "every" && (typeof cadence !== "object" || !Number.isInteger(cadence.debounce_ms) || cadence.debounce_ms <= 0)) {
|
|
29
|
+
throw new Error(`cadence must be "every" or { debounce_ms: <positive integer> }`);
|
|
30
|
+
}
|
|
31
|
+
return { provider: "git", git_dir: DEFAULT_GIT_DIR, branch, cadence, ids_ref: IDS_REF, remote: null };
|
|
32
|
+
}
|
|
33
|
+
/** A git remote name (`git check-ref-format`'s rules, roughly): no spaces, no `..`, no leading `-`, no `/` at either end. */
|
|
34
|
+
export function validRemoteName(name) {
|
|
35
|
+
return /^[A-Za-z0-9._-]+$/.test(name) && !name.startsWith("-") && !name.includes("..") && !name.endsWith(".lock");
|
|
36
|
+
}
|
|
37
|
+
/** The remote binding `history remote set` writes, from the verb's options (defaults: `origin`, the local branch, `off`, `off`); the URL refused when it carries credentials. */
|
|
38
|
+
export function remoteConfigFor(opts, localBranch) {
|
|
39
|
+
if (typeof opts.url !== "string" || opts.url.trim() === "")
|
|
40
|
+
throw new Error("--url is required (a git URL or a local path; credentials never enter omgbase)");
|
|
41
|
+
refuseCredentials(opts.url, "the remote URL");
|
|
42
|
+
const name = opts.name ?? DEFAULT_REMOTE_NAME;
|
|
43
|
+
if (!validRemoteName(name))
|
|
44
|
+
throw new Error(`not a remote name: ${JSON.stringify(name)}`);
|
|
45
|
+
const branch = opts.branch ?? localBranch;
|
|
46
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(branch) || branch.startsWith("-") || branch.includes("..") || branch.endsWith("/") || branch.endsWith(".lock"))
|
|
47
|
+
throw new Error(`not a branch name: ${JSON.stringify(branch)}`);
|
|
48
|
+
const push = opts.push ?? "off";
|
|
49
|
+
if (!PUSH_POLICIES.includes(push))
|
|
50
|
+
throw new Error(`--push must be one of ${PUSH_POLICIES.join("|")} (got ${JSON.stringify(push)})`);
|
|
51
|
+
const fetch = opts.fetch ?? "off";
|
|
52
|
+
if (!FETCH_POLICIES.includes(fetch))
|
|
53
|
+
throw new Error(`--fetch must be one of ${FETCH_POLICIES.join("|")} (got ${JSON.stringify(fetch)})`);
|
|
54
|
+
return { name, url: opts.url, branch, push: push, fetch: fetch };
|
|
55
|
+
}
|
|
56
|
+
/** A row's `remote` member read back: the binding when it is well-formed, else null (a row without one, or one an older engine wrote). */
|
|
57
|
+
export function readRemoteConfig(v) {
|
|
58
|
+
if (typeof v !== "object" || v === null)
|
|
59
|
+
return null;
|
|
60
|
+
const r = v;
|
|
61
|
+
if (typeof r.name !== "string" || typeof r.url !== "string" || typeof r.branch !== "string")
|
|
62
|
+
return null;
|
|
63
|
+
return {
|
|
64
|
+
name: r.name, url: r.url, branch: r.branch,
|
|
65
|
+
push: PUSH_POLICIES.includes(r.push) ? r.push : "off",
|
|
66
|
+
fetch: FETCH_POLICIES.includes(r.fetch) ? r.fetch : "off",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** Parse the `--cadence every|<ms>` flag. */
|
|
70
|
+
export function parseCadence(raw) {
|
|
71
|
+
if (raw === "every")
|
|
72
|
+
return "every";
|
|
73
|
+
const ms = /^\d+$/.test(raw) ? Number(raw) : NaN;
|
|
74
|
+
if (!Number.isInteger(ms) || ms <= 0)
|
|
75
|
+
throw new Error(`--cadence must be "every" or a positive number of milliseconds (got ${JSON.stringify(raw)})`);
|
|
76
|
+
return { debounce_ms: ms };
|
|
77
|
+
}
|
|
78
|
+
/** Read a manifest row's config as the git config, defaults filled (a row written by a later 6.x may carry more keys). */
|
|
79
|
+
export function readGitConfig(config) {
|
|
80
|
+
const c = config;
|
|
81
|
+
const cadence = c.cadence === "every" || c.cadence === undefined ? "every" : typeof c.cadence === "object" && c.cadence !== null && typeof c.cadence.debounce_ms === "number" ? { debounce_ms: c.cadence.debounce_ms } : "every";
|
|
82
|
+
return {
|
|
83
|
+
provider: "git",
|
|
84
|
+
git_dir: typeof c.git_dir === "string" && c.git_dir !== "" ? c.git_dir : DEFAULT_GIT_DIR,
|
|
85
|
+
branch: typeof c.branch === "string" && c.branch !== "" ? c.branch : DEFAULT_BRANCH,
|
|
86
|
+
cadence,
|
|
87
|
+
ids_ref: typeof c.ids_ref === "string" ? c.ids_ref : IDS_REF,
|
|
88
|
+
remote: readRemoteConfig(c.remote),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Store } from "@omgbase/core";
|
|
2
|
+
type Database = Store["db"];
|
|
3
|
+
export declare const GIT_CURSOR_SOURCE = "$git";
|
|
4
|
+
export interface GitPushed {
|
|
5
|
+
/** the tip the remote holds since the last push */
|
|
6
|
+
tip: string;
|
|
7
|
+
/** the feed seq that tip's `omgbase-seq` trailer covers — `pushed_through`; null when the tip has no trailer of this epoch */
|
|
8
|
+
seq: number | null;
|
|
9
|
+
}
|
|
10
|
+
export interface GitCursor {
|
|
11
|
+
tip: string;
|
|
12
|
+
epoch: string;
|
|
13
|
+
seq: number;
|
|
14
|
+
pushed: GitPushed | null;
|
|
15
|
+
}
|
|
16
|
+
export declare function readGitCursor(db: Database, repoId: string): GitCursor | null;
|
|
17
|
+
/** Write the position; `pushed` is kept from the existing row unless given. */
|
|
18
|
+
export declare function writeGitCursor(db: Database, repoId: string, cursor: Omit<GitCursor, "pushed"> & {
|
|
19
|
+
pushed?: GitPushed | null;
|
|
20
|
+
}): void;
|
|
21
|
+
/** Record a push (17.6): the row's position is kept, `pushed` replaced; a repository with no row yet gets one at the tip. */
|
|
22
|
+
export declare function writeGitPushed(db: Database, repoId: string, epoch: string, pushed: GitPushed | null): void;
|
|
23
|
+
export declare function clearGitCursor(db: Database, repoId: string): void;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The worker's cursor (spec/sync §6 "The `$git` cursor row", 4.5; 4.6): one
|
|
2
|
+
// row of `sync_state` per repository — `(repo_id, '$git', '')`, like the
|
|
3
|
+
// semantic watermark's `$semantic` — `revision` the branch tip sha, `cursor`
|
|
4
|
+
// JSON `{ epoch, seq, pushed? }`. `{ epoch, seq }` is a CACHE of what the tip's
|
|
5
|
+
// `omgbase-seq` trailer says, refined (17.6) past it when the tip is not one of
|
|
6
|
+
// the worker's own commits (a fast-forward to the remote's tip) or when rows
|
|
7
|
+
// folded into no commit (their bytes were already in the tree): the worker
|
|
8
|
+
// resumes from the trailer, or from the row when the row names the tip and
|
|
9
|
+
// stands further on. `pushed` (17.6, Phase 6b) records the last push — the tip
|
|
10
|
+
// pushed and the seq its trailer covers, `pushed_through` on the wire. The
|
|
11
|
+
// worker writes the row after every commit; a crash between the ref and the
|
|
12
|
+
// row leaves the row behind the tip, which the next run reads past.
|
|
13
|
+
export const GIT_CURSOR_SOURCE = "$git";
|
|
14
|
+
export function readGitCursor(db, repoId) {
|
|
15
|
+
const row = db.prepare("SELECT revision, cursor FROM sync_state WHERE repo_id = ? AND source_id = ? AND path = ''").get(repoId, GIT_CURSOR_SOURCE);
|
|
16
|
+
if (!row?.revision || !row.cursor)
|
|
17
|
+
return null;
|
|
18
|
+
try {
|
|
19
|
+
const c = JSON.parse(row.cursor);
|
|
20
|
+
if (typeof c.epoch !== "string" || typeof c.seq !== "number")
|
|
21
|
+
return null;
|
|
22
|
+
const p = c.pushed;
|
|
23
|
+
const pushed = p && typeof p.tip === "string" ? { tip: p.tip, seq: typeof p.seq === "number" ? p.seq : null } : null;
|
|
24
|
+
return { tip: row.revision, epoch: c.epoch, seq: c.seq, pushed };
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Write the position; `pushed` is kept from the existing row unless given. */
|
|
31
|
+
export function writeGitCursor(db, repoId, cursor) {
|
|
32
|
+
const pushed = cursor.pushed !== undefined ? cursor.pushed : (readGitCursor(db, repoId)?.pushed ?? null);
|
|
33
|
+
db.prepare(`INSERT INTO sync_state (repo_id, source_id, path, revision, cursor) VALUES (?, ?, '', ?, ?)
|
|
34
|
+
ON CONFLICT(repo_id, source_id, path) DO UPDATE SET revision = excluded.revision, cursor = excluded.cursor`).run(repoId, GIT_CURSOR_SOURCE, cursor.tip, JSON.stringify({ epoch: cursor.epoch, seq: cursor.seq, ...(pushed ? { pushed } : {}) }));
|
|
35
|
+
}
|
|
36
|
+
/** Record a push (17.6): the row's position is kept, `pushed` replaced; a repository with no row yet gets one at the tip. */
|
|
37
|
+
export function writeGitPushed(db, repoId, epoch, pushed) {
|
|
38
|
+
const existing = readGitCursor(db, repoId);
|
|
39
|
+
if (existing)
|
|
40
|
+
writeGitCursor(db, repoId, { ...existing, pushed });
|
|
41
|
+
else if (pushed)
|
|
42
|
+
writeGitCursor(db, repoId, { tip: pushed.tip, epoch, seq: pushed.seq ?? 0, pushed });
|
|
43
|
+
}
|
|
44
|
+
export function clearGitCursor(db, repoId) {
|
|
45
|
+
db.prepare("DELETE FROM sync_state WHERE repo_id = ? AND source_id = ? AND path = ''").run(repoId, GIT_CURSOR_SOURCE);
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=cursor.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const EXCLUDE_LINE = ".omgbase/";
|
|
2
|
+
/** The `.git/info/exclude` path of a repository rooted at `root`, or null when `root/.git` does not exist. */
|
|
3
|
+
export declare function excludeFileFor(root: string): string | null;
|
|
4
|
+
/** Ensure `.omgbase/` is listed in the user's `.git/info/exclude`; returns what was done. */
|
|
5
|
+
export declare function ensureExcluded(root: string): "absent" | "present" | "added";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
// The user's own repository is never touched — except for ONE line in its
|
|
4
|
+
// `.git/info/exclude` (never `.gitignore`, a tracked file): `.omgbase/`, so the
|
|
5
|
+
// engine's bare repository and database are not offered to the user's `git
|
|
6
|
+
// add`. A worktree's `.git` is a FILE (`gitdir: <path>`); the exclude file
|
|
7
|
+
// then lives under the pointed-to directory (a worktree's `info/` is the common
|
|
8
|
+
// dir's; `git rev-parse --git-path info/exclude` would say the same, but no git
|
|
9
|
+
// is spawned on the user's repository).
|
|
10
|
+
export const EXCLUDE_LINE = ".omgbase/";
|
|
11
|
+
/** The `.git/info/exclude` path of a repository rooted at `root`, or null when `root/.git` does not exist. */
|
|
12
|
+
export function excludeFileFor(root) {
|
|
13
|
+
const dotGit = join(root, ".git");
|
|
14
|
+
if (!existsSync(dotGit))
|
|
15
|
+
return null;
|
|
16
|
+
let gitDir = dotGit;
|
|
17
|
+
if (statSync(dotGit).isFile()) {
|
|
18
|
+
const m = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, "utf8"));
|
|
19
|
+
if (!m)
|
|
20
|
+
return null;
|
|
21
|
+
gitDir = isAbsolute(m[1].trim()) ? m[1].trim() : resolve(root, m[1].trim());
|
|
22
|
+
// A linked worktree's gitdir is `<common>/worktrees/<name>`; `info/` is the common dir's.
|
|
23
|
+
const common = join(gitDir, "commondir");
|
|
24
|
+
if (existsSync(common))
|
|
25
|
+
gitDir = resolve(gitDir, readFileSync(common, "utf8").trim());
|
|
26
|
+
}
|
|
27
|
+
return join(gitDir, "info", "exclude");
|
|
28
|
+
}
|
|
29
|
+
/** Ensure `.omgbase/` is listed in the user's `.git/info/exclude`; returns what was done. */
|
|
30
|
+
export function ensureExcluded(root) {
|
|
31
|
+
const file = excludeFileFor(root);
|
|
32
|
+
if (file === null)
|
|
33
|
+
return "absent";
|
|
34
|
+
const existing = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
35
|
+
if (existing.split("\n").some((l) => l.trim() === EXCLUDE_LINE || l.trim() === ".omgbase"))
|
|
36
|
+
return "present";
|
|
37
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
38
|
+
appendFileSync(file, `${existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""}${EXCLUDE_LINE}\n`);
|
|
39
|
+
return "added";
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=exclude.js.map
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
export interface GitIdentity {
|
|
2
|
+
name: string;
|
|
3
|
+
email: string;
|
|
4
|
+
}
|
|
5
|
+
export interface CommitIdentity {
|
|
6
|
+
author: GitIdentity;
|
|
7
|
+
committer: GitIdentity;
|
|
8
|
+
/** RFC 3339 — rendered as `@<seconds> +0000` for both dates. */
|
|
9
|
+
ts: string;
|
|
10
|
+
}
|
|
11
|
+
export interface GitRunOptions {
|
|
12
|
+
input?: Buffer | string;
|
|
13
|
+
/** extra environment for this call (`GIT_INDEX_FILE`, the identities) */
|
|
14
|
+
env?: Record<string, string>;
|
|
15
|
+
/** do not throw on a non-zero exit (the caller reads `status`) */
|
|
16
|
+
allowFailure?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface GitRunResult {
|
|
19
|
+
status: number;
|
|
20
|
+
stdout: Buffer;
|
|
21
|
+
stderr: string;
|
|
22
|
+
}
|
|
23
|
+
export declare class GitError extends Error {
|
|
24
|
+
readonly args: string[];
|
|
25
|
+
readonly status: number;
|
|
26
|
+
readonly stderr: string;
|
|
27
|
+
constructor(args: string[], status: number, stderr: string);
|
|
28
|
+
}
|
|
29
|
+
/** The git executable: `git` on PATH, or what `OMGBASE_GIT_BIN` names (a host pinning one git; the tests pointing at a missing one). The Rust twin honours the same variable. */
|
|
30
|
+
export declare const GIT_BIN_ENV = "OMGBASE_GIT_BIN";
|
|
31
|
+
export declare function gitBin(): string;
|
|
32
|
+
export declare const ZERO_SHA: string;
|
|
33
|
+
/** The empty tree's sha (git's well-known constant for the SHA-1 object format). */
|
|
34
|
+
export declare const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
35
|
+
export declare const SHA_RE: RegExp;
|
|
36
|
+
/** `ts` (RFC 3339) as git's raw date `@<seconds> +0000`. */
|
|
37
|
+
export declare function gitDate(ts: string): string;
|
|
38
|
+
export declare function gitAvailable(): {
|
|
39
|
+
ok: true;
|
|
40
|
+
detail: string;
|
|
41
|
+
} | {
|
|
42
|
+
ok: false;
|
|
43
|
+
reason: string;
|
|
44
|
+
};
|
|
45
|
+
/** Tests: forget the probe's answer. */
|
|
46
|
+
export declare function resetGitProbe(): void;
|
|
47
|
+
/** A bare repository at `gitDir`, driven through the plumbing. */
|
|
48
|
+
export declare class GitRepo {
|
|
49
|
+
readonly gitDir: string;
|
|
50
|
+
constructor(gitDir: string);
|
|
51
|
+
/** Does the bare repository exist (its `HEAD` file)? */
|
|
52
|
+
exists(): boolean;
|
|
53
|
+
/** `git init --bare -b <branch>` (idempotent: an existing repository is left as it is). */
|
|
54
|
+
init(branch: string): void;
|
|
55
|
+
/** Delete the bare repository. */
|
|
56
|
+
remove(): boolean;
|
|
57
|
+
run(args: string[], opts?: GitRunOptions & {
|
|
58
|
+
noGitDir?: boolean;
|
|
59
|
+
}): GitRunResult;
|
|
60
|
+
text(args: string[], opts?: GitRunOptions): string;
|
|
61
|
+
/** The branch tip, or null when the ref does not exist. */
|
|
62
|
+
tip(branch: string): string | null;
|
|
63
|
+
/** `git hash-object -w --stdin`: the blob's sha. */
|
|
64
|
+
writeBlob(bytes: Buffer | string): string;
|
|
65
|
+
/**
|
|
66
|
+
* Build a tree: the parent commit's tree (or empty) read into a temporary
|
|
67
|
+
* index, `update-index --index-info` with the adds (`100644 blob <sha>\t<path>`)
|
|
68
|
+
* and removes (`0 <zeros>\t<path>`), `write-tree`. Returns the tree sha. The
|
|
69
|
+
* index file lives inside the bare repository and is removed afterwards.
|
|
70
|
+
*/
|
|
71
|
+
buildTree(baseTree: string | null, adds: {
|
|
72
|
+
path: string;
|
|
73
|
+
blob: string;
|
|
74
|
+
}[], removes: string[]): string;
|
|
75
|
+
/** The tree a commit points at. */
|
|
76
|
+
treeOf(commit: string): string;
|
|
77
|
+
/** `git commit-tree` with the message on stdin and the identities/dates in the environment; the commit sha. `parent` may be several (a merge commit, 17.6). */
|
|
78
|
+
commitTree(tree: string, parent: string | string[] | null, message: string, id: CommitIdentity): string;
|
|
79
|
+
/** `git update-ref` with the old value as the compare-and-swap (`ZERO_SHA` = the ref must not exist). */
|
|
80
|
+
updateRef(branch: string, newSha: string, oldSha: string | null): void;
|
|
81
|
+
/** `git update-ref <ref> <new> <old>` on any full ref name (the ids ref, the remote-tracking and bookkeeping refs). */
|
|
82
|
+
updateRefRaw(ref: string, newSha: string, oldSha: string | null): void;
|
|
83
|
+
/** `git update-ref <ref> <new>` without a compare-and-swap (a tracking or bookkeeping ref that mirrors something else). */
|
|
84
|
+
setRef(ref: string, sha: string): void;
|
|
85
|
+
/** The author of a commit: `%an` and `%ae`. */
|
|
86
|
+
authorOf(commit: string): GitIdentity;
|
|
87
|
+
/** `git update-ref -d <ref>`; a missing ref is fine. */
|
|
88
|
+
deleteRef(ref: string): void;
|
|
89
|
+
/** The sha a full ref name points at, or null when it does not exist. */
|
|
90
|
+
ref(ref: string): string | null;
|
|
91
|
+
/** Is `ancestor` an ancestor of (or equal to) `descendant`? (`git merge-base --is-ancestor`.) */
|
|
92
|
+
isAncestor(ancestor: string, descendant: string): boolean;
|
|
93
|
+
/** `git merge-base a b`, or null when the histories are unrelated. */
|
|
94
|
+
mergeBase(a: string, b: string): string | null;
|
|
95
|
+
/** The commits reachable from `to` and not from `from`, oldest first (`git rev-list --reverse from..to`; `from` null = all of `to`). */
|
|
96
|
+
revList(from: string | null, to: string): string[];
|
|
97
|
+
/** The parents of a commit. */
|
|
98
|
+
parentsOf(commit: string): string[];
|
|
99
|
+
/** A tree-ish's blobs: `path → blob sha` (`git ls-tree -r`; only blobs — the content branch holds nothing else). */
|
|
100
|
+
treeEntries(treeish: string): Map<string, string>;
|
|
101
|
+
/** The paths whose blob differs between two tree-ishes: `path → the blob in b` (null = gone in b). `git diff-tree -r`. */
|
|
102
|
+
diffTrees(a: string, b: string): Map<string, string | null>;
|
|
103
|
+
/** A blob's bytes. */
|
|
104
|
+
blob(sha: string): Buffer;
|
|
105
|
+
/** The configured remotes (`git remote`). */
|
|
106
|
+
remotes(): string[];
|
|
107
|
+
/** `git remote add` or `set-url` so that `name` points at `url`; `remote.<name>.fetch` left as git sets it (the tracking refs under `refs/remotes/<name>/`). */
|
|
108
|
+
setRemote(name: string, url: string): void;
|
|
109
|
+
/** `git remote remove`; a missing remote is fine. */
|
|
110
|
+
removeRemote(name: string): void;
|
|
111
|
+
/**
|
|
112
|
+
* `git push <remote> <refspec>…` — NEVER `--force`. Returns `ok`, or `rejected`
|
|
113
|
+
* when the remote refused a non-fast-forward (git's `[rejected]` /
|
|
114
|
+
* `non-fast-forward` / `fetch first` / `stale info` lines), or `failed` with
|
|
115
|
+
* git's stderr for anything else (no network, no such remote).
|
|
116
|
+
*/
|
|
117
|
+
push(remote: string, refspecs: string[]): {
|
|
118
|
+
status: "ok";
|
|
119
|
+
} | {
|
|
120
|
+
status: "rejected";
|
|
121
|
+
stderr: string;
|
|
122
|
+
} | {
|
|
123
|
+
status: "failed";
|
|
124
|
+
stderr: string;
|
|
125
|
+
};
|
|
126
|
+
/** `git fetch <remote> <refspec>…` (the refspecs may carry `+`: a tracking ref follows the remote wherever it went). Returns null on success, git's stderr on failure. */
|
|
127
|
+
fetch(remote: string, refspecs: string[]): string | null;
|
|
128
|
+
/** `git ls-remote <remote> <ref>`: the sha the remote holds for `ref`, null when it has none; throws when the remote cannot be reached. */
|
|
129
|
+
lsRemote(remote: string, ref: string): string | null;
|
|
130
|
+
/** The type of an object (`commit`, `blob`, `tree`, `tag`), or null when the object is missing. */
|
|
131
|
+
objectType(sha: string): string | null;
|
|
132
|
+
/** A commit's raw message (`%B`). */
|
|
133
|
+
message(commit: string): string;
|
|
134
|
+
/**
|
|
135
|
+
* The commits of `branch`, oldest first, each as `{ sha, committerUnix, message }`;
|
|
136
|
+
* `grep` (fixed string) narrows to the commits whose message contains it.
|
|
137
|
+
*/
|
|
138
|
+
log(branch: string, opts?: {
|
|
139
|
+
grep?: string;
|
|
140
|
+
regex?: string;
|
|
141
|
+
}): {
|
|
142
|
+
sha: string;
|
|
143
|
+
committerUnix: number;
|
|
144
|
+
message: string;
|
|
145
|
+
}[];
|
|
146
|
+
/**
|
|
147
|
+
* `git cat-file --batch` over `<commit>:<path>` specs: the bytes of each, or
|
|
148
|
+
* null when the object is missing (a path not in that tree). One spawn.
|
|
149
|
+
*/
|
|
150
|
+
blobsAt(specs: string[]): (Buffer | null)[];
|
|
151
|
+
/** `git ls-tree -r <ref>` as text (the fixtures' projection). */
|
|
152
|
+
lsTree(ref: string): string;
|
|
153
|
+
/** `git count-objects -v` as a map (the measurement script's `size`/`size-pack`, in KiB). */
|
|
154
|
+
countObjects(): Record<string, number>;
|
|
155
|
+
/** `git gc --quiet` (the measurement script packs before counting). */
|
|
156
|
+
gc(): void;
|
|
157
|
+
}
|