1use 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
24pub const ZERO_OID: &str = "0000000000000000000000000000000000000000";
29
30#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct TreeEntry {
36 pub mode: String,
38 pub sha: String,
40 pub path: String,
43}
44
45pub 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
55pub fn read_blob(repo: &Path, sha: &str) -> Result<Vec<u8>, SyncError> {
59 run_git(repo, &["cat-file", "blob", sha])
60}
61
62pub 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 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
94pub 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 let _ = std::fs::remove_file(&index_path);
119
120 result
121}
122
123fn 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 if let Some(base_ref) = base {
132 run_git_index(repo, index_path, &["read-tree", base_ref])?;
133 }
134
135 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
149pub 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
171pub 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
180pub 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 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
228pub fn resolve_ref(repo: &Path, ref_name: &str) -> Result<Option<String>, SyncError> {
230 resolve_revision(repo, &format!("{ref_name}^{{commit}}"))
231}
232
233#[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
244pub fn ref_exists(repo: &Path, ref_name: &str) -> Result<bool, SyncError> {
246 Ok(resolve_ref(repo, ref_name)?.is_some())
247}
248
249pub 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
259pub 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
275pub 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 Ok(false)
295 } else {
296 Err(git_error(&args, &output.stderr))
297 }
298}
299
300pub 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
325pub 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
344pub 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 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 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
403fn 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 Ok(None)
420 } else {
421 Err(git_error(&["rev-parse", "--verify", spec], &output.stderr))
424 }
425}
426
427fn 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
433fn 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
448fn 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
464fn 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 }
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
493fn 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
499fn 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 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 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 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 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 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 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 assert_eq!(fetch(dst, "origin", "refs/lore/sessions").unwrap(), None);
649
650 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 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 assert_eq!(
674 resolve_ref(dst, "refs/lore/sessions").unwrap(),
675 Some(local_commit)
676 );
677
678 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 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 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 let err = update_ref_checked(repo, "refs/lore/sessions", &commit2, None).unwrap_err();
735 assert!(matches!(err, SyncError::RefCasMismatch(_)));
736
737 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 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 std::fs::write(repo.join("README.md"), "hello").unwrap();
763 git(repo, &["add", "README.md"]);
764 git(repo, &["commit", "-q", "-m", "init"]);
765
766 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 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 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 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 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 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 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 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 assert!(!remote_ref_exists(repo, "origin", "refs/lore/sessions").unwrap());
911
912 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 let result = read_tree(dir.path(), "refs/lore/sessions");
929 assert!(result.is_err());
930 }
931}