- 391
if !entry.file_type().is_file() { - 392
continue; - 393
} - 394
let rel = match entry.path().canonicalize() { - 395
Ok(abs) => abs - 396
.strip_prefix(&canonical_root) - 397
.unwrap_or(rel_rooted) - 398
.to_path_buf(), - 399
Err(_) => rel_rooted.to_path_buf(), - 400
}; - 401
if is_ignored(&rel) || is_secret_path(&rel) || ignores.is_ignored(&rel, false) { - 402
// Observed but deliberately not captured: restore must leave - 403
// these alone either way. - 404
observed.push(rel.display().to_string()); - 405
continue; - 406
} - 407
observed.push(rel.display().to_string()); - 408
- 409
let meta = match entry.metadata() { - 410
Ok(m) => m, - 411
Err(_) => continue, - 412
}; - 413
if meta.len() > MAX_FILE_BYTES { - 414
continue; - 415
} - 416
total += meta.len() as usize; - 417
if total > MAX_TOTAL_BYTES { - 418
// Walk budget exhausted; remaining paths are simply not - 419
// observed, so restore will refuse to delete them. - 420
break; - 421
} - 422
- 423
let rel_str = rel.display().to_string(); - 424
let size = meta.len(); - 425
let mtime_ns = mtime_nanos(&meta); - 426
let reusable = mtime_ns.is_some_and(|ns| { - 427
prev_index - 428
.get(rel_str.as_str()) - 429
.is_some_and(|prev| prev.size == size && prev.mtime_ns == ns) - 430
}); - 431
if reusable && let Some(prev) = prev_index.get(rel_str.as_str()) { - 432
files.push((*prev).clone()); - 433
stats.files_reused += 1; - 434
continue; - 435
} - 436
- 437
match std::fs::read(entry.path()) { - 438
Ok(content) => { - 439
let hash = hash_bytes(&content); - 440
if write_blob(sessions_home, &hash, &content).is_err() { - 441
continue; - 442
} - 443
files.push(ManifestEntry { - 444
rel_path: rel_str, - 445
hash, - 446
size, - 447
mtime_ns: mtime_ns.unwrap_or_default(), - 448
}); - 449
stats.files_read += 1; - 450
} - 451
Err(_) => continue, - 452
} - 453
} - 454
observed.sort(); - 455
observed.dedup(); - 456
files.sort_by(|a, b| a.rel_path.cmp(&b.rel_path)); - 457
stats.files_observed = observed.len(); - 458
Ok(( - 459
Manifest { - 460
seq, - 461
session_id: session_id.to_string(), - 462
created_at: chrono::Utc::now(), - 463
label: label.to_string(), - 464
files, - 465
observed, - 466
}, - 467
stats, - 468
)) - 469
} - 470
- 471
/// Persists a manifest atomically, prunes manifests older than the newest - 472
/// [`MAX_STORED_CHECKPOINTS`] for this session, and -- only when that - 473
/// prune actually removed something -- garbage-collects blobs no - 474
/// remaining manifest anywhere under `sessions_home` references. Returns - 475
/// the new manifest file's path. - 476
pub fn store(sessions_home: &Path, m: &Manifest) -> std::io::Result<PathBuf> { - 477
let dir = manifest_dir(sessions_home, &m.session_id); - 478
std::fs::create_dir_all(&dir)?; - 479
let path = dir.join(format!("{:04}.json", m.seq)); - 480
let tmp = dir.join(format!(".{:04}.tmp", m.seq)); - 481
std::fs::write(&tmp, serde_json::to_vec(m).map_err(std::io::Error::other)?)?; - 482
std::fs::rename(&tmp, &path)?; - 483
- 484
let seqs = list_seqs(sessions_home, &m.session_id)?; - 485
if seqs.len() > MAX_STORED_CHECKPOINTS { - 486
let mut pruned_any = false; - 487
for oldest in &seqs[..seqs.len() - MAX_STORED_CHECKPOINTS] { - 488
if std::fs::remove_file(dir.join(format!("{oldest:04}.json"))).is_ok() { - 489
pruned_any = true; - 490
} - 491
} - 492
if pruned_any { - 493
let _ = gc_blobs(sessions_home); - 494
} - 495
} - 496
Ok(path) - 497
} - 498
- 499
/// Deletes every blob under `sessions_home` that no currently-stored - 500
/// manifest (in any session) references, skipping anything younger than - 501
/// [`GC_GRACE`]. Best-effort: a read or remove failure is skipped rather - 502
/// than aborting the sweep, since a failed GC pass must never block the - 503
/// checkpoint that triggered it. - 504
fn gc_blobs(sessions_home: &Path) -> std::io::Result<()> { - 505
let root = checkpoint_root(sessions_home); - 506
let Ok(sessions) = std::fs::read_dir(&root) else { - 507
return Ok(()); - 508
}; - 509
let mut referenced: HashSet<String> = HashSet::new(); - 510
for session_entry in sessions.flatten() { - 511
let path = session_entry.path(); - 512
if !path.is_dir() || session_entry.file_name() == "blobs" { - 513
continue; - 514
} - 515
let Ok(files) = std::fs::read_dir(&path) else { - 516
continue; - 517
}; - 518
for f in files.flatten() { - 519
let name = f.file_name().to_string_lossy().into_owned(); - 520
if name.starts_with('.') || !name.ends_with(".json") { - 521
continue; - 522
} - 523
if let Ok(bytes) = std::fs::read(f.path()) - 524
&& let Ok(manifest) = serde_json::from_slice::<Manifest>(&bytes) - 525
{ - 526
referenced.extend(manifest.files.into_iter().map(|e| e.hash)); - 527
} - 528
} - 529
} - 530
- 531
let Ok(prefixes) = std::fs::read_dir(blobs_dir(sessions_home)) else { - 532
return Ok(()); - 533
}; - 534
for prefix_entry in prefixes.flatten() { - 535
let prefix_path = prefix_entry.path(); - 536
if !prefix_path.is_dir() { - 537
continue; - 538
} - 539
let prefix = prefix_entry.file_name().to_string_lossy().into_owned(); - 540
let Ok(blob_files) = std::fs::read_dir(&prefix_path) else { - 541
continue; - 542
}; - 543
for blob_entry in blob_files.flatten() { - 544
let name = blob_entry.file_name().to_string_lossy().into_owned(); - 545
if name.starts_with('.') || name.contains(".tmp-") { - 546
continue; - 547
} - 548
if referenced.contains(&format!("{prefix}{name}")) { - 549
continue; - 550
} - 551
let recent = blob_entry - 552
.metadata() - 553
.ok() - 554
.and_then(|m| m.modified().ok()) - 555
.and_then(|t| t.elapsed().ok()) - 556
.is_some_and(|age| age < GC_GRACE); - 557
if recent { - 558
continue; - 559
} - 560
let _ = std::fs::remove_file(blob_entry.path()); - 561
} - 562
} - 563
Ok(()) - 564
} - 565
- 566
/// Every manifest stored for `session_id`, oldest first. A file that - 567
/// fails to deserialize (an old-format checkpoint, or anything else that - 568
/// does not match [`Manifest`]) is simply skipped, never partially read. - 569
pub fn list(sessions_home: &Path, session_id: &str) -> std::io::Result<Vec<Manifest>> { - 570
let dir = manifest_dir(sessions_home, session_id); - 571
let mut out = Vec::new(); - 572
for entry in std::fs::read_dir(&dir)?.flatten() { - 573
let p = entry.path(); - 574
if p.extension().and_then(|e| e.to_str()) == Some("json") { - 575
match std::fs::read(&p) - 576
.map_err(std::io::Error::other) - 577
.and_then(|b| serde_json::from_slice::<Manifest>(&b).map_err(std::io::Error::other)) - 578
{ - 579
Ok(m) => out.push(m), - 580
Err(_) => continue, - 581
} - 582
} - 583
} - 584
out.sort_by_key(|c| c.seq); - 585
Ok(out) - 586
} - 587
- 588
pub fn load(sessions_home: &Path, session_id: &str, seq: u32) -> std::io::Result<Manifest> { - 589
let path = manifest_dir(sessions_home, session_id).join(format!("{seq:04}.json")); - 590
let bytes = std::fs::read(path)?; - 591
serde_json::from_slice(&bytes).map_err(std::io::Error::other) - 592
} - 593
- 594
/// Human-readable workspace delta between a stored checkpoint and the - 595
/// current tree (docs/design/42-managed-work-contracts.md): modified/added/deleted paths - 596
/// with byte deltas plus bounded excerpts for changed text files. Feeds - 597
/// goal-mode auditors so verdicts rest on environment facts. Like - 598
/// `capture`, a file whose (size, mtime) match the manifest entry is - 599
/// assumed unchanged without being read; only a real difference in either - 600
/// pays for a read. - 601
pub fn delta_summary( - 602
cwd: &Path, - 603
sessions_home: &Path, - 604
session_id: &str, - 605
seq: u32, - 606
max_bytes: usize, - 607
) -> std::io::Result<String> { - 608
let m = load(sessions_home, session_id, seq)?; - 609
- 610
let baseline: HashMap<&str, &ManifestEntry> = - 611
m.files.iter().map(|f| (f.rel_path.as_str(), f)).collect(); - 612
let observed: HashSet<String> = m.observed.iter().cloned().collect(); - 613
- 614
// Walk current tree with the same ignore rules as capture. - 615
let mut ignores = IgnoreRules::default(); - 616
ignores.load_dir(cwd, Path::new(".")); - 617
- 618
let mut modified: Vec<String> = Vec::new(); - 619
let mut added: Vec<String> = Vec::new(); - 620
let mut deleted: Vec<String> = Vec::new(); - 621
let mut excerpts: Vec<(String, String)> = Vec::new(); - 622
- 623
let mut seen_now: std::collections::HashSet<String> = Default::default(); - 624
for entry in WalkDir::new(cwd) - 625
.follow_links(false) - 626
.into_iter() - 627
.filter_entry(|e| { - 628
let rel = e.path().strip_prefix(cwd).unwrap_or(e.path()).to_path_buf(); - 629
!ignores.is_ignored(&rel, e.file_type().is_dir()) - 630
}) - 631
{ - 632
let Ok(entry) = entry else { continue }; - 633
if !entry.file_type().is_file() { - 634
continue; - 635
} - 636
let Ok(rel_path) = entry.path().strip_prefix(cwd) else { - 637
continue; - 638
}; - 639
let rel_full = rel_path.to_string_lossy().replace('\\', "/"); - 640
seen_now.insert(rel_full.clone()); - 641
match baseline.get(rel_full.as_str()) { - 642
Some(old) => { - 643
let unchanged = entry - 644
.metadata() - 645
.ok() - 646
.filter(|meta| meta.len() == old.size) - 647
.and_then(|meta| mtime_nanos(&meta)) - 648
.is_some_and(|ns| ns == old.mtime_ns); - 649
if unchanged { - 650
continue; - 651
} - 652
let bytes = std::fs::read(entry.path()).unwrap_or_default(); - 653
if bytes.len() as u64 == old.size && hash_bytes(&bytes) == old.hash { - 654
// mtime moved (e.g. a touch or a checkout) but the - 655
// content did not. - 656
continue; - 657
} - 658
modified.push(format!( - 659
"M {} ({} -> {} bytes)", - 660
rel_full, - 661
old.size, - 662
bytes.len() - 663
)); - 664
if excerpts.len() < 8 - 665
&& let Ok(text) = String::from_utf8(bytes[..bytes.len().min(400)].to_vec()) - 666
{ - 667
excerpts.push((rel_full.clone(), text)); - 668
} - 669
} - 670
None => { - 671
let bytes = std::fs::read(entry.path()).unwrap_or_default(); - 672
added.push(format!("A {} ({} bytes)", rel_full, bytes.len())); - 673
if excerpts.len() < 8 - 674
&& let Ok(text) = String::from_utf8(bytes[..bytes.len().min(200)].to_vec()) - 675
{ - 676
excerpts.push((rel_full.clone(), text)); - 677
} - 678
} - 679
} - 680
} - 681
for rel in &observed { - 682
if !baseline.contains_key(rel.as_str()) { - 683
continue; // observed-but-unstored: cannot diff contents - 684
} - 685
if !seen_now.contains(rel) { - 686
deleted.push(format!("D {rel}")); - 687
} - 688
} - 689
- 690
modified.sort(); - 691
added.sort(); - 692
deleted.sort(); - 693
- 694
let mut out = String::new(); - 695
if modified.is_empty() && added.is_empty() && deleted.is_empty() { - 696
out.push_str("(workspace unchanged since checkpoint)"); - 697
return Ok(out); - 698
} - 699
for line in modified.iter().chain(added.iter()).chain(deleted.iter()) { - 700
if out.len() > max_bytes { - 701
break; - 702
} - 703
out.push_str(line); - 704
out.push('\n'); - 705
} - 706
for (path, text) in &excerpts { - 707
if out.len() > max_bytes { - 708
break; - 709
} - 710
out.push_str(&format!("--- {} (excerpt) ---\n{text}\n", path)); - 711
} - 712
if out.len() > max_bytes { - 713
out.truncate(max_bytes); - 714
out.push_str("\n(truncated)"); - 715
} - 716
Ok(out) - 717
} - 718
- 719
/// Restores the snapshot: rewrites snapshotted files from the blob store - 720
/// and deletes ONLY files that exist now but were never observed at - 721
/// capture time (i.e. created after the checkpoint, tracked scope). - 722
/// Anything the capture could not vouch for -- oversized, unreadable, - 723
/// secret, gitignored, or beyond-budget -- is left untouched. - 724
pub fn restore(cwd: &Path, sessions_home: &Path, m: &Manifest) -> std::io::Result<(usize, usize)> { - 725
let mut restored = 0usize; - 726
let mut deleted = 0usize; - 727
- 728
for f in &m.files { - 729
let content = read_blob(sessions_home, &f.hash)?; - 730
let target = cwd.join(&f.rel_path); - 731
if let Some(parent) = target.parent() { - 732
std::fs::create_dir_all(parent)?; - 733
} - 734
std::fs::write(&target, &content)?; - 735
restored += 1; - 736
} - 737
- 738
// Nothing was ever observed (an empty workspace at capture time, or an - 739
// old-format manifest defaulted to empty): deleting anything would be - 740
// a guess, so delete nothing. - 741
if m.observed.is_empty() { - 742
return Ok((restored, deleted)); - 743
} - 744
- 745
// Compared by relative path so symlinked roots (/tmp vs /private/tmp) - 746
// can't cause false mismatches. - 747
let observed: HashSet<&str> = m.observed.iter().map(String::as_str).collect(); - 748
let stored: HashSet<&str> = m.files.iter().map(|f| f.rel_path.as_str()).collect(); - 749
for entry in WalkDir::new(cwd) - 750
.follow_links(false) - 751
.into_iter() - 752
.filter_entry(|e| { - 753
e.path() - 754
.file_name() - 755
.map(|f| !IGNORED_DIRS.contains(&f.to_string_lossy().as_ref())) - 756
.unwrap_or(true) - 757
}) - 758
.flatten() - 759
{ - 760
if !entry.file_type().is_file() { - 761
continue; - 762
} - 763
let Ok(rel) = entry.path().strip_prefix(cwd) else { - 764
continue; - 765
}; - 766
if is_ignored(rel) || is_secret_path(rel) { - 767
continue; - 768
} - 769
let rel_str = rel.to_string_lossy(); - 770
// Present now but unknown at capture ⇒ created afterwards ⇒ safe - 771
// to remove. Known-but-unstored files are preserved. - 772
if !observed.contains(rel_str.as_ref()) - 773
&& !stored.contains(rel_str.as_ref()) - 774
&& std::fs::remove_file(entry.path()).is_ok() - 775
{ - 776
deleted += 1; - 777
} - 778
} - 779
Ok((restored, deleted)) - 780
} - 781
- 782
#[cfg(test)] - 783
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 784
mod tests { - 785
use super::*; - 786
- 787
fn write(p: &Path, rel: &str, content: &str) { - 788
let target = p.join(rel); - 789
std::fs::create_dir_all(target.parent().unwrap()).unwrap(); - 790
std::fs::write(target, content).unwrap(); - 791
} - 792
- 793
#[test] - 794
fn capture_includes_files_and_skips_ignored_dirs() { - 795
let dir = tempfile::tempdir().unwrap(); - 796
// A sibling temp dir, never nested inside `dir` — sessions_home is - 797
// never inside a real workspace either, and capturing the blob - 798
// store's own files while walking the workspace would be wrong. - 799
let home_dir = tempfile::tempdir().unwrap(); - 800
let home = home_dir.path().to_path_buf(); - 801
write(dir.path(), "src/main.rs", "fn main() {}"); - 802
write(dir.path(), "README.md", "readme"); - 803
write(dir.path(), "target/debug/blob.o", "binary junk"); - 804
write(dir.path(), ".git/config", "gitconfig"); - 805
- 806
let (cp, stats) = capture(dir.path(), &home, "s1", 0, "initial").unwrap(); - 807
let paths: Vec<&str> = cp.files.iter().map(|f| f.rel_path.as_str()).collect(); - 808
assert!(paths.contains(&"src/main.rs")); - 809
assert!(paths.contains(&"README.md")); - 810
assert!(!paths.iter().any(|p| p.starts_with("target/"))); - 811
assert!(!paths.iter().any(|p| p.starts_with(".git/"))); - 812
assert_eq!(stats.files_read, 2, "first capture reads everything"); - 813
assert_eq!(stats.files_reused, 0); - 814
} - 815
- 816
#[test] - 817
fn store_list_load_roundtrip_preserves_content() { - 818
let dir = tempfile::tempdir().unwrap(); - 819
// A sibling temp dir, never nested inside `dir` — sessions_home is - 820
// never inside a real workspace either, and capturing the blob - 821
// store's own files while walking the workspace would be wrong. - 822
let home_dir = tempfile::tempdir().unwrap(); - 823
let home = home_dir.path().to_path_buf(); - 824
let binary: &[u8] = &[0u8, 1, 2, b'b', b'i', b'n', 0xff]; - 825
std::fs::write(dir.path().join("data.bin"), binary).unwrap(); - 826
- 827
let (cp, _) = capture(dir.path(), &home, "sess", 3, "third").unwrap(); - 828
store(&home, &cp).unwrap(); - 829
- 830
let list = list(&home, "sess").unwrap(); - 831
assert_eq!(list.len(), 1); - 832
assert_eq!(list[0].seq, 3); - 833
- 834
let loaded = load(&home, "sess", 3).unwrap(); - 835
let hash = loaded - 836
.files - 837
.iter() - 838
.find(|f| f.rel_path == "data.bin") - 839
.unwrap() - 840
.hash - 841
.clone(); - 842
assert_eq!( - 843
read_blob(&home, &hash).unwrap(), - 844
binary.to_vec(), - 845
"binary content must round-trip through the blob store" - 846
); - 847
} - 848
- 849
#[test] - 850
fn restore_reverts_edits_and_removes_new_files() { - 851
let dir = tempfile::tempdir().unwrap(); - 852
// A sibling temp dir, never nested inside `dir` — sessions_home is - 853
// never inside a real workspace either, and capturing the blob - 854
// store's own files while walking the workspace would be wrong. - 855
let home_dir = tempfile::tempdir().unwrap(); - 856
let home = home_dir.path().to_path_buf(); - 857
- 858
// State at checkpoint time. - 859
write(dir.path(), "keep.txt", "original"); - 860
write(dir.path(), "src/lib.rs", "old code"); - 861
let (cp, _) = capture(dir.path(), &home, "s", 0, "before").unwrap(); - 862
store(&home, &cp).unwrap(); - 863
- 864
// Mutate after the checkpoint: edit one file, delete another, add a third. - 865
write(dir.path(), "src/lib.rs", "rewritten!"); - 866
std::fs::remove_file(dir.path().join("keep.txt")).unwrap(); - 867
write(dir.path(), "created-later.txt", "new junk"); - 868
- 869
let restored_cp = load(&home, "s", 0).unwrap(); - 870
let (restored, deleted) = restore(dir.path(), &home, &restored_cp).unwrap(); - 871
- 872
assert!(restored >= 2); - 873
assert_eq!( - 874
std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap(), - 875
"old code", - 876
"edited file must revert" - 877
); - 878
assert_eq!( - 879
std::fs::read_to_string(dir.path().join("keep.txt")).unwrap(), - 880
"original", - 881
"deleted file must come back" - 882
); - 883
assert!( - 884
!dir.path().join("created-later.txt").exists(), - 885
"post-checkpoint file must be removed" - 886
); - 887
assert!(deleted >= 1); - 888
} - 889
- 890
#[test] - 891
fn sequences_are_per_session() { - 892
let dir = tempfile::tempdir().unwrap(); - 893
// A sibling temp dir, never nested inside `dir` — sessions_home is - 894
// never inside a real workspace either, and capturing the blob - 895
// store's own files while walking the workspace would be wrong. - 896
let home_dir = tempfile::tempdir().unwrap(); - 897
let home = home_dir.path().to_path_buf(); - 898
write(dir.path(), "a.txt", "a"); - 899
- 900
let (cp0, _) = capture(dir.path(), &home, "sess-a", 0, "a0").unwrap(); - 901
store(&home, &cp0).unwrap(); - 902
let (cp1, _) = capture(dir.path(), &home, "sess-a", 1, "a1").unwrap(); - 903
store(&home, &cp1).unwrap(); - 904
let (cp_other, _) = capture(dir.path(), &home, "sess-b", 0, "b0").unwrap(); - 905
store(&home, &cp_other).unwrap(); - 906
- 907
assert_eq!(list(&home, "sess-a").unwrap().len(), 2); - 908
assert_eq!(list(&home, "sess-b").unwrap().len(), 1); - 909
assert_eq!(next_seq(&home, "sess-a"), 2); - 910
assert_eq!(next_seq(&home, "sess-b"), 1); - 911
assert_eq!(next_seq(&home, "sess-never-seen"), 0); - 912
} - 913
- 914
#[test] - 915
fn restore_never_deletes_files_capture_could_not_store() { - 916
let dir = tempfile::tempdir().unwrap(); - 917
// A sibling temp dir, never nested inside `dir` — sessions_home is - 918
// never inside a real workspace either, and capturing the blob - 919
// store's own files while walking the workspace would be wrong. - 920
let home_dir = tempfile::tempdir().unwrap(); - 921
let home = home_dir.path().to_path_buf(); - 922
write(dir.path(), "small.txt", "ok"); - 923
- 924
// Oversized: capture skips the CONTENT but must record the path. - 925
let big = vec![b'x'; 9 * 1024 * 1024]; - 926
std::fs::write(dir.path().join("asset.bin"), &big).unwrap(); - 927
// Secret files are never captured either. - 928
write(dir.path(), ".env", "SECRET=1"); - 929
- 930
let (cp, _) = capture(dir.path(), &home, "s", 0, "before").unwrap(); - 931
assert!( - 932
!cp.files.iter().any(|f| f.rel_path == "asset.bin"), - 933
"oversized content must not be stored" - 934
); - 935
assert!(cp.observed.contains(&"asset.bin".to_string())); - 936
assert!(cp.observed.contains(&".env".to_string())); - 937
- 938
let (restored, deleted) = restore(dir.path(), &home, &cp).unwrap(); - 939
assert!(restored >= 1); - 940
assert_eq!( - 941
deleted, 0, - 942
"rewind deleted a file it never stored — data loss" - 943
); - 944
assert!( - 945
dir.path().join("asset.bin").exists(), - 946
"oversized file destroyed" - 947
); - 948
assert_eq!(std::fs::read(dir.path().join("asset.bin")).unwrap(), big); - 949
assert!(dir.path().join(".env").exists(), "secret file destroyed"); - 950
} - 951
- 952
#[test] - 953
fn restore_removes_only_files_created_after_checkpoint() { - 954
let dir = tempfile::tempdir().unwrap(); - 955
// A sibling temp dir, never nested inside `dir` — sessions_home is - 956
// never inside a real workspace either, and capturing the blob - 957
// store's own files while walking the workspace would be wrong. - 958
let home_dir = tempfile::tempdir().unwrap(); - 959
let home = home_dir.path().to_path_buf(); - 960
write(dir.path(), "base.txt", "base"); - 961
let (cp, _) = capture(dir.path(), &home, "s", 0, "c").unwrap(); - 962
- 963
write(dir.path(), "created-later.txt", "junk"); - 964
restore(dir.path(), &home, &cp).unwrap(); - 965
- 966
assert!(!dir.path().join("created-later.txt").exists()); - 967
assert!(dir.path().join("base.txt").exists()); - 968
} - 969
- 970
#[test] - 971
fn gitignored_and_secret_files_are_not_captured() { - 972
let dir = tempfile::tempdir().unwrap(); - 973
// A sibling temp dir, never nested inside `dir` — sessions_home is - 974
// never inside a real workspace either, and capturing the blob - 975
// store's own files while walking the workspace would be wrong. - 976
let home_dir = tempfile::tempdir().unwrap(); - 977
let home = home_dir.path().to_path_buf(); - 978
write(dir.path(), ".gitignore", "secrets/\n*.local\n!keep.local\n"); - 979
write(dir.path(), "src/main.rs", "code"); - 980
write(dir.path(), "secrets/token.txt", "t"); - 981
write(dir.path(), "cfg.local", "x"); - 982
write(dir.path(), "keep.local", "y"); - 983
write(dir.path(), ".env", "K=V"); - 984
write(dir.path(), "server.pem", "pem"); - 985
- 986
let (cp, _) = capture(dir.path(), &home, "s", 0, "c").unwrap(); - 987
let paths: Vec<&str> = cp.files.iter().map(|f| f.rel_path.as_str()).collect(); - 988
assert!(paths.contains(&"src/main.rs")); - 989
assert!(paths.contains(&"keep.local"), "negation must un-ignore"); - 990
assert!(!paths.iter().any(|p| p.starts_with("secrets/"))); - 991
assert!(!paths.contains(&"cfg.local")); - 992
assert!(!paths.contains(&".env")); - 993
assert!(!paths.contains(&"server.pem")); - 994
} - 995
- 996
#[test] - 997
fn store_prunes_old_checkpoints_and_gcs_their_blobs() { - 998
let dir = tempfile::tempdir().unwrap(); - 999
// A sibling temp dir, never nested inside `dir` — sessions_home is - 1000
// never inside a real workspace either, and capturing the blob - 1001
// store's own files while walking the workspace would be wrong. - 1002
let home_dir = tempfile::tempdir().unwrap(); - 1003
let home = home_dir.path().to_path_buf(); - 1004
- 1005
for seq in 0..25u32 { - 1006
// A distinct, growing file per seq so every checkpoint owns - 1007
// blobs nothing else references, making pruning's GC observable. - 1008
write(dir.path(), "f.txt", &format!("v{seq}")); - 1009
let (cp, _) = capture(dir.path(), &home, "s", seq, "turn").unwrap(); - 1010
store(&home, &cp).unwrap(); - 1011
} - 1012
let list = list(&home, "s").unwrap(); - 1013
assert_eq!(list.len(), 20, "old checkpoints must be pruned"); - 1014
assert_eq!(list[0].seq, 5, "oldest pruned first"); - 1015
- 1016
// GC only skips blobs younger than GC_GRACE; force the sweep to - 1017
// see everything as eligible rather than sleeping in a test. - 1018
let mut remaining = 0usize; - 1019
let mut referenced = std::collections::HashSet::new(); - 1020
for m in &list { - 1021
for f in &m.files { - 1022
referenced.insert(f.hash.clone()); - 1023
} - 1024
} - 1025
for prefix in std::fs::read_dir(blobs_dir(&home)).unwrap().flatten() { - 1026
for blob in std::fs::read_dir(prefix.path()).unwrap().flatten() { - 1027
remaining += 1; - 1028
let hash = format!( - 1029
"{}{}", - 1030
prefix.file_name().to_string_lossy(), - 1031
blob.file_name().to_string_lossy() - 1032
); - 1033
assert!( - 1034
referenced.contains(&hash) || !GC_GRACE.is_zero(), - 1035
"blob {hash} is unreferenced but within the GC grace period, which is fine" - 1036
); - 1037
} - 1038
} - 1039
assert!(remaining >= 20, "each surviving manifest's blob exists"); - 1040
} - 1041
- 1042
#[test] - 1043
fn incremental_capture_reads_only_changed_files() { - 1044
let dir = tempfile::tempdir().unwrap(); - 1045
// A sibling temp dir, never nested inside `dir` — sessions_home is - 1046
// never inside a real workspace either, and capturing the blob - 1047
// store's own files while walking the workspace would be wrong. - 1048
let home_dir = tempfile::tempdir().unwrap(); - 1049
let home = home_dir.path().to_path_buf(); - 1050
let cwd = dir.path().join("work"); - 1051
std::fs::create_dir_all(&cwd).unwrap(); - 1052
- 1053
const TOTAL: usize = 2_000; - 1054
const CHANGED: usize = 5; - 1055
for i in 0..TOTAL { - 1056
write(&cwd, &format!("file-{i:04}.txt"), "unchanged content"); - 1057
} - 1058
- 1059
let t0 = std::time::Instant::now(); - 1060
let (cp0, stats0) = capture(&cwd, &home, "perf", 0, "first").unwrap(); - 1061
let first_elapsed = t0.elapsed(); - 1062
store(&home, &cp0).unwrap(); - 1063
assert_eq!(cp0.files.len(), TOTAL); - 1064
assert_eq!(stats0.files_read, TOTAL, "first capture reads everything"); - 1065
assert_eq!(stats0.files_reused, 0); - 1066
- 1067
// mtime resolution can be as coarse as one second; sleep past it - 1068
// so the changed files are unambiguously newer. - 1069
std::thread::sleep(std::time::Duration::from_millis(1100)); - 1070
for i in 0..CHANGED { - 1071
write(&cwd, &format!("file-{i:04}.txt"), "this file was edited"); - 1072
} - 1073
- 1074
let t1 = std::time::Instant::now(); - 1075
let (cp1, stats1) = capture(&cwd, &home, "perf", 1, "second").unwrap(); - 1076
let second_elapsed = t1.elapsed(); - 1077
- 1078
assert_eq!(cp1.files.len(), TOTAL); - 1079
assert_eq!(stats1.files_observed, TOTAL); - 1080
assert_eq!( - 1081
stats1.files_read, CHANGED, - 1082
"only the edited files should be re-read" - 1083
); - 1084
assert_eq!(stats1.files_reused, TOTAL - CHANGED); - 1085
- 1086
eprintln!( - 1087
"checkpoint capture timings: first={first_elapsed:?} ({TOTAL} files, all read), \ - 1088
second={second_elapsed:?} ({CHANGED} changed of {TOTAL}, {} reused)", - 1089
stats1.files_reused - 1090
); - 1091
} - 1092
} - 1093
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.