1use std::path::Path;
2
3use chrono::{DateTime, Utc};
4use gix::object::tree::EntryKind;
5use gix::{ObjectId, Repository};
6use serde::{Deserialize, Serialize};
7use tracing::{debug, info};
8
9use crate::error::{GitStorageError, Result};
10use crate::ops::{self, gix_err};
11use crate::store::NativeGitStorage;
12
13pub const SHADOW_REF_PREFIX: &str = "refs/opensession/shadows/";
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ShadowMeta {
20 pub session_id: String,
21 pub created_at: DateTime<Utc>,
22 pub checkpoint_count: usize,
23 pub tracked_files: Vec<String>,
24 pub project_root: String,
25}
26
27#[derive(Debug)]
29pub struct FileSnapshot {
30 pub rel_path: String,
31 pub content: Vec<u8>,
32}
33
34pub struct FileRemoval {
36 pub rel_path: String,
37}
38
39#[derive(Debug, Clone)]
41pub struct CheckpointInfo {
42 pub number: usize,
43 pub commit_id: gix::ObjectId,
44 pub file_count: usize,
45 pub timestamp: DateTime<Utc>,
46 pub message: String,
47}
48
49pub struct ShadowStorage;
50
51fn shadow_ref_name(session_id: &str) -> String {
54 format!("{SHADOW_REF_PREFIX}{session_id}")
55}
56
57fn build_tree(
59 repo: &Repository,
60 base_tree_id: ObjectId,
61 files: &[FileSnapshot],
62 removals: &[FileRemoval],
63 meta: &ShadowMeta,
64) -> Result<ObjectId> {
65 let mut editor = repo.edit_tree(base_tree_id).map_err(gix_err)?;
66
67 for file in files {
69 let blob_id = repo.write_blob(&file.content).map_err(gix_err)?.detach();
70 editor
71 .upsert(&file.rel_path, EntryKind::Blob, blob_id)
72 .map_err(gix_err)?;
73 }
74
75 for removal in removals {
77 editor.remove(&removal.rel_path).map_err(gix_err)?;
78 }
79
80 let meta_bytes = serde_json::to_vec_pretty(meta)?;
82 let meta_blob = repo.write_blob(&meta_bytes).map_err(gix_err)?.detach();
83 editor
84 .upsert(".opensession/meta.json", EntryKind::Blob, meta_blob)
85 .map_err(gix_err)?;
86
87 let tree_id = editor.write().map_err(gix_err)?.detach();
88 Ok(tree_id)
89}
90
91fn walk_tree<F>(
95 repo: &Repository,
96 tree: &gix::Tree<'_>,
97 prefix: &str,
98 visitor: &mut F,
99) -> Result<()>
100where
101 F: FnMut(&str, &[u8]) -> Result<()>,
102{
103 for entry in tree.iter() {
104 let entry = entry.map_err(gix_err)?;
105 let name = entry.filename().to_string();
106
107 if prefix.is_empty() && name == ".opensession" {
109 continue;
110 }
111
112 let path = if prefix.is_empty() {
113 name.clone()
114 } else {
115 format!("{prefix}/{name}")
116 };
117
118 if entry.mode().is_tree() {
119 let subtree = repo
120 .find_object(entry.oid())
121 .map_err(gix_err)?
122 .try_into_tree()
123 .map_err(gix_err)?;
124 walk_tree(repo, &subtree, &path, visitor)?;
125 } else {
126 let blob = repo.find_object(entry.oid()).map_err(gix_err)?;
127 visitor(&path, &blob.data)?;
128 }
129 }
130 Ok(())
131}
132
133fn count_tree_files(repo: &Repository, tree_id: ObjectId) -> Result<usize> {
135 let tree = repo.find_tree(tree_id).map_err(gix_err)?;
136 let mut count = 0usize;
137 walk_tree(repo, &tree, "", &mut |_, _| {
138 count += 1;
139 Ok(())
140 })?;
141 Ok(count)
142}
143
144fn read_tree_files(repo: &Repository, tree_id: ObjectId) -> Result<Vec<FileSnapshot>> {
146 let tree = repo.find_tree(tree_id).map_err(gix_err)?;
147 let mut files = Vec::new();
148 walk_tree(repo, &tree, "", &mut |path, data| {
149 files.push(FileSnapshot {
150 rel_path: path.to_string(),
151 content: data.to_vec(),
152 });
153 Ok(())
154 })?;
155 Ok(files)
156}
157
158fn walk_commits(repo: &Repository, tip_id: ObjectId) -> Result<Vec<(ObjectId, gix::objs::Commit)>> {
160 let mut result = Vec::new();
161 let mut current = tip_id;
162 loop {
163 let obj = repo.find_object(current).map_err(gix_err)?;
164 let commit = obj.try_into_commit().map_err(gix_err)?;
165 let decoded: gix::objs::Commit = commit
166 .decode()
167 .map_err(gix_err)?
168 .to_owned()
169 .map_err(gix_err)?;
170 let parent = {
171 let c: &gix::objs::Commit = &decoded;
172 c.parents.first().copied()
173 };
174 result.push((current, decoded));
175 match parent {
176 Some(p) => current = p,
177 None => break,
178 }
179 }
180 result.reverse();
181 Ok(result)
182}
183
184impl ShadowStorage {
187 pub fn checkpoint(
195 repo_path: &Path,
196 session_id: &str,
197 files: &[FileSnapshot],
198 removals: &[FileRemoval],
199 meta: &ShadowMeta,
200 ) -> Result<usize> {
201 let repo = ops::open_repo(repo_path)?;
202 let ref_name = shadow_ref_name(session_id);
203
204 let tip = ops::find_ref_tip(&repo, &ref_name)?;
205
206 let (base_tree_id, parent) = match tip {
207 Some(id) => {
208 let tree = ops::commit_tree_id(&repo, id.detach())?;
209 (tree, Some(id.detach()))
210 }
211 None => (ObjectId::empty_tree(repo.object_hash()), None),
212 };
213
214 let tree_id = build_tree(&repo, base_tree_id, files, removals, meta)?;
215
216 let checkpoint_num = if parent.is_some() {
217 meta.checkpoint_count - 1 } else {
219 0
220 };
221 let changed = files.len() + removals.len();
222 let message = format!("checkpoint {checkpoint_num}: {changed} files");
223
224 let commit_id = ops::create_commit(&repo, &ref_name, tree_id, parent, &message)?;
225
226 if parent.is_some() {
227 debug!(
228 session_id,
229 checkpoint = checkpoint_num,
230 commit = %commit_id,
231 "Shadow checkpoint"
232 );
233 } else {
234 info!(
235 session_id,
236 commit = %commit_id,
237 "Created shadow branch with checkpoint 0"
238 );
239 }
240
241 Ok(checkpoint_num)
242 }
243
244 pub fn read_checkpoint(
246 repo_path: &Path,
247 session_id: &str,
248 checkpoint: Option<usize>,
249 ) -> Result<Vec<FileSnapshot>> {
250 let repo = ops::open_repo(repo_path)?;
251 let ref_name = shadow_ref_name(session_id);
252
253 let tip = ops::find_ref_tip(&repo, &ref_name)?
254 .ok_or_else(|| GitStorageError::ShadowNotFound(session_id.to_string()))?;
255
256 let target_commit_id = match checkpoint {
257 None => tip.detach(),
258 Some(n) => {
259 let commits = walk_commits(&repo, tip.detach())?;
260 if n >= commits.len() {
261 return Err(GitStorageError::Other(format!(
262 "checkpoint {n} not found (max: {})",
263 commits.len() - 1
264 )));
265 }
266 commits[n].0
267 }
268 };
269
270 let tree_id = ops::commit_tree_id(&repo, target_commit_id)?;
271 read_tree_files(&repo, tree_id)
272 }
273
274 pub fn list_checkpoints(repo_path: &Path, session_id: &str) -> Result<Vec<CheckpointInfo>> {
276 let repo = ops::open_repo(repo_path)?;
277 let ref_name = shadow_ref_name(session_id);
278
279 let tip = ops::find_ref_tip(&repo, &ref_name)?
280 .ok_or_else(|| GitStorageError::ShadowNotFound(session_id.to_string()))?;
281
282 let commits = walk_commits(&repo, tip.detach())?;
283 let mut infos = Vec::with_capacity(commits.len());
284
285 for (i, (oid, commit)) in commits.iter().enumerate() {
286 let tree_id = commit.tree;
287 let file_count = count_tree_files(&repo, tree_id)?;
288 let timestamp =
289 DateTime::from_timestamp(commit.committer.time.seconds, 0).unwrap_or_default();
290
291 infos.push(CheckpointInfo {
292 number: i,
293 commit_id: *oid,
294 file_count,
295 timestamp,
296 message: commit.message.to_string(),
297 });
298 }
299
300 Ok(infos)
301 }
302
303 pub fn read_meta(repo_path: &Path, session_id: &str) -> Result<Option<ShadowMeta>> {
305 let repo = ops::open_repo(repo_path)?;
306 let ref_name = shadow_ref_name(session_id);
307
308 let tip = match ops::find_ref_tip(&repo, &ref_name)? {
309 Some(t) => t,
310 None => return Ok(None),
311 };
312
313 let tree_id = ops::commit_tree_id(&repo, tip.detach())?;
314 let tree = repo.find_tree(tree_id).map_err(gix_err)?;
315
316 match tree
317 .lookup_entry_by_path(".opensession/meta.json")
318 .map_err(gix_err)?
319 {
320 Some(entry) => {
321 let blob = entry.object().map_err(gix_err)?;
322 let meta: ShadowMeta = serde_json::from_slice(&blob.data)?;
323 Ok(Some(meta))
324 }
325 None => Ok(None),
326 }
327 }
328
329 pub fn condense(
331 repo_path: &Path,
332 session_id: &str,
333 final_hail: &[u8],
334 final_meta: &[u8],
335 ) -> Result<String> {
336 let storage = NativeGitStorage;
337 let rel_path =
338 crate::GitStorage::store(&storage, repo_path, session_id, final_hail, final_meta)?;
339
340 Self::drop(repo_path, session_id)?;
341
342 info!(session_id, "Condensed shadow → {rel_path}");
343 Ok(rel_path)
344 }
345
346 pub fn drop(repo_path: &Path, session_id: &str) -> Result<bool> {
348 let repo = ops::open_repo(repo_path)?;
349 let ref_name = shadow_ref_name(session_id);
350
351 let tip = match ops::find_ref_tip(&repo, &ref_name)? {
352 Some(t) => t,
353 None => return Ok(false),
354 };
355
356 ops::delete_ref(&repo, &ref_name, tip.detach())?;
357
358 info!(session_id, "Dropped shadow branch");
359 Ok(true)
360 }
361
362 pub fn list(repo_path: &Path) -> Result<Vec<ShadowMeta>> {
364 let repo = ops::open_repo(repo_path)?;
365 let refs = repo.references().map_err(gix_err)?;
366 let prefix_iter = refs.prefixed(SHADOW_REF_PREFIX).map_err(gix_err)?;
367
368 let mut metas = Vec::new();
369
370 for reference in prefix_iter {
371 let reference = reference.map_err(GitStorageError::Gix)?;
372 let ref_name = reference.name().as_bstr().to_string();
373 let session_id = ref_name
374 .strip_prefix(SHADOW_REF_PREFIX)
375 .unwrap_or(&ref_name);
376
377 match Self::read_meta(repo_path, session_id)? {
378 Some(meta) => metas.push(meta),
379 None => {
380 debug!(session_id, "Shadow ref exists but no meta found");
381 }
382 }
383 }
384
385 Ok(metas)
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392 use crate::test_utils::init_test_repo;
393
394 fn make_meta(session_id: &str, checkpoint_count: usize, tracked: &[&str]) -> ShadowMeta {
395 ShadowMeta {
396 session_id: session_id.to_string(),
397 created_at: Utc::now(),
398 checkpoint_count,
399 tracked_files: tracked.iter().map(|s| s.to_string()).collect(),
400 project_root: "/tmp/test".to_string(),
401 }
402 }
403
404 fn make_files(paths: &[(&str, &[u8])]) -> Vec<FileSnapshot> {
405 paths
406 .iter()
407 .map(|(p, c)| FileSnapshot {
408 rel_path: p.to_string(),
409 content: c.to_vec(),
410 })
411 .collect()
412 }
413
414 #[test]
415 fn test_create_and_checkpoint() {
416 let tmp = tempfile::tempdir().unwrap();
417 init_test_repo(tmp.path());
418
419 let files = make_files(&[
420 ("src/main.rs", b"fn main() {}"),
421 ("src/lib.rs", b"pub mod foo;"),
422 ]);
423 let meta = make_meta("sess-001", 1, &["src/main.rs", "src/lib.rs"]);
424
425 ShadowStorage::checkpoint(tmp.path(), "sess-001", &files, &[], &meta).unwrap();
426
427 let repo = gix::open(tmp.path()).unwrap();
429 let tip = ops::find_ref_tip(&repo, &shadow_ref_name("sess-001")).unwrap();
430 assert!(tip.is_some());
431
432 let read_meta = ShadowStorage::read_meta(tmp.path(), "sess-001")
434 .unwrap()
435 .unwrap();
436 assert_eq!(read_meta.session_id, "sess-001");
437 assert_eq!(read_meta.checkpoint_count, 1);
438 assert_eq!(read_meta.tracked_files.len(), 2);
439 }
440
441 #[test]
442 fn test_incremental_checkpoint() {
443 let tmp = tempfile::tempdir().unwrap();
444 init_test_repo(tmp.path());
445
446 let files0 = make_files(&[("src/main.rs", b"v1")]);
448 let meta0 = make_meta("sess-002", 1, &["src/main.rs"]);
449 ShadowStorage::checkpoint(tmp.path(), "sess-002", &files0, &[], &meta0).unwrap();
450
451 let files1 = make_files(&[("src/lib.rs", b"v1")]);
453 let meta1 = make_meta("sess-002", 2, &["src/main.rs", "src/lib.rs"]);
454 let cp1 = ShadowStorage::checkpoint(tmp.path(), "sess-002", &files1, &[], &meta1).unwrap();
455 assert_eq!(cp1, 1);
456
457 let files2 = make_files(&[("tests/test.rs", b"#[test]")]);
459 let meta2 = make_meta(
460 "sess-002",
461 3,
462 &["src/main.rs", "src/lib.rs", "tests/test.rs"],
463 );
464 let cp2 = ShadowStorage::checkpoint(tmp.path(), "sess-002", &files2, &[], &meta2).unwrap();
465 assert_eq!(cp2, 2);
466
467 let checkpoints = ShadowStorage::list_checkpoints(tmp.path(), "sess-002").unwrap();
469 assert_eq!(checkpoints.len(), 3);
470 }
471
472 #[test]
473 fn test_read_checkpoint_latest() {
474 let tmp = tempfile::tempdir().unwrap();
475 init_test_repo(tmp.path());
476
477 let files = make_files(&[("src/main.rs", b"fn main() { println!(\"hello\"); }")]);
478 let meta = make_meta("sess-003", 1, &["src/main.rs"]);
479 ShadowStorage::checkpoint(tmp.path(), "sess-003", &files, &[], &meta).unwrap();
480
481 let files2 = make_files(&[("src/main.rs", b"fn main() { println!(\"updated\"); }")]);
483 let meta2 = make_meta("sess-003", 2, &["src/main.rs"]);
484 ShadowStorage::checkpoint(tmp.path(), "sess-003", &files2, &[], &meta2).unwrap();
485
486 let snapshots = ShadowStorage::read_checkpoint(tmp.path(), "sess-003", None).unwrap();
488 assert_eq!(snapshots.len(), 1);
489 assert_eq!(snapshots[0].rel_path, "src/main.rs");
490 assert_eq!(
491 snapshots[0].content,
492 b"fn main() { println!(\"updated\"); }"
493 );
494 }
495
496 #[test]
497 fn test_read_checkpoint_specific() {
498 let tmp = tempfile::tempdir().unwrap();
499 init_test_repo(tmp.path());
500
501 let files0 = make_files(&[("src/main.rs", b"version-0")]);
503 let meta0 = make_meta("sess-004", 1, &["src/main.rs"]);
504 ShadowStorage::checkpoint(tmp.path(), "sess-004", &files0, &[], &meta0).unwrap();
505
506 let files1 = make_files(&[("src/main.rs", b"version-1")]);
508 let meta1 = make_meta("sess-004", 2, &["src/main.rs"]);
509 ShadowStorage::checkpoint(tmp.path(), "sess-004", &files1, &[], &meta1).unwrap();
510
511 let snapshots = ShadowStorage::read_checkpoint(tmp.path(), "sess-004", Some(0)).unwrap();
513 let main = snapshots
514 .iter()
515 .find(|f| f.rel_path == "src/main.rs")
516 .unwrap();
517 assert_eq!(main.content, b"version-0");
518
519 let snapshots = ShadowStorage::read_checkpoint(tmp.path(), "sess-004", Some(1)).unwrap();
521 let main = snapshots
522 .iter()
523 .find(|f| f.rel_path == "src/main.rs")
524 .unwrap();
525 assert_eq!(main.content, b"version-1");
526 }
527
528 #[test]
529 fn test_file_deletion() {
530 let tmp = tempfile::tempdir().unwrap();
531 init_test_repo(tmp.path());
532
533 let files = make_files(&[("src/main.rs", b"main"), ("src/old.rs", b"old")]);
535 let meta = make_meta("sess-005", 1, &["src/main.rs", "src/old.rs"]);
536 ShadowStorage::checkpoint(tmp.path(), "sess-005", &files, &[], &meta).unwrap();
537
538 let removals = vec![FileRemoval {
540 rel_path: "src/old.rs".to_string(),
541 }];
542 let meta1 = make_meta("sess-005", 2, &["src/main.rs"]);
543 ShadowStorage::checkpoint(tmp.path(), "sess-005", &[], &removals, &meta1).unwrap();
544
545 let snapshots = ShadowStorage::read_checkpoint(tmp.path(), "sess-005", None).unwrap();
547 assert_eq!(snapshots.len(), 1);
548 assert_eq!(snapshots[0].rel_path, "src/main.rs");
549 }
550
551 #[test]
552 fn test_condense() {
553 let tmp = tempfile::tempdir().unwrap();
554 init_test_repo(tmp.path());
555
556 let files0 = make_files(&[("a.rs", b"v0")]);
558 let meta0 = make_meta("sess-006", 1, &["a.rs"]);
559 ShadowStorage::checkpoint(tmp.path(), "sess-006", &files0, &[], &meta0).unwrap();
560
561 let files1 = make_files(&[("a.rs", b"v1")]);
562 let meta1 = make_meta("sess-006", 2, &["a.rs"]);
563 ShadowStorage::checkpoint(tmp.path(), "sess-006", &files1, &[], &meta1).unwrap();
564
565 let files2 = make_files(&[("a.rs", b"v2")]);
566 let meta2 = make_meta("sess-006", 3, &["a.rs"]);
567 ShadowStorage::checkpoint(tmp.path(), "sess-006", &files2, &[], &meta2).unwrap();
568
569 let hail = b"{\"type\":\"header\"}\n";
571 let meta_json = b"{\"session_id\":\"sess-006\"}";
572 let rel = ShadowStorage::condense(tmp.path(), "sess-006", hail, meta_json).unwrap();
573 assert!(rel.contains("sess-006"));
574
575 let repo = gix::open(tmp.path()).unwrap();
577 assert!(ops::find_ref_tip(&repo, &shadow_ref_name("sess-006"))
578 .unwrap()
579 .is_none());
580
581 let storage = NativeGitStorage;
583 let loaded = crate::GitStorage::load(&storage, tmp.path(), "sess-006")
584 .unwrap()
585 .unwrap();
586 assert_eq!(loaded, hail);
587 }
588
589 #[test]
590 fn test_drop() {
591 let tmp = tempfile::tempdir().unwrap();
592 init_test_repo(tmp.path());
593
594 let files = make_files(&[("a.rs", b"content")]);
595 let meta = make_meta("sess-007", 1, &["a.rs"]);
596 ShadowStorage::checkpoint(tmp.path(), "sess-007", &files, &[], &meta).unwrap();
597
598 let dropped = ShadowStorage::drop(tmp.path(), "sess-007").unwrap();
599 assert!(dropped);
600
601 let dropped = ShadowStorage::drop(tmp.path(), "sess-007").unwrap();
603 assert!(!dropped);
604 }
605
606 #[test]
607 fn test_list_multiple() {
608 let tmp = tempfile::tempdir().unwrap();
609 init_test_repo(tmp.path());
610
611 for id in ["alpha", "beta", "gamma"] {
612 let files = make_files(&[("f.rs", id.as_bytes())]);
613 let meta = make_meta(id, 1, &["f.rs"]);
614 ShadowStorage::checkpoint(tmp.path(), id, &files, &[], &meta).unwrap();
615 }
616
617 let mut metas = ShadowStorage::list(tmp.path()).unwrap();
618 metas.sort_by(|a, b| a.session_id.cmp(&b.session_id));
619 assert_eq!(metas.len(), 3);
620 assert_eq!(metas[0].session_id, "alpha");
621 assert_eq!(metas[1].session_id, "beta");
622 assert_eq!(metas[2].session_id, "gamma");
623 }
624
625 #[test]
626 fn test_list_checkpoints() {
627 let tmp = tempfile::tempdir().unwrap();
628 init_test_repo(tmp.path());
629
630 let files0 = make_files(&[("a.rs", b"v0")]);
631 let meta0 = make_meta("sess-log", 1, &["a.rs"]);
632 ShadowStorage::checkpoint(tmp.path(), "sess-log", &files0, &[], &meta0).unwrap();
633
634 let files1 = make_files(&[("b.rs", b"v1")]);
635 let meta1 = make_meta("sess-log", 2, &["a.rs", "b.rs"]);
636 ShadowStorage::checkpoint(tmp.path(), "sess-log", &files1, &[], &meta1).unwrap();
637
638 let checkpoints = ShadowStorage::list_checkpoints(tmp.path(), "sess-log").unwrap();
639 assert_eq!(checkpoints.len(), 2);
640 assert_eq!(checkpoints[0].number, 0);
641 assert_eq!(checkpoints[1].number, 1);
642 assert!(checkpoints[0].message.contains("checkpoint 0"));
643 assert!(checkpoints[1].message.contains("checkpoint 1"));
644 }
645
646 #[test]
647 fn test_shadow_ref_invisible() {
648 let tmp = tempfile::tempdir().unwrap();
649 init_test_repo(tmp.path());
650
651 let files = make_files(&[("a.rs", b"data")]);
652 let meta = make_meta("sess-invisible", 1, &["a.rs"]);
653 ShadowStorage::checkpoint(tmp.path(), "sess-invisible", &files, &[], &meta).unwrap();
654
655 let output = std::process::Command::new("git")
657 .args(["branch", "-a"])
658 .current_dir(tmp.path())
659 .output()
660 .expect("git branch failed");
661 let branches = String::from_utf8_lossy(&output.stdout);
662 assert!(
663 !branches.contains("shadow"),
664 "Shadow ref visible in git branch: {branches}"
665 );
666 }
667
668 #[test]
669 fn test_read_meta_recovery() {
670 let tmp = tempfile::tempdir().unwrap();
671 init_test_repo(tmp.path());
672
673 let files0 = make_files(&[("x.rs", b"v0")]);
675 let meta0 = make_meta("sess-recover", 1, &["x.rs"]);
676 ShadowStorage::checkpoint(tmp.path(), "sess-recover", &files0, &[], &meta0).unwrap();
677
678 let files1 = make_files(&[("y.rs", b"v1")]);
679 let meta1 = make_meta("sess-recover", 2, &["x.rs", "y.rs"]);
680 ShadowStorage::checkpoint(tmp.path(), "sess-recover", &files1, &[], &meta1).unwrap();
681
682 let meta = ShadowStorage::read_meta(tmp.path(), "sess-recover")
684 .unwrap()
685 .unwrap();
686 assert_eq!(meta.checkpoint_count, 2);
687 assert_eq!(meta.tracked_files.len(), 2);
688 assert!(meta.tracked_files.contains(&"x.rs".to_string()));
689 assert!(meta.tracked_files.contains(&"y.rs".to_string()));
690 }
691
692 #[test]
693 fn test_checkpoint_invalid_repo() {
694 let tmp = tempfile::tempdir().unwrap();
695 let files = make_files(&[("a.rs", b"content")]);
697 let meta = make_meta("sess-bad", 1, &["a.rs"]);
698 let err =
699 ShadowStorage::checkpoint(tmp.path(), "sess-bad", &files, &[], &meta).unwrap_err();
700 assert!(
701 matches!(err, GitStorageError::NotARepo(_)),
702 "expected NotARepo, got: {err}"
703 );
704 }
705
706 #[test]
707 fn test_read_meta_nonexistent() {
708 let tmp = tempfile::tempdir().unwrap();
709 init_test_repo(tmp.path());
710
711 let meta = ShadowStorage::read_meta(tmp.path(), "no-such-session").unwrap();
713 assert!(meta.is_none());
714 }
715
716 #[test]
717 fn test_read_checkpoint_out_of_range() {
718 let tmp = tempfile::tempdir().unwrap();
719 init_test_repo(tmp.path());
720
721 let files = make_files(&[("a.rs", b"v0")]);
723 let meta = make_meta("sess-range", 1, &["a.rs"]);
724 ShadowStorage::checkpoint(tmp.path(), "sess-range", &files, &[], &meta).unwrap();
725
726 let err = ShadowStorage::read_checkpoint(tmp.path(), "sess-range", Some(5)).unwrap_err();
728 assert!(
729 matches!(err, GitStorageError::Other(_)),
730 "expected Other error for out-of-range checkpoint, got: {err}"
731 );
732 }
733
734 #[test]
735 fn test_drop_nonexistent() {
736 let tmp = tempfile::tempdir().unwrap();
737 init_test_repo(tmp.path());
738
739 let dropped = ShadowStorage::drop(tmp.path(), "no-such-shadow").unwrap();
741 assert!(!dropped);
742 }
743
744 #[test]
745 fn test_checkpoint_empty_files() {
746 let tmp = tempfile::tempdir().unwrap();
747 init_test_repo(tmp.path());
748
749 let meta = make_meta("sess-empty", 1, &[]);
751 let cp = ShadowStorage::checkpoint(tmp.path(), "sess-empty", &[], &[], &meta).unwrap();
752 assert_eq!(cp, 0);
753
754 let snapshots = ShadowStorage::read_checkpoint(tmp.path(), "sess-empty", None).unwrap();
756 assert!(
757 snapshots.is_empty(),
758 "expected no user files, got: {}",
759 snapshots.len()
760 );
761
762 let read_meta = ShadowStorage::read_meta(tmp.path(), "sess-empty")
764 .unwrap()
765 .unwrap();
766 assert_eq!(read_meta.session_id, "sess-empty");
767 }
768
769 #[test]
770 fn test_condense_nonexistent() {
771 let tmp = tempfile::tempdir().unwrap();
772 init_test_repo(tmp.path());
773
774 let result = ShadowStorage::condense(
777 tmp.path(),
778 "no-shadow",
779 b"{\"event\":\"test\"}\n",
780 b"{\"id\":\"no-shadow\"}",
781 );
782 assert!(result.is_ok());
784 }
785}