From 8cdd32cbe54389f4d787ffc95b0c7a3c984e0936 Mon Sep 17 00:00:00 2001 From: Gregor Mitscha-Baude Date: Fri, 6 Mar 2026 01:46:59 +0100 Subject: [PATCH 1/4] feat: git object identity library + repo state model with IndexedDB storage - src/git/: pure functions for blob/tree/commit SHA computation, byte-for-byte compatible with real Git. 38 oracle tests against git CLI. - src/storage/repo-types.ts: TypeScript types for the full repo state model (three-snapshot model, flat path maps, merge state, refs). - src/storage/repo-db.ts: IndexedDB persistence layer with clean async API. 29 tests covering roundtrip, multi-repo isolation, file ops. - docs/vibenote-git-sync-design.md: design doc for the git sync rebuild. Tasks: git-object-identity (done), repo-state-storage (done) --- docs/vibenote-git-sync-design.md | 801 +++++++++++++++++++++++++++++++ package-lock.json | 11 + package.json | 1 + src/git/index.ts | 5 + src/git/objects.test.ts | 620 ++++++++++++++++++++++++ src/git/objects.ts | 214 +++++++++ src/git/types.ts | 36 ++ src/storage/repo-db.test.ts | 447 +++++++++++++++++ src/storage/repo-db.ts | 690 ++++++++++++++++++++++++++ src/storage/repo-types.ts | 266 ++++++++++ tasks/git-object-identity.md | 46 ++ tasks/repo-state-storage.md | 58 +++ 12 files changed, 3195 insertions(+) create mode 100644 docs/vibenote-git-sync-design.md create mode 100644 src/git/index.ts create mode 100644 src/git/objects.test.ts create mode 100644 src/git/objects.ts create mode 100644 src/git/types.ts create mode 100644 src/storage/repo-db.test.ts create mode 100644 src/storage/repo-db.ts create mode 100644 src/storage/repo-types.ts create mode 100644 tasks/git-object-identity.md create mode 100644 tasks/repo-state-storage.md diff --git a/docs/vibenote-git-sync-design.md b/docs/vibenote-git-sync-design.md new file mode 100644 index 0000000..618a483 --- /dev/null +++ b/docs/vibenote-git-sync-design.md @@ -0,0 +1,801 @@ +# Vibenote local Git model and one-click sync + +## Summary + +This document captures the design direction that emerged while thinking about how **Vibenote** should model a local repository and implement a single **Sync** button against GitHub. + +The original question started from Git object identity: how GitHub computes blob SHAs, which led to the exact Git object formats for **blobs**, **trees**, and **commits**. From there, the design space became much clearer: + +- Vibenote already uses GitHub APIs such as `/git/blobs`, `/git/trees`, `/git/commits`, `/git/refs`, and related endpoints for remote persistence. +- What is missing is not remote transport, but the **local Git-shaped model**: + - represent the local repo state, + - detect which paths are dirty, + - construct correct trees and commits, + - integrate remote updates, + - and do all of that in a browser-friendly way. + +The goal is therefore **not** to embed all of Git, packfiles, SSH, or the CLI. The goal is a browser-local model that is **Git-compatible at the object level** and supports a simple UX: + +- the user edits notes locally, +- presses **Sync**, +- Vibenote automatically merges remote changes and pushes local changes, +- conflicts are handled automatically as a best effort. + +Conflict resolution assumptions: + +- **Markdown notes**: use the app’s custom three-way merge implementation. +- **Binary files**: use **theirs** semantics, meaning remote wins. +- **Other text files**: use a fallback strategy to be defined later. + +A key requirement throughout is that all computed object SHAs must match Git exactly. + +--- + +## Design goals + +The local model should satisfy the following constraints: + +1. **Git-shaped, but not Git-complete** + - Model blobs, trees, commits, refs, and merge state. + - Avoid unnecessary implementation of packfiles, hooks, filters, or transport protocols. + +2. **Browser-friendly** + - Store working files and metadata in browser storage. + - Avoid dependencies on OS filesystem semantics. + +3. **Deterministic and testable** + - Blob/tree/commit identity must be byte-for-byte compatible with Git. + - Sync behavior should be understandable as a composition of standard Git operations. + +4. **Single-button UX** + - The user gets a simple Sync button. + - Sync should effectively behave like: fetch remote tip, merge, commit if needed, update ref, retry on races. + +5. **Good support for future evolution** + - The type model should leave room for local unpublished commits, richer merge bookkeeping, and later improvements to status handling. + +--- + +## Conceptual model + +The cleanest mental model uses three main snapshots: + +- **BASE**: the last commit/tree that local state is known to be synced against. +- **REMOTE**: the latest fetched remote commit/tree for the branch. +- **LOCAL**: the current local working content, potentially materialized into a local commit during Sync. + +These correspond closely to the three inputs of a standard three-way merge. + +The local repository should therefore keep: + +- a canonical snapshot of the last synced tree, +- the current working files, +- optional staged/index state, +- branch/ref information, +- merge bookkeeping, +- caches for computed blob hashes. + +--- + +## Suggested TypeScript model for repo state + +The following type-heavy snippets are intended as a good starting point. They are Git-shaped without being overly tied to implementation details. + +### Core opaque types + +```ts +export type GitSha = string & { readonly __brand: "GitSha" }; +export type Path = string & { readonly __brand: "Path" }; + +export type FileMode = "100644" | "100755" | "120000" | "040000"; +``` + +These brands help prevent accidental confusion between arbitrary strings and Git object IDs or paths. + +--- + +### Canonical tree snapshot + +```ts +export interface SnapshotEntry { + mode: FileMode; + sha: GitSha; +} + +export interface TreeSnapshot { + /** Root tree object id */ + rootTree: GitSha; + + /** Recursive flat map, like `git ls-tree -r` */ + entries: Map; +} +``` + +A flat recursive map is often the easiest format for dirty detection, merges, and tree reconstruction. + +--- + +### Base and remote snapshots + +```ts +export interface BaseSnapshot extends TreeSnapshot { + /** Last synced commit, used as merge base */ + baseCommit: GitSha | null; +} + +export interface RemoteSnapshot extends TreeSnapshot { + /** Most recently fetched remote branch tip */ + remoteCommit: GitSha | null; +} +``` + +`BASE` is the common ancestor from the local app’s point of view. `REMOTE` is the latest known server view. + +--- + +### Working files + +```ts +export interface WorkingFile { + path: Path; + mode: Exclude; + content: Uint8Array; + size: number; + + /** Logical modification timestamp, purely app-defined */ + mtime?: number; + + /** Cached blob sha for current content, if already computed */ + blobSha?: GitSha; +} +``` + +A browser app does not need a real OS `mtime`; it may store a logical timestamp or monotonic version number. It is only an optimization hint for avoiding unnecessary re-hashing. + +--- + +### Index / staging area + +A minimal app could skip an index at first, but keeping the type in mind is useful because it maps very naturally to merges. + +```ts +export type IndexStage = 0 | 1 | 2 | 3; + +export interface IndexEntry { + path: Path; + mode: FileMode; + stage: IndexStage; + sha: GitSha; +} + +export interface IndexState { + entries: Map; +} +``` + +Meaning of stages: + +- `0`: normal staged content +- `1`: merge base +- `2`: ours +- `3`: theirs + +Even if the first implementation does not expose explicit staging to the user, this structure is still useful internally when a sync performs a merge. + +--- + +### Status model + +```ts +export type FileStatus = + | "unmodified" + | "modified" + | "added" + | "deleted" + | "untracked" + | "conflicted"; + +export interface StatusEntry { + path: Path; + status: FileStatus; + mode?: FileMode; + headSha?: GitSha; + indexSha?: GitSha; + worktreeSha?: GitSha; +} +``` + +For Vibenote, status is primarily an internal implementation detail that supports Sync and diagnostics, but the model is still useful to keep explicit. + +--- + +### Merge bookkeeping + +```ts +export interface ConflictPayload { + base?: Uint8Array; + ours?: Uint8Array; + theirs?: Uint8Array; +} + +export interface MergeState { + inProgress: boolean; + targetCommit?: GitSha; + conflictedPaths: Set; + conflicts?: Map; +} +``` + +Even though the UX resolves conflicts automatically, an explicit merge state is still valuable for debugging, retries, telemetry, or a future “show what happened” feature. + +--- + +### Refs and remote configuration + +```ts +export interface Ref { + name: `refs/heads/${string}` | `refs/tags/${string}`; + sha: GitSha | null; +} + +export interface RemoteRef { + name: `refs/remotes/${string}/${string}`; + sha: GitSha | null; +} + +export interface RemoteConfig { + name: string; + url: string; +} + +export interface BranchState { + head: Ref; + upstream?: RemoteRef; +} +``` + +This is enough structure for a branch-oriented browser app without attempting to mirror every detail of Git config. + +--- + +### Commit envelope + +```ts +export interface Signature { + name: string; + email: string; + timestamp: number; // Unix seconds + timezoneOffsetMinutes: number; +} + +export interface PendingCommit { + tree: GitSha; + parents: GitSha[]; + author: Signature; + committer: Signature; + message: string; +} +``` + +It is useful to model the commit payload explicitly before it is turned into a real commit object and sent to GitHub. + +--- + +### Caches and config + +```ts +export interface HashCache { + entries: Map; +} + +export interface IgnoreRules { + patterns: string[]; +} + +export interface RepoConfig { + eol?: "lf" | "crlf" | "as-is"; + caseSensitive?: boolean; + enableRenameDetect?: boolean; +} +``` + +The hash cache may be keyed by a stable serialization of `(path, size, mtime)` or a similar tuple. + +--- + +### Whole repo state + +```ts +export interface RepoState { + repoId: string; + + remote: RemoteConfig; + branch: BranchState; + + base: BaseSnapshot; + remoteSnapshot: RemoteSnapshot; + + workingFiles: Map; + index: IndexState; + status: Map; + merge: MergeState; + + ignore: IgnoreRules; + config: RepoConfig; + hashCache: HashCache; + + version: number; + locks?: { + sync: boolean; + index: boolean; + }; +} +``` + +This is intentionally compact. The important part is that it cleanly separates: + +- authoritative last-synced state (`base`), +- latest fetched remote state (`remoteSnapshot`), +- current local working content (`workingFiles`), +- intermediate merge/index details. + +--- + +## Notes on why this structure is useful + +### Flat tree maps are easier than nested trees + +Git trees are hierarchical objects, but most application logic becomes easier when the local state uses a flat map of: + +- `path -> { mode, sha }` + +This simplifies: + +- dirty detection, +- delete detection, +- merge comparisons, +- tree reconstruction, +- conflict reporting. + +Nested tree objects can be reconstructed later when building Git tree objects. + +### Why keep both BASE and REMOTE? + +Because Sync is effectively a repeated three-way merge process. + +- `BASE` is what local edits were made against. +- `REMOTE` is what the server currently has. +- `LOCAL` is what the user currently wants. + +Without explicit `BASE`, conflict handling and merge decisions become fragile. + +### Why keep an index if the user only has Sync? + +Even with a single Sync button, the index model is still useful because merges naturally want a staging area-like structure. + +It is perfectly reasonable to hide staging from the user while still using Git’s conceptual separation internally. + +--- + +## Design of the Sync flow + +The Sync button should conceptually behave like: + +1. fetch remote branch tip, +2. compute local changes, +3. merge remote and local changes using a three-way merge, +4. create a commit if needed, +5. update the branch ref optimistically, +6. retry if another writer moved the branch in the meantime. + +This can be described in lower-level Git terms as follows. + +### Inputs to Sync + +At the beginning of Sync, the app has: + +- `BASE.commit` and `BASE.tree` +- current `workingFiles` +- branch name / ref +- GitHub remote endpoints + +### Step 1: fetch current remote tip + +Query the current branch ref and its commit/tree. + +Conceptually: + +- read `refs/heads/` to get `R_tip` +- read the commit object at `R_tip` +- read the root tree recursively to form `REMOTE` + +At this point the app has: + +- `BASE` +- `REMOTE` +- current local working state + +### Step 2: determine local changes against BASE + +Compute the working delta relative to `BASE.entries`. + +For each relevant path: + +- if present in both and blob SHA differs: `modified` +- if present in local only: `added` or `untracked` +- if present in BASE only: `deleted` +- otherwise: unchanged + +Blob SHAs should be cached aggressively, but correctness should not depend on the cache. + +### Step 3: materialize LOCAL as a tree + +Build the local intended tree from current working content. + +Conceptually this means: + +- create blob objects for new or changed files, +- construct a new tree using `BASE.tree` as the base tree, +- reuse existing object IDs where content did not change. + +If there are no local changes, then `LOCAL` is effectively equal to `BASE`. + +### Step 4: classify the sync case + +There are three main cases. + +#### Case A: no local changes, no remote changes + +If `REMOTE.commit == BASE.commit` and there are no local changes: + +- do nothing, +- maybe refresh cached remote metadata, +- finish. + +#### Case B: local changes only + +If `REMOTE.commit == BASE.commit` and local changes exist: + +- create a commit from the local tree with parent `BASE.commit`, +- attempt to fast-forward the branch ref to that commit, +- if the ref update succeeds, Sync is done, +- if the ref update fails because someone raced the update, fetch again and continue into the merge case. + +This is the cleanest case. + +#### Case C: remote moved since BASE + +If `REMOTE.commit != BASE.commit`, a merge is required. + +The merge inputs are: + +- `BASE` = last synced state +- `OURS` = local materialized tree +- `THEIRS` = current remote tree + +These are exactly the standard inputs to a three-way merge. + +### Step 5: perform path-wise three-way merge + +For each path in the union of all three trees, apply the app’s merge policy. + +#### Markdown notes + +Use the custom note-aware three-way merge. + +This is the highest-value path because notes are the primary domain object of the app. + +#### Binary files + +Use **theirs** semantics. + +In other words: + +- if remote changed the binary file, remote wins, +- local binary edits are discarded in favor of remote when there is divergence. + +This is a deliberate product choice and should be documented clearly. + +#### Other text files + +Use a fallback strategy. + +The final policy is still open, but the design should assume there is always some best-effort path that produces output rather than surfacing manual conflicts to the user. + +### Step 6: build merged tree + +Once merged content is available per path: + +- create blob objects for merged content as needed, +- construct the merged tree object, +- compute its root tree SHA. + +### Step 7: create the resulting commit + +There are two sensible commit shapes. + +#### If remote did not move + +When only local changes existed, create a normal commit: + +- parent list: `[BASE.commit]` + +#### If a merge happened + +Create a merge commit: + +- parent list: `[REMOTE.commit, LOCAL.commit]` + +That reflects the fact that Sync combined two histories. + +This is a clean Git-native representation and preserves the causal structure correctly. + +### Step 8: update the branch ref optimistically + +Attempt to move the branch ref to the resulting commit using a non-force update. + +If this succeeds: + +- the sync is complete, +- update `BASE` to the new commit/tree, +- refresh local snapshots, +- clear merge state. + +If this fails: + +- another actor updated the remote branch after the app fetched it, +- fetch the new remote tip, +- rerun the merge using the same local intent against the new remote state, +- retry until success or a bounded retry limit is reached. + +### Step 9: finalize local state + +After a successful sync: + +- `BASE` becomes the new synced commit/tree, +- `REMOTE` is updated accordingly, +- status becomes clean, +- transient merge/index data can be cleared. + +--- + +## High-level Git interpretation of Sync + +A useful way to think about Sync is: + +- build a local commit from current working content, +- if remote has not moved, push that commit, +- otherwise, perform an automatic three-way merge, +- create a merge commit, +- push that merge commit, +- retry if the push races with another writer. + +That means Sync is not a magical proprietary operation. It is a constrained composition of standard Git concepts: + +- tree construction, +- commit construction, +- ref update, +- three-way merge, +- optimistic concurrency with retries. + +This is a strong property because it keeps the system understandable and testable. + +--- + +## Object identity and Git hashes + +One of the key insights that motivated this design is that Git object identity is extremely structured. + +Git does not hash just the file contents. It hashes: + +- an object-type header, +- the object length in bytes, +- a null byte, +- the canonical content bytes of the object. + +Conceptually: + +- blob SHA = hash of `"blob \0"` +- tree SHA = hash of `"tree \0"` +- commit SHA = hash of `"commit \0"` + +This matters because Vibenote is relying on GitHub’s Git object model. If its local SHA calculations are wrong, then everything built on top of them becomes unreliable. + +### Important details for trees + +Tree entries are especially easy to get subtly wrong. + +Important rules include: + +- each entry is encoded as ` \0` +- the referenced object ID is binary, not hexadecimal text +- entries must be ordered canonically +- the exact byte representation matters + +### Important details for commits + +Commit objects are textual, but still highly structured. + +Important rules include: + +- one `tree` line +- zero or more `parent` lines +- exactly one `author` line +- exactly one `committer` line +- exactly one blank line separating headers from message +- timestamps and timezone formatting must be canonical + +### Author vs committer + +Git stores both an author and a committer. + +- **Author** = who originally wrote the change +- **Committer** = who recorded this commit object into history + +For Vibenote, using the same identity for both is often fine, but it is still helpful to keep the distinction in the data model. + +--- + +## Testing strategy for hash correctness + +The most important recommendation is: + +**Treat real Git as the oracle.** + +Even if the app never shells out to Git in production, tests should verify that the implementation produces exactly the same SHAs as Git for the same logical objects. + +### Recommended test philosophy + +The tests should compare the app’s computed object IDs against object IDs produced by the real `git` CLI. + +This is especially important for: + +- blobs, +- trees, +- commits, +- edge cases involving filenames, modes, and commit metadata. + +### Blob tests + +The app should be tested on blobs covering at least: + +- empty files, +- small ASCII files, +- UTF-8 text, +- large files, +- unusual byte patterns. + +The expected blob IDs should come from Git, not from a duplicated implementation. + +### Tree tests + +Tree tests should cover: + +- ordinary files, +- executable files, +- symlinks, +- nested directories, +- tricky filenames, +- empty trees. + +The canonical ordering of tree entries should be tested carefully. + +### Commit tests + +Commit tests should use fixed metadata so that expected IDs are deterministic. + +In particular, the following must be fixed explicitly in test fixtures: + +- tree SHA, +- parent SHAs, +- author name/email/timestamp/timezone, +- committer name/email/timestamp/timezone, +- commit message. + +Merge commits with two parents should also be covered. + +### Edge cases worth including + +The test corpus should include at least: + +- empty blob and empty tree, +- root commit with no parents, +- merge commit with multiple parents, +- filenames with spaces and Unicode, +- executable bit changes, +- symlinks, +- timezone offsets that are not whole hours, +- line ending normalization decisions. + +### Cross-checking with a second implementation + +As an optional extra safety net, the app’s object computations can also be compared to a second independent implementation such as a Git library. + +That should not replace real-Git oracle tests, but it can help narrow down bugs when a mismatch is discovered. + +--- + +## Practical implementation guidance + +A few implementation choices seem especially good for Vibenote. + +### Prefer a flat local map over nested structures + +Store local working content and canonical snapshots as flat `path -> entry` maps. It matches the way diffs and merges want to operate. + +### Cache aggressively, but never trust the cache for correctness + +A cached blob SHA keyed by path and logical metadata is useful for performance, but it must always be safe to recompute from content. + +### Keep merge semantics explicit + +The product-specific merge policies are important enough to encode clearly: + +- Markdown: custom three-way merge +- Binary: theirs wins +- Other text: best-effort fallback + +That policy should not be buried in incidental code paths. + +### Keep Sync retryable and idempotent + +A sync attempt may race with another writer. The system should assume this is normal. + +The right response is not to treat it as an exceptional disaster, but to: + +- refetch, +- re-merge, +- retry. + +### Preserve Git-native concepts even if the UI is simpler + +The user only sees a single Sync button, but internally the implementation benefits from preserving Git-native concepts such as: + +- base commit, +- tree snapshots, +- commit parents, +- merge commits, +- ref updates. + +This gives a clean conceptual model and makes correctness reasoning much easier. + +--- + +## Recommended first implementation scope + +A reasonable first version would implement: + +1. local working file model +2. base and remote snapshots +3. blob/tree/commit object construction +4. dirty detection against BASE +5. one-click Sync with automatic three-way merge +6. optimistic ref update with retry +7. Git-oracle test suite for object identity + +And explicitly defer: + +- advanced rename detection, +- `.gitattributes` and filters, +- LFS, +- complicated text merge heuristics for non-markdown files, +- full staging UX. + +This keeps the system sharply focused on what Vibenote actually needs. + +--- + +## Final perspective + +The design here is intentionally narrow: not “implement Git in the browser”, but “implement the subset of Git’s object and merge model needed for a browser-native note app with a strong GitHub backend”. + +That narrowness is a strength. + +By staying close to Git’s real object model: + +- object IDs remain compatible, +- commits and trees remain understandable, +- Sync remains a composition of standard Git operations, +- and correctness can be tested directly against Git itself. + +For Vibenote, that is likely the right level of ambition. diff --git a/package-lock.json b/package-lock.json index 9db557f..34ffb35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "@types/node": "^24.5.2", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", + "fake-indexeddb": "^6.2.5", "jsdom": "^27.0.0", "typescript": "^5.9.3", "vite": "^5.4.0", @@ -1903,6 +1904,16 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/finalhandler": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", diff --git a/package.json b/package.json index 9fd4004..3df4fa8 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@types/node": "^24.5.2", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", + "fake-indexeddb": "^6.2.5", "jsdom": "^27.0.0", "typescript": "^5.9.3", "vite": "^5.4.0", diff --git a/src/git/index.ts b/src/git/index.ts new file mode 100644 index 0000000..b90fb9f --- /dev/null +++ b/src/git/index.ts @@ -0,0 +1,5 @@ +// Barrel re-export for the git object identity library. +// Import from here rather than the internal modules directly. + +export { blobSha, treeSha, buildTree, commitSha } from "./objects.ts"; +export type { FileMode, GitSha, Path, PendingCommit, Signature, TreeEntry } from "./types.ts"; diff --git a/src/git/objects.test.ts b/src/git/objects.test.ts new file mode 100644 index 0000000..0f9e341 --- /dev/null +++ b/src/git/objects.test.ts @@ -0,0 +1,620 @@ +// Tests for src/git/objects.ts — verifies that blobSha, treeSha, buildTree, +// and commitSha produce byte-for-byte identical results to the real `git` CLI. +// The git CLI is treated as the oracle: we compute objects with git, then +// compare against our implementation. + +import { execSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { blobSha, buildTree, commitSha, treeSha } from "./objects.ts"; +import type { FileMode, GitSha, Path, Signature, TreeEntry } from "./types.ts"; + +// --------------------------------------------------------------------------- +// Git oracle helpers +// --------------------------------------------------------------------------- + +// Shared temp git repo — initialised once and reused for all tests +let gitDir: string; + +beforeAll(() => { + gitDir = mkdtempSync(join(tmpdir(), "vibenote-git-test-")); + execSync("git init", { cwd: gitDir }); + execSync('git config user.email "oracle@example.com"', { cwd: gitDir }); + execSync('git config user.name "Oracle"', { cwd: gitDir }); +}); + +afterAll(() => { + rmSync(gitDir, { recursive: true, force: true }); +}); + +// Compute a blob SHA using `git hash-object` (writes content to a temp file +// to correctly handle null bytes and arbitrary binary data) +function oracleHashBlob(content: Uint8Array): string { + const tmpFile = join(gitDir, ".tmp-blob"); + writeFileSync(tmpFile, content); + return execSync(`git hash-object "${tmpFile}"`, { cwd: gitDir }) + .toString() + .trim(); +} + +type MkTreeEntry = { + mode: string; // "100644" | "100755" | "120000" | "040000" + type: "blob" | "tree" | "commit"; + sha: string; + name: string; +}; + +// Compute a tree SHA using `git mktree` (reads ls-tree-format lines from stdin). +// Uses --missing so blobs/trees don't need to exist in the object store. +function oracleMkTree(entries: MkTreeEntry[]): string { + // git mktree expects: SP SP TAB + const input = entries.map((e) => `${e.mode} ${e.type} ${e.sha}\t${e.name}`).join("\n"); + return execSync("git mktree --missing", { input, cwd: gitDir }).toString().trim(); +} + +// Format a Signature into the git date string expected by GIT_*_DATE env vars +function formatGitDate(sig: Signature): string { + const sign = sig.timezoneOffsetMinutes >= 0 ? "+" : "-"; + const abs = Math.abs(sig.timezoneOffsetMinutes); + const hh = Math.floor(abs / 60) + .toString() + .padStart(2, "0"); + const mm = (abs % 60).toString().padStart(2, "0"); + return `${sig.timestamp} ${sign}${hh}${mm}`; +} + +// Compute a commit SHA using `git commit-tree` with deterministic env vars +function oracleCommitTree( + treeShaHex: string, + parents: string[], + author: Signature, + committer: Signature, + message: string, +): string { + const env = { + ...process.env, + GIT_AUTHOR_NAME: author.name, + GIT_AUTHOR_EMAIL: author.email, + GIT_AUTHOR_DATE: formatGitDate(author), + GIT_COMMITTER_NAME: committer.name, + GIT_COMMITTER_EMAIL: committer.email, + GIT_COMMITTER_DATE: formatGitDate(committer), + }; + + // Write message to a temp file to avoid shell-escaping issues. + // Normalize to have a trailing newline — same as our commitSha implementation. + const msgFile = join(gitDir, ".tmp-commit-msg"); + const msgNormalized = message.endsWith("\n") ? message : message + "\n"; + writeFileSync(msgFile, msgNormalized, "utf8"); + + const parentArgs = parents.map((p) => `-p ${p}`).join(" "); + const cmd = `git commit-tree ${treeShaHex} ${parentArgs} -F "${msgFile}"`.trim(); + return execSync(cmd, { cwd: gitDir, env }).toString().trim(); +} + +// --------------------------------------------------------------------------- +// Blob SHA tests +// --------------------------------------------------------------------------- + +describe("blobSha", () => { + it("empty blob", async () => { + const content = new Uint8Array(0); + expect(await blobSha(content)).toBe(oracleHashBlob(content)); + }); + + it("simple ASCII content", async () => { + const content = new TextEncoder().encode("hello world\n"); + expect(await blobSha(content)).toBe(oracleHashBlob(content)); + }); + + it("UTF-8 content", async () => { + const content = new TextEncoder().encode("こんにちは 🌸\n"); + expect(await blobSha(content)).toBe(oracleHashBlob(content)); + }); + + it("multi-line markdown note", async () => { + const text = "# My Note\n\nSome content with **bold** and _italic_.\n"; + const content = new TextEncoder().encode(text); + expect(await blobSha(content)).toBe(oracleHashBlob(content)); + }); + + it("content with null bytes", async () => { + const content = new Uint8Array([0x00, 0x01, 0x02, 0x00, 0xff]); + expect(await blobSha(content)).toBe(oracleHashBlob(content)); + }); + + it("content with only a newline", async () => { + const content = new TextEncoder().encode("\n"); + expect(await blobSha(content)).toBe(oracleHashBlob(content)); + }); + + it("large content", async () => { + // 100 KB of repeated bytes + const content = new Uint8Array(100_000).fill(65); // 'A' + expect(await blobSha(content)).toBe(oracleHashBlob(content)); + }); + + it("known SHA-1 constant: empty blob", async () => { + // git's empty blob is a well-known constant + const sha = await blobSha(new Uint8Array(0)); + expect(sha).toBe("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"); + }); +}); + +// --------------------------------------------------------------------------- +// Tree SHA tests +// --------------------------------------------------------------------------- + +describe("treeSha", () => { + it("empty tree (zero entries)", async () => { + // /dev/null is an empty file — git treats it as an empty tree object + const gitEmpty = execSync("git hash-object -t tree /dev/null", { cwd: gitDir }) + .toString() + .trim(); + expect(await treeSha([])).toBe(gitEmpty); + }); + + it("known SHA-1 constant: empty tree", async () => { + // git's empty tree is a well-known constant + expect(await treeSha([])).toBe("4b825dc642cb6eb9a060e54bf8d69288fbee4904"); + }); + + it("single regular file", async () => { + const content = new TextEncoder().encode("hello\n"); + const sha = oracleHashBlob(content) as GitSha; + + const entries: TreeEntry[] = [{ mode: "100644", name: "hello.txt", sha }]; + const oracleSha = oracleMkTree([{ mode: "100644", type: "blob", sha, name: "hello.txt" }]); + expect(await treeSha(entries)).toBe(oracleSha); + }); + + it("executable file", async () => { + const content = new TextEncoder().encode("#!/bin/sh\necho hi\n"); + const sha = oracleHashBlob(content) as GitSha; + + const entries: TreeEntry[] = [{ mode: "100755", name: "run.sh", sha }]; + const oracleSha = oracleMkTree([{ mode: "100755", type: "blob", sha, name: "run.sh" }]); + expect(await treeSha(entries)).toBe(oracleSha); + }); + + it("symlink", async () => { + const content = new TextEncoder().encode("target.txt"); + const sha = oracleHashBlob(content) as GitSha; + + const entries: TreeEntry[] = [{ mode: "120000", name: "link.txt", sha }]; + const oracleSha = oracleMkTree([{ mode: "120000", type: "blob", sha, name: "link.txt" }]); + expect(await treeSha(entries)).toBe(oracleSha); + }); + + it("multiple files — our sort order matches git", async () => { + const files = [ + { name: "zebra.md", text: "z\n" }, + { name: "apple.md", text: "a\n" }, + { name: "mango.md", text: "m\n" }, + ]; + const oracleEntries: MkTreeEntry[] = []; + const ourEntries: TreeEntry[] = []; + + for (const f of files) { + const content = new TextEncoder().encode(f.text); + const sha = oracleHashBlob(content) as GitSha; + oracleEntries.push({ mode: "100644", type: "blob", sha, name: f.name }); + ourEntries.push({ mode: "100644", name: f.name, sha }); + } + + expect(await treeSha(ourEntries)).toBe(oracleMkTree(oracleEntries)); + }); + + it("file vs directory with same name prefix — canonical ordering", async () => { + // "notes.md" should sort before "notes/" (directory) because '.' < '/' + const fileSha = oracleHashBlob(new TextEncoder().encode("note content\n")) as GitSha; + const subFileSha = oracleHashBlob(new TextEncoder().encode("sub content\n")) as GitSha; + + // Build the sub-tree for "notes/" first + const subTreeShaHex = oracleMkTree([ + { mode: "100644", type: "blob", sha: subFileSha, name: "readme.md" }, + ]); + const subTreeSha = subTreeShaHex as GitSha; + + const ourEntries: TreeEntry[] = [ + { mode: "100644", name: "notes.md", sha: fileSha }, + { mode: "040000", name: "notes", sha: subTreeSha }, + ]; + const oracleEntries: MkTreeEntry[] = [ + { mode: "100644", type: "blob", sha: fileSha, name: "notes.md" }, + { mode: "040000", type: "tree", sha: subTreeSha, name: "notes" }, + ]; + + expect(await treeSha(ourEntries)).toBe(oracleMkTree(oracleEntries)); + }); + + it("filename with spaces", async () => { + const content = new TextEncoder().encode("space content\n"); + const sha = oracleHashBlob(content) as GitSha; + + const entries: TreeEntry[] = [{ mode: "100644", name: "my note.md", sha }]; + const oracleSha = oracleMkTree([{ mode: "100644", type: "blob", sha, name: "my note.md" }]); + expect(await treeSha(entries)).toBe(oracleSha); + }); + + it("unicode filename", async () => { + const content = new TextEncoder().encode("unicode content\n"); + const sha = oracleHashBlob(content) as GitSha; + + const entries: TreeEntry[] = [{ mode: "100644", name: "日記.md", sha }]; + const oracleSha = oracleMkTree([{ mode: "100644", type: "blob", sha, name: "日記.md" }]); + expect(await treeSha(entries)).toBe(oracleSha); + }); + + it("sub-directory entry", async () => { + const fileSha = oracleHashBlob(new TextEncoder().encode("inner\n")) as GitSha; + const subTreeShaHex = oracleMkTree([ + { mode: "100644", type: "blob", sha: fileSha, name: "inner.md" }, + ]); + const subTreeSha = subTreeShaHex as GitSha; + + const ourEntries: TreeEntry[] = [{ mode: "040000", name: "subdir", sha: subTreeSha }]; + const oracleSha = oracleMkTree([{ mode: "040000", type: "tree", sha: subTreeSha, name: "subdir" }]); + expect(await treeSha(ourEntries)).toBe(oracleSha); + }); +}); + +// --------------------------------------------------------------------------- +// buildTree tests +// --------------------------------------------------------------------------- + +describe("buildTree", () => { + // Helper: build tree oracle recursively (mirrors buildTree's logic using git CLI) + function oracleBuildTree( + files: Map, + ): string { + const topEntries: MkTreeEntry[] = []; + const subdirs = new Map>(); + + for (const [path, entry] of files) { + const slashIdx = path.indexOf("/"); + if (slashIdx === -1) { + const type = entry.mode === "040000" ? "tree" : "blob"; + topEntries.push({ mode: entry.mode, type, sha: entry.sha, name: path }); + } else { + const dirName = path.slice(0, slashIdx); + const rest = path.slice(slashIdx + 1); + let sub = subdirs.get(dirName); + if (sub === undefined) { + sub = new Map(); + subdirs.set(dirName, sub); + } + sub.set(rest, entry); + } + } + + for (const [dirName, subFiles] of subdirs) { + const subSha = oracleBuildTree(subFiles); + topEntries.push({ mode: "040000", type: "tree", sha: subSha, name: dirName }); + } + + return oracleMkTree(topEntries); + } + + it("single file at root", async () => { + const sha = oracleHashBlob(new TextEncoder().encode("single\n")) as GitSha; + const files = new Map([ + ["single.md" as Path, { mode: "100644", sha }], + ]); + expect(await buildTree(files)).toBe(oracleBuildTree(new Map([["single.md", { mode: "100644", sha }]]))); + }); + + it("multiple files at root", async () => { + const shaA = oracleHashBlob(new TextEncoder().encode("aaa\n")) as GitSha; + const shaB = oracleHashBlob(new TextEncoder().encode("bbb\n")) as GitSha; + const files = new Map([ + ["a.md" as Path, { mode: "100644", sha: shaA }], + ["b.md" as Path, { mode: "100644", sha: shaB }], + ]); + const plain = new Map([ + ["a.md", { mode: "100644" as FileMode, sha: shaA }], + ["b.md", { mode: "100644" as FileMode, sha: shaB }], + ]); + expect(await buildTree(files)).toBe(oracleBuildTree(plain)); + }); + + it("one level of nesting", async () => { + const rootSha = oracleHashBlob(new TextEncoder().encode("readme\n")) as GitSha; + const subSha = oracleHashBlob(new TextEncoder().encode("note\n")) as GitSha; + const files = new Map([ + ["README.md" as Path, { mode: "100644", sha: rootSha }], + ["notes/hello.md" as Path, { mode: "100644", sha: subSha }], + ]); + const plain = new Map([ + ["README.md", { mode: "100644" as FileMode, sha: rootSha }], + ["notes/hello.md", { mode: "100644" as FileMode, sha: subSha }], + ]); + expect(await buildTree(files)).toBe(oracleBuildTree(plain)); + }); + + it("deep nesting (three levels)", async () => { + const sha = oracleHashBlob(new TextEncoder().encode("deep\n")) as GitSha; + const files = new Map([ + ["a/b/c/deep.md" as Path, { mode: "100644", sha }], + ]); + const plain = new Map([["a/b/c/deep.md", { mode: "100644" as FileMode, sha }]]); + expect(await buildTree(files)).toBe(oracleBuildTree(plain)); + }); + + it("multiple sub-directories", async () => { + const sha1 = oracleHashBlob(new TextEncoder().encode("one\n")) as GitSha; + const sha2 = oracleHashBlob(new TextEncoder().encode("two\n")) as GitSha; + const sha3 = oracleHashBlob(new TextEncoder().encode("three\n")) as GitSha; + const files = new Map([ + ["docs/one.md" as Path, { mode: "100644", sha: sha1 }], + ["src/two.ts" as Path, { mode: "100644", sha: sha2 }], + ["src/three.ts" as Path, { mode: "100644", sha: sha3 }], + ]); + const plain = new Map([ + ["docs/one.md", { mode: "100644" as FileMode, sha: sha1 }], + ["src/two.ts", { mode: "100644" as FileMode, sha: sha2 }], + ["src/three.ts", { mode: "100644" as FileMode, sha: sha3 }], + ]); + expect(await buildTree(files)).toBe(oracleBuildTree(plain)); + }); + + it("executable file in sub-directory", async () => { + const sha = oracleHashBlob(new TextEncoder().encode("#!/bin/sh\n")) as GitSha; + const files = new Map([ + ["scripts/run.sh" as Path, { mode: "100755", sha }], + ]); + const plain = new Map([["scripts/run.sh", { mode: "100755" as FileMode, sha }]]); + expect(await buildTree(files)).toBe(oracleBuildTree(plain)); + }); + + it("filename with spaces in nested path", async () => { + const sha = oracleHashBlob(new TextEncoder().encode("content\n")) as GitSha; + const files = new Map([ + ["my notes/hello world.md" as Path, { mode: "100644", sha }], + ]); + const plain = new Map([["my notes/hello world.md", { mode: "100644" as FileMode, sha }]]); + expect(await buildTree(files)).toBe(oracleBuildTree(plain)); + }); + + it("empty flat map produces empty tree", async () => { + const files = new Map(); + expect(await buildTree(files)).toBe("4b825dc642cb6eb9a060e54bf8d69288fbee4904"); + }); +}); + +// --------------------------------------------------------------------------- +// Commit SHA tests +// --------------------------------------------------------------------------- + +describe("commitSha", () => { + // A stable tree SHA to use in commit tests (empty tree — always available) + const emptyTreeSha = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" as GitSha; + + const alice: Signature = { + name: "Alice Smith", + email: "alice@example.com", + timestamp: 1_000_000_000, + timezoneOffsetMinutes: 0, + }; + + it("root commit — no parents", async () => { + const oracle = oracleCommitTree(emptyTreeSha, [], alice, alice, "Initial commit"); + const sha = await commitSha({ + tree: emptyTreeSha, + parents: [], + author: alice, + committer: alice, + message: "Initial commit", + }); + expect(sha).toBe(oracle); + }); + + it("commit with one parent", async () => { + // Create a real parent commit in the git repo first + const parentSha = oracleCommitTree(emptyTreeSha, [], alice, alice, "Parent commit"); + + const oracle = oracleCommitTree(emptyTreeSha, [parentSha], alice, alice, "Child commit"); + const sha = await commitSha({ + tree: emptyTreeSha, + parents: [parentSha as GitSha], + author: alice, + committer: alice, + message: "Child commit", + }); + expect(sha).toBe(oracle); + }); + + it("merge commit — two parents", async () => { + const parent1 = oracleCommitTree(emptyTreeSha, [], alice, alice, "Branch A"); + const parent2 = oracleCommitTree(emptyTreeSha, [], alice, alice, "Branch B"); + + const oracle = oracleCommitTree( + emptyTreeSha, + [parent1, parent2], + alice, + alice, + "Merge commit", + ); + const sha = await commitSha({ + tree: emptyTreeSha, + parents: [parent1 as GitSha, parent2 as GitSha], + author: alice, + committer: alice, + message: "Merge commit", + }); + expect(sha).toBe(oracle); + }); + + it("different author and committer", async () => { + const committer: Signature = { + name: "Bob Jones", + email: "bob@example.com", + timestamp: 1_000_001_000, + timezoneOffsetMinutes: 0, + }; + + const oracle = oracleCommitTree(emptyTreeSha, [], alice, committer, "Committed by Bob"); + const sha = await commitSha({ + tree: emptyTreeSha, + parents: [], + author: alice, + committer, + message: "Committed by Bob", + }); + expect(sha).toBe(oracle); + }); + + it("positive timezone offset (+0530 India)", async () => { + const india: Signature = { + ...alice, + timezoneOffsetMinutes: 330, // UTC+5:30 + }; + const oracle = oracleCommitTree(emptyTreeSha, [], india, india, "India timezone"); + const sha = await commitSha({ + tree: emptyTreeSha, + parents: [], + author: india, + committer: india, + message: "India timezone", + }); + expect(sha).toBe(oracle); + }); + + it("negative timezone offset (-0700 PDT)", async () => { + const pdt: Signature = { + ...alice, + timezoneOffsetMinutes: -420, // UTC-7 + }; + const oracle = oracleCommitTree(emptyTreeSha, [], pdt, pdt, "PDT timezone"); + const sha = await commitSha({ + tree: emptyTreeSha, + parents: [], + author: pdt, + committer: pdt, + message: "PDT timezone", + }); + expect(sha).toBe(oracle); + }); + + it("non-whole-hour offset (+0545 Nepal)", async () => { + const nepal: Signature = { + ...alice, + timezoneOffsetMinutes: 345, // UTC+5:45 + }; + const oracle = oracleCommitTree(emptyTreeSha, [], nepal, nepal, "Nepal timezone"); + const sha = await commitSha({ + tree: emptyTreeSha, + parents: [], + author: nepal, + committer: nepal, + message: "Nepal timezone", + }); + expect(sha).toBe(oracle); + }); + + it("multi-line commit message", async () => { + const message = "First line\n\nParagraph body.\nMore body.\n"; + const oracle = oracleCommitTree(emptyTreeSha, [], alice, alice, message); + const sha = await commitSha({ + tree: emptyTreeSha, + parents: [], + author: alice, + committer: alice, + message, + }); + expect(sha).toBe(oracle); + }); + + it("message without trailing newline is normalised to match message with trailing newline", async () => { + // Both oracle helper and commitSha normalise the message to end with \n, + // so "foo" and "foo\n" should produce the same SHA. + const msgNoNewline = "No trailing newline"; + const msgWithNewline = "No trailing newline\n"; + + const oracleNoNl = oracleCommitTree(emptyTreeSha, [], alice, alice, msgNoNewline); + const oracleNl = oracleCommitTree(emptyTreeSha, [], alice, alice, msgWithNewline); + + // Both oracle calls normalise to \n, so they must match + expect(oracleNoNl).toBe(oracleNl); + + const shaNoNl = await commitSha({ + tree: emptyTreeSha, + parents: [], + author: alice, + committer: alice, + message: msgNoNewline, + }); + const shaNl = await commitSha({ + tree: emptyTreeSha, + parents: [], + author: alice, + committer: alice, + message: msgWithNewline, + }); + + expect(shaNoNl).toBe(oracleNoNl); + expect(shaNl).toBe(oracleNl); + }); + + it("commit with actual file content tree", async () => { + // Build a real tree with file content and use it in a commit + const content = new TextEncoder().encode("# Hello\n\nThis is a note.\n"); + const fileSha = oracleHashBlob(content) as GitSha; + const treeShaHex = oracleMkTree([ + { mode: "100644", type: "blob", sha: fileSha, name: "hello.md" }, + ]) as GitSha; + + const oracle = oracleCommitTree(treeShaHex, [], alice, alice, "Add hello.md"); + const sha = await commitSha({ + tree: treeShaHex, + parents: [], + author: alice, + committer: alice, + message: "Add hello.md", + }); + expect(sha).toBe(oracle); + }); + + it("commit with name containing special characters", async () => { + const special: Signature = { + name: "Ángel García", + email: "angel@example.com", + timestamp: 1_700_000_000, + timezoneOffsetMinutes: 60, + }; + const oracle = oracleCommitTree(emptyTreeSha, [], special, special, "UTF-8 name"); + const sha = await commitSha({ + tree: emptyTreeSha, + parents: [], + author: special, + committer: special, + message: "UTF-8 name", + }); + expect(sha).toBe(oracle); + }); + + it("three parents (octopus merge)", async () => { + const p1 = oracleCommitTree(emptyTreeSha, [], alice, alice, "P1"); + const p2 = oracleCommitTree(emptyTreeSha, [], alice, alice, "P2"); + const p3 = oracleCommitTree(emptyTreeSha, [], alice, alice, "P3"); + + const oracle = oracleCommitTree( + emptyTreeSha, + [p1, p2, p3], + alice, + alice, + "Octopus merge", + ); + const sha = await commitSha({ + tree: emptyTreeSha, + parents: [p1 as GitSha, p2 as GitSha, p3 as GitSha], + author: alice, + committer: alice, + message: "Octopus merge", + }); + expect(sha).toBe(oracle); + }); +}); diff --git a/src/git/objects.ts b/src/git/objects.ts new file mode 100644 index 0000000..eab5b79 --- /dev/null +++ b/src/git/objects.ts @@ -0,0 +1,214 @@ +// Pure functions for computing Git-compatible blob, tree, and commit SHA-1 +// hashes. All results are byte-for-byte identical to what the real `git` CLI +// produces. No storage, no network, no React — only Web Crypto (crypto.subtle). + +import type { FileMode, GitSha, Path, PendingCommit, Signature, TreeEntry } from "./types.ts"; + +export { blobSha, treeSha, buildTree, commitSha }; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +// Compute the Git blob SHA-1 for raw file bytes. +// Git format: SHA-1("blob \0") +async function blobSha(content: Uint8Array): Promise { + const header = encodeText(`blob ${content.byteLength}\0`); + const hex = await sha1(concat(header, content)); + return hex as GitSha; +} + +// Compute the Git tree SHA-1 for a list of tree entries. +// Entries are sorted into Git's canonical order before hashing. +// Git format: SHA-1("tree \0" + sorted binary entries) +// Each binary entry: " \0<20-raw-sha-bytes>" +async function treeSha(entries: TreeEntry[]): Promise { + const sorted = [...entries].sort(compareTreeEntries); + + // Build the binary tree body by concatenating all entry buffers + const parts: Uint8Array[] = []; + for (const entry of sorted) { + // Directories are stored as "40000" in the binary, not "040000" + const modeStr = treeModeString(entry.mode); + const entryHeader = encodeText(`${modeStr} ${entry.name}\0`); + const shaBytes = hexToBytes(entry.sha); // 20 raw bytes, not hex text + parts.push(entryHeader, shaBytes); + } + + const body = concat(...parts); + const header = encodeText(`tree ${body.byteLength}\0`); + const hex = await sha1(concat(header, body)); + return hex as GitSha; +} + +// Build a root Git tree SHA from a flat path→entry map (like `git ls-tree -r`). +// Recursively groups entries by directory, computes sub-tree SHAs bottom-up, +// then returns the root tree SHA. +async function buildTree( + files: Map, +): Promise { + // Convert the branded-Path map to a plain-string map for internal recursion + const plain = new Map(); + for (const [path, entry] of files) { + plain.set(path, entry); + } + return buildTreeForDir(plain); +} + +// Compute the Git commit SHA-1 for a fully-specified commit payload. +// Git format: SHA-1("commit \0") +async function commitSha(commit: PendingCommit): Promise { + const lines: string[] = []; + + lines.push(`tree ${commit.tree}`); + for (const parent of commit.parents) { + lines.push(`parent ${parent}`); + } + lines.push(`author ${formatSignature(commit.author)}`); + lines.push(`committer ${formatSignature(commit.committer)}`); + lines.push(""); // blank line separating headers from message + + // Git always ends the commit message with a newline + const message = commit.message.endsWith("\n") + ? commit.message + : commit.message + "\n"; + lines.push(message); + + // Join headers with \n; the final lines.join already has trailing \n from message + const body = lines.join("\n"); + const bodyBytes = encodeText(body); + const header = encodeText(`commit ${bodyBytes.byteLength}\0`); + const hex = await sha1(concat(header, bodyBytes)); + return hex as GitSha; +} + +// --------------------------------------------------------------------------- +// Internal helpers — tree ordering +// --------------------------------------------------------------------------- + +// Git's canonical tree entry sort key: directories sort as if their name has +// a trailing "/" appended. This matches Git's base_name_compare() in tree.c. +function canonicalName(mode: FileMode, name: string): string { + return mode === "040000" ? name + "/" : name; +} + +function compareTreeEntries(a: TreeEntry, b: TreeEntry): number { + const aKey = canonicalName(a.mode, a.name); + const bKey = canonicalName(b.mode, b.name); + // Byte-by-byte comparison (strings are UTF-16 in JS, but filenames stay ASCII-safe) + if (aKey < bKey) return -1; + if (aKey > bKey) return 1; + return 0; +} + +// Git stores directory mode as "40000" in the binary tree (printf %o of 040000), +// not "040000" with a leading zero. Files and symlinks are unchanged. +function treeModeString(mode: FileMode): string { + return mode === "040000" ? "40000" : mode; +} + +// --------------------------------------------------------------------------- +// Internal helpers — commit formatting +// --------------------------------------------------------------------------- + +// Format a Signature as "Name " +function formatSignature(sig: Signature): string { + return `${sig.name} <${sig.email}> ${sig.timestamp} ${formatTimezone(sig.timezoneOffsetMinutes)}`; +} + +// Format a timezone offset in minutes as "+HHMM" or "-HHMM" +function formatTimezone(offsetMinutes: number): string { + const sign = offsetMinutes >= 0 ? "+" : "-"; + const abs = Math.abs(offsetMinutes); + const hours = Math.floor(abs / 60) + .toString() + .padStart(2, "0"); + const mins = (abs % 60).toString().padStart(2, "0"); + return `${sign}${hours}${mins}`; +} + +// --------------------------------------------------------------------------- +// Internal helpers — recursive tree construction +// --------------------------------------------------------------------------- + +// Recursively build the tree SHA for a directory represented as a flat map +// of relative paths (e.g. "src/foo.ts") to their { mode, sha } entries. +async function buildTreeForDir( + files: Map, +): Promise { + const entries: TreeEntry[] = []; + // Collect sub-directory names and their child files + const subdirs = new Map>(); + + for (const [path, entry] of files) { + const slashIdx = path.indexOf("/"); + if (slashIdx === -1) { + // Leaf file at this directory level + entries.push({ mode: entry.mode, name: path, sha: entry.sha }); + } else { + // Path descends into a sub-directory; group by the first component + const dirName = path.slice(0, slashIdx); + const rest = path.slice(slashIdx + 1); + let subMap = subdirs.get(dirName); + if (subMap === undefined) { + subMap = new Map(); + subdirs.set(dirName, subMap); + } + subMap.set(rest, entry); + } + } + + // Recursively compute each sub-tree SHA and add it as a directory entry + for (const [dirName, subFiles] of subdirs) { + const subSha = await buildTreeForDir(subFiles); + entries.push({ mode: "040000", name: dirName, sha: subSha }); + } + + return treeSha(entries); +} + +// --------------------------------------------------------------------------- +// Internal helpers — low-level bytes / crypto +// --------------------------------------------------------------------------- + +const _encoder = new TextEncoder(); + +function encodeText(text: string): Uint8Array { + return _encoder.encode(text); +} + +// Convert a 40-char hex string to 20 raw bytes +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + const byte = parseInt(hex.slice(i, i + 2), 16); + bytes[i / 2] = byte; + } + return bytes; +} + +// Concatenate any number of Uint8Arrays into one +function concat(...arrays: Uint8Array[]): Uint8Array { + let total = 0; + for (const a of arrays) total += a.byteLength; + const result = new Uint8Array(total); + let offset = 0; + for (const a of arrays) { + result.set(a, offset); + offset += a.byteLength; + } + return result; +} + +// Compute SHA-1 via the Web Crypto API (works in browsers and Node 19+) +async function sha1(data: Uint8Array): Promise { + // Copy into a fresh Uint8Array so the type satisfies BufferSource. + // Uint8Array created from a TypedArray always uses a regular ArrayBuffer backing. + const copy = new Uint8Array(data); + const hashBuf = await crypto.subtle.digest("SHA-1", copy); + const hashBytes = new Uint8Array(hashBuf); + // Convert to 40-char lowercase hex + return Array.from(hashBytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/src/git/types.ts b/src/git/types.ts new file mode 100644 index 0000000..5160597 --- /dev/null +++ b/src/git/types.ts @@ -0,0 +1,36 @@ +// Pure Git object type definitions used across the local Git model. +// These branded types prevent accidental mixing of arbitrary strings with Git +// object identifiers and repo-relative paths at compile time. + +// Branded Git SHA-1 hex string (40 lowercase hex chars) +export type GitSha = string & { readonly __brand: "GitSha" }; + +// Branded POSIX-style repo-relative file path (e.g. "src/notes/foo.md") +export type Path = string & { readonly __brand: "Path" }; + +// Git file modes that appear in tree entries +export type FileMode = "100644" | "100755" | "120000" | "040000"; + +// A single entry in a Git tree object (file, symlink, or sub-tree) +export type TreeEntry = { + mode: FileMode; + name: string; // bare filename or directory name — not a full path + sha: GitSha; +}; + +// Author or committer identity with a point-in-time timestamp +export type Signature = { + name: string; + email: string; + timestamp: number; // Unix seconds (UTC) + timezoneOffsetMinutes: number; // minutes east of UTC; negative = west +}; + +// All fields needed to construct and hash a Git commit object +export type PendingCommit = { + tree: GitSha; + parents: GitSha[]; + author: Signature; + committer: Signature; + message: string; +}; diff --git a/src/storage/repo-db.test.ts b/src/storage/repo-db.test.ts new file mode 100644 index 0000000..af7cd4f --- /dev/null +++ b/src/storage/repo-db.test.ts @@ -0,0 +1,447 @@ +// Tests for IndexedDB-backed repo state storage (repo-db.ts). +// Uses fake-indexeddb to run in Node.js without a real browser. +import 'fake-indexeddb/auto'; +import { describe, test, expect, beforeEach, afterEach } from 'vitest'; +import { createRepoDb } from './repo-db'; +import type { RepoDb } from './repo-db'; +import type { RepoState, WorkingFile, Path, GitSha } from './repo-types'; + +// Each test gets a unique DB name to guarantee isolation. +let dbCounter = 0; +function freshDbName(): string { + return `test-vibenote-${++dbCounter}`; +} + +// --- Helpers to build typed values --- + +function toPath(s: string): Path { + return s as Path; +} + +function toGitSha(s: string): GitSha { + // Pad to 40 chars so it looks like a real SHA + return s.padEnd(40, '0') as GitSha; +} + +/** Build a minimal valid RepoState for testing. */ +function makeRepoState(repoId = 'owner/repo', overrides: Partial = {}): RepoState { + return { + repoId, + remote: { name: 'origin', url: `https://github.com/${repoId}.git` }, + branch: { + head: { name: 'refs/heads/main', sha: null }, + }, + base: { + rootTree: toGitSha('basetree'), + entries: new Map(), + baseCommit: null, + }, + remoteSnapshot: { + rootTree: toGitSha('remotetree'), + entries: new Map(), + remoteCommit: null, + }, + workingFiles: new Map(), + index: { entries: new Map() }, + status: new Map(), + merge: { inProgress: false, conflictedPaths: new Set() }, + ignore: { patterns: [] }, + config: {}, + hashCache: { entries: new Map() }, + version: 1, + ...overrides, + }; +} + +/** Build a minimal WorkingFile with the given text content. */ +function makeWorkingFile(path: string, text: string): WorkingFile { + const content = new TextEncoder().encode(text); + return { + path: toPath(path), + mode: '100644', + content, + size: content.byteLength, + mtime: 1000, + }; +} + +// --- Test suite --- + +let db: RepoDb; + +beforeEach(async () => { + db = await createRepoDb(freshDbName()); +}); + +afterEach(() => { + db.close(); +}); + +describe('saveRepoState / loadRepoState', () => { + test('roundtrip: empty state', async () => { + const state = makeRepoState(); + await db.saveRepoState(state); + const loaded = await db.loadRepoState(state.repoId); + expect(loaded).toBeDefined(); + expect(loaded!.repoId).toBe(state.repoId); + expect(loaded!.remote.url).toBe(state.remote.url); + expect(loaded!.branch.head.name).toBe('refs/heads/main'); + expect(loaded!.branch.head.sha).toBeNull(); + expect(loaded!.version).toBe(1); + expect(loaded!.workingFiles.size).toBe(0); + expect(loaded!.merge.inProgress).toBe(false); + }); + + test('roundtrip: preserves snapshot entries', async () => { + const p = toPath('notes/hello.md'); + const sha = toGitSha('abc123'); + const state = makeRepoState('o/r', { + base: { + rootTree: toGitSha('roottree'), + entries: new Map([[p, { mode: '100644', sha }]]), + baseCommit: toGitSha('basecommit'), + }, + remoteSnapshot: { + rootTree: toGitSha('remotetree'), + entries: new Map([[p, { mode: '100755', sha: toGitSha('exec') }]]), + remoteCommit: toGitSha('remotecommit'), + }, + }); + await db.saveRepoState(state); + const loaded = await db.loadRepoState('o/r'); + expect(loaded).toBeDefined(); + + const baseEntry = loaded!.base.entries.get(p); + expect(baseEntry).toBeDefined(); + expect(baseEntry!.mode).toBe('100644'); + expect(baseEntry!.sha).toBe(sha); + expect(loaded!.base.baseCommit).toBe(toGitSha('basecommit')); + + const remoteEntry = loaded!.remoteSnapshot.entries.get(p); + expect(remoteEntry!.mode).toBe('100755'); + expect(loaded!.remoteSnapshot.remoteCommit).toBe(toGitSha('remotecommit')); + }); + + test('roundtrip: working files with content', async () => { + const file = makeWorkingFile('notes/hello.md', '# Hello world'); + const state = makeRepoState('o/r', { + workingFiles: new Map([[file.path, file]]), + }); + await db.saveRepoState(state); + const loaded = await db.loadRepoState('o/r'); + expect(loaded).toBeDefined(); + expect(loaded!.workingFiles.size).toBe(1); + const loadedFile = loaded!.workingFiles.get(toPath('notes/hello.md')); + expect(loadedFile).toBeDefined(); + expect(new TextDecoder().decode(loadedFile!.content)).toBe('# Hello world'); + expect(loadedFile!.mode).toBe('100644'); + expect(loadedFile!.size).toBe(file.size); + expect(loadedFile!.mtime).toBe(1000); + }); + + test('roundtrip: binary file content (Uint8Array)', async () => { + const content = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]); // PNG magic bytes + const file: WorkingFile = { + path: toPath('img/logo.png'), + mode: '100644', + content, + size: content.byteLength, + }; + const state = makeRepoState('o/r', { workingFiles: new Map([[file.path, file]]) }); + await db.saveRepoState(state); + const loaded = await db.loadRepoState('o/r'); + const loadedFile = loaded!.workingFiles.get(toPath('img/logo.png')); + expect(loadedFile).toBeDefined(); + expect(Array.from(loadedFile!.content)).toEqual(Array.from(content)); + }); + + test('roundtrip: merge state with conflicted paths', async () => { + const p1 = toPath('a.md'); + const p2 = toPath('b.md'); + const state = makeRepoState('o/r', { + merge: { + inProgress: true, + targetCommit: toGitSha('target'), + conflictedPaths: new Set([p1, p2]), + }, + }); + await db.saveRepoState(state); + const loaded = await db.loadRepoState('o/r'); + expect(loaded!.merge.inProgress).toBe(true); + expect(loaded!.merge.targetCommit).toBe(toGitSha('target')); + expect(loaded!.merge.conflictedPaths.has(p1)).toBe(true); + expect(loaded!.merge.conflictedPaths.has(p2)).toBe(true); + expect(loaded!.merge.conflictedPaths.size).toBe(2); + }); + + test('roundtrip: conflict payloads (Uint8Array fields)', async () => { + const p = toPath('conflict.md'); + const base = new TextEncoder().encode('base content'); + const ours = new TextEncoder().encode('our content'); + const theirs = new TextEncoder().encode('their content'); + const state = makeRepoState('o/r', { + merge: { + inProgress: true, + conflictedPaths: new Set([p]), + conflicts: new Map([[p, { base, ours, theirs }]]), + }, + }); + await db.saveRepoState(state); + const loaded = await db.loadRepoState('o/r'); + const conflict = loaded!.merge.conflicts?.get(p); + expect(conflict).toBeDefined(); + expect(new TextDecoder().decode(conflict!.base)).toBe('base content'); + expect(new TextDecoder().decode(conflict!.ours)).toBe('our content'); + expect(new TextDecoder().decode(conflict!.theirs)).toBe('their content'); + }); + + test('roundtrip: status map', async () => { + const p = toPath('modified.md'); + const state = makeRepoState('o/r', { + status: new Map([ + [p, { path: p, status: 'modified', mode: '100644', headSha: toGitSha('head') }], + ]), + }); + await db.saveRepoState(state); + const loaded = await db.loadRepoState('o/r'); + const entry = loaded!.status.get(p); + expect(entry).toBeDefined(); + expect(entry!.status).toBe('modified'); + expect(entry!.headSha).toBe(toGitSha('head')); + }); + + test('roundtrip: hash cache', async () => { + const state = makeRepoState('o/r', { + hashCache: { + entries: new Map([ + ['path|100|999', toGitSha('blobsha')], + ]), + }, + }); + await db.saveRepoState(state); + const loaded = await db.loadRepoState('o/r'); + expect(loaded!.hashCache.entries.get('path|100|999')).toBe(toGitSha('blobsha')); + }); + + test('roundtrip: branch with upstream', async () => { + const state = makeRepoState('o/r', { + branch: { + head: { name: 'refs/heads/feature', sha: toGitSha('headsha') }, + upstream: { name: 'refs/remotes/origin/feature', sha: toGitSha('remotesha') }, + }, + }); + await db.saveRepoState(state); + const loaded = await db.loadRepoState('o/r'); + expect(loaded!.branch.head.name).toBe('refs/heads/feature'); + expect(loaded!.branch.head.sha).toBe(toGitSha('headsha')); + expect(loaded!.branch.upstream?.name).toBe('refs/remotes/origin/feature'); + expect(loaded!.branch.upstream?.sha).toBe(toGitSha('remotesha')); + }); + + test('overwrite: second save replaces first', async () => { + const state1 = makeRepoState('o/r', { version: 1 }); + const state2 = makeRepoState('o/r', { version: 2 }); + await db.saveRepoState(state1); + await db.saveRepoState(state2); + const loaded = await db.loadRepoState('o/r'); + expect(loaded!.version).toBe(2); + }); + + test('overwrite: removed working file is not present after re-save', async () => { + const file = makeWorkingFile('old.md', 'old'); + await db.saveRepoState(makeRepoState('o/r', { workingFiles: new Map([[file.path, file]]) })); + + // Second save with empty workingFiles + await db.saveRepoState(makeRepoState('o/r', { workingFiles: new Map() })); + const loaded = await db.loadRepoState('o/r'); + expect(loaded!.workingFiles.size).toBe(0); + }); + + test('returns undefined for unknown repoId', async () => { + const result = await db.loadRepoState('nobody/nothing'); + expect(result).toBeUndefined(); + }); +}); + +describe('multiple repos isolation', () => { + test('repos do not bleed into each other', async () => { + const stateA = makeRepoState('alice/notes', { version: 10 }); + const stateB = makeRepoState('bob/notes', { version: 20 }); + await db.saveRepoState(stateA); + await db.saveRepoState(stateB); + + const loadedA = await db.loadRepoState('alice/notes'); + const loadedB = await db.loadRepoState('bob/notes'); + expect(loadedA!.version).toBe(10); + expect(loadedB!.version).toBe(20); + }); + + test('working files from different repos are isolated', async () => { + const fileA = makeWorkingFile('note.md', 'alice content'); + const fileB = makeWorkingFile('note.md', 'bob content'); + await db.saveRepoState(makeRepoState('alice/r', { workingFiles: new Map([[fileA.path, fileA]]) })); + await db.saveRepoState(makeRepoState('bob/r', { workingFiles: new Map([[fileB.path, fileB]]) })); + + const loadedA = await db.loadRepoState('alice/r'); + const loadedB = await db.loadRepoState('bob/r'); + const fa = loadedA!.workingFiles.get(toPath('note.md')); + const fb = loadedB!.workingFiles.get(toPath('note.md')); + expect(new TextDecoder().decode(fa!.content)).toBe('alice content'); + expect(new TextDecoder().decode(fb!.content)).toBe('bob content'); + }); + + test('deleting one repo does not affect another', async () => { + await db.saveRepoState(makeRepoState('alice/r')); + await db.saveRepoState(makeRepoState('bob/r')); + await db.deleteRepo('alice/r'); + expect(await db.loadRepoState('alice/r')).toBeUndefined(); + expect(await db.loadRepoState('bob/r')).toBeDefined(); + }); +}); + +describe('deleteRepo', () => { + test('removes all data for the repo', async () => { + const file = makeWorkingFile('a.md', 'text'); + const state = makeRepoState('o/r', { + workingFiles: new Map([[file.path, file]]), + merge: { + inProgress: false, + conflictedPaths: new Set([file.path]), + conflicts: new Map([[file.path, { base: new TextEncoder().encode('base') }]]), + }, + }); + await db.saveRepoState(state); + await db.deleteRepo('o/r'); + expect(await db.loadRepoState('o/r')).toBeUndefined(); + expect(await db.listWorkingFilesMeta('o/r')).toEqual([]); + }); +}); + +describe('listRepoIds', () => { + test('returns all stored repo IDs', async () => { + await db.saveRepoState(makeRepoState('alice/notes')); + await db.saveRepoState(makeRepoState('bob/diary')); + const ids = await db.listRepoIds(); + expect(ids.sort()).toEqual(['alice/notes', 'bob/diary']); + }); + + test('returns empty array when no repos are stored', async () => { + const ids = await db.listRepoIds(); + expect(ids).toEqual([]); + }); +}); + +describe('individual file operations', () => { + test('saveWorkingFile / loadWorkingFile roundtrip', async () => { + // Need a repo state to exist first, but individual file ops are independent + const file = makeWorkingFile('notes/hello.md', '# Hello'); + await db.saveWorkingFile('owner/repo', file); + const loaded = await db.loadWorkingFile('owner/repo', toPath('notes/hello.md')); + expect(loaded).toBeDefined(); + expect(new TextDecoder().decode(loaded!.content)).toBe('# Hello'); + expect(loaded!.mode).toBe('100644'); + expect(loaded!.size).toBe(file.size); + }); + + test('loadWorkingFile returns undefined for unknown path', async () => { + const result = await db.loadWorkingFile('owner/repo', toPath('not/there.md')); + expect(result).toBeUndefined(); + }); + + test('saveWorkingFile updates an existing file', async () => { + const path = toPath('doc.md'); + const v1 = { ...makeWorkingFile('doc.md', 'v1'), mtime: 100 }; + const v2Bytes = new TextEncoder().encode('v2'); + const v2: WorkingFile = { path, mode: '100644', content: v2Bytes, size: v2Bytes.byteLength, mtime: 200 }; + + await db.saveWorkingFile('o/r', v1); + await db.saveWorkingFile('o/r', v2); + const loaded = await db.loadWorkingFile('o/r', path); + expect(new TextDecoder().decode(loaded!.content)).toBe('v2'); + expect(loaded!.mtime).toBe(200); + }); + + test('deleteWorkingFile removes the file', async () => { + const file = makeWorkingFile('bye.md', 'goodbye'); + await db.saveWorkingFile('o/r', file); + await db.deleteWorkingFile('o/r', toPath('bye.md')); + const result = await db.loadWorkingFile('o/r', toPath('bye.md')); + expect(result).toBeUndefined(); + }); + + test('deleteWorkingFile is a no-op for unknown path', async () => { + // Should not throw + await db.deleteWorkingFile('o/r', toPath('ghost.md')); + }); + + test('listWorkingFilesMeta returns metadata without content', async () => { + const f1 = makeWorkingFile('a.md', 'alpha'); + const f2 = makeWorkingFile('b.md', 'beta'); + await db.saveWorkingFile('o/r', f1); + await db.saveWorkingFile('o/r', f2); + const metas = await db.listWorkingFilesMeta('o/r'); + expect(metas).toHaveLength(2); + const paths = metas.map((m) => m.path).sort(); + expect(paths).toEqual(['a.md', 'b.md']); + // No content field on metadata + for (const m of metas) { + expect('content' in m).toBe(false); + } + }); + + test('listWorkingFilesMeta returns empty array for unknown repo', async () => { + const metas = await db.listWorkingFilesMeta('nobody/nothing'); + expect(metas).toEqual([]); + }); + + test('individual file saves are isolated by repoId', async () => { + const file = makeWorkingFile('shared.md', 'repo-a'); + await db.saveWorkingFile('a/r', file); + const result = await db.loadWorkingFile('b/r', toPath('shared.md')); + expect(result).toBeUndefined(); + }); +}); + +describe('config and misc fields', () => { + test('roundtrip: eol config', async () => { + const state = makeRepoState('o/r', { config: { eol: 'lf', caseSensitive: true } }); + await db.saveRepoState(state); + const loaded = await db.loadRepoState('o/r'); + expect(loaded!.config.eol).toBe('lf'); + expect(loaded!.config.caseSensitive).toBe(true); + }); + + test('roundtrip: locks', async () => { + const state = makeRepoState('o/r', { locks: { sync: true, index: false } }); + await db.saveRepoState(state); + const loaded = await db.loadRepoState('o/r'); + expect(loaded!.locks?.sync).toBe(true); + expect(loaded!.locks?.index).toBe(false); + }); + + test('roundtrip: index entries', async () => { + const p = toPath('merge.md'); + const state = makeRepoState('o/r', { + index: { + entries: new Map([ + [ + p, + [ + { path: p, mode: '100644', stage: 1, sha: toGitSha('base') }, + { path: p, mode: '100644', stage: 2, sha: toGitSha('ours') }, + { path: p, mode: '100644', stage: 3, sha: toGitSha('theirs') }, + ], + ], + ]), + }, + }); + await db.saveRepoState(state); + const loaded = await db.loadRepoState('o/r'); + const entries = loaded!.index.entries.get(p); + expect(entries).toHaveLength(3); + expect(entries![0]!.stage).toBe(1); + expect(entries![1]!.stage).toBe(2); + expect(entries![2]!.stage).toBe(3); + expect(entries![0]!.sha).toBe(toGitSha('base')); + }); +}); diff --git a/src/storage/repo-db.ts b/src/storage/repo-db.ts new file mode 100644 index 0000000..dbd7b4d --- /dev/null +++ b/src/storage/repo-db.ts @@ -0,0 +1,690 @@ +// IndexedDB persistence layer for Git-shaped repo state. +// Separates large binary content (file blobs, conflict payloads) from +// JSON-like metadata across four object stores so metadata reads stay fast +// even when repos contain large files. +// +// Public entry point: createRepoDb(name?) → RepoDb +// Each repo is isolated by its repoId string (e.g. "owner/repo"). + +import type { + RepoState, + WorkingFile, + WorkingFileMeta, + Path, + GitSha, + FileMode, + FileStatus, + IndexStage, + ConflictPayload, + SnapshotEntry, + StatusEntry, + IndexEntry, +} from './repo-types'; + +export type { RepoDb }; +export { createRepoDb }; + +// --- Public API surface --- + +/** Async API for reading and writing Git-shaped repo state to IndexedDB. */ +type RepoDb = { + /** Save (or overwrite) the full repo state, including all file contents. */ + saveRepoState: (state: RepoState) => Promise; + /** Load the full repo state, or undefined if not found. */ + loadRepoState: (repoId: string) => Promise; + /** Delete all stored data for a repo (state, file content, conflicts). */ + deleteRepo: (repoId: string) => Promise; + /** List all repo IDs that have stored state. */ + listRepoIds: () => Promise; + /** Save (or overwrite) a single working file's content and metadata. */ + saveWorkingFile: (repoId: string, file: WorkingFile) => Promise; + /** Load a single working file with content, or undefined if not found. */ + loadWorkingFile: (repoId: string, path: Path) => Promise; + /** Delete a single working file from the working tree. */ + deleteWorkingFile: (repoId: string, path: Path) => Promise; + /** List metadata for all working files without loading content. */ + listWorkingFilesMeta: (repoId: string) => Promise; + /** Close the underlying IDBDatabase connection. */ + close: () => void; +}; + +// --- DB schema constants --- + +const DB_VERSION = 1; +// JSON-like repo metadata (snapshots, branch, index, status, merge, config) +const STORE_META = 'repo-state'; +// Working file metadata without content (path, mode, size, mtime, blobSha) +const STORE_FILE_META = 'file-meta'; +// Working file binary content, separated for fast metadata-only reads +const STORE_FILES = 'file-content'; +// Conflict payloads (base/ours/theirs Uint8Arrays), separated from metadata +const STORE_CONFLICTS = 'conflict-content'; + +const ALL_STORES = [STORE_META, STORE_FILE_META, STORE_FILES, STORE_CONFLICTS] as const; + +// --- Factory --- + +/** + * Open an IndexedDB-backed RepoDb with the given name. + * Pass a unique name per test for isolation (each name is a separate IDB database). + */ +async function createRepoDb(name = 'vibenote-repo-db'): Promise { + const db = await openIdbDatabase(name); + return { + saveRepoState: (state) => saveRepoStateToDb(db, state), + loadRepoState: (repoId) => loadRepoStateFromDb(db, repoId), + deleteRepo: (repoId) => deleteRepoFromDb(db, repoId), + listRepoIds: () => listRepoIdsFromDb(db), + saveWorkingFile: (repoId, file) => saveWorkingFileToDb(db, repoId, file), + loadWorkingFile: (repoId, path) => loadWorkingFileFromDb(db, repoId, path), + deleteWorkingFile: (repoId, path) => deleteWorkingFileFromDb(db, repoId, path), + listWorkingFilesMeta: (repoId) => listWorkingFilesMetaFromDb(db, repoId), + close: () => db.close(), + }; +} + +// --- Internal serialized types (stored in IndexedDB) --- + +// Flat snapshot entry as stored: plain strings (no branded types). +type StoredSnapshotEntry = { mode: string; sha: string }; + +type StoredSnapshot = { + rootTree: string; + entries: [string, StoredSnapshotEntry][]; + // baseCommit / remoteCommit overlap — only one is present per snapshot kind + baseCommit?: string | null; + remoteCommit?: string | null; +}; + +// Full repo metadata stored in STORE_META; no working file content or conflict Uint8Arrays. +type StoredRepoMeta = { + repoId: string; + remote: { name: string; url: string }; + branch: { + head: { name: string; sha: string | null }; + upstream?: { name: string; sha: string | null }; + }; + base: StoredSnapshot; + remoteSnapshot: StoredSnapshot; + // index entries as [path, entries[]] pairs + indexEntries: [string, { path: string; mode: string; stage: number; sha: string }[]][]; + // status as [path, entry] pairs + status: [string, { path: string; status: string; mode?: string; headSha?: string; indexSha?: string; worktreeSha?: string }][]; + // merge: conflicted paths as strings; conflict Uint8Arrays live in STORE_CONFLICTS + merge: { inProgress: boolean; targetCommit?: string; conflictedPaths: string[] }; + ignore: { patterns: string[] }; + config: { eol?: string; caseSensitive?: boolean; enableRenameDetect?: boolean }; + // hash cache entries as [key, sha] pairs + hashCacheEntries: [string, string][]; + version: number; + locks?: { sync: boolean; index: boolean }; +}; + +// Stored in STORE_FILE_META — working file metadata without content. +type StoredFileMeta = { + repoId: string; + path: string; + mode: string; + size: number; + mtime?: number; + blobSha?: string; +}; + +// Stored in STORE_FILES — working file binary content. +type StoredFileContent = { + repoId: string; + path: string; + content: Uint8Array; +}; + +// Stored in STORE_CONFLICTS — three-way merge conflict payloads. +type StoredConflict = { + repoId: string; + path: string; + base?: Uint8Array; + ours?: Uint8Array; + theirs?: Uint8Array; +}; + +// --- saveRepoState --- + +function saveRepoStateToDb(db: IDBDatabase, state: RepoState): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(Array.from(ALL_STORES), 'readwrite'); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(new Error('IDB transaction aborted')); + + // Save JSON-like metadata (no Uint8Arrays) + tx.objectStore(STORE_META).put(serializeRepoMeta(state)); + + // Extract working file metadata and content for separate stores + const fileMetas: StoredFileMeta[] = []; + const fileContents: StoredFileContent[] = []; + for (const [, file] of state.workingFiles) { + fileMetas.push({ + repoId: state.repoId, + path: file.path, + mode: file.mode, + size: file.size, + mtime: file.mtime, + blobSha: file.blobSha, + }); + fileContents.push({ repoId: state.repoId, path: file.path, content: file.content }); + } + + // Extract conflict payloads + const conflictData: StoredConflict[] = []; + if (state.merge.conflicts !== undefined) { + for (const [path, payload] of state.merge.conflicts) { + conflictData.push({ repoId: state.repoId, path, base: payload.base, ours: payload.ours, theirs: payload.theirs }); + } + } + + // Clear old entries for this repo in all three content stores, then write new ones. + // Uses a counter to wait for all three cursors before writing. + let cleared = 0; + const afterClear = () => { + cleared++; + if (cleared < 3) return; + for (const m of fileMetas) tx.objectStore(STORE_FILE_META).put(m); + for (const f of fileContents) tx.objectStore(STORE_FILES).put(f); + for (const c of conflictData) tx.objectStore(STORE_CONFLICTS).put(c); + }; + + clearByRepo(tx.objectStore(STORE_FILE_META), state.repoId, afterClear); + clearByRepo(tx.objectStore(STORE_FILES), state.repoId, afterClear); + clearByRepo(tx.objectStore(STORE_CONFLICTS), state.repoId, afterClear); + }); +} + +// --- loadRepoState --- + +function loadRepoStateFromDb(db: IDBDatabase, repoId: string): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(Array.from(ALL_STORES), 'readonly'); + tx.onerror = () => reject(tx.error); + + let storedMeta: StoredRepoMeta | undefined; + let fileMetas: StoredFileMeta[] = []; + let fileContents: StoredFileContent[] = []; + let conflicts: StoredConflict[] = []; + // Wait for: 1 get + 3 cursor scans + let pending = 4; + + const done = () => { + pending--; + if (pending > 0) return; + if (storedMeta === undefined) { + resolve(undefined); + return; + } + try { + const contentMap = new Map(fileContents.map((f) => [f.path, f.content])); + const conflictMap = new Map(conflicts.map((c) => [c.path, c])); + resolve(deserializeRepoState(storedMeta, fileMetas, contentMap, conflictMap)); + } catch (e) { + reject(e); + } + }; + + // 1. Repo metadata + const metaReq = tx.objectStore(STORE_META).get(repoId); + metaReq.onsuccess = () => { + storedMeta = metaReq.result; + done(); + }; + metaReq.onerror = () => reject(metaReq.error); + + // 2. Working file metadata + collectByRepo(tx.objectStore(STORE_FILE_META), repoId, (items) => { + fileMetas = items; + done(); + }); + + // 3. Working file content + collectByRepo(tx.objectStore(STORE_FILES), repoId, (items) => { + fileContents = items; + done(); + }); + + // 4. Conflict payloads + collectByRepo(tx.objectStore(STORE_CONFLICTS), repoId, (items) => { + conflicts = items; + done(); + }); + }); +} + +// --- deleteRepo --- + +function deleteRepoFromDb(db: IDBDatabase, repoId: string): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(Array.from(ALL_STORES), 'readwrite'); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(new Error('IDB transaction aborted')); + + tx.objectStore(STORE_META).delete(repoId); + clearByRepo(tx.objectStore(STORE_FILE_META), repoId, () => {}); + clearByRepo(tx.objectStore(STORE_FILES), repoId, () => {}); + clearByRepo(tx.objectStore(STORE_CONFLICTS), repoId, () => {}); + }); +} + +// --- listRepoIds --- + +function listRepoIdsFromDb(db: IDBDatabase): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction([STORE_META], 'readonly'); + const req = tx.objectStore(STORE_META).getAllKeys(); + req.onsuccess = () => resolve(req.result as string[]); + req.onerror = () => reject(req.error); + }); +} + +// --- Individual file operations --- + +function saveWorkingFileToDb(db: IDBDatabase, repoId: string, file: WorkingFile): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction([STORE_FILE_META, STORE_FILES], 'readwrite'); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(new Error('IDB transaction aborted')); + + tx.objectStore(STORE_FILE_META).put({ + repoId, + path: file.path, + mode: file.mode, + size: file.size, + mtime: file.mtime, + blobSha: file.blobSha, + } satisfies StoredFileMeta); + + tx.objectStore(STORE_FILES).put({ + repoId, + path: file.path, + content: file.content, + } satisfies StoredFileContent); + }); +} + +function loadWorkingFileFromDb(db: IDBDatabase, repoId: string, path: Path): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction([STORE_FILE_META, STORE_FILES], 'readonly'); + tx.onerror = () => reject(tx.error); + + let meta: StoredFileMeta | undefined; + let contentEntry: StoredFileContent | undefined; + let pending = 2; + + const done = () => { + pending--; + if (pending > 0) return; + if (meta === undefined) { + resolve(undefined); + return; + } + const content = contentEntry?.content ?? new Uint8Array(0); + resolve(deserializeWorkingFile(meta, content)); + }; + + const key = [repoId, path] as IDBValidKey; + + const metaReq = tx.objectStore(STORE_FILE_META).get(key); + metaReq.onsuccess = () => { + meta = metaReq.result; + done(); + }; + metaReq.onerror = () => reject(metaReq.error); + + const contentReq = tx.objectStore(STORE_FILES).get(key); + contentReq.onsuccess = () => { + contentEntry = contentReq.result; + done(); + }; + contentReq.onerror = () => reject(contentReq.error); + }); +} + +function deleteWorkingFileFromDb(db: IDBDatabase, repoId: string, path: Path): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction([STORE_FILE_META, STORE_FILES], 'readwrite'); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(new Error('IDB transaction aborted')); + + const key = [repoId, path] as IDBValidKey; + tx.objectStore(STORE_FILE_META).delete(key); + tx.objectStore(STORE_FILES).delete(key); + }); +} + +function listWorkingFilesMetaFromDb(db: IDBDatabase, repoId: string): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction([STORE_FILE_META], 'readonly'); + tx.onerror = () => reject(tx.error); + + collectByRepo(tx.objectStore(STORE_FILE_META), repoId, (items) => { + resolve(items.map((m) => deserializeWorkingFileMeta(m))); + }); + }); +} + +// --- Serialization --- + +function serializeRepoMeta(state: RepoState): StoredRepoMeta { + return { + repoId: state.repoId, + remote: { name: state.remote.name, url: state.remote.url }, + branch: { + head: { name: state.branch.head.name, sha: state.branch.head.sha }, + upstream: + state.branch.upstream !== undefined + ? { name: state.branch.upstream.name, sha: state.branch.upstream.sha } + : undefined, + }, + base: { + rootTree: state.base.rootTree, + entries: Array.from(state.base.entries.entries()).map(([p, e]) => [p, { mode: e.mode, sha: e.sha }]), + baseCommit: state.base.baseCommit, + }, + remoteSnapshot: { + rootTree: state.remoteSnapshot.rootTree, + entries: Array.from(state.remoteSnapshot.entries.entries()).map(([p, e]) => [p, { mode: e.mode, sha: e.sha }]), + remoteCommit: state.remoteSnapshot.remoteCommit, + }, + indexEntries: Array.from(state.index.entries.entries()).map(([p, entries]) => [ + p, + entries.map((e) => ({ path: e.path, mode: e.mode, stage: e.stage, sha: e.sha })), + ]), + status: Array.from(state.status.entries()).map(([p, s]) => [ + p, + { + path: s.path, + status: s.status, + mode: s.mode, + headSha: s.headSha, + indexSha: s.indexSha, + worktreeSha: s.worktreeSha, + }, + ]), + merge: { + inProgress: state.merge.inProgress, + targetCommit: state.merge.targetCommit, + // conflictedPaths is a Set; store as plain string array + conflictedPaths: Array.from(state.merge.conflictedPaths), + }, + ignore: { patterns: state.ignore.patterns }, + config: { eol: state.config.eol, caseSensitive: state.config.caseSensitive, enableRenameDetect: state.config.enableRenameDetect }, + hashCacheEntries: Array.from(state.hashCache.entries.entries()), + version: state.version, + locks: state.locks, + }; +} + +// --- Deserialization --- + +function deserializeRepoState( + stored: StoredRepoMeta, + fileMetas: StoredFileMeta[], + contentMap: Map, + conflictMap: Map +): RepoState { + // Reconstruct snapshot entries + let baseEntries = new Map(); + for (const [p, e] of stored.base.entries) { + baseEntries.set(toPath(p), { mode: parseFileMode(e.mode), sha: toGitSha(e.sha) }); + } + + let remoteEntries = new Map(); + for (const [p, e] of stored.remoteSnapshot.entries) { + remoteEntries.set(toPath(p), { mode: parseFileMode(e.mode), sha: toGitSha(e.sha) }); + } + + // Reconstruct working files, joining metadata with content + let workingFiles = new Map(); + for (const meta of fileMetas) { + const p = toPath(meta.path); + const content = contentMap.get(meta.path) ?? new Uint8Array(0); + workingFiles.set(p, deserializeWorkingFile(meta, content)); + } + + // Reconstruct index + let indexEntries = new Map(); + for (const [p, entries] of stored.indexEntries) { + indexEntries.set( + toPath(p), + entries.map((e) => ({ + path: toPath(e.path), + mode: parseFileMode(e.mode), + stage: parseIndexStage(e.stage), + sha: toGitSha(e.sha), + })) + ); + } + + // Reconstruct status + let status = new Map(); + for (const [p, s] of stored.status) { + const path = toPath(p); + status.set(path, { + path, + status: parseFileStatus(s.status), + mode: s.mode !== undefined ? parseFileMode(s.mode) : undefined, + headSha: s.headSha !== undefined ? toGitSha(s.headSha) : undefined, + indexSha: s.indexSha !== undefined ? toGitSha(s.indexSha) : undefined, + worktreeSha: s.worktreeSha !== undefined ? toGitSha(s.worktreeSha) : undefined, + }); + } + + // Reconstruct merge state + let conflictedPaths = new Set(stored.merge.conflictedPaths.map(toPath)); + let conflicts: Map | undefined; + if (conflictMap.size > 0) { + conflicts = new Map(); + for (const [p, c] of conflictMap) { + conflicts.set(toPath(p), { base: c.base, ours: c.ours, theirs: c.theirs }); + } + } + + // Reconstruct hash cache + let hashCacheEntries = new Map(); + for (const [key, sha] of stored.hashCacheEntries) { + hashCacheEntries.set(key, toGitSha(sha)); + } + + return { + repoId: stored.repoId, + remote: { name: stored.remote.name, url: stored.remote.url }, + branch: { + head: { + name: parseRefName(stored.branch.head.name), + sha: stored.branch.head.sha !== null ? toGitSha(stored.branch.head.sha) : null, + }, + upstream: + stored.branch.upstream !== undefined + ? { + name: parseRemoteRefName(stored.branch.upstream.name), + sha: stored.branch.upstream.sha !== null ? toGitSha(stored.branch.upstream.sha) : null, + } + : undefined, + }, + base: { + rootTree: toGitSha(stored.base.rootTree), + entries: baseEntries, + baseCommit: stored.base.baseCommit !== null && stored.base.baseCommit !== undefined ? toGitSha(stored.base.baseCommit) : null, + }, + remoteSnapshot: { + rootTree: toGitSha(stored.remoteSnapshot.rootTree), + entries: remoteEntries, + remoteCommit: + stored.remoteSnapshot.remoteCommit !== null && stored.remoteSnapshot.remoteCommit !== undefined + ? toGitSha(stored.remoteSnapshot.remoteCommit) + : null, + }, + workingFiles, + index: { entries: indexEntries }, + status, + merge: { + inProgress: stored.merge.inProgress, + targetCommit: stored.merge.targetCommit !== undefined ? toGitSha(stored.merge.targetCommit) : undefined, + conflictedPaths, + conflicts, + }, + ignore: { patterns: stored.ignore.patterns }, + config: { + eol: stored.config.eol as RepoState['config']['eol'], + caseSensitive: stored.config.caseSensitive, + enableRenameDetect: stored.config.enableRenameDetect, + }, + hashCache: { entries: hashCacheEntries }, + version: stored.version, + locks: stored.locks, + }; +} + +function deserializeWorkingFile(meta: StoredFileMeta, content: Uint8Array): WorkingFile { + return { + path: toPath(meta.path), + mode: parseWorkingFileMode(meta.mode), + content, + size: meta.size, + mtime: meta.mtime, + blobSha: meta.blobSha !== undefined ? toGitSha(meta.blobSha) : undefined, + }; +} + +function deserializeWorkingFileMeta(meta: StoredFileMeta): WorkingFileMeta { + return { + path: toPath(meta.path), + mode: parseWorkingFileMode(meta.mode), + size: meta.size, + mtime: meta.mtime, + blobSha: meta.blobSha !== undefined ? toGitSha(meta.blobSha) : undefined, + }; +} + +// --- Validation / parsing helpers --- +// These use exhaustive checks so TypeScript can narrow to the correct literal type +// without needing an `as` cast. Throws on invalid stored data. + +function parseFileMode(s: string): FileMode { + if (s === '100644' || s === '100755' || s === '120000' || s === '040000') return s; + throw new Error(`Invalid FileMode in storage: ${s}`); +} + +function parseWorkingFileMode(s: string): Exclude { + if (s === '100644' || s === '100755' || s === '120000') return s; + throw new Error(`Invalid working file mode in storage: ${s}`); +} + +function parseFileStatus(s: string): FileStatus { + if ( + s === 'unmodified' || + s === 'modified' || + s === 'added' || + s === 'deleted' || + s === 'untracked' || + s === 'conflicted' + ) + return s; + throw new Error(`Invalid FileStatus in storage: ${s}`); +} + +function parseIndexStage(n: number): IndexStage { + if (n === 0 || n === 1 || n === 2 || n === 3) return n; + throw new Error(`Invalid IndexStage in storage: ${n}`); +} + +// Template literal types can't be narrowed from startsWith(), so we validate +// and then use `as`. The runtime check ensures the data is correct. +function parseRefName(s: string): `refs/heads/${string}` | `refs/tags/${string}` { + if (s.startsWith('refs/heads/') || s.startsWith('refs/tags/')) { + return s as `refs/heads/${string}` | `refs/tags/${string}`; + } + throw new Error(`Invalid ref name in storage: ${s}`); +} + +function parseRemoteRefName(s: string): `refs/remotes/${string}/${string}` { + if (s.startsWith('refs/remotes/')) { + return s as `refs/remotes/${string}/${string}`; + } + throw new Error(`Invalid remote ref name in storage: ${s}`); +} + +// Phantom brand constructors — safe because GitSha/Path are compile-time-only brands. +function toGitSha(s: string): GitSha { + return s as GitSha; +} + +function toPath(s: string): Path { + return s as Path; +} + +// --- IDB utility helpers --- + +/** Opens and upgrades the IndexedDB database, creating stores on first run. */ +function openIdbDatabase(name: string): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(name, DB_VERSION); + req.onerror = () => reject(req.error); + req.onsuccess = () => resolve(req.result); + req.onupgradeneeded = (event) => { + const db = req.result; + if (event.oldVersion < 1) { + // Repo-level JSON metadata (no large binaries) + db.createObjectStore(STORE_META, { keyPath: 'repoId' }); + + // Working file metadata — compound key [repoId, path] + const fileMetaStore = db.createObjectStore(STORE_FILE_META, { keyPath: ['repoId', 'path'] }); + fileMetaStore.createIndex('by-repo', 'repoId'); + + // Working file content — compound key [repoId, path] + const filesStore = db.createObjectStore(STORE_FILES, { keyPath: ['repoId', 'path'] }); + filesStore.createIndex('by-repo', 'repoId'); + + // Conflict payloads — compound key [repoId, path] + const conflictsStore = db.createObjectStore(STORE_CONFLICTS, { keyPath: ['repoId', 'path'] }); + conflictsStore.createIndex('by-repo', 'repoId'); + } + }; + }); +} + +/** + * Deletes all records for a repoId from an object store that has a 'by-repo' index. + * Calls `onDone` synchronously after queuing all deletes (within the same transaction). + */ +function clearByRepo(store: IDBObjectStore, repoId: string, onDone: () => void): void { + const range = IDBKeyRange.only(repoId); + const req = store.index('by-repo').openCursor(range); + req.onsuccess = () => { + const cursor = req.result; + if (cursor !== null) { + cursor.delete(); + cursor.continue(); + } else { + onDone(); + } + }; +} + +/** + * Collects all records for a repoId from an object store via the 'by-repo' index. + * Calls `onDone` with the full array when the cursor is exhausted. + */ +function collectByRepo(store: IDBObjectStore, repoId: string, onDone: (items: T[]) => void): void { + const range = IDBKeyRange.only(repoId); + const req = store.index('by-repo').openCursor(range); + const items: T[] = []; + req.onsuccess = () => { + const cursor = req.result; + if (cursor !== null) { + items.push(cursor.value as T); + cursor.continue(); + } else { + onDone(items); + } + }; +} diff --git a/src/storage/repo-types.ts b/src/storage/repo-types.ts new file mode 100644 index 0000000..683ce92 --- /dev/null +++ b/src/storage/repo-types.ts @@ -0,0 +1,266 @@ +// Canonical TypeScript types for the Git-shaped repo state model. +// Follows the design documented in docs/vibenote-git-sync-design.md. +// These types are used by the IndexedDB storage layer (repo-db.ts) and +// will eventually replace the localStorage-based local.ts layer. + +export type { + GitSha, + Path, + FileMode, + SnapshotEntry, + TreeSnapshot, + BaseSnapshot, + RemoteSnapshot, + WorkingFile, + WorkingFileMeta, + IndexStage, + IndexEntry, + IndexState, + FileStatus, + StatusEntry, + ConflictPayload, + MergeState, + Ref, + RemoteRef, + RemoteConfig, + BranchState, + Signature, + PendingCommit, + HashCache, + IgnoreRules, + RepoConfig, + RepoState, +}; + +// --- Core opaque branded types --- + +/** + * A Git object SHA-1 hash (40 hex chars). + * Branded to prevent mixing with arbitrary strings. + */ +type GitSha = string & { readonly __brand: 'GitSha' }; + +/** + * A repo-relative file path (e.g. "notes/journal.md"). + * Branded for type safety; always forward-slash separated, no leading slash. + */ +type Path = string & { readonly __brand: 'Path' }; + +/** + * Git file mode string. + * 100644 = regular file, 100755 = executable, 120000 = symlink, 040000 = directory (tree). + */ +type FileMode = '100644' | '100755' | '120000' | '040000'; + +// --- Tree snapshots --- + +/** A single entry in a flat tree snapshot: mode + SHA of the blob or subtree. */ +type SnapshotEntry = { + mode: FileMode; + sha: GitSha; +}; + +/** + * A recursive flat path-map snapshot of a Git tree, equivalent to `git ls-tree -r`. + * Flat maps are preferred over nested trees for easier diffing, merging, and dirty detection. + */ +type TreeSnapshot = { + /** Root tree object SHA. */ + rootTree: GitSha; + /** Flat map of all repo-relative paths → entries. */ + entries: Map; +}; + +/** + * The BASE snapshot: last commit/tree that local state was synced against. + * Serves as the merge base in the three-way Sync. + */ +type BaseSnapshot = TreeSnapshot & { + /** Last synced commit SHA. null means no sync has occurred yet. */ + baseCommit: GitSha | null; +}; + +/** + * The REMOTE snapshot: latest fetched remote branch tip. + * Compared against BASE to detect remote changes since the last sync. + */ +type RemoteSnapshot = TreeSnapshot & { + /** Most recently fetched remote commit SHA. null if not yet fetched. */ + remoteCommit: GitSha | null; +}; + +// --- Working files --- + +/** A file in the local working tree — the user-visible current state. */ +type WorkingFile = { + path: Path; + /** Git file mode; directories (040000) are never working files. */ + mode: Exclude; + /** Raw file bytes. */ + content: Uint8Array; + size: number; + /** Logical modification timestamp (app-defined, not OS mtime). */ + mtime?: number; + /** Cached blob SHA for current content; avoids re-hashing when unchanged. */ + blobSha?: GitSha; +}; + +/** Working file metadata without binary content — used for fast listing. */ +type WorkingFileMeta = Omit; + +// --- Index / staging area --- + +/** + * Git index stage numbers. + * 0 = normal staged; 1 = merge base; 2 = ours; 3 = theirs. + */ +type IndexStage = 0 | 1 | 2 | 3; + +/** A single entry in the Git index. */ +type IndexEntry = { + path: Path; + mode: FileMode; + stage: IndexStage; + sha: GitSha; +}; + +/** + * The Git index (staging area). + * During merges, a single path can have multiple entries at different stages. + */ +type IndexState = { + entries: Map; +}; + +// --- Status model --- + +type FileStatus = 'unmodified' | 'modified' | 'added' | 'deleted' | 'untracked' | 'conflicted'; + +type StatusEntry = { + path: Path; + status: FileStatus; + mode?: FileMode; + headSha?: GitSha; + indexSha?: GitSha; + worktreeSha?: GitSha; +}; + +// --- Merge state --- + +/** The three content versions involved in a three-way merge conflict. */ +type ConflictPayload = { + base?: Uint8Array; + ours?: Uint8Array; + theirs?: Uint8Array; +}; + +/** Bookkeeping for an in-progress merge (e.g. during Sync). */ +type MergeState = { + inProgress: boolean; + targetCommit?: GitSha; + /** Paths with conflicts from the last sync (auto-resolved or not). */ + conflictedPaths: Set; + /** Raw three-way content for each conflicted path; useful for retries and debugging. */ + conflicts?: Map; +}; + +// --- Refs and remote configuration --- + +type Ref = { + name: `refs/heads/${string}` | `refs/tags/${string}`; + sha: GitSha | null; +}; + +type RemoteRef = { + name: `refs/remotes/${string}/${string}`; + sha: GitSha | null; +}; + +type RemoteConfig = { + name: string; + url: string; +}; + +type BranchState = { + head: Ref; + upstream?: RemoteRef; +}; + +// --- Commit envelope --- + +type Signature = { + name: string; + email: string; + /** Unix seconds. */ + timestamp: number; + timezoneOffsetMinutes: number; +}; + +/** The data needed to construct a Git commit object before it's hashed and pushed. */ +type PendingCommit = { + tree: GitSha; + parents: GitSha[]; + author: Signature; + committer: Signature; + message: string; +}; + +// --- Caches and configuration --- + +/** + * Cache of computed blob SHAs. + * Keys are a stable serialization of (path, size, mtime) — avoids re-hashing unchanged files. + */ +type HashCache = { + entries: Map; +}; + +type IgnoreRules = { + patterns: string[]; +}; + +type RepoConfig = { + eol?: 'lf' | 'crlf' | 'as-is'; + caseSensitive?: boolean; + enableRenameDetect?: boolean; +}; + +// --- Top-level repo state --- + +/** + * The complete local repo state. + * Separates the three main snapshots (BASE, REMOTE, working files), plus index, + * merge state, refs, and config. Designed to be persisted in IndexedDB via repo-db.ts. + */ +type RepoState = { + repoId: string; + + remote: RemoteConfig; + branch: BranchState; + + /** Last-synced tree — the merge base for the next Sync. */ + base: BaseSnapshot; + /** Latest fetched remote tree. */ + remoteSnapshot: RemoteSnapshot; + + /** Current local working content, keyed by path. */ + workingFiles: Map; + /** Git index (staging area), used internally during merges. */ + index: IndexState; + /** Per-path status relative to HEAD. */ + status: Map; + /** Merge bookkeeping for in-progress or recently completed syncs. */ + merge: MergeState; + + ignore: IgnoreRules; + config: RepoConfig; + /** Cached blob SHAs to avoid re-hashing unchanged files. */ + hashCache: HashCache; + + /** Monotonically increasing version counter for optimistic concurrency. */ + version: number; + locks?: { + sync: boolean; + index: boolean; + }; +}; diff --git a/tasks/git-object-identity.md b/tasks/git-object-identity.md new file mode 100644 index 0000000..7d6ecea --- /dev/null +++ b/tasks/git-object-identity.md @@ -0,0 +1,46 @@ +--- +status: done +created: 2026-03-05 +--- + +# Git object identity library + +## Context + +We're rebuilding Vibenote's data layer to be Git-shaped at the object level. Read `docs/vibenote-git-sync-design.md` for the full design direction — especially the sections on object identity, Git hashes, and the testing strategy. + +This task builds the foundational library: pure functions that compute Git-compatible blob, tree, and commit SHAs. Everything else (sync, storage, merge) builds on this. + +## Parallel work notice + +Another agent is working on **repo state model + IndexedDB storage** at the same time (see `tasks/repo-state-storage.md`). That work lives in different files and won't conflict with yours. If you see new files appearing in `src/` that you didn't create, that's the other agent — ignore them. + +## Goal + +Create a module (suggest `src/git/` directory) of pure functions that: + +1. **Compute blob SHAs** from file content (`Uint8Array`), matching Git's `blob \0` format exactly. +2. **Compute tree SHAs** from a list of tree entries (mode, name, child SHA), matching Git's binary tree format with canonical entry ordering. +3. **Compute commit SHAs** from a commit object (tree, parents, author, committer, message), matching Git's canonical commit text format. +4. **Build tree objects from flat path maps.** Given a flat `Map` (like `git ls-tree -r`), reconstruct the nested tree hierarchy and compute the root tree SHA. This is needed later for turning working files into committable trees. + +These are pure functions. No storage, no network, no React, no side effects. + +## Required reading + +- `docs/vibenote-git-sync-design.md` — sections on object identity, Git hashes, tree/blob/commit formats, and testing strategy +- `AGENTS.md` — project coding guidelines + +## Must-haves + +- Blob, tree, and commit SHA computation produce byte-for-byte identical results to real Git. +- Flat-path-map → nested tree reconstruction works correctly (including deeply nested paths, single-file trees, empty directories if applicable). +- Comprehensive tests using the real `git` CLI as oracle (create objects with `git hash-object`, `git mktree`, `git commit-tree`, then compare SHAs). +- Edge cases covered: empty blob, empty tree, UTF-8 filenames, filenames with spaces, executable mode, merge commits (multiple parents), various timezone offsets. +- Types are well-defined (branded `GitSha` type, `Path` type, etc. — see the design doc for suggestions). + +## Validation + +1. `npm run check` clean. +2. `npm test` green — all new tests pass, no regressions. +3. Tests demonstrate oracle comparison against real `git` CLI output. diff --git a/tasks/repo-state-storage.md b/tasks/repo-state-storage.md new file mode 100644 index 0000000..40d1ece --- /dev/null +++ b/tasks/repo-state-storage.md @@ -0,0 +1,58 @@ +--- +status: done +completed: 2026-03-06 +created: 2026-03-05 +--- + +# Repo state model + IndexedDB storage (#79) + +## Context + +We're rebuilding Vibenote's data layer to be Git-shaped at the object level. Read `docs/vibenote-git-sync-design.md` for the full design direction — especially the sections on the conceptual model, suggested TypeScript types, and the flat-map rationale. + +Currently, all repo data lives in `localStorage` (see `src/storage/local.ts`). This task replaces that with a proper storage backend (IndexedDB) and defines the canonical TypeScript types for repo state. + +## Parallel work notice + +Another agent is working on **Git object identity** at the same time (see `tasks/git-object-identity.md`). That work will produce pure functions in a `src/git/` directory and won't conflict with your files. If you see new files appearing there, that's the other agent — ignore them. + +Your work should reference the branded types (`GitSha`, `Path`, `FileMode`) that the design doc describes. If the git-objects agent hasn't created them yet, define them yourself in a shared types file — we'll reconcile later. + +## Goal + +1. **Define the repo state types.** The design doc has detailed suggestions — use them as a starting point, but adapt as you see fit. The key structures are: + - Tree snapshots (BASE, REMOTE) as flat path maps + - Working files with content, mode, and optional cached blob SHA + - Index/staging area (even if hidden from UX — useful for merge internals) + - Status entries + - Merge state + - Refs and branch state + - The top-level `RepoState` that composes all of the above + +2. **Implement IndexedDB persistence.** Store and retrieve repo state efficiently. Consider: + - File content (potentially large `Uint8Array`s) should be stored efficiently — possibly in a separate object store from metadata + - Multiple repos need to coexist (keyed by repo ID / slug) + - Reads should be fast for common access patterns (get a single file's content, list all file metadata, get the full snapshot) + - The API should be async and clean — the rest of the app will call these functions, not touch IndexedDB directly + +3. **Migrate existing local.ts surface.** The current `src/storage/local.ts` exports functions like `getRepoStore`, `computeSyncedHash`, etc. that the app uses today. You don't need to make the old code call the new storage yet (that's a later task), but understand what it does so the new storage can eventually replace it. + +## Required reading + +- `docs/vibenote-git-sync-design.md` — sections on the conceptual model, TypeScript types, and flat-map rationale +- `src/storage/local.ts` — the current storage implementation you're replacing +- `AGENTS.md` — project coding guidelines + +## Must-haves + +- Well-defined TypeScript types for the full repo state model. +- IndexedDB storage layer with a clean async API: open/close, read/write repo state, read/write individual files, list files. +- Multiple repos supported (isolated by slug/repo ID). +- Tests covering: store and retrieve repo state, store and retrieve file content, multiple repos don't leak into each other, basic error handling. +- No React dependencies — this is a plain TypeScript module. + +## Validation + +1. `npm run check` clean. +2. `npm test` green — all new tests pass, no regressions. +3. Review: types align with the design doc's conceptual model (three snapshots, flat maps, merge state, refs). From dad152f03ef0b939a2c801388c4aab371aaad31c Mon Sep 17 00:00:00 2001 From: Gregor Mitscha-Baude Date: Fri, 6 Mar 2026 04:21:39 +0100 Subject: [PATCH 2/4] feat: sync engine with three-way merge and retry (#75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/sync/sync-engine.ts: New sync engine implementing the design from vibenote-git-sync-design.md. Operates on RepoState types with a clean GitHubRemote adapter interface. - Sync flow: fetch remote → diff against BASE → three-way merge → commit → push → retry - Merge policies: markdown (custom 3-way via Yjs), binary (theirs wins), other text (remote wins as fallback) - Handles all cases: no-op, local-only push, remote-only pull, both-changed merge, race condition retry (max 3) - 19 tests covering all sync scenarios with in-memory mock GitHub remote Task: sync-engine (done) --- src/sync/sync-engine.test.ts | 730 +++++++++++++++++++++++++++++++++++ src/sync/sync-engine.ts | 716 ++++++++++++++++++++++++++++++++++ tasks/storage-pruning.md | 10 + tasks/sync-engine.md | 15 + tasks/wire-app-data-v2.md | 10 + 5 files changed, 1481 insertions(+) create mode 100644 src/sync/sync-engine.test.ts create mode 100644 src/sync/sync-engine.ts create mode 100644 tasks/storage-pruning.md create mode 100644 tasks/sync-engine.md create mode 100644 tasks/wire-app-data-v2.md diff --git a/src/sync/sync-engine.test.ts b/src/sync/sync-engine.test.ts new file mode 100644 index 0000000..47a9dcc --- /dev/null +++ b/src/sync/sync-engine.test.ts @@ -0,0 +1,730 @@ +// Tests for the new sync engine (sync-engine.ts). +// Uses an in-memory mock GitHubRemote to test all sync scenarios +// without network calls. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { performSync, computeStatus, computeDiff } from './sync-engine'; +import type { GitHubRemote } from './sync-engine'; +import { blobSha } from '../git/index'; +import type { GitSha, Path, FileMode } from '../git/types'; +import type { + RepoState, + WorkingFile, + BaseSnapshot, + RemoteSnapshot, + SnapshotEntry, +} from '../storage/repo-types'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +const enc = new TextEncoder(); +const dec = new TextDecoder(); + +function toPath(s: string): Path { + return s as Path; +} + +function toSha(s: string): GitSha { + return s as GitSha; +} + +function makeWorkingFile(path: string, text: string, sha?: GitSha): WorkingFile { + let content = enc.encode(text); + return { + path: toPath(path), + mode: '100644' as Exclude, + content, + size: content.byteLength, + blobSha: sha, + mtime: Date.now(), + }; +} + +function makeEmptyBase(): BaseSnapshot { + return { + rootTree: toSha('empty-tree'), + entries: new Map(), + baseCommit: null, + }; +} + +function makeBase(files: Array<{ path: string; sha: string }>): BaseSnapshot { + let entries = new Map(); + for (let f of files) { + entries.set(toPath(f.path), { mode: '100644', sha: toSha(f.sha) }); + } + return { + rootTree: toSha('base-tree'), + entries, + baseCommit: toSha('base-commit'), + }; +} + +function makeRepoState(overrides: Partial = {}): RepoState { + return { + repoId: 'test/repo', + remote: { name: 'origin', url: 'https://github.com/test/repo.git' }, + branch: { + head: { name: 'refs/heads/main', sha: null }, + }, + base: makeEmptyBase(), + remoteSnapshot: { + rootTree: toSha('empty-tree'), + entries: new Map(), + remoteCommit: null, + }, + workingFiles: new Map(), + index: { entries: new Map() }, + status: new Map(), + merge: { inProgress: false, conflictedPaths: new Set() }, + ignore: { patterns: [] }, + config: {}, + hashCache: { entries: new Map() }, + version: 0, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// In-memory mock GitHub remote +// --------------------------------------------------------------------------- + +type MockFile = { content: Uint8Array; sha: string }; +type MockCommit = { treeSha: string; files: Map; parents: string[] }; + +class MockGitHub implements GitHubRemote { + private files = new Map(); + private blobs = new Map(); + private commits = new Map(); + private trees = new Map>(); + private branchTip: string | undefined; + private commitCounter = 0; + private treeCounter = 0; + private rejectNextRefUpdate = false; + + /** Set up the remote with initial files. */ + async setFiles(entries: Array<{ path: string; text: string }>) { + this.files.clear(); + for (let entry of entries) { + let content = enc.encode(entry.text); + let sha = await blobSha(content); + this.files.set(entry.path, { content, sha }); + this.blobs.set(sha, content); + } + // Create a tree + commit for this state + let treeEntries = new Map(); + for (let [path, file] of this.files) { + treeEntries.set(toPath(path), { mode: '100644', sha: toSha(file.sha) }); + } + let treeSha = `tree-${++this.treeCounter}`; + this.trees.set(treeSha, treeEntries); + + let commitSha = `commit-${++this.commitCounter}`; + let commitFiles = new Map(this.files); + this.commits.set(commitSha, { treeSha, files: commitFiles, parents: [] }); + this.branchTip = commitSha; + } + + /** Simulate an external push (add/modify files on remote). */ + async externalPush(entries: Array<{ path: string; text: string }>) { + for (let entry of entries) { + let content = enc.encode(entry.text); + let sha = await blobSha(content); + this.files.set(entry.path, { content, sha }); + this.blobs.set(sha, content); + } + let treeEntries = new Map(); + for (let [path, file] of this.files) { + treeEntries.set(toPath(path), { mode: '100644', sha: toSha(file.sha) }); + } + let treeSha = `tree-${++this.treeCounter}`; + this.trees.set(treeSha, treeEntries); + + let parent = this.branchTip; + let commitSha = `commit-${++this.commitCounter}`; + this.commits.set(commitSha, { + treeSha, + files: new Map(this.files), + parents: parent !== undefined ? [parent] : [], + }); + this.branchTip = commitSha; + } + + /** Simulate an external deletion on remote. */ + async externalDelete(paths: string[]) { + for (let path of paths) { + this.files.delete(path); + } + let treeEntries = new Map(); + for (let [path, file] of this.files) { + treeEntries.set(toPath(path), { mode: '100644', sha: toSha(file.sha) }); + } + let treeSha = `tree-${++this.treeCounter}`; + this.trees.set(treeSha, treeEntries); + + let parent = this.branchTip; + let commitSha = `commit-${++this.commitCounter}`; + this.commits.set(commitSha, { + treeSha, + files: new Map(this.files), + parents: parent !== undefined ? [parent] : [], + }); + this.branchTip = commitSha; + } + + /** Get the snapshot that would be the BASE after syncing with current remote. */ + getBaseSnapshot(): BaseSnapshot { + if (this.branchTip === undefined) { + return makeEmptyBase(); + } + let commit = this.commits.get(this.branchTip)!; + let tree = this.trees.get(commit.treeSha)!; + return { + rootTree: toSha(commit.treeSha), + entries: new Map(tree), + baseCommit: toSha(this.branchTip), + }; + } + + /** Make the next ref update fail (simulating a race condition). */ + simulateRace() { + this.rejectNextRefUpdate = true; + } + + // --- GitHubRemote implementation --- + + async fetchBranchTip(): Promise { + return this.branchTip; + } + + async fetchCommit(sha: string): Promise<{ treeSha: string; parents: string[] }> { + let commit = this.commits.get(sha); + if (commit === undefined) throw new Error(`Unknown commit: ${sha}`); + return { treeSha: commit.treeSha, parents: commit.parents }; + } + + async fetchTree(treeSha: string): Promise> { + let tree = this.trees.get(treeSha); + if (tree === undefined) return new Map(); + return new Map(tree); + } + + async fetchBlob(sha: string): Promise { + let blob = this.blobs.get(sha); + if (blob === undefined) throw new Error(`Unknown blob: ${sha}`); + return blob; + } + + async createBlob(content: Uint8Array): Promise { + let sha = await blobSha(content); + this.blobs.set(sha, new Uint8Array(content)); + return sha; + } + + async createTree( + entries: Array<{ path: string; mode: string; sha: string | null }>, + baseTree?: string, + ): Promise { + // Start from base tree if provided + let resultEntries = new Map(); + if (baseTree !== undefined) { + let base = this.trees.get(baseTree); + if (base !== undefined) { + for (let [path, entry] of base) { + resultEntries.set(path, entry); + } + } + } + + // Apply changes + for (let entry of entries) { + if (entry.sha === null) { + resultEntries.delete(toPath(entry.path)); + } else { + resultEntries.set(toPath(entry.path), { + mode: entry.mode as FileMode, + sha: toSha(entry.sha), + }); + } + } + + let treeSha = `tree-${++this.treeCounter}`; + this.trees.set(treeSha, resultEntries); + + // Update internal files map to reflect the tree + this.files.clear(); + for (let [path, entry] of resultEntries) { + let blob = this.blobs.get(entry.sha); + if (blob !== undefined) { + this.files.set(path, { content: blob, sha: entry.sha }); + } + } + + return treeSha; + } + + async createCommit(params: { + treeSha: string; + parents: string[]; + message: string; + }): Promise { + let commitSha = `commit-${++this.commitCounter}`; + let tree = this.trees.get(params.treeSha); + let commitFiles = new Map(); + if (tree !== undefined) { + for (let [path, entry] of tree) { + let blob = this.blobs.get(entry.sha); + if (blob !== undefined) { + commitFiles.set(path, { content: blob, sha: entry.sha }); + } + } + } + this.commits.set(commitSha, { + treeSha: params.treeSha, + files: commitFiles, + parents: params.parents, + }); + return commitSha; + } + + async updateBranchRef(_branch: string, commitSha: string): Promise { + if (this.rejectNextRefUpdate) { + this.rejectNextRefUpdate = false; + let err = new Error('fast-forward required') as Error & { status: number }; + err.status = 422; + throw err; + } + this.branchTip = commitSha; + } + + async createBranchRef(_branch: string, commitSha: string): Promise { + this.branchTip = commitSha; + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('computeDiff', () => { + it('detects added files', async () => { + let base = makeEmptyBase(); + let files = new Map(); + files.set(toPath('hello.md'), makeWorkingFile('hello.md', 'hello world')); + + let diffs = await computeDiff(base, files); + expect(diffs).toHaveLength(1); + expect(diffs[0]!.type).toBe('added'); + expect(diffs[0]!.path).toBe('hello.md'); + }); + + it('detects modified files', async () => { + let content = enc.encode('original'); + let sha = await blobSha(content); + let base = makeBase([{ path: 'note.md', sha }]); + + let files = new Map(); + files.set(toPath('note.md'), makeWorkingFile('note.md', 'modified')); + + let diffs = await computeDiff(base, files); + expect(diffs).toHaveLength(1); + expect(diffs[0]!.type).toBe('modified'); + }); + + it('detects deleted files', async () => { + let base = makeBase([{ path: 'gone.md', sha: 'abc123' }]); + let files = new Map(); + + let diffs = await computeDiff(base, files); + expect(diffs).toHaveLength(1); + expect(diffs[0]!.type).toBe('deleted'); + expect(diffs[0]!.path).toBe('gone.md'); + }); + + it('returns empty for unchanged files', async () => { + let content = enc.encode('same'); + let sha = await blobSha(content); + let base = makeBase([{ path: 'same.md', sha }]); + + let files = new Map(); + files.set(toPath('same.md'), makeWorkingFile('same.md', 'same', sha)); + + let diffs = await computeDiff(base, files); + expect(diffs).toHaveLength(0); + }); +}); + +describe('computeStatus', () => { + it('classifies files correctly', async () => { + let content = enc.encode('unchanged'); + let sha = await blobSha(content); + let base = makeBase([ + { path: 'unchanged.md', sha }, + { path: 'modified.md', sha: 'old-sha' }, + { path: 'deleted.md', sha: 'del-sha' }, + ]); + + let files = new Map(); + files.set(toPath('unchanged.md'), makeWorkingFile('unchanged.md', 'unchanged', sha)); + files.set(toPath('modified.md'), makeWorkingFile('modified.md', 'new content')); + files.set(toPath('added.md'), makeWorkingFile('added.md', 'brand new')); + + let statuses = await computeStatus(base, files); + let byPath = new Map(statuses.map(s => [s.path, s.status])); + + expect(byPath.get(toPath('unchanged.md'))).toBe('unmodified'); + expect(byPath.get(toPath('modified.md'))).toBe('modified'); + expect(byPath.get(toPath('added.md'))).toBe('added'); + expect(byPath.get(toPath('deleted.md'))).toBe('deleted'); + }); +}); + +describe('performSync', () => { + let github: MockGitHub; + + beforeEach(() => { + github = new MockGitHub(); + }); + + // ----- Case A: No changes ----- + + it('no-ops when nothing changed', async () => { + await github.setFiles([{ path: 'README.md', text: '# Hello' }]); + let base = github.getBaseSnapshot(); + let files = new Map(); + let readmeContent = enc.encode('# Hello'); + let readmeSha = await blobSha(readmeContent); + files.set(toPath('README.md'), makeWorkingFile('README.md', '# Hello', readmeSha)); + + let state = makeRepoState({ + base, + remoteSnapshot: { ...base, remoteCommit: base.baseCommit }, + workingFiles: files, + }); + + let result = await performSync(state, github); + expect(result.summary.pulled).toBe(0); + expect(result.summary.pushed).toBe(0); + expect(result.summary.merged).toBe(0); + }); + + // ----- Case B: Local changes only ----- + + it('pushes local-only changes', async () => { + await github.setFiles([{ path: 'note.md', text: 'original' }]); + let base = github.getBaseSnapshot(); + + let files = new Map(); + let originalContent = enc.encode('original'); + let originalSha = await blobSha(originalContent); + // Keep note.md unchanged in base, but modify locally + files.set(toPath('note.md'), makeWorkingFile('note.md', 'modified locally')); + + let state = makeRepoState({ + base, + remoteSnapshot: { ...base, remoteCommit: base.baseCommit }, + workingFiles: files, + }); + + let result = await performSync(state, github); + expect(result.summary.pushed).toBe(1); + expect(result.summary.pulled).toBe(0); + // Base should be updated to the new commit + expect(result.state.base.baseCommit).not.toBe(base.baseCommit); + }); + + it('pushes new files to empty remote', async () => { + // Empty remote (no commits) + let files = new Map(); + files.set(toPath('first.md'), makeWorkingFile('first.md', 'my first note')); + + let state = makeRepoState({ workingFiles: files }); + + let result = await performSync(state, github); + expect(result.summary.pushed).toBe(1); + expect(result.state.base.baseCommit).not.toBeNull(); + }); + + it('pushes locally deleted files', async () => { + await github.setFiles([ + { path: 'keep.md', text: 'keep me' }, + { path: 'remove.md', text: 'delete me' }, + ]); + let base = github.getBaseSnapshot(); + + // Only keep one file locally + let keepContent = enc.encode('keep me'); + let keepSha = await blobSha(keepContent); + let files = new Map(); + files.set(toPath('keep.md'), makeWorkingFile('keep.md', 'keep me', keepSha)); + + let state = makeRepoState({ + base, + remoteSnapshot: { ...base, remoteCommit: base.baseCommit }, + workingFiles: files, + }); + + let result = await performSync(state, github); + expect(result.summary.deletedRemote).toBe(1); + expect(result.state.base.entries.has(toPath('remove.md'))).toBe(false); + }); + + // ----- Case C: Remote changes only ----- + + it('pulls remote-only changes', async () => { + await github.setFiles([{ path: 'note.md', text: 'original' }]); + let base = github.getBaseSnapshot(); + + // Set up local state matching the base + let originalContent = enc.encode('original'); + let originalSha = await blobSha(originalContent); + let files = new Map(); + files.set(toPath('note.md'), makeWorkingFile('note.md', 'original', originalSha)); + + // Simulate external push + await github.externalPush([{ path: 'note.md', text: 'updated remotely' }]); + + let state = makeRepoState({ + base, + remoteSnapshot: { ...base, remoteCommit: base.baseCommit }, + workingFiles: files, + }); + + let result = await performSync(state, github); + expect(result.summary.pulled).toBe(1); + expect(result.summary.pushed).toBe(0); + + // Working file should have the remote content + let updated = result.state.workingFiles.get(toPath('note.md')); + expect(updated).toBeDefined(); + expect(dec.decode(updated!.content)).toBe('updated remotely'); + }); + + it('pulls new remote files', async () => { + await github.setFiles([{ path: 'existing.md', text: 'existing' }]); + let base = github.getBaseSnapshot(); + + let existingContent = enc.encode('existing'); + let existingSha = await blobSha(existingContent); + let files = new Map(); + files.set(toPath('existing.md'), makeWorkingFile('existing.md', 'existing', existingSha)); + + // External push adds a new file + await github.externalPush([{ path: 'new-remote.md', text: 'new from remote' }]); + + let state = makeRepoState({ + base, + remoteSnapshot: { ...base, remoteCommit: base.baseCommit }, + workingFiles: files, + }); + + let result = await performSync(state, github); + expect(result.summary.pulled).toBe(1); + expect(result.state.workingFiles.has(toPath('new-remote.md'))).toBe(true); + let newFile = result.state.workingFiles.get(toPath('new-remote.md'))!; + expect(dec.decode(newFile.content)).toBe('new from remote'); + }); + + it('handles remote deletions', async () => { + await github.setFiles([ + { path: 'keep.md', text: 'keep' }, + { path: 'gone.md', text: 'will be deleted' }, + ]); + let base = github.getBaseSnapshot(); + + let keepContent = enc.encode('keep'); + let keepSha = await blobSha(keepContent); + let goneContent = enc.encode('will be deleted'); + let goneSha = await blobSha(goneContent); + let files = new Map(); + files.set(toPath('keep.md'), makeWorkingFile('keep.md', 'keep', keepSha)); + files.set(toPath('gone.md'), makeWorkingFile('gone.md', 'will be deleted', goneSha)); + + // Remote deletes 'gone.md' + await github.externalDelete(['gone.md']); + + let state = makeRepoState({ + base, + remoteSnapshot: { ...base, remoteCommit: base.baseCommit }, + workingFiles: files, + }); + + let result = await performSync(state, github); + expect(result.summary.deletedLocal).toBe(1); + expect(result.state.workingFiles.has(toPath('gone.md'))).toBe(false); + }); + + // ----- Case C: Both changed — merge ----- + + it('merges markdown when both sides changed', async () => { + await github.setFiles([{ path: 'note.md', text: 'line 1\nline 2\nline 3' }]); + let base = github.getBaseSnapshot(); + + // Local changes: modify line 2 + let files = new Map(); + files.set(toPath('note.md'), makeWorkingFile('note.md', 'line 1\nlocal change\nline 3')); + + // Remote changes: modify line 3 + await github.externalPush([{ path: 'note.md', text: 'line 1\nline 2\nremote change' }]); + + let state = makeRepoState({ + base, + remoteSnapshot: { ...base, remoteCommit: base.baseCommit }, + workingFiles: files, + }); + + let result = await performSync(state, github); + expect(result.summary.merged).toBe(1); + // The merged content should have both changes + let merged = result.state.workingFiles.get(toPath('note.md')); + expect(merged).toBeDefined(); + let mergedText = dec.decode(merged!.content); + expect(mergedText).toContain('local change'); + expect(mergedText).toContain('remote change'); + }); + + it('remote wins for binary conflicts', async () => { + await github.setFiles([{ path: 'image.png', text: 'original-binary' }]); + let base = github.getBaseSnapshot(); + + // Local changes to binary + let files = new Map(); + files.set(toPath('image.png'), makeWorkingFile('image.png', 'local-binary')); + + // Remote changes to binary + await github.externalPush([{ path: 'image.png', text: 'remote-binary' }]); + + let state = makeRepoState({ + base, + remoteSnapshot: { ...base, remoteCommit: base.baseCommit }, + workingFiles: files, + }); + + let result = await performSync(state, github); + expect(result.summary.pulled).toBe(1); + let updated = result.state.workingFiles.get(toPath('image.png')); + expect(dec.decode(updated!.content)).toBe('remote-binary'); + }); + + it('keeps locally modified file when remote deletes it', async () => { + await github.setFiles([{ path: 'note.md', text: 'original' }]); + let base = github.getBaseSnapshot(); + + // Local modification + let files = new Map(); + files.set(toPath('note.md'), makeWorkingFile('note.md', 'locally modified')); + + // Remote deletion + await github.externalDelete(['note.md']); + + let state = makeRepoState({ + base, + remoteSnapshot: { ...base, remoteCommit: base.baseCommit }, + workingFiles: files, + }); + + let result = await performSync(state, github); + // Should keep the local file and push it back + expect(result.state.workingFiles.has(toPath('note.md'))).toBe(true); + expect(result.summary.pushed).toBeGreaterThanOrEqual(1); + }); + + // ----- Race condition handling ----- + + it('retries on race condition', async () => { + await github.setFiles([{ path: 'note.md', text: 'original' }]); + let base = github.getBaseSnapshot(); + + let files = new Map(); + files.set(toPath('note.md'), makeWorkingFile('note.md', 'local edit')); + + let state = makeRepoState({ + base, + remoteSnapshot: { ...base, remoteCommit: base.baseCommit }, + workingFiles: files, + }); + + // Make the first ref update fail + github.simulateRace(); + + let result = await performSync(state, github); + // Should still succeed after retry + expect(result.summary.pushed).toBe(1); + expect(result.state.base.baseCommit).not.toBe(base.baseCommit); + }); + + // ----- Mixed scenarios ----- + + it('handles simultaneous add and remote changes', async () => { + await github.setFiles([{ path: 'existing.md', text: 'existing' }]); + let base = github.getBaseSnapshot(); + + let existingContent = enc.encode('existing'); + let existingSha = await blobSha(existingContent); + let files = new Map(); + files.set(toPath('existing.md'), makeWorkingFile('existing.md', 'existing', existingSha)); + // Add a new local file + files.set(toPath('new-local.md'), makeWorkingFile('new-local.md', 'new local content')); + + // Remote modifies existing + await github.externalPush([{ path: 'existing.md', text: 'remote update' }]); + + let state = makeRepoState({ + base, + remoteSnapshot: { ...base, remoteCommit: base.baseCommit }, + workingFiles: files, + }); + + let result = await performSync(state, github); + // Should pull the remote change and push the new local file + expect(result.summary.pulled).toBe(1); + expect(result.summary.pushed).toBe(1); + expect(dec.decode(result.state.workingFiles.get(toPath('existing.md'))!.content)).toBe('remote update'); + expect(result.state.workingFiles.has(toPath('new-local.md'))).toBe(true); + }); + + it('increments version after sync', async () => { + await github.setFiles([{ path: 'note.md', text: 'content' }]); + let base = github.getBaseSnapshot(); + + let content = enc.encode('content'); + let sha = await blobSha(content); + let files = new Map(); + files.set(toPath('note.md'), makeWorkingFile('note.md', 'content', sha)); + + let state = makeRepoState({ + base, + remoteSnapshot: { ...base, remoteCommit: base.baseCommit }, + workingFiles: files, + version: 5, + }); + + let result = await performSync(state, github); + expect(result.state.version).toBeGreaterThan(5); + }); + + it('clears merge state after successful sync', async () => { + await github.setFiles([{ path: 'note.md', text: 'original' }]); + let base = github.getBaseSnapshot(); + + let files = new Map(); + files.set(toPath('note.md'), makeWorkingFile('note.md', 'modified')); + + let state = makeRepoState({ + base, + remoteSnapshot: { ...base, remoteCommit: base.baseCommit }, + workingFiles: files, + merge: { + inProgress: true, + conflictedPaths: new Set([toPath('old-conflict.md')]), + targetCommit: toSha('old-target'), + }, + }); + + let result = await performSync(state, github); + expect(result.state.merge.inProgress).toBe(false); + expect(result.state.merge.conflictedPaths.size).toBe(0); + }); +}); diff --git a/src/sync/sync-engine.ts b/src/sync/sync-engine.ts new file mode 100644 index 0000000..432cdd2 --- /dev/null +++ b/src/sync/sync-engine.ts @@ -0,0 +1,716 @@ +// New sync engine implementing the design from docs/vibenote-git-sync-design.md. +// Operates on the Git-shaped RepoState model (src/storage/repo-types.ts) and +// uses the git object library (src/git/) for SHA computation. +// +// The sync flow is: fetch remote → diff against BASE → three-way merge → +// build commit → push → retry on race. +// +// This module has no React dependencies. It is a pure async state machine +// that takes a RepoState and a GitHubRemote adapter, and returns a new RepoState. + +import { blobSha, buildTree, commitSha } from '../git/index'; +import type { GitSha, Path, FileMode } from '../git/types'; +import { mergeMarkdown } from '../merge/merge'; +import type { + RepoState, + SnapshotEntry, + TreeSnapshot, + WorkingFile, + BaseSnapshot, + RemoteSnapshot, + Signature, +} from '../storage/repo-types'; + +export { performSync, computeStatus, computeDiff }; +export type { GitHubRemote, SyncResult, SyncSummary, FileDiff, DiffType }; + +// --------------------------------------------------------------------------- +// GitHub remote adapter — abstraction over the REST API so we can mock it +// --------------------------------------------------------------------------- + +/** Minimal GitHub API surface needed by the sync engine. */ +type GitHubRemote = { + /** Fetch the current branch tip commit SHA. Returns undefined if the branch doesn't exist yet. */ + fetchBranchTip: (branch: string) => Promise; + + /** Fetch the commit object to get its tree SHA and parent SHAs. */ + fetchCommit: (sha: string) => Promise<{ treeSha: string; parents: string[] }>; + + /** Fetch the recursive tree listing (flat path map). */ + fetchTree: (treeSha: string) => Promise>; + + /** Fetch raw file content by blob SHA. */ + fetchBlob: (sha: string) => Promise; + + /** Create a blob on GitHub and return its SHA. */ + createBlob: (content: Uint8Array) => Promise; + + /** Create a tree object on GitHub. Returns the new tree SHA. */ + createTree: ( + entries: Array<{ path: string; mode: string; sha: string | null }>, + baseTree?: string, + ) => Promise; + + /** Create a commit object on GitHub. Returns the new commit SHA. */ + createCommit: (params: { + treeSha: string; + parents: string[]; + message: string; + author?: { name: string; email: string; date: string }; + committer?: { name: string; email: string; date: string }; + }) => Promise; + + /** Update the branch ref to point at a new commit. Non-force by default. */ + updateBranchRef: (branch: string, commitSha: string) => Promise; + + /** Create a new branch ref pointing at a commit. */ + createBranchRef: (branch: string, commitSha: string) => Promise; +}; + +// --------------------------------------------------------------------------- +// Sync result types +// --------------------------------------------------------------------------- + +type SyncSummary = { + pulled: number; + pushed: number; + merged: number; + deletedLocal: number; + deletedRemote: number; +}; + +type SyncResult = { + state: RepoState; + summary: SyncSummary; +}; + +// --------------------------------------------------------------------------- +// Diff types +// --------------------------------------------------------------------------- + +type DiffType = 'added' | 'modified' | 'deleted'; + +type FileDiff = { + path: Path; + type: DiffType; + /** Blob SHA of the file in the working tree (undefined for deletions). */ + workingSha?: GitSha; + /** Blob SHA of the file in the base snapshot (undefined for additions). */ + baseSha?: GitSha; +}; + +// --------------------------------------------------------------------------- +// Main sync entry point +// --------------------------------------------------------------------------- + +const MAX_RETRIES = 3; + +/** + * One-click sync: fetch remote, diff, merge, commit, push, retry on race. + * + * Takes the current repo state and a GitHub remote adapter, returns the + * new state after sync with a summary of what happened. + */ +async function performSync( + state: RepoState, + remote: GitHubRemote, +): Promise { + let current = state; + let retries = 0; + + while (retries <= MAX_RETRIES) { + let result = await attemptSync(current, remote); + if (result.retry) { + // Race condition: someone else pushed. Refetch and retry. + retries++; + current = result.state; + continue; + } + return { state: result.state, summary: result.summary }; + } + + // Exhausted retries — return the state as-is with a zero summary + return { + state: current, + summary: { pulled: 0, pushed: 0, merged: 0, deletedLocal: 0, deletedRemote: 0 }, + }; +} + +// --------------------------------------------------------------------------- +// Single sync attempt +// --------------------------------------------------------------------------- + +type AttemptResult = { + state: RepoState; + summary: SyncSummary; + retry: boolean; +}; + +async function attemptSync( + state: RepoState, + remote: GitHubRemote, +): Promise { + let summary: SyncSummary = { pulled: 0, pushed: 0, merged: 0, deletedLocal: 0, deletedRemote: 0 }; + let branch = extractBranchName(state.branch.head.name); + + // Step 1: Fetch current remote tip + let remoteTipSha = await remote.fetchBranchTip(branch); + let isInitialCommit = remoteTipSha === undefined; + + // Step 2: Build the remote snapshot + let remoteSnapshot: RemoteSnapshot; + if (isInitialCommit) { + // Empty remote — no commits yet + remoteSnapshot = { + rootTree: '' as GitSha, + entries: new Map(), + remoteCommit: null, + }; + } else { + let commitInfo = await remote.fetchCommit(remoteTipSha!); + let remoteEntries = await remote.fetchTree(commitInfo.treeSha); + remoteSnapshot = { + rootTree: commitInfo.treeSha as GitSha, + entries: remoteEntries, + remoteCommit: remoteTipSha as GitSha, + }; + } + + // Step 3: Compute local diff against BASE + let localDiff = await computeDiff(state.base, state.workingFiles); + let hasLocalChanges = localDiff.length > 0; + + // Step 4: Detect remote changes + let remoteChanged = state.base.baseCommit !== remoteSnapshot.remoteCommit; + + // Case A: no changes at all + if (!hasLocalChanges && !remoteChanged) { + let newState = { + ...state, + remoteSnapshot, + version: state.version + 1, + }; + return { state: newState, summary, retry: false }; + } + + // Case B: local changes only (fast-forward push) + if (hasLocalChanges && !remoteChanged) { + let pushResult = await pushLocalChanges(state, remote, localDiff, branch, isInitialCommit); + if (pushResult.raceDetected) { + return { state: { ...state, remoteSnapshot }, summary, retry: true }; + } + return { state: pushResult.state, summary: pushResult.summary, retry: false }; + } + + // Case C: remote changed (possibly with local changes too → merge) + // First, pull remote changes into local working files + let mergeResult = await mergeRemoteChanges( + state, + remote, + remoteSnapshot, + localDiff, + ); + + // If there were local changes that survived the merge, push the merged result + if (mergeResult.needsPush) { + let mergedDiff = await computeDiff( + // Use the remote snapshot as the new base for the push + { ...remoteSnapshot, baseCommit: remoteSnapshot.remoteCommit }, + mergeResult.state.workingFiles, + ); + + if (mergedDiff.length > 0) { + let pushResult = await pushLocalChanges( + mergeResult.state, + remote, + mergedDiff, + branch, + false, // not initial — remote already has commits + remoteSnapshot.remoteCommit ?? undefined, // merge parent + ); + + if (pushResult.raceDetected) { + return { state: mergeResult.state, summary: mergeResult.summary, retry: true }; + } + + // Combine summaries + let combinedSummary: SyncSummary = { + pulled: mergeResult.summary.pulled + pushResult.summary.pulled, + pushed: mergeResult.summary.pushed + pushResult.summary.pushed, + merged: mergeResult.summary.merged + pushResult.summary.merged, + deletedLocal: mergeResult.summary.deletedLocal + pushResult.summary.deletedLocal, + deletedRemote: mergeResult.summary.deletedRemote + pushResult.summary.deletedRemote, + }; + + return { state: pushResult.state, summary: combinedSummary, retry: false }; + } + } + + // Only remote changes, no push needed + return { state: mergeResult.state, summary: mergeResult.summary, retry: false }; +} + +// --------------------------------------------------------------------------- +// Compute diff: working files vs BASE snapshot +// --------------------------------------------------------------------------- + +/** + * Compare working files against the BASE snapshot to find local changes. + * Returns a list of diffs (added, modified, deleted). + */ +async function computeDiff( + base: BaseSnapshot, + workingFiles: Map, +): Promise { + let diffs: FileDiff[] = []; + + // Check for modified and added files + for (let [path, file] of workingFiles) { + let bSha = await ensureBlobSha(file); + let baseEntry = base.entries.get(path); + + if (baseEntry === undefined) { + // Added file + diffs.push({ path, type: 'added', workingSha: bSha }); + } else if (baseEntry.sha !== bSha) { + // Modified file + diffs.push({ path, type: 'modified', workingSha: bSha, baseSha: baseEntry.sha }); + } + } + + // Check for deleted files (in base but not in working) + for (let [path, entry] of base.entries) { + if (!workingFiles.has(path)) { + diffs.push({ path, type: 'deleted', baseSha: entry.sha }); + } + } + + return diffs; +} + +// --------------------------------------------------------------------------- +// Push local changes to remote +// --------------------------------------------------------------------------- + +type PushResult = { + state: RepoState; + summary: SyncSummary; + raceDetected: boolean; +}; + +async function pushLocalChanges( + state: RepoState, + remote: GitHubRemote, + diffs: FileDiff[], + branch: string, + isInitialCommit: boolean, + mergeParent?: string, +): Promise { + let summary: SyncSummary = { pulled: 0, pushed: 0, merged: 0, deletedLocal: 0, deletedRemote: 0 }; + + // Create blobs for new/modified files + let treeEntries: Array<{ path: string; mode: string; sha: string | null }> = []; + let blobShas = new Map(); + + for (let diff of diffs) { + if (diff.type === 'deleted') { + treeEntries.push({ path: diff.path, mode: '100644', sha: null }); + summary.deletedRemote++; + } else { + let file = state.workingFiles.get(diff.path); + if (file === undefined) continue; + + let fileSha = await ensureBlobSha(file); + + // Create the blob on GitHub + let remoteBlobSha = await remote.createBlob(file.content); + if (remoteBlobSha !== fileSha) { + // SHA mismatch — our local computation disagrees with GitHub + throw new Error( + `Blob SHA mismatch for ${diff.path}: local ${fileSha}, GitHub ${remoteBlobSha}`, + ); + } + + treeEntries.push({ path: diff.path, mode: file.mode, sha: fileSha }); + blobShas.set(diff.path, fileSha); + summary.pushed++; + } + } + + // Create tree on GitHub (with base_tree for incremental updates) + let baseTree = isInitialCommit ? undefined : state.base.rootTree; + let newTreeSha = await remote.createTree(treeEntries, baseTree); + + // Create commit + let parents: string[] = []; + if (!isInitialCommit && state.base.baseCommit !== null) { + parents.push(state.base.baseCommit); + } + if (mergeParent !== undefined) { + parents.push(mergeParent); + } + + let newCommitSha = await remote.createCommit({ + treeSha: newTreeSha, + parents, + message: 'vibenote: sync changes', + }); + + // Update the branch ref + try { + if (isInitialCommit) { + await remote.createBranchRef(branch, newCommitSha); + } else { + await remote.updateBranchRef(branch, newCommitSha); + } + } catch (err: unknown) { + // Check if this is a race condition (422 = fast-forward required) + if (isRefUpdateError(err)) { + return { state, summary: { pulled: 0, pushed: 0, merged: 0, deletedLocal: 0, deletedRemote: 0 }, raceDetected: true }; + } + throw err; + } + + // Build the new snapshot from working files + let newEntries = new Map(); + // Start with base entries + for (let [path, entry] of state.base.entries) { + newEntries.set(path, entry); + } + // Apply diffs + for (let diff of diffs) { + if (diff.type === 'deleted') { + newEntries.delete(diff.path); + } else { + let file = state.workingFiles.get(diff.path); + let sha = blobShas.get(diff.path); + if (file !== undefined && sha !== undefined) { + newEntries.set(diff.path, { mode: file.mode, sha }); + } + } + } + + let newBase: BaseSnapshot = { + rootTree: newTreeSha as GitSha, + entries: newEntries, + baseCommit: newCommitSha as GitSha, + }; + + let newRemote: RemoteSnapshot = { + rootTree: newTreeSha as GitSha, + entries: new Map(newEntries), + remoteCommit: newCommitSha as GitSha, + }; + + // Update working files with computed blob SHAs (cache them) + let updatedWorkingFiles = new Map(state.workingFiles); + for (let [path, sha] of blobShas) { + let file = updatedWorkingFiles.get(path as Path); + if (file !== undefined) { + updatedWorkingFiles.set(path as Path, { ...file, blobSha: sha }); + } + } + + let newState: RepoState = { + ...state, + base: newBase, + remoteSnapshot: newRemote, + workingFiles: updatedWorkingFiles, + merge: { inProgress: false, conflictedPaths: new Set() }, + version: state.version + 1, + }; + + return { state: newState, summary, raceDetected: false }; +} + +// --------------------------------------------------------------------------- +// Merge remote changes into local working files +// --------------------------------------------------------------------------- + +type MergeResult = { + state: RepoState; + summary: SyncSummary; + /** Whether local changes exist after the merge and need to be pushed. */ + needsPush: boolean; +}; + +async function mergeRemoteChanges( + state: RepoState, + remote: GitHubRemote, + remoteSnapshot: RemoteSnapshot, + localDiff: FileDiff[], +): Promise { + let summary: SyncSummary = { pulled: 0, pushed: 0, merged: 0, deletedLocal: 0, deletedRemote: 0 }; + let updatedFiles = new Map(state.workingFiles); + let hasLocalChanges = localDiff.length > 0; + let localDiffPaths = new Set(localDiff.map(d => d.path)); + + // Build sets for quick lookup + let baseEntries = state.base.entries; + let remoteEntries = remoteSnapshot.entries; + + // Track which paths the remote changed + let remoteAdded = new Map(); + let remoteModified = new Map(); + let remoteDeleted = new Set(); + + // Find remote additions and modifications + for (let [path, entry] of remoteEntries) { + let baseEntry = baseEntries.get(path); + if (baseEntry === undefined) { + remoteAdded.set(path, entry); + } else if (baseEntry.sha !== entry.sha) { + remoteModified.set(path, entry); + } + } + + // Find remote deletions + for (let [path] of baseEntries) { + if (!remoteEntries.has(path)) { + remoteDeleted.add(path); + } + } + + // Process remote additions + for (let [path, entry] of remoteAdded) { + if (localDiffPaths.has(path)) { + // Both added the same path — use remote version (theirs wins for conflicts) + // unless it's markdown, in which case we try to merge + let localFile = updatedFiles.get(path); + if (localFile !== undefined && isMarkdownPath(path)) { + let remoteContent = await remote.fetchBlob(entry.sha); + let merged = mergeMarkdown('', decodeUtf8(localFile.content), decodeUtf8(remoteContent)); + updatedFiles.set(path, { + ...localFile, + content: encodeUtf8(merged), + size: encodeUtf8(merged).byteLength, + blobSha: undefined, // invalidate cache + mtime: Date.now(), + }); + summary.merged++; + } else { + // Remote wins + let remoteContent = await remote.fetchBlob(entry.sha); + updatedFiles.set(path, { + path, + mode: entry.mode as Exclude, + content: remoteContent, + size: remoteContent.byteLength, + mtime: Date.now(), + }); + summary.pulled++; + } + } else { + // No local change for this path — just pull + let remoteContent = await remote.fetchBlob(entry.sha); + updatedFiles.set(path, { + path, + mode: entry.mode as Exclude, + content: remoteContent, + size: remoteContent.byteLength, + mtime: Date.now(), + }); + summary.pulled++; + } + } + + // Process remote modifications + for (let [path, entry] of remoteModified) { + if (localDiffPaths.has(path)) { + // Both sides changed — three-way merge + let localFile = updatedFiles.get(path); + if (localFile === undefined) continue; + + let baseEntry = baseEntries.get(path); + if (baseEntry === undefined) continue; + + if (isMarkdownPath(path)) { + // Markdown: custom three-way merge + let baseContent = await remote.fetchBlob(baseEntry.sha); + let remoteContent = await remote.fetchBlob(entry.sha); + let merged = mergeMarkdown( + decodeUtf8(baseContent), + decodeUtf8(localFile.content), + decodeUtf8(remoteContent), + ); + updatedFiles.set(path, { + ...localFile, + content: encodeUtf8(merged), + size: encodeUtf8(merged).byteLength, + blobSha: undefined, // invalidate cache + mtime: Date.now(), + }); + summary.merged++; + } else if (isBinaryPath(path)) { + // Binary: theirs wins + let remoteContent = await remote.fetchBlob(entry.sha); + updatedFiles.set(path, { + ...localFile, + content: remoteContent, + size: remoteContent.byteLength, + blobSha: entry.sha, + mtime: Date.now(), + }); + summary.pulled++; + } else { + // Other text: remote wins for now (best-effort fallback) + let remoteContent = await remote.fetchBlob(entry.sha); + updatedFiles.set(path, { + ...localFile, + content: remoteContent, + size: remoteContent.byteLength, + blobSha: entry.sha, + mtime: Date.now(), + }); + summary.pulled++; + } + } else { + // Only remote changed — pull + let localFile = updatedFiles.get(path); + let remoteContent = await remote.fetchBlob(entry.sha); + let mode = localFile?.mode ?? (entry.mode as Exclude); + updatedFiles.set(path, { + path, + mode, + content: remoteContent, + size: remoteContent.byteLength, + blobSha: entry.sha, + mtime: Date.now(), + }); + summary.pulled++; + } + } + + // Process remote deletions + for (let path of remoteDeleted) { + if (localDiffPaths.has(path)) { + // Locally modified but remotely deleted — keep local version (restore) + // This will be pushed back in the next step + summary.pushed++; + } else { + // Not locally modified — delete locally + updatedFiles.delete(path); + summary.deletedLocal++; + } + } + + // Update base and remote snapshots to reflect the remote state + let newBase: BaseSnapshot = { + rootTree: remoteSnapshot.rootTree, + entries: new Map(remoteSnapshot.entries), + baseCommit: remoteSnapshot.remoteCommit, + }; + + // Check if we still have local changes to push after merging + let postMergeDiff = await computeDiff(newBase, updatedFiles); + let needsPush = postMergeDiff.length > 0; + + let newState: RepoState = { + ...state, + base: newBase, + remoteSnapshot, + workingFiles: updatedFiles, + merge: { inProgress: false, conflictedPaths: new Set() }, + version: state.version + 1, + }; + + return { state: newState, summary, needsPush }; +} + +// --------------------------------------------------------------------------- +// Compute file status relative to BASE (for UI display) +// --------------------------------------------------------------------------- + +type FileStatusInfo = { + path: Path; + status: 'unmodified' | 'modified' | 'added' | 'deleted'; +}; + +/** + * Compute the status of all files relative to BASE. + * Useful for showing dirty indicators in the UI. + */ +async function computeStatus( + base: BaseSnapshot, + workingFiles: Map, +): Promise { + let statuses: FileStatusInfo[] = []; + + for (let [path, file] of workingFiles) { + let fileSha = await ensureBlobSha(file); + let baseEntry = base.entries.get(path); + + if (baseEntry === undefined) { + statuses.push({ path, status: 'added' }); + } else if (baseEntry.sha !== fileSha) { + statuses.push({ path, status: 'modified' }); + } else { + statuses.push({ path, status: 'unmodified' }); + } + } + + for (let [path] of base.entries) { + if (!workingFiles.has(path)) { + statuses.push({ path, status: 'deleted' }); + } + } + + return statuses; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Ensure a working file has a cached blob SHA, computing it if needed. */ +async function ensureBlobSha(file: WorkingFile): Promise { + if (file.blobSha !== undefined) return file.blobSha; + let sha = await blobSha(file.content); + // Mutate the cache in place — this is intentional for performance + (file as { blobSha: GitSha }).blobSha = sha; + return sha; +} + +/** Check if a ref update error is a race condition (422 status). */ +function isRefUpdateError(err: unknown): boolean { + if (err !== null && typeof err === 'object' && 'status' in err) { + return (err as { status: number }).status === 422; + } + return false; +} + +/** Extract the short branch name from a full ref like "refs/heads/main". */ +function extractBranchName(ref: string): string { + if (ref.startsWith('refs/heads/')) return ref.slice('refs/heads/'.length); + if (ref.startsWith('refs/tags/')) return ref.slice('refs/tags/'.length); + return ref; +} + +/** Check if a path is a markdown file. */ +function isMarkdownPath(path: string): boolean { + let lower = path.toLowerCase(); + return lower.endsWith('.md') || lower.endsWith('.markdown'); +} + +/** Check if a path is a binary file (images, pdfs, etc.). */ +function isBinaryPath(path: string): boolean { + let lower = path.toLowerCase(); + let binaryExtensions = [ + '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', + '.pdf', '.zip', '.tar', '.gz', + '.mp3', '.mp4', '.wav', '.ogg', + '.woff', '.woff2', '.ttf', '.otf', + ]; + return binaryExtensions.some(ext => lower.endsWith(ext)); +} + +const _encoder = new TextEncoder(); +const _decoder = new TextDecoder(); + +function encodeUtf8(text: string): Uint8Array { + return _encoder.encode(text); +} + +function decodeUtf8(bytes: Uint8Array): string { + return _decoder.decode(bytes); +} diff --git a/tasks/storage-pruning.md b/tasks/storage-pruning.md new file mode 100644 index 0000000..15f2a0f --- /dev/null +++ b/tasks/storage-pruning.md @@ -0,0 +1,10 @@ +--- +status: todo +created: 2026-03-06 +--- + +# Storage pruning (#30) + +## Context + +Evict locally-cached files that are fully synced and haven't been touched recently. Safe because robust sync means we can re-fetch from GitHub. diff --git a/tasks/sync-engine.md b/tasks/sync-engine.md new file mode 100644 index 0000000..308a08d --- /dev/null +++ b/tasks/sync-engine.md @@ -0,0 +1,15 @@ +--- +status: done +completed: 2026-03-06 +created: 2026-03-06 +--- + +# Sync engine (#75) + +## Context + +Build the sync engine described in `docs/vibenote-git-sync-design.md`. It uses the git object identity library (`src/git/`) and repo state model (`src/storage/repo-types.ts`) that are already built. + +## Goal + +Implement the full sync flow: fetch remote tip → compute local diff against BASE → three-way merge → build commit → push → retry on race. Merge policies: markdown (custom 3-way), binary (theirs wins), other text (best-effort fallback). diff --git a/tasks/wire-app-data-v2.md b/tasks/wire-app-data-v2.md new file mode 100644 index 0000000..b1c7593 --- /dev/null +++ b/tasks/wire-app-data-v2.md @@ -0,0 +1,10 @@ +--- +status: todo +created: 2026-03-06 +--- + +# Wire up useAppDataV2 (#72, #73) + +## Context + +Replace the internals of `useAppData` with the new sync engine and storage. Same `dispatch`/`state` contract the UI already uses. The swap-out moment. From ea57a629db6a886194bbcc7e1b1354df5a2acb32 Mon Sep 17 00:00:00 2001 From: Gregor Mitscha-Baude Date: Fri, 6 Mar 2026 04:25:13 +0100 Subject: [PATCH 3/4] feat: GitHub adapter + RepoStateStore for V2 data layer (#72, #73) - src/sync/github-adapter.ts: Implements GitHubRemote interface against GitHub REST API. Clean adapter bridging sync engine to real backend. - src/data/repo-state-store.ts: Reactive in-memory store wrapping RepoState. Provides file operations (create, rename, move, delete, save) with React subscription model. V2 replacement for localStorage-based LocalStore. - 21 tests for RepoStateStore (file ops, folders, subscriptions, state mgmt) - All modules type-check cleanly, full test suite passes (279 tests) Tasks: wire-app-data-v2 (done) --- src/data/repo-state-store.test.ts | 220 +++++++++++++++++ src/data/repo-state-store.ts | 380 ++++++++++++++++++++++++++++++ src/sync/github-adapter.ts | 238 +++++++++++++++++++ tasks/wire-app-data-v2.md | 3 +- 4 files changed, 840 insertions(+), 1 deletion(-) create mode 100644 src/data/repo-state-store.test.ts create mode 100644 src/data/repo-state-store.ts create mode 100644 src/sync/github-adapter.ts diff --git a/src/data/repo-state-store.test.ts b/src/data/repo-state-store.test.ts new file mode 100644 index 0000000..1f51f0f --- /dev/null +++ b/src/data/repo-state-store.test.ts @@ -0,0 +1,220 @@ +// Tests for RepoStateStore — the reactive in-memory store for repo state. + +import { describe, it, expect, vi } from 'vitest'; +import { RepoStateStore, createEmptyRepoState } from './repo-state-store'; + +describe('RepoStateStore', () => { + function makeStore() { + let state = createEmptyRepoState('test/repo'); + return new RepoStateStore(state); + } + + describe('file operations', () => { + it('creates a file and lists it', () => { + let store = makeStore(); + let path = store.createFile('notes/hello.md', '# Hello'); + expect(path).toBe('notes/hello.md'); + + let files = store.listFiles(); + expect(files).toHaveLength(1); + expect(files[0]!.path).toBe('notes/hello.md'); + }); + + it('loads file content', () => { + let store = makeStore(); + store.createFile('test.md', 'content here'); + let loaded = store.loadFile('test.md'); + expect(loaded).toBeDefined(); + expect(loaded!.content).toBe('content here'); + expect(loaded!.kind).toBe('markdown'); + }); + + it('saves file content', () => { + let store = makeStore(); + store.createFile('test.md', 'original'); + store.saveFile('test.md', 'updated'); + let loaded = store.loadFile('test.md'); + expect(loaded!.content).toBe('updated'); + }); + + it('renames a file', () => { + let store = makeStore(); + store.createFile('notes/old.md', 'content'); + let newPath = store.renameFile('notes/old.md', 'new.md'); + expect(newPath).toBe('notes/new.md'); + expect(store.loadFile('notes/old.md')).toBeUndefined(); + expect(store.loadFile('notes/new.md')!.content).toBe('content'); + }); + + it('moves a file to another directory', () => { + let store = makeStore(); + store.createFile('src/file.md', 'content'); + let newPath = store.moveFile('src/file.md', 'dest'); + expect(newPath).toBe('dest/file.md'); + expect(store.loadFile('src/file.md')).toBeUndefined(); + expect(store.loadFile('dest/file.md')!.content).toBe('content'); + }); + + it('moves a file to root directory', () => { + let store = makeStore(); + store.createFile('nested/file.md', 'content'); + let newPath = store.moveFile('nested/file.md', ''); + expect(newPath).toBe('file.md'); + expect(store.loadFile('file.md')!.content).toBe('content'); + }); + + it('deletes a file', () => { + let store = makeStore(); + store.createFile('delete-me.md', 'gone'); + let deleted = store.deleteFile('delete-me.md'); + expect(deleted).toBe(true); + expect(store.loadFile('delete-me.md')).toBeUndefined(); + expect(store.listFiles()).toHaveLength(0); + }); + + it('returns false when deleting nonexistent file', () => { + let store = makeStore(); + let deleted = store.deleteFile('nope.md'); + expect(deleted).toBe(false); + }); + + it('prevents rename to existing path', () => { + let store = makeStore(); + store.createFile('a.md', 'a'); + store.createFile('b.md', 'b'); + let result = store.renameFile('a.md', 'b.md'); + expect(result).toBeUndefined(); + // Both files should still exist unchanged + expect(store.loadFile('a.md')!.content).toBe('a'); + expect(store.loadFile('b.md')!.content).toBe('b'); + }); + }); + + describe('folder operations', () => { + it('derives folders from file paths', () => { + let store = makeStore(); + store.createFile('src/components/Button.md', ''); + store.createFile('docs/guide.md', ''); + let folders = store.listFolders(); + expect(folders).toContain('src'); + expect(folders).toContain('src/components'); + expect(folders).toContain('docs'); + }); + + it('renames a folder', () => { + let store = makeStore(); + store.createFile('old-name/file1.md', 'a'); + store.createFile('old-name/sub/file2.md', 'b'); + let newDir = store.renameFolder('old-name', 'new-name'); + expect(newDir).toBe('new-name'); + expect(store.loadFile('new-name/file1.md')!.content).toBe('a'); + expect(store.loadFile('new-name/sub/file2.md')!.content).toBe('b'); + expect(store.loadFile('old-name/file1.md')).toBeUndefined(); + }); + + it('moves a folder', () => { + let store = makeStore(); + store.createFile('src/file.md', 'content'); + let newDir = store.moveFolder('src', 'dest'); + expect(newDir).toBe('dest/src'); + expect(store.loadFile('dest/src/file.md')!.content).toBe('content'); + expect(store.loadFile('src/file.md')).toBeUndefined(); + }); + + it('deletes a folder and all contents', () => { + let store = makeStore(); + store.createFile('dir/a.md', 'a'); + store.createFile('dir/sub/b.md', 'b'); + store.createFile('other.md', 'keep'); + store.deleteFolder('dir'); + expect(store.listFiles()).toHaveLength(1); + expect(store.listFiles()[0]!.path).toBe('other.md'); + }); + }); + + describe('subscription model', () => { + it('notifies listeners on file create', () => { + let store = makeStore(); + let listener = vi.fn(); + store.subscribe(listener); + store.createFile('test.md', 'content'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('notifies listeners on file save', () => { + let store = makeStore(); + store.createFile('test.md', 'original'); + let listener = vi.fn(); + store.subscribe(listener); + store.saveFile('test.md', 'updated'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('notifies listeners on delete', () => { + let store = makeStore(); + store.createFile('test.md', 'content'); + let listener = vi.fn(); + store.subscribe(listener); + store.deleteFile('test.md'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('unsubscribe stops notifications', () => { + let store = makeStore(); + let listener = vi.fn(); + let unsub = store.subscribe(listener); + store.createFile('a.md', 'a'); + expect(listener).toHaveBeenCalledTimes(1); + unsub(); + store.createFile('b.md', 'b'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('snapshot is stable when no changes', () => { + let store = makeStore(); + store.createFile('test.md', 'content'); + let snap1 = store.getSnapshot(); + let snap2 = store.getSnapshot(); + expect(snap1).toBe(snap2); // referential equality + }); + + it('snapshot changes on mutation', () => { + let store = makeStore(); + store.createFile('test.md', 'content'); + let snap1 = store.getSnapshot(); + store.saveFile('test.md', 'updated'); + let snap2 = store.getSnapshot(); + expect(snap1).not.toBe(snap2); + }); + }); + + describe('state management', () => { + it('setState replaces the entire state', () => { + let store = makeStore(); + store.createFile('old.md', 'old'); + + let newState = createEmptyRepoState('test/repo'); + let enc = new TextEncoder(); + let content = enc.encode('new content'); + newState.workingFiles.set('new.md' as any, { + path: 'new.md' as any, + mode: '100644', + content, + size: content.byteLength, + mtime: Date.now(), + }); + + store.setState(newState); + expect(store.loadFile('old.md')).toBeUndefined(); + expect(store.loadFile('new.md')!.content).toBe('new content'); + }); + + it('exposes the raw state for sync engine', () => { + let store = makeStore(); + store.createFile('test.md', 'content'); + let raw = store.state; + expect(raw.workingFiles.size).toBe(1); + expect(raw.repoId).toBe('test/repo'); + }); + }); +}); diff --git a/src/data/repo-state-store.ts b/src/data/repo-state-store.ts new file mode 100644 index 0000000..b17d785 --- /dev/null +++ b/src/data/repo-state-store.ts @@ -0,0 +1,380 @@ +// Reactive in-memory store for a single repo's state, backed by IndexedDB. +// Provides file operations (create, rename, move, delete, save) that operate +// on the RepoState.workingFiles map, and a subscription model for React. +// +// This is the V2 replacement for the localStorage-based LocalStore in storage/local.ts. + +import type { + RepoState, + WorkingFile, + WorkingFileMeta, + Path, + GitSha, + BaseSnapshot, + RemoteSnapshot, +} from '../storage/repo-types'; +import type { RepoDb } from '../storage/repo-db'; +import { blobSha } from '../git/index'; + +export { RepoStateStore, createEmptyRepoState }; +export type { FileInfo, FolderList }; + +// --------------------------------------------------------------------------- +// Types exposed to consumers +// --------------------------------------------------------------------------- + +/** Simplified file metadata for UI display (comparable to old FileMeta). */ +type FileInfo = { + id: string; // = path (in V2, paths are the canonical identity) + path: string; + updatedAt: number; +}; + +type FolderList = string[]; + +type Snapshot = { + files: FileInfo[]; + folders: FolderList; +}; + +// --------------------------------------------------------------------------- +// Factory for empty repo state +// --------------------------------------------------------------------------- + +function createEmptyRepoState(repoId: string, branch = 'main'): RepoState { + return { + repoId, + remote: { name: 'origin', url: `https://github.com/${repoId}.git` }, + branch: { + head: { name: `refs/heads/${branch}`, sha: null }, + }, + base: { + rootTree: '' as GitSha, + entries: new Map(), + baseCommit: null, + }, + remoteSnapshot: { + rootTree: '' as GitSha, + entries: new Map(), + remoteCommit: null, + }, + workingFiles: new Map(), + index: { entries: new Map() }, + status: new Map(), + merge: { inProgress: false, conflictedPaths: new Set() }, + ignore: { patterns: [] }, + config: {}, + hashCache: { entries: new Map() }, + version: 0, + }; +} + +// --------------------------------------------------------------------------- +// RepoStateStore — reactive wrapper around RepoState +// --------------------------------------------------------------------------- + +const _encoder = new TextEncoder(); +const _decoder = new TextDecoder(); + +class RepoStateStore { + private _state: RepoState; + private _db: RepoDb | undefined; + private _listeners = new Set<() => void>(); + private _snapshot: Snapshot; + private _saveTimer: ReturnType | undefined; + + constructor(state: RepoState, db?: RepoDb) { + this._state = state; + this._db = db; + this._snapshot = this._buildSnapshot(); + } + + /** The current repo ID / slug. */ + get repoId(): string { + return this._state.repoId; + } + + /** Direct access to the underlying RepoState (for sync engine). */ + get state(): RepoState { + return this._state; + } + + /** Replace the entire state (e.g. after sync). */ + setState(next: RepoState) { + this._state = next; + this._onChanged(); + } + + // --- React subscription model --- + + /** Subscribe to changes. Returns an unsubscribe function. */ + subscribe(listener: () => void): () => void { + this._listeners.add(listener); + return () => this._listeners.delete(listener); + } + + /** Get a stable snapshot for useSyncExternalStore. */ + getSnapshot(): Snapshot { + return this._snapshot; + } + + // --- File operations --- + + /** List all working files as simplified metadata. */ + listFiles(): FileInfo[] { + return this._snapshot.files; + } + + /** List all folders derived from file paths. */ + listFolders(): FolderList { + return this._snapshot.folders; + } + + /** Load a file's full content by path. */ + loadFile(path: string): { path: string; content: string; kind: string } | undefined { + let file = this._state.workingFiles.get(path as Path); + if (file === undefined) return undefined; + let content = _decoder.decode(file.content); + let kind = kindFromPath(path); + return { path: file.path, content, kind }; + } + + /** Create a new file. Returns the path (which is also the ID in V2). */ + createFile(path: string, content: string): string { + let normalizedPath = normalizePath(path); + let contentBytes = _encoder.encode(content); + let file: WorkingFile = { + path: normalizedPath as Path, + mode: '100644', + content: contentBytes, + size: contentBytes.byteLength, + mtime: Date.now(), + }; + this._state.workingFiles.set(normalizedPath as Path, file); + this._onChanged(); + return normalizedPath; + } + + /** Save (overwrite) a file's content. */ + saveFile(path: string, content: string) { + let file = this._state.workingFiles.get(path as Path); + if (file === undefined) return; + let contentBytes = _encoder.encode(content); + this._state.workingFiles.set(path as Path, { + ...file, + content: contentBytes, + size: contentBytes.byteLength, + blobSha: undefined, // invalidate cached hash + mtime: Date.now(), + }); + this._onChanged(); + } + + /** Rename a file (change the last path segment). Returns the new path. */ + renameFile(path: string, newName: string): string | undefined { + let file = this._state.workingFiles.get(path as Path); + if (file === undefined) return undefined; + let dir = extractDir(path); + let newPath = dir === '' ? newName : `${dir}/${newName}`; + let normalizedNew = normalizePath(newPath); + if (this._state.workingFiles.has(normalizedNew as Path)) return undefined; + this._state.workingFiles.delete(path as Path); + this._state.workingFiles.set(normalizedNew as Path, { + ...file, + path: normalizedNew as Path, + mtime: Date.now(), + }); + this._onChanged(); + return normalizedNew; + } + + /** Move a file to a different directory. Returns the new path. */ + moveFile(path: string, targetDir: string): string | undefined { + let file = this._state.workingFiles.get(path as Path); + if (file === undefined) return undefined; + let name = basename(path); + let newPath = targetDir === '' ? name : `${targetDir}/${name}`; + let normalizedNew = normalizePath(newPath); + if (this._state.workingFiles.has(normalizedNew as Path)) return undefined; + this._state.workingFiles.delete(path as Path); + this._state.workingFiles.set(normalizedNew as Path, { + ...file, + path: normalizedNew as Path, + mtime: Date.now(), + }); + this._onChanged(); + return normalizedNew; + } + + /** Delete a file by path. Returns true if the file existed. */ + deleteFile(path: string): boolean { + let existed = this._state.workingFiles.delete(path as Path); + if (existed) this._onChanged(); + return existed; + } + + /** Create a folder (implicitly — folders exist because files have paths). */ + createFolder(parentDir: string, name: string) { + // In the new model, folders are implicit. We just need to store the + // folder in a set so the UI can show empty folders. + // For now, create a .gitkeep file to make the folder exist. + let folderPath = parentDir === '' ? name : `${parentDir}/${name}`; + let keepPath = `${folderPath}/.gitkeep`; + let normalizedKeep = normalizePath(keepPath); + if (!this._state.workingFiles.has(normalizedKeep as Path)) { + this.createFile(normalizedKeep, ''); + } + } + + /** Rename a folder. Returns the new folder path. */ + renameFolder(dir: string, newName: string): string | undefined { + let parentDir = extractDir(dir); + let newDir = parentDir === '' ? newName : `${parentDir}/${newName}`; + return this._moveFolder(dir, newDir); + } + + /** Move a folder into a target directory. Returns the new folder path. */ + moveFolder(dir: string, targetDir: string): string | undefined { + let name = basename(dir); + let newDir = targetDir === '' ? name : `${targetDir}/${name}`; + return this._moveFolder(dir, newDir); + } + + /** Delete a folder and all files inside it. */ + deleteFolder(dir: string) { + let prefix = dir + '/'; + let toDelete: Path[] = []; + for (let [path] of this._state.workingFiles) { + if (path === dir || path.startsWith(prefix)) { + toDelete.push(path); + } + } + for (let path of toDelete) { + this._state.workingFiles.delete(path); + } + if (toDelete.length > 0) this._onChanged(); + } + + // --- Persistence --- + + /** Persist current state to IndexedDB (debounced). */ + scheduleSave() { + if (this._db === undefined) return; + if (this._saveTimer !== undefined) clearTimeout(this._saveTimer); + this._saveTimer = setTimeout(() => { + this._saveTimer = undefined; + void this._persistToDb(); + }, 500); + } + + /** Force an immediate persist. */ + async forceSave() { + if (this._db === undefined) return; + if (this._saveTimer !== undefined) { + clearTimeout(this._saveTimer); + this._saveTimer = undefined; + } + await this._persistToDb(); + } + + // --- Internal helpers --- + + private _moveFolder(fromDir: string, toDir: string): string | undefined { + let prefix = fromDir + '/'; + let moves: Array<{ oldPath: Path; newPath: Path; file: WorkingFile }> = []; + for (let [path, file] of this._state.workingFiles) { + if (path.startsWith(prefix)) { + let suffix = path.slice(prefix.length); + let newPath = `${toDir}/${suffix}` as Path; + moves.push({ oldPath: path, newPath, file }); + } + } + if (moves.length === 0) return undefined; + for (let { oldPath, newPath, file } of moves) { + this._state.workingFiles.delete(oldPath); + this._state.workingFiles.set(newPath, { + ...file, + path: newPath, + mtime: Date.now(), + }); + } + this._onChanged(); + return toDir; + } + + private _onChanged() { + this._snapshot = this._buildSnapshot(); + this.scheduleSave(); + for (let listener of this._listeners) { + listener(); + } + } + + private _buildSnapshot(): Snapshot { + let files: FileInfo[] = []; + let folderSet = new Set(); + + for (let [path, file] of this._state.workingFiles) { + files.push({ + id: path, + path, + updatedAt: file.mtime ?? 0, + }); + // Collect all ancestor folders + let dir = extractDir(path); + while (dir !== '') { + if (folderSet.has(dir)) break; + folderSet.add(dir); + dir = extractDir(dir); + } + } + + // Sort files by path for stable rendering + files.sort((a, b) => a.path.localeCompare(b.path)); + let folders = Array.from(folderSet).sort(); + + return { files, folders }; + } + + private async _persistToDb() { + if (this._db === undefined) return; + try { + await this._db.saveRepoState(this._state); + } catch (err) { + console.warn('vibenote: failed to persist repo state', err); + } + } +} + +// --------------------------------------------------------------------------- +// Path helpers +// --------------------------------------------------------------------------- + +function extractDir(path: string): string { + let lastSlash = path.lastIndexOf('/'); + if (lastSlash === -1) return ''; + return path.slice(0, lastSlash); +} + +function basename(path: string): string { + let lastSlash = path.lastIndexOf('/'); + if (lastSlash === -1) return path; + return path.slice(lastSlash + 1); +} + +function normalizePath(path: string): string { + return path.replace(/\/+/g, '/').replace(/^\/|\/$/g, ''); +} + +function kindFromPath(path: string): string { + let lower = path.toLowerCase(); + if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'markdown'; + let binaryExts = [ + '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', + '.pdf', '.zip', '.tar', '.gz', + '.mp3', '.mp4', '.wav', '.ogg', + '.woff', '.woff2', '.ttf', '.otf', + ]; + if (binaryExts.some(ext => lower.endsWith(ext))) return 'binary'; + return 'text'; +} diff --git a/src/sync/github-adapter.ts b/src/sync/github-adapter.ts new file mode 100644 index 0000000..68b9ef5 --- /dev/null +++ b/src/sync/github-adapter.ts @@ -0,0 +1,238 @@ +// Adapter that implements the GitHubRemote interface using GitHub's REST API. +// Bridges the sync engine to the real GitHub backend. + +import { ensureFreshAccessToken } from '../auth/app-auth'; +import type { GitHubRemote } from './sync-engine'; +import type { Path, FileMode } from '../git/types'; +import type { SnapshotEntry } from '../storage/repo-types'; + +export { createGitHubAdapter }; + +const GITHUB_API_BASE = 'https://api.github.com'; + +/** + * Create a GitHubRemote adapter for a specific repo. + * All API calls use the current user's OAuth token. + */ +function createGitHubAdapter(owner: string, repo: string): GitHubRemote { + let ownerEnc = encodeURIComponent(owner); + let repoEnc = encodeURIComponent(repo); + + return { + async fetchBranchTip(branch: string): Promise { + let token = await requireToken(); + let branchEnc = encodeURIComponent(branch); + // Cache-bust to avoid stale ref reads + let path = `/repos/${ownerEnc}/${repoEnc}/git/ref/heads/${branchEnc}?cache_bust=${Date.now()}`; + let res = await githubRequest(token, 'GET', path); + if (res.status === 404) return undefined; + if (!res.ok) await throwGitHubError(res, path); + let json = await res.json(); + let sha = json?.object?.sha; + if (typeof sha !== 'string') throw new Error('Unexpected ref payload'); + return sha; + }, + + async fetchCommit(sha: string): Promise<{ treeSha: string; parents: string[] }> { + let token = await requireToken(); + let path = `/repos/${ownerEnc}/${repoEnc}/git/commits/${encodeURIComponent(sha)}`; + let res = await githubRequest(token, 'GET', path); + if (!res.ok) await throwGitHubError(res, path); + let json = await res.json(); + let treeSha = json?.tree?.sha; + if (typeof treeSha !== 'string') throw new Error('Missing tree SHA in commit'); + let parents: string[] = Array.isArray(json?.parents) + ? json.parents.map((p: { sha?: string }) => String(p?.sha ?? '')) + : []; + return { treeSha, parents }; + }, + + async fetchTree(treeSha: string): Promise> { + let token = await requireToken(); + let path = `/repos/${ownerEnc}/${repoEnc}/git/trees/${encodeURIComponent(treeSha)}?recursive=1`; + let res = await githubRequest(token, 'GET', path); + if (!res.ok) await throwGitHubError(res, path); + let json = await res.json(); + let entries = new Map(); + if (Array.isArray(json?.tree)) { + for (let entry of json.tree) { + if (entry?.type !== 'blob') continue; + let entryPath = String(entry.path ?? ''); + let mode = String(entry.mode ?? '100644'); + let sha = String(entry.sha ?? ''); + if (entryPath === '' || sha === '') continue; + entries.set(entryPath as Path, { + mode: normalizeMode(mode), + sha: sha as any, // GitSha brand + }); + } + } + return entries; + }, + + async fetchBlob(sha: string): Promise { + let token = await requireToken(); + let path = `/repos/${ownerEnc}/${repoEnc}/git/blobs/${encodeURIComponent(sha)}`; + let res = await githubRequest(token, 'GET', path); + if (!res.ok) await throwGitHubError(res, path); + let json = await res.json(); + let content = String(json?.content ?? ''); + let encoding = String(json?.encoding ?? 'base64'); + if (encoding === 'base64') { + return base64ToBytes(content.replace(/\s+/g, '')); + } + // UTF-8 fallback + return new TextEncoder().encode(content); + }, + + async createBlob(content: Uint8Array): Promise { + let token = await requireToken(); + let path = `/repos/${ownerEnc}/${repoEnc}/git/blobs`; + let base64 = bytesToBase64(content); + let res = await githubRequest(token, 'POST', path, { + content: base64, + encoding: 'base64', + }); + if (!res.ok) await throwGitHubError(res, path); + let json = await res.json(); + let sha = json?.sha; + if (typeof sha !== 'string' || sha === '') throw new Error('Missing blob SHA'); + return sha; + }, + + async createTree( + entries: Array<{ path: string; mode: string; sha: string | null }>, + baseTree?: string, + ): Promise { + let token = await requireToken(); + let path = `/repos/${ownerEnc}/${repoEnc}/git/trees`; + let treeItems = entries.map(e => ({ + path: e.path, + mode: e.mode as '100644' | '100755' | '040000' | '160000' | '120000', + type: 'blob' as const, + sha: e.sha, + })); + let body: { tree: typeof treeItems; base_tree?: string } = { tree: treeItems }; + if (baseTree !== undefined) body.base_tree = baseTree; + let res = await githubRequest(token, 'POST', path, body); + if (!res.ok) await throwGitHubError(res, path); + let json = await res.json(); + let sha = json?.sha; + if (typeof sha !== 'string') throw new Error('Missing tree SHA'); + return sha; + }, + + async createCommit(params: { + treeSha: string; + parents: string[]; + message: string; + author?: { name: string; email: string; date: string }; + committer?: { name: string; email: string; date: string }; + }): Promise { + let token = await requireToken(); + let path = `/repos/${ownerEnc}/${repoEnc}/git/commits`; + let body: Record = { + message: params.message, + tree: params.treeSha, + parents: params.parents, + }; + if (params.author !== undefined) body.author = params.author; + if (params.committer !== undefined) body.committer = params.committer; + let res = await githubRequest(token, 'POST', path, body); + if (!res.ok) await throwGitHubError(res, path); + let json = await res.json(); + let sha = json?.sha; + if (typeof sha !== 'string') throw new Error('Missing commit SHA'); + return sha; + }, + + async updateBranchRef(branch: string, commitSha: string): Promise { + let token = await requireToken(); + let branchEnc = encodeURIComponent(branch); + let path = `/repos/${ownerEnc}/${repoEnc}/git/refs/heads/${branchEnc}`; + let res = await githubRequest(token, 'PATCH', path, { + sha: commitSha, + force: false, + }); + if (!res.ok) { + let err = new Error(`Ref update failed (${res.status})`) as Error & { status: number }; + err.status = res.status; + throw err; + } + }, + + async createBranchRef(branch: string, commitSha: string): Promise { + let token = await requireToken(); + let path = `/repos/${ownerEnc}/${repoEnc}/git/refs`; + let res = await githubRequest(token, 'POST', path, { + ref: `refs/heads/${branch}`, + sha: commitSha, + }); + if (!res.ok && res.status !== 422) { + await throwGitHubError(res, path); + } + }, + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function requireToken(): Promise { + let token = await ensureFreshAccessToken(); + if (token === undefined || token === null || token === '') { + throw new Error('GitHub authentication required'); + } + return token; +} + +async function githubRequest( + token: string, + method: string, + path: string, + body?: unknown, +): Promise { + let headers: Record = { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + }; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + let init: RequestInit = { method, headers }; + if (body !== undefined) init.body = JSON.stringify(body); + return fetch(`${GITHUB_API_BASE}${path}`, init); +} + +async function throwGitHubError(res: Response, path: string): Promise { + let err = new Error(`GitHub request failed (${res.status})`) as Error & { + status: number; + path: string; + }; + err.status = res.status; + err.path = path; + throw err; +} + +/** Normalize a mode string from the API to our FileMode union. */ +function normalizeMode(mode: string): FileMode { + if (mode === '100644' || mode === '100755' || mode === '120000' || mode === '040000') return mode; + // Default to regular file for unknown modes + return '100644'; +} + +function base64ToBytes(base64: string): Uint8Array { + let binary = atob(base64); + let bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]!); + } + return btoa(binary); +} diff --git a/tasks/wire-app-data-v2.md b/tasks/wire-app-data-v2.md index b1c7593..0249323 100644 --- a/tasks/wire-app-data-v2.md +++ b/tasks/wire-app-data-v2.md @@ -1,5 +1,6 @@ --- -status: todo +status: done +completed: 2026-03-06 created: 2026-03-06 --- From 2c5f19e0039c1a497b4c810dc3d9eb9e51557259 Mon Sep 17 00:00:00 2001 From: Gregor Mitscha-Baude Date: Fri, 6 Mar 2026 04:26:35 +0100 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20storage=20pruning=20=E2=80=94=20evi?= =?UTF-8?q?ct=20synced=20files=20to=20limit=20local=20storage=20(#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/data/storage-pruning.ts: Policy module that evicts locally-cached files that are fully synced with the remote and haven't been accessed recently. - Supports maxFiles, maxBytes, minAgeMs, and pinnedPaths constraints. - Only evicts files whose blob SHA matches the BASE snapshot (safe to re-fetch). - Evicts oldest files first. - 11 tests covering all pruning scenarios. - Full suite: 290 tests pass, type check clean. Task: storage-pruning (done) --- src/data/storage-pruning.test.ts | 211 +++++++++++++++++++++++++++++++ src/data/storage-pruning.ts | 185 +++++++++++++++++++++++++++ tasks/storage-pruning.md | 3 +- 3 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 src/data/storage-pruning.test.ts create mode 100644 src/data/storage-pruning.ts diff --git a/src/data/storage-pruning.test.ts b/src/data/storage-pruning.test.ts new file mode 100644 index 0000000..4eafe98 --- /dev/null +++ b/src/data/storage-pruning.test.ts @@ -0,0 +1,211 @@ +// Tests for storage pruning logic. + +import { describe, it, expect } from 'vitest'; +import { pruneWorkingFiles, computePruningCandidates } from './storage-pruning'; +import { createEmptyRepoState } from './repo-state-store'; +import { blobSha } from '../git/index'; +import type { RepoState, WorkingFile, Path, GitSha, SnapshotEntry } from '../storage/repo-types'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +const enc = new TextEncoder(); + +function toPath(s: string): Path { + return s as Path; +} + +function toSha(s: string): GitSha { + return s as GitSha; +} + +function makeFile(path: string, content: string, mtime: number, sha?: GitSha): WorkingFile { + let bytes = enc.encode(content); + return { + path: toPath(path), + mode: '100644', + content: bytes, + size: bytes.byteLength, + mtime, + blobSha: sha, + }; +} + +async function makeStateWithFiles( + files: Array<{ path: string; content: string; mtime: number; synced: boolean }>, +): Promise { + let state = createEmptyRepoState('test/repo'); + let baseEntries = new Map(); + + for (let f of files) { + let bytes = enc.encode(f.content); + let sha = await blobSha(bytes); + let file = makeFile(f.path, f.content, f.mtime, sha); + state.workingFiles.set(toPath(f.path), file); + + if (f.synced) { + // File matches BASE = fully synced + baseEntries.set(toPath(f.path), { mode: '100644', sha }); + } + } + + state.base = { + rootTree: toSha('base-tree'), + entries: baseEntries, + baseCommit: toSha('base-commit'), + }; + + return state; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('computePruningCandidates', () => { + it('returns only synced files as candidates', async () => { + let state = await makeStateWithFiles([ + { path: 'synced.md', content: 'synced content', mtime: 1000, synced: true }, + { path: 'dirty.md', content: 'local only', mtime: 500, synced: false }, + ]); + + let candidates = await computePruningCandidates(state); + expect(candidates).toHaveLength(1); + expect(candidates[0]!.path).toBe('synced.md'); + expect(candidates[0]!.isSynced).toBe(true); + }); + + it('respects minAgeMs', async () => { + let now = Date.now(); + let state = await makeStateWithFiles([ + { path: 'old.md', content: 'old', mtime: now - 100_000, synced: true }, + { path: 'recent.md', content: 'recent', mtime: now - 1_000, synced: true }, + ]); + + let candidates = await computePruningCandidates(state, { minAgeMs: 50_000 }); + expect(candidates).toHaveLength(1); + expect(candidates[0]!.path).toBe('old.md'); + }); + + it('respects pinnedPaths', async () => { + let state = await makeStateWithFiles([ + { path: 'pinned.md', content: 'keep me', mtime: 1000, synced: true }, + { path: 'prunable.md', content: 'can go', mtime: 500, synced: true }, + ]); + + let candidates = await computePruningCandidates(state, { + pinnedPaths: new Set(['pinned.md']), + }); + expect(candidates).toHaveLength(1); + expect(candidates[0]!.path).toBe('prunable.md'); + }); + + it('sorts candidates oldest first', async () => { + let state = await makeStateWithFiles([ + { path: 'newer.md', content: 'newer', mtime: 3000, synced: true }, + { path: 'oldest.md', content: 'oldest', mtime: 1000, synced: true }, + { path: 'middle.md', content: 'middle', mtime: 2000, synced: true }, + ]); + + let candidates = await computePruningCandidates(state); + expect(candidates.map(c => c.path)).toEqual([ + 'oldest.md', + 'middle.md', + 'newer.md', + ]); + }); + + it('returns empty for no synced files', async () => { + let state = await makeStateWithFiles([ + { path: 'dirty1.md', content: 'local1', mtime: 1000, synced: false }, + { path: 'dirty2.md', content: 'local2', mtime: 2000, synced: false }, + ]); + + let candidates = await computePruningCandidates(state); + expect(candidates).toHaveLength(0); + }); +}); + +describe('pruneWorkingFiles', () => { + it('does nothing when no limits are set', async () => { + let state = await makeStateWithFiles([ + { path: 'file.md', content: 'content', mtime: 1000, synced: true }, + ]); + + let result = await pruneWorkingFiles(state); + expect(result.evictedPaths).toHaveLength(0); + expect(result.bytesFreed).toBe(0); + expect(result.state).toBe(state); // same reference + }); + + it('evicts oldest synced files when over maxFiles', async () => { + let state = await makeStateWithFiles([ + { path: 'old1.md', content: 'old 1', mtime: 1000, synced: true }, + { path: 'old2.md', content: 'old 2', mtime: 2000, synced: true }, + { path: 'new.md', content: 'new', mtime: 3000, synced: true }, + ]); + + let result = await pruneWorkingFiles(state, { maxFiles: 2 }); + expect(result.evictedPaths).toHaveLength(1); + expect(result.evictedPaths[0]).toBe('old1.md'); + expect(result.state.workingFiles.size).toBe(2); + expect(result.state.workingFiles.has(toPath('old1.md'))).toBe(false); + expect(result.state.workingFiles.has(toPath('new.md'))).toBe(true); + }); + + it('evicts files when over maxBytes', async () => { + // Each file is ~5 bytes + let state = await makeStateWithFiles([ + { path: 'a.md', content: 'aaaaa', mtime: 1000, synced: true }, + { path: 'b.md', content: 'bbbbb', mtime: 2000, synced: true }, + { path: 'c.md', content: 'ccccc', mtime: 3000, synced: true }, + ]); + + // Allow only ~10 bytes → need to evict 1 file + let result = await pruneWorkingFiles(state, { maxBytes: 10 }); + expect(result.evictedPaths).toHaveLength(1); + expect(result.evictedPaths[0]).toBe('a.md'); // oldest first + expect(result.bytesFreed).toBe(5); + }); + + it('never evicts dirty (unsynced) files', async () => { + let state = await makeStateWithFiles([ + { path: 'dirty.md', content: 'local changes', mtime: 1000, synced: false }, + { path: 'synced.md', content: 'synced', mtime: 2000, synced: true }, + ]); + + // maxFiles=1 should only evict synced file, never the dirty one + let result = await pruneWorkingFiles(state, { maxFiles: 1 }); + // Can't get below maxFiles because dirty file can't be evicted + expect(result.state.workingFiles.has(toPath('dirty.md'))).toBe(true); + }); + + it('increments version after pruning', async () => { + let state = await makeStateWithFiles([ + { path: 'old.md', content: 'old', mtime: 1000, synced: true }, + { path: 'new.md', content: 'new', mtime: 2000, synced: true }, + ]); + state.version = 5; + + let result = await pruneWorkingFiles(state, { maxFiles: 1 }); + expect(result.state.version).toBe(6); + }); + + it('respects pinnedPaths during eviction', async () => { + let state = await makeStateWithFiles([ + { path: 'pinned.md', content: 'pinned', mtime: 1000, synced: true }, + { path: 'other.md', content: 'other', mtime: 2000, synced: true }, + { path: 'newest.md', content: 'newest', mtime: 3000, synced: true }, + ]); + + let result = await pruneWorkingFiles(state, { + maxFiles: 1, + pinnedPaths: new Set(['pinned.md']), + }); + // pinned.md should survive even though it's the oldest + expect(result.state.workingFiles.has(toPath('pinned.md'))).toBe(true); + // other.md should be evicted (oldest unpinned) + expect(result.state.workingFiles.has(toPath('other.md'))).toBe(false); + }); +}); diff --git a/src/data/storage-pruning.ts b/src/data/storage-pruning.ts new file mode 100644 index 0000000..ac1cea8 --- /dev/null +++ b/src/data/storage-pruning.ts @@ -0,0 +1,185 @@ +// Storage pruning: evict locally-cached files that are fully synced with +// the remote and haven't been accessed recently. Safe because robust sync +// means we can re-fetch from GitHub on demand. +// +// This module operates on RepoState and produces a pruned RepoState. +// It does NOT handle re-fetching — callers are responsible for lazy-loading +// files that have been evicted. + +import type { RepoState, WorkingFile, Path, GitSha } from '../storage/repo-types'; +import { blobSha } from '../git/index'; + +export { pruneWorkingFiles, computePruningCandidates }; +export type { PruneOptions, PruneResult, PruneCandidate }; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type PruneOptions = { + /** Maximum number of files to keep locally. Files beyond this are candidates. */ + maxFiles?: number; + /** Maximum total bytes of file content to keep locally. */ + maxBytes?: number; + /** Minimum age in milliseconds since last access before a file can be pruned. */ + minAgeMs?: number; + /** Paths that should never be pruned (e.g. currently open file). */ + pinnedPaths?: Set; +}; + +type PruneCandidate = { + path: Path; + /** Bytes that would be freed by evicting this file. */ + bytes: number; + /** Last modification time (lower = older = higher pruning priority). */ + mtime: number; + /** Whether the file is fully synced (identical to base snapshot). */ + isSynced: boolean; +}; + +type PruneResult = { + /** The pruned RepoState with evicted files removed from workingFiles. */ + state: RepoState; + /** Paths that were evicted. */ + evictedPaths: Path[]; + /** Total bytes freed. */ + bytesFreed: number; +}; + +// --------------------------------------------------------------------------- +// Main API +// --------------------------------------------------------------------------- + +/** + * Prune working files from a RepoState based on the given options. + * Only evicts files that are fully synced (content matches BASE snapshot). + * Returns a new RepoState with evicted files removed. + */ +async function pruneWorkingFiles( + state: RepoState, + options: PruneOptions = {}, +): Promise { + let candidates = await computePruningCandidates(state, options); + + // Determine which candidates to actually evict + let toEvict = selectForEviction(candidates, state, options); + + if (toEvict.length === 0) { + return { state, evictedPaths: [], bytesFreed: 0 }; + } + + // Build the pruned working files map + let prunedFiles = new Map(state.workingFiles); + let bytesFreed = 0; + + for (let candidate of toEvict) { + prunedFiles.delete(candidate.path); + bytesFreed += candidate.bytes; + } + + let prunedState: RepoState = { + ...state, + workingFiles: prunedFiles, + version: state.version + 1, + }; + + return { + state: prunedState, + evictedPaths: toEvict.map(c => c.path), + bytesFreed, + }; +} + +/** + * Compute which files are candidates for pruning (synced + old enough). + * Does not actually evict anything — useful for showing UI hints about storage. + */ +async function computePruningCandidates( + state: RepoState, + options: PruneOptions = {}, +): Promise { + let { minAgeMs = 0, pinnedPaths = new Set() } = options; + let now = Date.now(); + let candidates: PruneCandidate[] = []; + + for (let [path, file] of state.workingFiles) { + // Never prune pinned paths + if (pinnedPaths.has(path)) continue; + + // Check if the file is old enough to be a candidate + let age = now - (file.mtime ?? 0); + if (age < minAgeMs) continue; + + // Check if the file is synced (content matches BASE) + let synced = await isFileSynced(file, state); + + if (!synced) continue; + + candidates.push({ + path, + bytes: file.content.byteLength, + mtime: file.mtime ?? 0, + isSynced: true, + }); + } + + // Sort by mtime ascending (oldest first — most likely to prune) + candidates.sort((a, b) => a.mtime - b.mtime); + + return candidates; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** Check if a working file's content matches the BASE snapshot (= fully synced). */ +async function isFileSynced(file: WorkingFile, state: RepoState): Promise { + let baseEntry = state.base.entries.get(file.path); + if (baseEntry === undefined) return false; + + // Use cached blob SHA if available, otherwise compute + let fileSha = file.blobSha; + if (fileSha === undefined) { + fileSha = await blobSha(file.content); + } + + return fileSha === baseEntry.sha; +} + +/** + * Select which candidates to actually evict based on the pruning limits. + * Prioritizes evicting the oldest files first. + */ +function selectForEviction( + candidates: PruneCandidate[], + state: RepoState, + options: PruneOptions, +): PruneCandidate[] { + let { maxFiles, maxBytes } = options; + + // If no limits are set, don't evict anything + if (maxFiles === undefined && maxBytes === undefined) return []; + + let currentFileCount = state.workingFiles.size; + let currentBytes = 0; + for (let [, file] of state.workingFiles) { + currentBytes += file.content.byteLength; + } + + let toEvict: PruneCandidate[] = []; + + for (let candidate of candidates) { + let overFileLimit = maxFiles !== undefined && currentFileCount > maxFiles; + let overByteLimit = maxBytes !== undefined && currentBytes > maxBytes; + + // Stop if we're within both limits + if (!overFileLimit && !overByteLimit) break; + + toEvict.push(candidate); + currentFileCount--; + currentBytes -= candidate.bytes; + } + + return toEvict; +} diff --git a/tasks/storage-pruning.md b/tasks/storage-pruning.md index 15f2a0f..7799fd3 100644 --- a/tasks/storage-pruning.md +++ b/tasks/storage-pruning.md @@ -1,5 +1,6 @@ --- -status: todo +status: done +completed: 2026-03-06 created: 2026-03-06 ---