Skip to main content

lore_cli/sync/
gitref.rs

1//! Git plumbing for the lore store refs under `refs/lore/*`.
2//!
3//! Every operation shells out to the user's `git` binary (no libgit2) so it
4//! inherits their authentication, SSH config, credential helpers, and remotes
5//! for free. `git` is already a hard dependency of the project. This mirrors the
6//! style of [`crate::git`] but uses plumbing commands (`hash-object`, `mktree`
7//! via a temporary index, `commit-tree`, `update-ref`, `cat-file`, `ls-tree`)
8//! so the store ref lives entirely outside `refs/heads/*` and never checks out
9//! into the working tree.
10//!
11//! All functions take an explicit repository path, run with that path as the
12//! working directory, capture stderr, and return a [`SyncError::Git`] with the
13//! command and stderr on failure.
14
15use std::collections::BTreeMap;
16use std::io::Write;
17use std::path::{Path, PathBuf};
18use std::process::{Command, Stdio};
19
20use uuid::Uuid;
21
22use super::SyncError;
23
24/// The all-zeros object id git uses to mean "this ref does not yet exist".
25///
26/// Passed as the expected old value to a checked ref update to assert the ref
27/// must be created (must not already exist).
28pub const ZERO_OID: &str = "0000000000000000000000000000000000000000";
29
30/// A single entry in a ref's tree.
31///
32/// Returned by [`read_tree`] so callers can rebuild a tree incrementally,
33/// preserving the git object of any unchanged blob (content-addressed dedup).
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct TreeEntry {
36    /// The git file mode (for example `100644` for a regular file).
37    pub mode: String,
38    /// The blob object SHA.
39    pub sha: String,
40    /// The full path of the entry within the tree (for example
41    /// `sessions/<uuid>.enc`).
42    pub path: String,
43}
44
45/// Writes raw bytes as a git blob and returns its object SHA.
46///
47/// Runs `git hash-object -w --stdin`, piping `data` to stdin. Because git
48/// objects are content-addressed, writing identical bytes always yields the
49/// same SHA, which is what makes incremental tree rebuilds dedup cleanly.
50pub fn write_blob(repo: &Path, data: &[u8]) -> Result<String, SyncError> {
51    let out = run_git_stdin(repo, &["hash-object", "-w", "--stdin"], data)?;
52    Ok(stdout_to_string(out))
53}
54
55/// Reads the bytes of a blob by its object SHA.
56///
57/// Runs `git cat-file blob <sha>`.
58pub fn read_blob(repo: &Path, sha: &str) -> Result<Vec<u8>, SyncError> {
59    run_git(repo, &["cat-file", "blob", sha])
60}
61
62/// Reads the full (recursive) tree of a ref as a list of blob entries.
63///
64/// Runs `git ls-tree -r <reference>`. The reference may be a ref name, a commit
65/// SHA, or a tree SHA. Returns an error if the reference cannot be resolved;
66/// call [`ref_exists`] first when the ref may be absent.
67pub fn read_tree(repo: &Path, reference: &str) -> Result<Vec<TreeEntry>, SyncError> {
68    let out = run_git(repo, &["ls-tree", "-r", reference])?;
69    let text = String::from_utf8_lossy(&out);
70
71    let mut entries = Vec::new();
72    for line in text.lines() {
73        // Each line is "<mode> <type> <sha>\t<path>".
74        let Some((meta, path)) = line.split_once('\t') else {
75            continue;
76        };
77        let mut parts = meta.split_whitespace();
78        let mode = parts.next().unwrap_or("").to_string();
79        let _object_type = parts.next();
80        let sha = parts.next().unwrap_or("").to_string();
81        if mode.is_empty() || sha.is_empty() {
82            continue;
83        }
84        entries.push(TreeEntry {
85            mode,
86            sha,
87            path: path.to_string(),
88        });
89    }
90
91    Ok(entries)
92}
93
94/// Builds a new tree incrementally and returns its object SHA.
95///
96/// When `base` is `Some`, the new tree starts from that ref or tree-ish and only
97/// the paths in `changes` are overwritten or added. Unchanged entries keep their
98/// existing blob objects, giving content-addressed dedup so the repository grows
99/// by near zero per sync. When `base` is `None`, the tree is built from scratch.
100///
101/// `changes` maps each path (for example `sessions/<uuid>.enc` or `meta/salt`)
102/// to the blob SHA that should live at that path. Nested paths are handled
103/// automatically.
104///
105/// Internally this uses a throwaway index file via `GIT_INDEX_FILE` so the
106/// user's real index is never touched.
107pub fn build_tree(
108    repo: &Path,
109    base: Option<&str>,
110    changes: &BTreeMap<String, String>,
111) -> Result<String, SyncError> {
112    let git_dir = absolute_git_dir(repo)?;
113    let index_path = git_dir.join(format!("lore-index-{}", Uuid::new_v4()));
114
115    let result = build_tree_with_index(repo, &index_path, base, changes);
116
117    // Best-effort cleanup of the temporary index regardless of outcome.
118    let _ = std::fs::remove_file(&index_path);
119
120    result
121}
122
123/// Performs the tree build against a specific temporary index path.
124fn build_tree_with_index(
125    repo: &Path,
126    index_path: &Path,
127    base: Option<&str>,
128    changes: &BTreeMap<String, String>,
129) -> Result<String, SyncError> {
130    // Seed the temporary index from the base tree, if any.
131    if let Some(base_ref) = base {
132        run_git_index(repo, index_path, &["read-tree", base_ref])?;
133    }
134
135    // Overwrite or add only the changed entries.
136    for (path, sha) in changes {
137        let cacheinfo = format!("100644,{sha},{path}");
138        run_git_index(
139            repo,
140            index_path,
141            &["update-index", "--add", "--cacheinfo", &cacheinfo],
142        )?;
143    }
144
145    let out = run_git_index(repo, index_path, &["write-tree"])?;
146    Ok(stdout_to_string(out))
147}
148
149/// Creates a commit object for a tree and returns its SHA.
150///
151/// Runs `git commit-tree <tree> [-p <parent>] -m <message>`. The repository must
152/// have a committer identity configured (the standard git requirement).
153pub fn commit_tree(
154    repo: &Path,
155    tree_sha: &str,
156    parent: Option<&str>,
157    message: &str,
158) -> Result<String, SyncError> {
159    let mut args: Vec<&str> = vec!["commit-tree", tree_sha];
160    if let Some(parent_sha) = parent {
161        args.push("-p");
162        args.push(parent_sha);
163    }
164    args.push("-m");
165    args.push(message);
166
167    let out = run_git(repo, &args)?;
168    Ok(stdout_to_string(out))
169}
170
171/// Points a ref at a commit.
172///
173/// Runs `git update-ref <ref_name> <commit_sha>`. `ref_name` should be a full
174/// ref name such as `refs/lore/sessions`.
175pub fn update_ref(repo: &Path, ref_name: &str, commit_sha: &str) -> Result<(), SyncError> {
176    run_git(repo, &["update-ref", ref_name, commit_sha])?;
177    Ok(())
178}
179
180/// Points a ref at a commit only if it currently holds the expected value.
181///
182/// This is a compare-and-swap: it runs `git update-ref <ref> <new> <old>`, where
183/// `old` is the value the ref must currently hold. When `old` is `None`, the
184/// all-zeros OID ([`ZERO_OID`]) is used to assert the ref must not yet exist.
185/// Git performs the comparison and the update atomically under a ref lock, so
186/// two concurrent syncs in the same repo cannot silently clobber each other.
187///
188/// On a mismatch (the ref moved or already exists) this returns
189/// [`SyncError::RefCasMismatch`] so a caller can re-read the ref and retry. Any
190/// other failure is reported as [`SyncError::Git`].
191pub fn update_ref_checked(
192    repo: &Path,
193    ref_name: &str,
194    new_sha: &str,
195    old: Option<&str>,
196) -> Result<(), SyncError> {
197    let old_value = old.unwrap_or(ZERO_OID);
198    let args = ["update-ref", ref_name, new_sha, old_value];
199
200    let output = Command::new("git")
201        .current_dir(repo)
202        .args(args)
203        .output()
204        .map_err(|e| SyncError::Git(format!("failed to spawn git: {e}")))?;
205
206    if output.status.success() {
207        return Ok(());
208    }
209
210    let stderr = String::from_utf8_lossy(&output.stderr);
211    let lowered = stderr.to_lowercase();
212    // git reports a CAS failure via the ref lock: "cannot lock ref ...: is at X
213    // but expected Y", or "reference already exists" when old is the zero OID.
214    if lowered.contains("but expected")
215        || lowered.contains("reference already exists")
216        || lowered.contains("cannot lock ref")
217        || lowered.contains("unable to resolve reference")
218    {
219        Err(SyncError::RefCasMismatch(format!(
220            "{ref_name} did not hold expected value {old_value}: {}",
221            stderr.trim()
222        )))
223    } else {
224        Err(git_error(&args, &output.stderr))
225    }
226}
227
228/// Resolves a ref to its commit SHA, or `None` if the ref does not exist.
229pub fn resolve_ref(repo: &Path, ref_name: &str) -> Result<Option<String>, SyncError> {
230    resolve_revision(repo, &format!("{ref_name}^{{commit}}"))
231}
232
233/// Resolves a ref to its tree SHA, or `None` if the ref does not exist.
234///
235/// Part of the git-ref sync foundation's public API. Retained for the global
236/// store and daemon wiring in later phases; the per-repo command resolves
237/// commits via [`resolve_ref`] and reads trees via [`read_tree`], so the binary
238/// does not yet call this directly.
239#[allow(dead_code)]
240pub fn resolve_tree(repo: &Path, ref_name: &str) -> Result<Option<String>, SyncError> {
241    resolve_revision(repo, &format!("{ref_name}^{{tree}}"))
242}
243
244/// Returns whether a ref exists in the repository.
245pub fn ref_exists(repo: &Path, ref_name: &str) -> Result<bool, SyncError> {
246    Ok(resolve_ref(repo, ref_name)?.is_some())
247}
248
249/// Pushes a lore ref to a remote using an explicit refspec.
250///
251/// Runs `git push <remote> <ref_name>:<ref_name>`. The explicit refspec means
252/// `refs/lore/*` syncs without the user configuring anything.
253pub fn push(repo: &Path, remote: &str, ref_name: &str) -> Result<(), SyncError> {
254    let refspec = format!("{ref_name}:{ref_name}");
255    run_git(repo, &["push", remote, &refspec])?;
256    Ok(())
257}
258
259/// Computes the remote-tracking ref name for a lore ref.
260///
261/// A lore ref such as `refs/lore/sessions` tracks a remote under
262/// `refs/lore/remotes/<remote>/sessions`, mirroring how `refs/remotes/*` shadows
263/// `refs/heads/*`. Fetching into this namespace (rather than into the live local
264/// ref) lets the merge model read remote state even when the local ref has
265/// diverged. Returns an error if `ref_name` is not under `refs/lore/`.
266pub fn tracking_ref_name(remote: &str, ref_name: &str) -> Result<String, SyncError> {
267    let name = ref_name.strip_prefix("refs/lore/").ok_or_else(|| {
268        SyncError::Git(format!(
269            "lore ref name must start with refs/lore/: {ref_name}"
270        ))
271    })?;
272    Ok(format!("refs/lore/remotes/{remote}/{name}"))
273}
274
275/// Returns whether a remote advertises `ref_name`, without fetching.
276///
277/// Runs `git ls-remote --exit-code <remote> <ref_name>`. `--exit-code` makes git
278/// exit 2 when no advertised ref matches, which is the expected "remote store not
279/// initialized yet" state rather than a failure. Any other nonzero exit (a real
280/// transport or auth problem) is surfaced as [`SyncError::Git`]. The exit code is
281/// inspected directly so detection never depends on localized stderr text.
282pub fn remote_ref_exists(repo: &Path, remote: &str, ref_name: &str) -> Result<bool, SyncError> {
283    let args = ["ls-remote", "--exit-code", remote, ref_name];
284    let output = Command::new("git")
285        .current_dir(repo)
286        .args(args)
287        .output()
288        .map_err(|e| SyncError::Git(format!("failed to spawn git: {e}")))?;
289
290    if output.status.success() {
291        Ok(true)
292    } else if output.status.code() == Some(2) {
293        // `--exit-code` reserves exit status 2 for "no matching ref advertised".
294        Ok(false)
295    } else {
296        Err(git_error(&args, &output.stderr))
297    }
298}
299
300/// Fetches a lore ref from a remote into its remote-tracking ref.
301///
302/// When the remote does not yet advertise `ref_name` (a remote store that has
303/// never been pushed to), this returns `Ok(None)` so callers can treat it as an
304/// expected empty state rather than a transport error. Remote ref existence is
305/// probed with [`remote_ref_exists`] (via exit code, never stderr text), so a
306/// genuine transport or auth failure still surfaces as [`SyncError::Git`].
307///
308/// Otherwise it runs
309/// `git fetch <remote> +refs/lore/<name>:refs/lore/remotes/<remote>/<name>`. The
310/// leading `+` forces the update, so the fetch succeeds even when the local
311/// `refs/lore/<name>` (or a previous tracking ref) has diverged. The merge model
312/// then reads the fetched state from the tracking ref, updates the local ref, and
313/// pushes. Returns `Ok(Some(tracking))` with the tracking ref name that now holds
314/// the remote state.
315pub fn fetch(repo: &Path, remote: &str, ref_name: &str) -> Result<Option<String>, SyncError> {
316    if !remote_ref_exists(repo, remote, ref_name)? {
317        return Ok(None);
318    }
319    let tracking = tracking_ref_name(remote, ref_name)?;
320    let refspec = format!("+{ref_name}:{tracking}");
321    run_git(repo, &["fetch", remote, &refspec])?;
322    Ok(Some(tracking))
323}
324
325/// Reads the tree of a lore ref's remote-tracking ref.
326///
327/// Convenience wrapper that resolves the tracking ref for `ref_name` on `remote`
328/// (see [`tracking_ref_name`]) and returns its tree entries, or an empty vector
329/// when nothing has been fetched into that tracking ref yet. Call after
330/// [`fetch`] to read remote state for merging.
331pub fn read_tracking_tree(
332    repo: &Path,
333    remote: &str,
334    ref_name: &str,
335) -> Result<Vec<TreeEntry>, SyncError> {
336    let tracking = tracking_ref_name(remote, ref_name)?;
337    if ref_exists(repo, &tracking)? {
338        read_tree(repo, &tracking)
339    } else {
340        Ok(Vec::new())
341    }
342}
343
344/// Adds a tracking-namespace lore fetch refspec to a remote's config (opt-in).
345///
346/// Configures `+refs/lore/*:refs/lore/remotes/<remote>/*` so a plain `git pull`
347/// safely pre-populates the remote-tracking refs without force-moving the live
348/// local `refs/lore/*`. The real merge into the local ref still happens through
349/// `lore sync`; fetching into the live ref directly would bypass the merge model
350/// and could make an unpushed local lore commit unreachable.
351///
352/// The operation is idempotent: if the tracking-namespace refspec is already
353/// configured, nothing changes. An older `+refs/lore/*:refs/lore/*` entry written
354/// by a previous build is migrated in place (removed, not duplicated) so plain
355/// fetches stop clobbering the local ref.
356pub fn add_lore_fetch_refspec(repo: &Path, remote: &str) -> Result<(), SyncError> {
357    let key = format!("remote.{remote}.fetch");
358    let desired = format!("+refs/lore/*:refs/lore/remotes/{remote}/*");
359    let old_form = "+refs/lore/*:refs/lore/*";
360
361    // `git config --get-all` exits non-zero when the key has no values, which is
362    // not an error for our purposes, so run it directly and tolerate that.
363    let output = Command::new("git")
364        .current_dir(repo)
365        .args(["config", "--get-all", &key])
366        .output()
367        .map_err(|e| SyncError::Git(format!("failed to spawn git: {e}")))?;
368
369    let existing: Vec<String> = if output.status.success() {
370        String::from_utf8_lossy(&output.stdout)
371            .lines()
372            .map(|line| line.trim().to_string())
373            .collect()
374    } else {
375        Vec::new()
376    };
377
378    let has_desired = existing.iter().any(|line| line == &desired);
379    let has_old_form = existing.iter().any(|line| line == old_form);
380
381    // Remove any stale old-form entry so a plain fetch stops force-updating the
382    // live local ref. The value pattern is an anchored regex matching the literal
383    // old form (`+` and `*` escaped); other unrelated refspecs are left intact.
384    if has_old_form {
385        run_git(
386            repo,
387            &[
388                "config",
389                "--unset-all",
390                &key,
391                r"^\+refs/lore/\*:refs/lore/\*$",
392            ],
393        )?;
394    }
395
396    if !has_desired {
397        run_git(repo, &["config", "--add", &key, &desired])?;
398    }
399
400    Ok(())
401}
402
403// ==================== Internal helpers ====================
404
405/// Resolves a revision specifier to an object SHA, returning `None` when the
406/// revision does not exist.
407fn resolve_revision(repo: &Path, spec: &str) -> Result<Option<String>, SyncError> {
408    let output = Command::new("git")
409        .current_dir(repo)
410        .args(["rev-parse", "--verify", "--quiet", spec])
411        .output()
412        .map_err(|e| SyncError::Git(format!("failed to spawn git: {e}")))?;
413
414    if output.status.success() {
415        Ok(Some(stdout_to_string(output.stdout)))
416    } else if output.stderr.is_empty() {
417        // `--quiet` suppresses the "unknown revision" message and exits 1 when
418        // the revision simply does not exist.
419        Ok(None)
420    } else {
421        // A non-empty stderr indicates a real failure (for example, the path is
422        // not a git repository).
423        Err(git_error(&["rev-parse", "--verify", spec], &output.stderr))
424    }
425}
426
427/// Returns the absolute path to the repository's git directory.
428fn absolute_git_dir(repo: &Path) -> Result<PathBuf, SyncError> {
429    let out = run_git(repo, &["rev-parse", "--absolute-git-dir"])?;
430    Ok(PathBuf::from(stdout_to_string(out)))
431}
432
433/// Runs a git command in `repo`, returning stdout bytes on success.
434fn run_git(repo: &Path, args: &[&str]) -> Result<Vec<u8>, SyncError> {
435    let output = Command::new("git")
436        .current_dir(repo)
437        .args(args)
438        .output()
439        .map_err(|e| SyncError::Git(format!("failed to spawn git: {e}")))?;
440
441    if output.status.success() {
442        Ok(output.stdout)
443    } else {
444        Err(git_error(args, &output.stderr))
445    }
446}
447
448/// Runs a git command in `repo` with a dedicated `GIT_INDEX_FILE`.
449fn run_git_index(repo: &Path, index_path: &Path, args: &[&str]) -> Result<Vec<u8>, SyncError> {
450    let output = Command::new("git")
451        .current_dir(repo)
452        .env("GIT_INDEX_FILE", index_path)
453        .args(args)
454        .output()
455        .map_err(|e| SyncError::Git(format!("failed to spawn git: {e}")))?;
456
457    if output.status.success() {
458        Ok(output.stdout)
459    } else {
460        Err(git_error(args, &output.stderr))
461    }
462}
463
464/// Runs a git command in `repo`, piping `input` to its stdin.
465fn run_git_stdin(repo: &Path, args: &[&str], input: &[u8]) -> Result<Vec<u8>, SyncError> {
466    let mut child = Command::new("git")
467        .current_dir(repo)
468        .args(args)
469        .stdin(Stdio::piped())
470        .stdout(Stdio::piped())
471        .stderr(Stdio::piped())
472        .spawn()
473        .map_err(|e| SyncError::Git(format!("failed to spawn git: {e}")))?;
474
475    {
476        let mut stdin = child
477            .stdin
478            .take()
479            .ok_or_else(|| SyncError::Git("failed to open git stdin".to_string()))?;
480        stdin.write_all(input)?;
481        // stdin is dropped here, closing the pipe so git can finish.
482    }
483
484    let output = child.wait_with_output()?;
485
486    if output.status.success() {
487        Ok(output.stdout)
488    } else {
489        Err(git_error(args, &output.stderr))
490    }
491}
492
493/// Builds a [`SyncError::Git`] from a command and its stderr.
494fn git_error(args: &[&str], stderr: &[u8]) -> SyncError {
495    let message = String::from_utf8_lossy(stderr);
496    SyncError::Git(format!("git {} failed: {}", args.join(" "), message.trim()))
497}
498
499/// Trims trailing whitespace from git stdout and returns it as a String.
500fn stdout_to_string(out: Vec<u8>) -> String {
501    String::from_utf8_lossy(&out).trim().to_string()
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    /// Runs a git command in tests, asserting success.
509    fn git(repo: &Path, args: &[&str]) {
510        let output = Command::new("git")
511            .current_dir(repo)
512            .args(args)
513            .output()
514            .expect("failed to spawn git");
515        assert!(
516            output.status.success(),
517            "git {args:?} failed: {}",
518            String::from_utf8_lossy(&output.stderr)
519        );
520    }
521
522    /// Initializes a temp repo with a committer identity configured.
523    ///
524    /// Commit and tag signing are disabled in the repo's local config so the
525    /// suite is deterministic regardless of the machine's global git config: a
526    /// global `commit.gpgsign=true` with a passphrase-protected signing key would
527    /// otherwise make committing prompt or hang during `cargo test sync::`.
528    fn init_repo(repo: &Path) {
529        git(repo, &["init", "-q"]);
530        git(repo, &["config", "user.name", "Lore Test"]);
531        git(repo, &["config", "user.email", "test@example.com"]);
532        git(repo, &["config", "commit.gpgsign", "false"]);
533        git(repo, &["config", "tag.gpgsign", "false"]);
534    }
535
536    #[test]
537    fn test_blob_tree_commit_ref_round_trip() {
538        let dir = tempfile::tempdir().unwrap();
539        let repo = dir.path();
540        init_repo(repo);
541
542        let session_id = "11111111-1111-1111-1111-111111111111";
543        let enc_bytes = b"encrypted-session-bytes";
544        let salt_bytes = b"salt-bytes-not-secret";
545
546        let enc_sha = write_blob(repo, enc_bytes).unwrap();
547        let salt_sha = write_blob(repo, salt_bytes).unwrap();
548
549        let mut changes = BTreeMap::new();
550        changes.insert(format!("sessions/{session_id}.enc"), enc_sha.clone());
551        changes.insert("meta/salt".to_string(), salt_sha.clone());
552
553        let tree = build_tree(repo, None, &changes).unwrap();
554        let commit = commit_tree(repo, &tree, None, "lore: initial").unwrap();
555        update_ref(repo, "refs/lore/sessions", &commit).unwrap();
556
557        assert!(ref_exists(repo, "refs/lore/sessions").unwrap());
558        assert_eq!(
559            resolve_ref(repo, "refs/lore/sessions").unwrap(),
560            Some(commit.clone())
561        );
562        assert!(resolve_tree(repo, "refs/lore/sessions").unwrap().is_some());
563
564        let entries = read_tree(repo, "refs/lore/sessions").unwrap();
565        assert_eq!(entries.len(), 2);
566
567        let enc_entry = entries
568            .iter()
569            .find(|e| e.path == format!("sessions/{session_id}.enc"))
570            .expect("session blob present");
571        assert_eq!(enc_entry.sha, enc_sha);
572        assert_eq!(read_blob(repo, &enc_entry.sha).unwrap(), enc_bytes);
573
574        let salt_entry = entries
575            .iter()
576            .find(|e| e.path == "meta/salt")
577            .expect("salt blob present");
578        assert_eq!(read_blob(repo, &salt_entry.sha).unwrap(), salt_bytes);
579    }
580
581    #[test]
582    fn test_incremental_rebuild_preserves_unchanged_blob() {
583        let dir = tempfile::tempdir().unwrap();
584        let repo = dir.path();
585        init_repo(repo);
586
587        let first_id = "aaaaaaaa-0000-0000-0000-000000000001";
588        let first_sha = write_blob(repo, b"first-session").unwrap();
589
590        let mut changes = BTreeMap::new();
591        changes.insert(format!("sessions/{first_id}.enc"), first_sha.clone());
592
593        let tree1 = build_tree(repo, None, &changes).unwrap();
594        let commit1 = commit_tree(repo, &tree1, None, "lore: first").unwrap();
595        update_ref(repo, "refs/lore/sessions", &commit1).unwrap();
596
597        // Add a second session, rebuilding from the existing tree.
598        let second_id = "bbbbbbbb-0000-0000-0000-000000000002";
599        let second_sha = write_blob(repo, b"second-session").unwrap();
600
601        let mut changes2 = BTreeMap::new();
602        changes2.insert(format!("sessions/{second_id}.enc"), second_sha.clone());
603
604        let tree2 = build_tree(repo, Some("refs/lore/sessions"), &changes2).unwrap();
605        let commit2 = commit_tree(repo, &tree2, Some(&commit1), "lore: second").unwrap();
606        update_ref(repo, "refs/lore/sessions", &commit2).unwrap();
607
608        let entries = read_tree(repo, "refs/lore/sessions").unwrap();
609        assert_eq!(entries.len(), 2);
610
611        // The first session's blob object is unchanged (content-addressed dedup).
612        let first_entry = entries
613            .iter()
614            .find(|e| e.path == format!("sessions/{first_id}.enc"))
615            .expect("first session still present");
616        assert_eq!(first_entry.sha, first_sha);
617
618        // The second session is present too.
619        let second_entry = entries
620            .iter()
621            .find(|e| e.path == format!("sessions/{second_id}.enc"))
622            .expect("second session present");
623        assert_eq!(second_entry.sha, second_sha);
624    }
625
626    #[test]
627    fn test_push_and_fetch_between_repos() {
628        let remote_dir = tempfile::tempdir().unwrap();
629        let remote = remote_dir.path();
630        git(remote, &["init", "--bare", "-q"]);
631        let remote_url = remote.to_str().unwrap();
632
633        // Destination repo has its OWN divergent local lore ref before fetching.
634        let dst_dir = tempfile::tempdir().unwrap();
635        let dst = dst_dir.path();
636        init_repo(dst);
637        git(dst, &["remote", "add", "origin", remote_url]);
638
639        let local_blob = write_blob(dst, b"local-only-reasoning").unwrap();
640        let mut local_changes = BTreeMap::new();
641        local_changes.insert("sessions/local.enc".to_string(), local_blob);
642        let local_tree = build_tree(dst, None, &local_changes).unwrap();
643        let local_commit = commit_tree(dst, &local_tree, None, "lore: local").unwrap();
644        update_ref(dst, "refs/lore/sessions", &local_commit).unwrap();
645
646        // First fetch against a remote with no lore ref is an expected empty
647        // state, not an error: nothing was fetched.
648        assert_eq!(fetch(dst, "origin", "refs/lore/sessions").unwrap(), None);
649
650        // Source repo creates and pushes a lore ref.
651        let src_dir = tempfile::tempdir().unwrap();
652        let src = src_dir.path();
653        init_repo(src);
654        git(src, &["remote", "add", "origin", remote_url]);
655
656        let blob = write_blob(src, b"reasoning-history").unwrap();
657        let mut changes = BTreeMap::new();
658        changes.insert("sessions/x.enc".to_string(), blob.clone());
659        let tree = build_tree(src, None, &changes).unwrap();
660        let commit = commit_tree(src, &tree, None, "lore: push").unwrap();
661        update_ref(src, "refs/lore/sessions", &commit).unwrap();
662        push(src, "origin", "refs/lore/sessions").unwrap();
663
664        // Now that the remote advertises the ref, fetch must succeed despite the
665        // divergent local ref and report the tracking ref it wrote into.
666        let tracking = fetch(dst, "origin", "refs/lore/sessions").unwrap();
667        assert_eq!(
668            tracking.as_deref(),
669            Some("refs/lore/remotes/origin/sessions")
670        );
671
672        // The local ref is untouched by the fetch.
673        assert_eq!(
674            resolve_ref(dst, "refs/lore/sessions").unwrap(),
675            Some(local_commit)
676        );
677
678        // Remote contents are readable from the tracking ref.
679        let entries = read_tracking_tree(dst, "origin", "refs/lore/sessions").unwrap();
680        let entry = entries
681            .iter()
682            .find(|e| e.path == "sessions/x.enc")
683            .expect("session transferred into tracking ref");
684        assert_eq!(read_blob(dst, &entry.sha).unwrap(), b"reasoning-history");
685
686        // Fetching again (now that the tracking ref also exists) still succeeds.
687        let tracking2 = fetch(dst, "origin", "refs/lore/sessions").unwrap();
688        assert_eq!(
689            tracking2.as_deref(),
690            Some("refs/lore/remotes/origin/sessions")
691        );
692    }
693
694    #[test]
695    fn test_tracking_ref_name_requires_lore_prefix() {
696        assert_eq!(
697            tracking_ref_name("origin", "refs/lore/sessions").unwrap(),
698            "refs/lore/remotes/origin/sessions"
699        );
700        assert!(tracking_ref_name("origin", "refs/heads/main").is_err());
701    }
702
703    #[test]
704    fn test_read_tracking_tree_empty_when_not_fetched() {
705        let dir = tempfile::tempdir().unwrap();
706        let repo = dir.path();
707        init_repo(repo);
708
709        let entries = read_tracking_tree(repo, "origin", "refs/lore/sessions").unwrap();
710        assert!(entries.is_empty());
711    }
712
713    #[test]
714    fn test_update_ref_checked_create_and_cas() {
715        let dir = tempfile::tempdir().unwrap();
716        let repo = dir.path();
717        init_repo(repo);
718
719        let blob = write_blob(repo, b"v1").unwrap();
720        let mut changes = BTreeMap::new();
721        changes.insert("sessions/a.enc".to_string(), blob);
722        let tree = build_tree(repo, None, &changes).unwrap();
723        let commit1 = commit_tree(repo, &tree, None, "lore: v1").unwrap();
724        let commit2 = commit_tree(repo, &tree, Some(&commit1), "lore: v2").unwrap();
725
726        // Creating the ref (expected old = None => zero OID) succeeds.
727        update_ref_checked(repo, "refs/lore/sessions", &commit1, None).unwrap();
728        assert_eq!(
729            resolve_ref(repo, "refs/lore/sessions").unwrap(),
730            Some(commit1.clone())
731        );
732
733        // Creating again with expected-not-exist must fail as a CAS mismatch.
734        let err = update_ref_checked(repo, "refs/lore/sessions", &commit2, None).unwrap_err();
735        assert!(matches!(err, SyncError::RefCasMismatch(_)));
736
737        // Updating with the correct expected old value succeeds.
738        update_ref_checked(repo, "refs/lore/sessions", &commit2, Some(&commit1)).unwrap();
739        assert_eq!(
740            resolve_ref(repo, "refs/lore/sessions").unwrap(),
741            Some(commit2.clone())
742        );
743
744        // Updating with a stale expected old value fails as a CAS mismatch and
745        // leaves the ref unchanged.
746        let err =
747            update_ref_checked(repo, "refs/lore/sessions", &commit1, Some(&commit1)).unwrap_err();
748        assert!(matches!(err, SyncError::RefCasMismatch(_)));
749        assert_eq!(
750            resolve_ref(repo, "refs/lore/sessions").unwrap(),
751            Some(commit2)
752        );
753    }
754
755    #[test]
756    fn test_lore_ref_not_in_branches_or_working_tree() {
757        let dir = tempfile::tempdir().unwrap();
758        let repo = dir.path();
759        init_repo(repo);
760
761        // Make a normal commit on the default branch.
762        std::fs::write(repo.join("README.md"), "hello").unwrap();
763        git(repo, &["add", "README.md"]);
764        git(repo, &["commit", "-q", "-m", "init"]);
765
766        // Build a lore ref with session and meta paths.
767        let blob = write_blob(repo, b"reasoning").unwrap();
768        let salt = write_blob(repo, b"salt").unwrap();
769        let mut changes = BTreeMap::new();
770        changes.insert("sessions/y.enc".to_string(), blob);
771        changes.insert("meta/salt".to_string(), salt);
772        let tree = build_tree(repo, None, &changes).unwrap();
773        let commit = commit_tree(repo, &tree, None, "lore: hidden").unwrap();
774        update_ref(repo, "refs/lore/sessions", &commit).unwrap();
775
776        // refs/lore/* must not appear among branches.
777        let branches = run_git(repo, &["branch", "--format=%(refname)"]).unwrap();
778        let branch_text = String::from_utf8_lossy(&branches);
779        assert!(
780            !branch_text.contains("refs/lore"),
781            "lore ref leaked into branches: {branch_text}"
782        );
783
784        // The lore tree's paths must not be checked out into the working tree.
785        assert!(!repo.join("sessions").exists());
786        assert!(!repo.join("meta").exists());
787    }
788
789    #[test]
790    fn test_resolve_ref_missing_returns_none() {
791        let dir = tempfile::tempdir().unwrap();
792        let repo = dir.path();
793        init_repo(repo);
794
795        assert_eq!(resolve_ref(repo, "refs/lore/sessions").unwrap(), None);
796        assert!(!ref_exists(repo, "refs/lore/sessions").unwrap());
797    }
798
799    #[test]
800    fn test_add_lore_fetch_refspec_idempotent() {
801        let remote_dir = tempfile::tempdir().unwrap();
802        git(remote_dir.path(), &["init", "--bare", "-q"]);
803
804        let dir = tempfile::tempdir().unwrap();
805        let repo = dir.path();
806        init_repo(repo);
807        git(
808            repo,
809            &[
810                "remote",
811                "add",
812                "origin",
813                remote_dir.path().to_str().unwrap(),
814            ],
815        );
816
817        add_lore_fetch_refspec(repo, "origin").unwrap();
818        // Second call must not add a duplicate.
819        add_lore_fetch_refspec(repo, "origin").unwrap();
820
821        let out = run_git(repo, &["config", "--get-all", "remote.origin.fetch"]).unwrap();
822        let text = String::from_utf8_lossy(&out);
823        let tracking = text
824            .lines()
825            .filter(|l| l.trim() == "+refs/lore/*:refs/lore/remotes/origin/*")
826            .count();
827        assert_eq!(
828            tracking, 1,
829            "tracking refspec should appear exactly once: {text}"
830        );
831        // The old live-ref form must never be written.
832        assert!(
833            !text.lines().any(|l| l.trim() == "+refs/lore/*:refs/lore/*"),
834            "old-form refspec must not be configured: {text}"
835        );
836    }
837
838    #[test]
839    fn test_add_lore_fetch_refspec_migrates_old_form() {
840        let remote_dir = tempfile::tempdir().unwrap();
841        git(remote_dir.path(), &["init", "--bare", "-q"]);
842
843        let dir = tempfile::tempdir().unwrap();
844        let repo = dir.path();
845        init_repo(repo);
846        git(
847            repo,
848            &[
849                "remote",
850                "add",
851                "origin",
852                remote_dir.path().to_str().unwrap(),
853            ],
854        );
855
856        // Simulate a stale old-form refspec written by an earlier build.
857        git(
858            repo,
859            &[
860                "config",
861                "--add",
862                "remote.origin.fetch",
863                "+refs/lore/*:refs/lore/*",
864            ],
865        );
866
867        add_lore_fetch_refspec(repo, "origin").unwrap();
868
869        let out = run_git(repo, &["config", "--get-all", "remote.origin.fetch"]).unwrap();
870        let text = String::from_utf8_lossy(&out);
871
872        // The old form is migrated away, not left alongside the new one.
873        assert!(
874            !text.lines().any(|l| l.trim() == "+refs/lore/*:refs/lore/*"),
875            "old-form refspec should be removed: {text}"
876        );
877        let tracking = text
878            .lines()
879            .filter(|l| l.trim() == "+refs/lore/*:refs/lore/remotes/origin/*")
880            .count();
881        assert_eq!(
882            tracking, 1,
883            "tracking refspec should appear exactly once after migration: {text}"
884        );
885
886        // A subsequent call stays idempotent.
887        add_lore_fetch_refspec(repo, "origin").unwrap();
888        let out = run_git(repo, &["config", "--get-all", "remote.origin.fetch"]).unwrap();
889        let text = String::from_utf8_lossy(&out);
890        let tracking = text
891            .lines()
892            .filter(|l| l.trim() == "+refs/lore/*:refs/lore/remotes/origin/*")
893            .count();
894        assert_eq!(tracking, 1, "migration must stay idempotent: {text}");
895    }
896
897    #[test]
898    fn test_remote_ref_exists_reflects_remote_state() {
899        let remote_dir = tempfile::tempdir().unwrap();
900        let remote = remote_dir.path();
901        git(remote, &["init", "--bare", "-q"]);
902        let remote_url = remote.to_str().unwrap();
903
904        let dir = tempfile::tempdir().unwrap();
905        let repo = dir.path();
906        init_repo(repo);
907        git(repo, &["remote", "add", "origin", remote_url]);
908
909        // No lore ref on the remote yet.
910        assert!(!remote_ref_exists(repo, "origin", "refs/lore/sessions").unwrap());
911
912        // Push one, then it is advertised.
913        let blob = write_blob(repo, b"reasoning").unwrap();
914        let mut changes = BTreeMap::new();
915        changes.insert("sessions/x.enc".to_string(), blob);
916        let tree = build_tree(repo, None, &changes).unwrap();
917        let commit = commit_tree(repo, &tree, None, "lore: push").unwrap();
918        update_ref(repo, "refs/lore/sessions", &commit).unwrap();
919        push(repo, "origin", "refs/lore/sessions").unwrap();
920
921        assert!(remote_ref_exists(repo, "origin", "refs/lore/sessions").unwrap());
922    }
923
924    #[test]
925    fn test_run_git_error_on_non_repo() {
926        let dir = tempfile::tempdir().unwrap();
927        // No git init here, so this is not a repository.
928        let result = read_tree(dir.path(), "refs/lore/sessions");
929        assert!(result.is_err());
930    }
931}