Skip to main content

gonzalo_store_git/
lib.rs

1//! Git-backed storage substrate. Each record is a JSON file in a git
2//! worktree; every write is committed, giving an auditable history. Remote
3//! replication via fast-forward `pull`/`push`.
4//!
5//! [`GitStore`] implements [`Store`](gonzalo_core::Store) but **not**
6//! [`BlobStore`](gonzalo_core::BlobStore): git is not a natural content-addressed
7//! blob store. Blob-backed records (e.g. checkpoint pre-images, code-graph
8//! slices) therefore require the `fs`, `s3`, or remote (daemon) substrates, not
9//! git (gonzalo#184).
10
11use async_trait::async_trait;
12use gonzalo_core::{
13    Body, CoreError, DEFAULT_ANCESTOR_CAP, DeletePlan, DeleteResult, Identity, KeyPrefix,
14    MergeOutcome, PurgePlan, PutPlan, PutResult, Record, RecordKey, Result, Revision,
15    decode_segment, merge, now_ms, plan_delete, plan_purge, plan_put, plan_put_raw,
16    reconciled_record, record_components, tombstone_winner, validate_ancestor_cap,
17};
18use rustix::fs::{FlockOperation, flock};
19use std::cell::RefCell;
20use std::collections::HashSet;
21use std::path::{Path, PathBuf};
22use std::rc::Rc;
23
24mod diff;
25pub use diff::{ChangedPaths, changed_paths, head_commit, is_git_repo};
26
27/// A put planner: `gonzalo_core::plan_put` (consumer write) or
28/// `gonzalo_core::plan_put_raw` (replication write). Both run inside the same
29/// locked read→plan→write→commit path, `GitStore::put_locked`.
30type PutPlanner = fn(Option<&Record>, Record, Option<Revision>, i64, usize) -> PutPlan;
31
32/// A record that diverged on both sides of a pull and could not be auto-merged;
33/// the local version is kept and both sides are surfaced for resolution.
34#[derive(Debug, Clone)]
35pub struct PullConflict {
36    pub key: RecordKey,
37    pub local: Box<Record>,
38    pub remote: Box<Record>,
39}
40
41/// What a [`GitStore::pull`] did.
42#[derive(Debug, Default)]
43#[must_use = "a PullReport may contain unresolved conflicts that must be handled"]
44pub struct PullReport {
45    /// The pull was a clean fast-forward (or already up-to-date).
46    pub fast_forwarded: bool,
47    /// Records taken from the remote because the local side was simply behind:
48    /// the local revision appears in the remote record's `ancestors`, so this
49    /// is an advance rather than a divergence, exactly as `sync` decides it
50    /// (spec §3.4, gonzalo#289). Nothing local is lost — the local revision is
51    /// already in the chain being adopted.
52    pub advanced: Vec<RecordKey>,
53    /// Records reconciled by a content-aware 3-way merge into a merge commit.
54    pub merged: Vec<RecordKey>,
55    /// Divergences kept local and surfaced for the caller to resolve.
56    pub conflicts: Vec<PullConflict>,
57}
58
59pub struct GitStore {
60    root: PathBuf,
61    /// Upper bound on `Record::ancestors` for every record this store commits
62    /// (spec §3.9). Defaults to `DEFAULT_ANCESTOR_CAP`.
63    cap: usize,
64}
65
66impl GitStore {
67    /// Open an existing git repo at `root`, or initialize one if absent.
68    pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
69        let root = root.into();
70        std::fs::create_dir_all(&root).map_err(|e| CoreError::Backend(e.to_string()))?;
71        match git2::Repository::open(&root) {
72            Ok(_) => {}
73            Err(_) => {
74                git2::Repository::init(&root).map_err(|e| CoreError::Backend(e.to_string()))?;
75            }
76        }
77        Ok(Self {
78            root,
79            cap: DEFAULT_ANCESTOR_CAP,
80        })
81    }
82
83    /// Bound the ancestor list this store keeps on every write (spec §3.9).
84    /// A cap of `0` is rejected.
85    pub fn with_ancestor_cap(mut self, cap: usize) -> Result<Self> {
86        self.cap = validate_ancestor_cap(cap)?;
87        Ok(self)
88    }
89
90    /// An owned copy of this handle, to move into a `spawn_blocking` closure.
91    fn handle(&self) -> Self {
92        Self {
93            root: self.root.clone(),
94            cap: self.cap,
95        }
96    }
97
98    /// Perform a conditional `put` or `put_raw`: take the repo lock, read the
99    /// current record, let `plan` (`plan_put` or `plan_put_raw`) decide, and
100    /// commit the planned record. Serializes the read→plan→write→commit
101    /// critical section over the shared index+HEAD; the lock releases when
102    /// `_lock` drops (all paths). Blocking; call from `run_blocking`.
103    fn put_locked(
104        &self,
105        record: Record,
106        expected: Option<Revision>,
107        plan: PutPlanner,
108    ) -> Result<PutResult> {
109        let _lock = lock_repo(&self.root)?;
110        let key = record.key.clone();
111        let current = self.read(&key)?;
112        // The decision (recreation re-stamping for `put`, verbatim for
113        // `put_raw`, ancestor folding for both) is the shared core planner's
114        // (spec §3.2).
115        match plan(current.as_ref(), record, expected, now_ms(), self.cap) {
116            PutPlan::Write(stored) => {
117                self.write_and_commit(&stored, &format!("put {key}"))?;
118                Ok(PutResult::Committed(stored.revision))
119            }
120            PutPlan::Conflict(conflict) => Ok(PutResult::Conflict(conflict)),
121            PutPlan::NotFound => Err(CoreError::NotFound(key)),
122            // Only consumer `plan_put` produces this: for a
123            // `RecordKind::Tombstone` record (deletes go through `delete_as`,
124            // replication through `put_raw`) and for a create over a manifest
125            // tombstone.
126            PutPlan::Rejected(reason) => Err(CoreError::Invalid(reason.to_string())),
127        }
128    }
129
130    /// Write `record` to its worktree file and commit it with `message`.
131    /// Call only while holding `lock_repo`.
132    fn write_and_commit(&self, record: &Record, message: &str) -> Result<()> {
133        let rel = rel_path(&record.key);
134        let abs = self.root.join(&rel);
135        if let Some(parent) = abs.parent() {
136            std::fs::create_dir_all(parent).map_err(be)?;
137        }
138        let bytes =
139            serde_json::to_vec_pretty(record).map_err(|e| CoreError::Serde(e.to_string()))?;
140        write_atomic(&abs, &bytes).map_err(be)?;
141        self.commit_file(&rel, message)
142    }
143
144    /// Remove `key`'s worktree file and commit the removal with `message`.
145    /// Call only while holding `lock_repo`.
146    fn remove_and_commit(&self, key: &RecordKey, message: &str) -> Result<()> {
147        let rel = rel_path(key);
148        match std::fs::remove_file(self.root.join(&rel)) {
149            Ok(()) => {}
150            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
151            Err(e) => return Err(be(e)),
152        }
153        self.commit_removal(&rel, message)
154    }
155
156    /// Whether consumer `list` reports `key`. A tombstone is hidden. A file
157    /// that vanished since the directory walk (a concurrent `purge`) is
158    /// dropped. A file that fails to read for any other reason (fails to
159    /// deserialize, or an unreadable entry such as a stray directory named
160    /// `*.json`) stays listed, exactly as before tombstones, so `get` keeps
161    /// surfacing the error instead of the key silently disappearing.
162    fn is_listed(&self, key: &RecordKey) -> bool {
163        match self.read(key) {
164            Ok(Some(rec)) => !rec.is_tombstone(),
165            Ok(None) => false,
166            Err(_) => true,
167        }
168    }
169
170    fn path_for(&self, key: &RecordKey) -> PathBuf {
171        let (ns, col, file) = record_components(key);
172        self.root.join(ns).join(col).join(file)
173    }
174
175    fn read(&self, key: &RecordKey) -> Result<Option<Record>> {
176        let path = self.path_for(key);
177        match std::fs::read(&path) {
178            Ok(bytes) => Ok(Some(
179                serde_json::from_slice(&bytes).map_err(|e| CoreError::Serde(e.to_string()))?,
180            )),
181            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
182            Err(e) => Err(CoreError::Backend(e.to_string())),
183        }
184    }
185
186    fn commit_file(&self, rel: &Path, message: &str) -> Result<()> {
187        let repo =
188            git2::Repository::open(&self.root).map_err(|e| CoreError::Backend(e.to_string()))?;
189        let mut index = repo
190            .index()
191            .map_err(|e| CoreError::Backend(e.to_string()))?;
192        index
193            .add_path(rel)
194            .map_err(|e| CoreError::Backend(e.to_string()))?;
195        index
196            .write()
197            .map_err(|e| CoreError::Backend(e.to_string()))?;
198        let tree_oid = index
199            .write_tree()
200            .map_err(|e| CoreError::Backend(e.to_string()))?;
201        let tree = repo
202            .find_tree(tree_oid)
203            .map_err(|e| CoreError::Backend(e.to_string()))?;
204        let sig = git2::Signature::now("gonzalo", "gonzalo@localhost")
205            .map_err(|e| CoreError::Backend(e.to_string()))?;
206        let parent = repo
207            .head()
208            .ok()
209            .and_then(|h| h.target())
210            .and_then(|oid| repo.find_commit(oid).ok());
211        let parents: Vec<&git2::Commit> = parent.iter().collect();
212        repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)
213            .map_err(|e| CoreError::Backend(e.to_string()))?;
214        Ok(())
215    }
216
217    /// Commit the removal of `rel` (a `git rm` equivalent): stage the deletion in
218    /// the index, write the tree, and record a commit. Mirrors `commit_file` but
219    /// removes the path from the index instead of adding it.
220    fn commit_removal(&self, rel: &Path, message: &str) -> Result<()> {
221        let repo =
222            git2::Repository::open(&self.root).map_err(|e| CoreError::Backend(e.to_string()))?;
223        let mut index = repo
224            .index()
225            .map_err(|e| CoreError::Backend(e.to_string()))?;
226        index
227            .remove_path(rel)
228            .map_err(|e| CoreError::Backend(e.to_string()))?;
229        index
230            .write()
231            .map_err(|e| CoreError::Backend(e.to_string()))?;
232        let tree_oid = index
233            .write_tree()
234            .map_err(|e| CoreError::Backend(e.to_string()))?;
235        let tree = repo
236            .find_tree(tree_oid)
237            .map_err(|e| CoreError::Backend(e.to_string()))?;
238        let sig = git2::Signature::now("gonzalo", "gonzalo@localhost")
239            .map_err(|e| CoreError::Backend(e.to_string()))?;
240        let parent = repo
241            .head()
242            .ok()
243            .and_then(|h| h.target())
244            .and_then(|oid| repo.find_commit(oid).ok());
245        let parents: Vec<&git2::Commit> = parent.iter().collect();
246        repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)
247            .map_err(|e| CoreError::Backend(e.to_string()))?;
248        Ok(())
249    }
250
251    /// Pull `branch` from `remote` (typically "origin"). A fast-forward advances
252    /// the branch; a divergence is reconciled by a content-aware 3-way merge
253    /// (gonzalo `merge()` per record, ADR 0017), with tombstones decided by
254    /// kind and revision first (spec §3.5), and unresolved records kept local
255    /// and reported in the [`PullReport`].
256    pub async fn pull(&self, remote: &str, branch: &str) -> Result<PullReport> {
257        let root = self.root.clone();
258        let remote = remote.to_string();
259        let branch = branch.to_string();
260        let cap = self.cap;
261        run_blocking(move || git_pull(&root, &remote, &branch, cap)).await
262    }
263
264    /// Push `branch` to `remote`.
265    pub async fn push(&self, remote: &str, branch: &str) -> Result<()> {
266        let root = self.root.clone();
267        let remote = remote.to_string();
268        let branch = branch.to_string();
269        run_blocking(move || git_push(&root, &remote, &branch)).await
270    }
271}
272
273/// The repo-relative path of `key`'s record file: `<ns>/<col>/<id>.json`.
274fn rel_path(key: &RecordKey) -> PathBuf {
275    let (ns, col, file) = record_components(key);
276    Path::new(&ns).join(&col).join(&file)
277}
278
279fn be<E: std::fmt::Display>(e: E) -> CoreError {
280    CoreError::Backend(e.to_string())
281}
282
283/// Replace the file at `path` with `bytes` in one step, the way the fs store's
284/// `write_durable` does (gonzalo#283).
285///
286/// Consumer `get` and `list` read the working tree without the repo lock. A
287/// plain `std::fs::write` truncates the file first, so a reader landing
288/// mid-write saw an empty or half-written record, and a crash between the
289/// truncate and the write left a zero-length one. Since tombstones (#203) a
290/// delete writes a file too, so it had the same exposure.
291///
292/// Instead: write a temp file beside the target, `sync_all` it so its bytes are
293/// on disk, rename it over the target (atomic against readers), then fsync the
294/// directory so the new entry survives a crash. A reader sees the old record or
295/// the new one, never a torn file. The temp name ends in `.tmp`, not `.json`,
296/// so a crash that leaves it behind can't be walked as a record, and it is
297/// never staged: commits add paths explicitly.
298fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
299    use std::io::Write;
300    let tmp = path.with_extension("json.tmp");
301    let mut f = std::fs::File::create(&tmp)?;
302    f.write_all(bytes)?;
303    f.sync_all()?;
304    drop(f);
305    std::fs::rename(&tmp, path)?;
306    if let Some(parent) = path.parent() {
307        fsync_dir(parent)?;
308    }
309    Ok(())
310}
311
312/// Best-effort fsync of the directory `path`, making a preceding `rename` into
313/// it durable across a crash. Where a platform rejects fsync on a directory
314/// handle (`InvalidInput`), treat it as a no-op rather than a write failure.
315/// Mirrors the fs store's helper; `gonzalo-core` does no I/O, so there is no
316/// shared home for it.
317fn fsync_dir(path: &Path) -> std::io::Result<()> {
318    let dir = std::fs::File::open(path)?;
319    match dir.sync_all() {
320        Ok(()) => Ok(()),
321        Err(e) if e.kind() == std::io::ErrorKind::InvalidInput => Ok(()),
322        Err(e) => Err(e),
323    }
324}
325
326/// Acquire the repo-level exclusive lock guarding the OCC critical section of
327/// `put`, `delete`, `purge` and `pull`.
328///
329/// Unlike `FsStore`, whose per-record lock suffices, every `GitStore` write
330/// mutates the *shared* on-disk index and HEAD (via `commit_file` /
331/// `commit_removal`), so serialization must be repo-wide: two writes on
332/// different keys still race on the same index+HEAD. The lock is a
333/// `<root>/.gonzalo-git.lock` file held exclusively via `flock`; it is released
334/// when the returned handle drops, which covers every exit path (the
335/// `Conflict`/`NotFound`/no-op early returns and any error). Blocking by
336/// design — call only from the `spawn_blocking` section.
337fn lock_repo(root: &Path) -> Result<std::fs::File> {
338    let lock_path = root.join(".gonzalo-git.lock");
339    let lock = std::fs::OpenOptions::new()
340        .create(true)
341        .truncate(false)
342        .write(true)
343        .open(&lock_path)
344        .map_err(be)?;
345    flock(&lock, FlockOperation::LockExclusive).map_err(be)?;
346    Ok(lock)
347}
348
349fn git_pull(root: &Path, remote: &str, branch: &str, cap: usize) -> Result<PullReport> {
350    // Hold the repo lock that `put`, `delete` and `purge` take, so a concurrent
351    // local write can't commit mid-pull and then be overwritten by the forced
352    // checkout below (spec §3.5). Nothing below re-takes it.
353    let _lock = lock_repo(root)?;
354    let repo = git2::Repository::open(root).map_err(be)?;
355    let mut rem = repo.find_remote(remote).map_err(be)?;
356    rem.fetch(&[branch], None, None).map_err(be)?;
357    let fetch_head = repo.find_reference("FETCH_HEAD").map_err(be)?;
358    let fetch_commit = repo
359        .reference_to_annotated_commit(&fetch_head)
360        .map_err(be)?;
361    let (analysis, _) = repo.merge_analysis(&[&fetch_commit]).map_err(be)?;
362
363    if analysis.is_up_to_date() {
364        return Ok(PullReport::default());
365    }
366    if analysis.is_fast_forward() {
367        let refname = format!("refs/heads/{branch}");
368        let mut reference = repo.find_reference(&refname).map_err(be)?;
369        reference
370            .set_target(fetch_commit.id(), "fast-forward")
371            .map_err(be)?;
372        repo.set_head(&refname).map_err(be)?;
373        repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))
374            .map_err(be)?;
375        return Ok(PullReport {
376            fast_forwarded: true,
377            ..Default::default()
378        });
379    }
380
381    merge_non_ff(&repo, remote, branch, fetch_commit.id(), cap)
382}
383
384/// Reconcile a diverged local branch with `remote_oid` by a content-aware 3-way
385/// merge, recorded in a two-parent merge commit (ADR 0017).
386///
387/// A path changed only on the remote takes the remote side verbatim. That
388/// includes a remote tombstone (a modified file) and a remote purge (a git
389/// deletion). A record changed on both sides is decided by kind before any body
390/// merge (spec §3.5): equal revisions are a no-op; two tombstones converge on
391/// the higher `(counter, hash)`; exactly one tombstone is a `PullConflict` that
392/// keeps local; two live records take gonzalo's class-aware `merge()`; a local
393/// purge against a remote edit takes the remote record. Records written into
394/// the index bypass `put_raw`, so the tombstone winner and merged record are
395/// truncated here to `cap`; records taken verbatim from the remote keep the
396/// remote's list.
397fn merge_non_ff(
398    repo: &git2::Repository,
399    remote: &str,
400    branch: &str,
401    remote_oid: git2::Oid,
402    cap: usize,
403) -> Result<PullReport> {
404    let local_oid = repo
405        .head()
406        .map_err(be)?
407        .target()
408        .ok_or_else(|| CoreError::Backend("local HEAD is unborn".into()))?;
409    let local_commit = repo.find_commit(local_oid).map_err(be)?;
410    let remote_commit = repo.find_commit(remote_oid).map_err(be)?;
411    let local_tree = local_commit.tree().map_err(be)?;
412    let remote_tree = remote_commit.tree().map_err(be)?;
413    // The merge base is the true common ancestor (git retains history); an
414    // unrelated history has no base, so treat every overlap as add/add.
415    let base_tree = match repo.merge_base(local_oid, remote_oid) {
416        Ok(base_oid) => Some(repo.find_commit(base_oid).map_err(be)?.tree().map_err(be)?),
417        Err(_) => None,
418    };
419
420    // Start the merged index from local, then fold in the remote-side changes.
421    let mut index = repo.index().map_err(be)?;
422    index.read_tree(&local_tree).map_err(be)?;
423
424    let local_changed = changed_paths_set(repo, base_tree.as_ref(), &local_tree)?;
425    let remote_diff = repo
426        .diff_tree_to_tree(base_tree.as_ref(), Some(&remote_tree), None)
427        .map_err(be)?;
428
429    let mut report = PullReport::default();
430    for delta in remote_diff.deltas() {
431        let Some(path) = delta
432            .new_file()
433            .path()
434            .or_else(|| delta.old_file().path())
435            .map(Path::to_path_buf)
436        else {
437            continue;
438        };
439
440        if !local_changed.contains(&path) {
441            // Changed only on the remote: apply the remote side verbatim. A
442            // remote tombstone is a modified file; a remote purge is a deletion.
443            if delta.status() == git2::Delta::Deleted {
444                index.remove_path(&path).map_err(be)?;
445            } else if let Some(bytes) = tree_blob(repo, &remote_tree, &path)? {
446                index
447                    .add_frombuffer(&blob_entry(&path), &bytes)
448                    .map_err(be)?;
449            }
450            continue;
451        }
452
453        // Changed on both sides.
454        let Some(key) = key_from_path(&path) else {
455            continue; // non-record file (should not occur in a record store)
456        };
457        let local_rec = record_at(repo, Some(&local_tree), &path)?;
458        let remote_rec = record_at(repo, Some(&remote_tree), &path)?;
459        match (local_rec, remote_rec) {
460            // Same revision, e.g. two independent deletes of one revision whose
461            // files differ only in `deleted_at`: already in sync, keep local.
462            (Some(local), Some(remote)) if local.revision == remote.revision => {}
463            // One side is only behind the other: its revision is in the other's
464            // `ancestors`, so this is an advance, not a divergence — the rule
465            // `sync` applies first (spec §3.4, gonzalo#289). Checked before the
466            // kind arms, so advancing to a tombstone is not mistaken for a
467            // delete-versus-edit conflict and advancing an `Opaque` body is not
468            // a spurious one.
469            (Some(local), Some(remote)) if remote.ancestors.contains(&local.revision) => {
470                stage_record(&mut index, &path, &remote)?;
471                report.advanced.push(key);
472            }
473            // Local is ahead: it is already staged from `local_tree`, and the
474            // remote's revision is in its chain, so there is nothing to take.
475            (Some(local), Some(remote)) if local.ancestors.contains(&remote.revision) => {}
476            // Two diverged tombstones: the higher (counter, hash) wins, carrying
477            // both chains. Checked by kind, and this arm must stay ahead of the
478            // body guard below: a manifest tombstone retains its body, so two
479            // diverged ones have differing bodies and would otherwise reach
480            // `merge(Opaque, ..)` and be reported as a spurious conflict.
481            (Some(local), Some(remote)) if local.is_tombstone() && remote.is_tombstone() => {
482                let winner = tombstone_winner(&local, &remote, cap);
483                stage_record(&mut index, &path, &winner)?;
484                report.merged.push(key);
485            }
486            // Delete vs edit: keep local (already staged) and surface both,
487            // the same policy as an unmergeable body.
488            (Some(local), Some(remote)) if local.is_tombstone() || remote.is_tombstone() => {
489                report.conflicts.push(PullConflict {
490                    key,
491                    local: Box::new(local),
492                    remote: Box::new(remote),
493                });
494            }
495            (Some(local), Some(remote)) if local.body != remote.body => {
496                let base_body = record_at(repo, base_tree.as_ref(), &path)?
497                    .map(|r| r.body)
498                    .unwrap_or(Body::Inline(Vec::new()));
499                match merge(
500                    local.kind.merge_class(),
501                    &base_body,
502                    &local.body,
503                    &remote.body,
504                ) {
505                    MergeOutcome::Merged(body) => {
506                        let merged = merged_record(&local, &remote, body, cap);
507                        stage_record(&mut index, &path, &merged)?;
508                        report.merged.push(key);
509                    }
510                    MergeOutcome::NeedsResolution => {
511                        // Keep local (already staged); surface both sides.
512                        report.conflicts.push(PullConflict {
513                            key,
514                            local: Box::new(local),
515                            remote: Box::new(remote),
516                        });
517                    }
518                }
519            }
520            // Local present, remote purged: keep local, which is already
521            // staged from `local_tree` (sync's `(Some, None)` copy row).
522            (Some(_local), None) => {}
523            // Local purged, remote edited: take the remote record, so pull
524            // never silently drops a concurrent remote edit (sync's
525            // `(None, Some)` copy row).
526            (None, Some(remote)) => {
527                stage_record(&mut index, &path, &remote)?;
528            }
529            // Equal bodies, or absent on both sides: keep local.
530            _ => {}
531        }
532    }
533
534    // Commit the reconciled tree with both parents, then advance the branch.
535    let tree_oid = index.write_tree_to(repo).map_err(be)?;
536    let tree = repo.find_tree(tree_oid).map_err(be)?;
537    // Check out the merged tree while HEAD still names the local commit, so
538    // libgit2's baseline is the local tree and a path the merge removed (a
539    // remote purge) is deleted from the worktree. Checking out after `set_head`
540    // would see baseline == target and leave the file behind as untracked.
541    // This also writes the merged index to disk. The untracked
542    // `.gonzalo-git.lock` is neither in the baseline nor the target, so it stays.
543    repo.checkout_tree(
544        tree.as_object(),
545        Some(git2::build::CheckoutBuilder::new().force()),
546    )
547    .map_err(be)?;
548    let sig = git2::Signature::now("gonzalo", "gonzalo@localhost").map_err(be)?;
549    let refname = format!("refs/heads/{branch}");
550    repo.commit(
551        Some(&refname),
552        &sig,
553        &sig,
554        &format!("merge {remote}/{branch}"),
555        &tree,
556        &[&local_commit, &remote_commit],
557    )
558    .map_err(be)?;
559    repo.set_head(&refname).map_err(be)?;
560    // No trailing `checkout_head`: the worktree and index already match the
561    // committed tree, so it would be a no-op.
562
563    Ok(report)
564}
565
566/// Serialize `record` as the store does on `put` and stage it at `path`.
567fn stage_record(index: &mut git2::Index, path: &Path, record: &Record) -> Result<()> {
568    let bytes = serde_json::to_vec_pretty(record).map_err(|e| CoreError::Serde(e.to_string()))?;
569    index.add_frombuffer(&blob_entry(path), &bytes).map_err(be)
570}
571
572/// Paths that differ between `base` (an empty tree if `None`) and `tree`.
573fn changed_paths_set(
574    repo: &git2::Repository,
575    base: Option<&git2::Tree>,
576    tree: &git2::Tree,
577) -> Result<HashSet<PathBuf>> {
578    let diff = repo.diff_tree_to_tree(base, Some(tree), None).map_err(be)?;
579    let mut set = HashSet::new();
580    for delta in diff.deltas() {
581        if let Some(path) = delta.new_file().path().or_else(|| delta.old_file().path()) {
582            set.insert(path.to_path_buf());
583        }
584    }
585    Ok(set)
586}
587
588/// The `Record` stored at `path` in `tree` (`None` if `tree` is `None` or the
589/// path is absent).
590fn record_at(
591    repo: &git2::Repository,
592    tree: Option<&git2::Tree>,
593    path: &Path,
594) -> Result<Option<Record>> {
595    let Some(tree) = tree else {
596        return Ok(None);
597    };
598    match tree.get_path(path) {
599        Ok(entry) => {
600            let obj = entry.to_object(repo).map_err(be)?;
601            let blob = obj
602                .as_blob()
603                .ok_or_else(|| CoreError::Backend("record path is not a blob".into()))?;
604            let rec = serde_json::from_slice(blob.content())
605                .map_err(|e| CoreError::Serde(e.to_string()))?;
606            Ok(Some(rec))
607        }
608        Err(_) => Ok(None),
609    }
610}
611
612/// Raw blob bytes at `path` in `tree`, or `None` if absent.
613fn tree_blob(repo: &git2::Repository, tree: &git2::Tree, path: &Path) -> Result<Option<Vec<u8>>> {
614    match tree.get_path(path) {
615        Ok(entry) => {
616            let obj = entry.to_object(repo).map_err(be)?;
617            Ok(obj.as_blob().map(|b| b.content().to_vec()))
618        }
619        Err(_) => Ok(None),
620    }
621}
622
623/// The `RecordKey` for a `ns/col/id.json` path, or `None` if it isn't one.
624fn key_from_path(path: &Path) -> Option<RecordKey> {
625    let comps: Vec<String> = path
626        .components()
627        .map(|c| c.as_os_str().to_string_lossy().into_owned())
628        .collect();
629    if comps.len() != 3 {
630        return None;
631    }
632    let id = comps[2].strip_suffix(".json")?;
633    Some(RecordKey::new(
634        decode_segment(&comps[0]),
635        decode_segment(&comps[1]),
636        decode_segment(id),
637    ))
638}
639
640/// A blank index entry for a regular file `path`; `add_frombuffer` fills the oid
641/// from the data.
642fn blob_entry(path: &Path) -> git2::IndexEntry {
643    git2::IndexEntry {
644        ctime: git2::IndexTime::new(0, 0),
645        mtime: git2::IndexTime::new(0, 0),
646        dev: 0,
647        ino: 0,
648        mode: 0o100644,
649        uid: 0,
650        gid: 0,
651        file_size: 0,
652        id: git2::Oid::zero(),
653        flags: 0,
654        flags_extended: 0,
655        path: path.to_string_lossy().into_owned().into_bytes(),
656    }
657}
658
659/// The merged record from an auto-resolved divergence: the shared core
660/// reconciliation (the same construction as `sync`), authored as
661/// `gonzalo-merge` from `git-pull`, with ancestors truncated to `cap`.
662fn merged_record(local: &Record, remote: &Record, body: Body, cap: usize) -> Record {
663    reconciled_record(
664        local,
665        remote,
666        body,
667        Identity::new("gonzalo-merge"),
668        "git-pull",
669        cap,
670    )
671}
672
673fn git_push(root: &Path, remote: &str, branch: &str) -> Result<()> {
674    let repo = git2::Repository::open(root).map_err(be)?;
675    let mut rem = repo.find_remote(remote).map_err(be)?;
676    let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
677
678    // libgit2's `push` returns Ok even when the remote refuses a ref update
679    // (e.g. non-fast-forward): the per-ref verdict arrives ONLY through the
680    // `push_update_reference` callback, whose `status` is `Some(msg)` on
681    // rejection and `None` on success. Capture every rejection so we can fail
682    // the push instead of silently reporting success.
683    let rejected: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(Vec::new()));
684    let mut callbacks = git2::RemoteCallbacks::new();
685    {
686        let rejected = Rc::clone(&rejected);
687        callbacks.push_update_reference(move |refname, status| {
688            if let Some(msg) = status {
689                rejected.borrow_mut().push(format!("{refname}: {msg}"));
690            }
691            Ok(())
692        });
693    }
694    let mut opts = git2::PushOptions::new();
695    opts.remote_callbacks(callbacks);
696    rem.push(&[refspec.as_str()], Some(&mut opts)).map_err(be)?;
697    drop(opts); // release the callback's borrow of `rejected` before we read it
698
699    let rejected = rejected.borrow();
700    if !rejected.is_empty() {
701        return Err(CoreError::Backend(format!(
702            "push rejected by remote '{remote}': {}",
703            rejected.join(", ")
704        )));
705    }
706    Ok(())
707}
708
709async fn run_blocking<F, T>(f: F) -> Result<T>
710where
711    F: FnOnce() -> Result<T> + Send + 'static,
712    T: Send + 'static,
713{
714    tokio::task::spawn_blocking(f)
715        .await
716        .map_err(|e| CoreError::Backend(e.to_string()))?
717}
718
719#[async_trait]
720impl gonzalo_core::Store for GitStore {
721    async fn get(&self, key: &RecordKey) -> Result<Option<Record>> {
722        // Consumer read: a tombstoned key looks absent (spec §3.2).
723        let store = self.handle();
724        let key = key.clone();
725        run_blocking(move || Ok(store.read(&key)?.filter(|rec| !rec.is_tombstone()))).await
726    }
727
728    async fn put(&self, record: Record, expected: Option<Revision>) -> Result<PutResult> {
729        let store = self.handle();
730        run_blocking(move || store.put_locked(record, expected, plan_put)).await
731    }
732
733    async fn put_raw(&self, record: Record, expected: Option<Revision>) -> Result<PutResult> {
734        // Replication write: same repo-wide critical section as `put`, decided
735        // by `plan_put_raw`, which never re-stamps. A create over a tombstone is
736        // a Conflict, never a recreation.
737        let store = self.handle();
738        run_blocking(move || store.put_locked(record, expected, plan_put_raw)).await
739    }
740
741    async fn list(&self, prefix: &KeyPrefix) -> Result<Vec<RecordKey>> {
742        // Consumer listing excludes tombstoned keys, which means reading each
743        // record file under the prefix (spec §8.4: a local read per key).
744        let store = self.handle();
745        let prefix = prefix.clone();
746        run_blocking(move || {
747            let mut keys = Vec::new();
748            collect_keys(&store.root, &prefix, &mut keys)?;
749            let mut out = Vec::with_capacity(keys.len());
750            for key in keys {
751                if store.is_listed(&key) {
752                    out.push(key);
753                }
754            }
755            Ok(out)
756        })
757        .await
758    }
759
760    // No `delete` here: the trait provides it as `delete_as(key, expected, None)`.
761    async fn delete_as(
762        &self,
763        key: &RecordKey,
764        expected: Option<Revision>,
765        author: Option<Identity>,
766    ) -> Result<DeleteResult> {
767        let store = self.handle();
768        let key = key.clone();
769        run_blocking(move || {
770            // Serialize the read→plan→write→commit critical section over the
771            // shared index+HEAD, exactly as `put`; the lock releases when `_lock`
772            // drops (all paths).
773            let _lock = lock_repo(&store.root)?;
774            let current = store.read(&key)?;
775            // A delete commits a tombstone at the record's normal path, so git
776            // history shows it as an ordinary modification (spec §3.3, §5.5).
777            // A no-op (absent key, or already a tombstone) makes no commit.
778            // `Some(author)` replaces `meta.author` on the tombstone.
779            match plan_delete(
780                current.as_ref(),
781                expected,
782                now_ms(),
783                store.cap,
784                author.as_ref(),
785            ) {
786                DeletePlan::Write(tombstone) => {
787                    store.write_and_commit(&tombstone, &format!("delete {key}"))?;
788                    Ok(DeleteResult::Deleted)
789                }
790                DeletePlan::Noop => Ok(DeleteResult::Deleted),
791                DeletePlan::Conflict(conflict) => Ok(DeleteResult::Conflict(conflict)),
792            }
793        })
794        .await
795    }
796
797    async fn get_raw(&self, key: &RecordKey) -> Result<Option<Record>> {
798        // Replication read: whatever the worktree holds, tombstones included.
799        let store = self.handle();
800        let key = key.clone();
801        run_blocking(move || store.read(&key)).await
802    }
803
804    async fn list_raw(&self, prefix: &KeyPrefix) -> Result<Vec<RecordKey>> {
805        // Replication listing: every `<id>.json` under the prefix, tombstones
806        // included, without reading any file.
807        let store = self.handle();
808        let prefix = prefix.clone();
809        run_blocking(move || {
810            let mut out = Vec::new();
811            collect_keys(&store.root, &prefix, &mut out)?;
812            Ok(out)
813        })
814        .await
815    }
816
817    async fn purge(&self, key: &RecordKey, expected: Revision) -> Result<DeleteResult> {
818        let store = self.handle();
819        let key = key.clone();
820        run_blocking(move || {
821            // The only physical removal in the system, in the same repo-wide
822            // critical section as `put`: read, decide (`plan_purge`), then
823            // remove the file and commit the removal.
824            let _lock = lock_repo(&store.root)?;
825            let current = store.read(&key)?;
826            match plan_purge(current.as_ref(), &expected) {
827                PurgePlan::Remove => {
828                    store.remove_and_commit(&key, &format!("purge {key}"))?;
829                    Ok(DeleteResult::Deleted)
830                }
831                PurgePlan::Noop => Ok(DeleteResult::Deleted),
832                PurgePlan::Conflict(conflict) => Ok(DeleteResult::Conflict(conflict)),
833            }
834        })
835        .await
836    }
837}
838
839fn collect_keys(root: &Path, prefix: &KeyPrefix, out: &mut Vec<RecordKey>) -> Result<()> {
840    let namespaces = match std::fs::read_dir(root) {
841        Ok(rd) => rd,
842        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
843        Err(e) => return Err(CoreError::Backend(e.to_string())),
844    };
845    for ns in namespaces {
846        let ns = ns.map_err(|e| CoreError::Backend(e.to_string()))?;
847        let ns_name = ns.file_name().to_string_lossy().to_string();
848        if ns_name == ".git" || !ns.path().is_dir() {
849            continue;
850        }
851        for col in std::fs::read_dir(ns.path()).map_err(|e| CoreError::Backend(e.to_string()))? {
852            let col = col.map_err(|e| CoreError::Backend(e.to_string()))?;
853            if !col.path().is_dir() {
854                continue;
855            }
856            let col_name = col.file_name().to_string_lossy().to_string();
857            for f in std::fs::read_dir(col.path()).map_err(|e| CoreError::Backend(e.to_string()))? {
858                let f = f.map_err(|e| CoreError::Backend(e.to_string()))?;
859                let fname = f.file_name().to_string_lossy().to_string();
860                if let Some(id) = fname.strip_suffix(".json") {
861                    // Path components are `segment`-encoded; decode to recover
862                    // the original key so `list()` round-trips.
863                    let key = RecordKey::new(
864                        decode_segment(&ns_name),
865                        decode_segment(&col_name),
866                        decode_segment(id),
867                    );
868                    if prefix.matches(&key) {
869                        out.push(key);
870                    }
871                }
872            }
873        }
874    }
875    Ok(())
876}