Skip to main content

opensession_git_native/
shadow.rs

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
13/// Ref prefix for shadow branches — outside refs/heads/ so they're invisible
14/// to `git branch` and not included in default pushes.
15pub const SHADOW_REF_PREFIX: &str = "refs/opensession/shadows/";
16
17/// Metadata stored in `.opensession/meta.json` inside each shadow tree.
18#[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/// A file snapshot to store in a checkpoint.
28#[derive(Debug)]
29pub struct FileSnapshot {
30    pub rel_path: String,
31    pub content: Vec<u8>,
32}
33
34/// A file removal to apply in a checkpoint.
35pub struct FileRemoval {
36    pub rel_path: String,
37}
38
39/// Information about a single checkpoint.
40#[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
51// ── Helpers ─────────────────────────────────────────────────────────────
52
53fn shadow_ref_name(session_id: &str) -> String {
54    format!("{SHADOW_REF_PREFIX}{session_id}")
55}
56
57/// Build a new tree by upserting files, removing files, and always updating meta.
58fn 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    // Upsert file snapshots
68    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    // Remove deleted files
76    for removal in removals {
77        editor.remove(&removal.rel_path).map_err(gix_err)?;
78    }
79
80    // Always upsert meta
81    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
91/// Generic tree walker that visits every non-meta blob in a tree.
92///
93/// The `visitor` receives `(relative_path, blob_data)` for each file.
94fn 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        // Skip .opensession/ metadata directory at root level
108        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
133/// Count non-meta files in a tree.
134fn 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
144/// Read all non-meta file entries from a tree, returning FileSnapshots.
145fn 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
158/// Walk a commit chain from tip back, collecting commits in chronological order.
159fn 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
184// ── ShadowStorage implementation ────────────────────────────────────────
185
186impl ShadowStorage {
187    /// Create or add a checkpoint on a shadow branch.
188    ///
189    /// If the shadow ref doesn't exist yet, creates the initial checkpoint
190    /// (checkpoint 0) with an empty-tree base and no parent commit.
191    /// Otherwise appends an incremental checkpoint on top of the existing tip.
192    ///
193    /// Returns the checkpoint number.
194    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 // meta already incremented
218        } 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    /// Read file snapshots from a checkpoint. `None` = latest.
245    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    /// List all checkpoints for a session.
275    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    /// Read the shadow meta from the latest checkpoint.
304    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    /// Condense: archive the final HAIL to the sessions branch, then delete the shadow.
330    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    /// Delete the shadow ref without archiving.
347    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    /// List all active shadow sessions.
363    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        // Verify ref exists
428        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        // Verify meta
433        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        // Checkpoint 0
447        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        // Checkpoint 1
452        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        // Checkpoint 2
458        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        // 3 checkpoints total
468        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        // Update
482        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        // Read latest
487        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        // cp 0
502        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        // cp 1 — update main.rs
507        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        // Read checkpoint 0 (before update)
512        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        // Read checkpoint 1
520        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        // cp 0: two files
534        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        // cp 1: delete old.rs
539        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        // old.rs should be gone
546        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        // Create shadow with 3 checkpoints
557        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        // Condense
570        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        // Shadow should be gone
576        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        // Archive should exist
582        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        // Second drop returns false
602        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        // `git branch -a` should not show shadow refs
656        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        // Create + 2 checkpoints
674        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        // Read meta — should reflect latest state
683        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        // Don't init git — just a bare directory
696        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        // No shadow created — read_meta should return None
712        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        // Create one checkpoint (checkpoint 0)
722        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        // Request checkpoint 5 which doesn't exist
727        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        // No shadow exists — drop returns false
740        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        // Checkpoint with no files — only meta should be written
750        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        // Read it back — should have zero user files
755        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        // But meta should still be readable
763        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        // No shadow exists — condense should still succeed (store works, drop returns false)
775        // But the session data will be archived even though there's no shadow to remove
776        let result = ShadowStorage::condense(
777            tmp.path(),
778            "no-shadow",
779            b"{\"event\":\"test\"}\n",
780            b"{\"id\":\"no-shadow\"}",
781        );
782        // condense calls store (succeeds) then drop (returns false, not error), so it succeeds
783        assert!(result.is_ok());
784    }
785}