- 16
//! collide) for turning a multi-thousand-file workspace's per-turn - 17
//! capture into a handful of stats plus however many files actually - 18
//! changed. - 19
//! - 20
//! Safety contract: `restore` only deletes files that were OBSERVED at - 21
//! capture time and are absent from the stored set's deletion candidates - 22
//! -- i.e. files the capture walk never saw (over budget, unreadable, - 23
//! secret, gitignored, or beyond the walk break) are left untouched. A - 24
//! rewind can lose the changes made during a session; it must never - 25
//! destroy files it knows nothing about. `store` prunes old manifests - 26
//! and garbage-collects blobs no remaining manifest (in any session under - 27
//! this sessions home) references; a blob written in the last - 28
//! [`GC_GRACE`] is never collected, so a concurrent capture that has - 29
//! written a blob but not yet stored the manifest pointing to it cannot - 30
//! race a prune elsewhere. A checkpoint file from before this manifest - 31
//! format (which embedded base64 file content directly) fails to - 32
//! deserialize -- missing `hash`/`size`/`mtime_ns` -- and is simply not - 33
//! read (AGENTS.md invariant 29): it is never partially read or migrated. - 34
- 35
use std::collections::{HashMap, HashSet}; - 36
use std::path::{Path, PathBuf}; - 37
- 38
use serde::{Deserialize, Serialize}; - 39
use sha2::{Digest, Sha256}; - 40
use walkdir::WalkDir; - 41
- 42
const IGNORED_DIRS: [&str; 5] = [".git", "target", "node_modules", ".vak", "dist"]; - 43
- 44
/// Rebuildable runtime artifacts (the vak-store SQLite index and its WAL - 45
/// sidecars). Never meaningful workspace content: capturing them into a - 46
/// checkpoint would snapshot a derived cache, and restoring a stale one - 47
/// would corrupt the live index. - 48
const IGNORED_RUNTIME_FILES: [&str; 3] = ["store.db", "store.db-wal", "store.db-shm"]; - 49
const MAX_FILE_BYTES: u64 = 8 * 1024 * 1024; - 50
const MAX_TOTAL_BYTES: usize = 64 * 1024 * 1024; - 51
/// Checkpoints accumulate once per turn; keep only the newest N per session. - 52
const MAX_STORED_CHECKPOINTS: usize = 20; - 53
/// A blob younger than this is never garbage-collected, whether or not a - 54
/// scan finds it referenced: it may belong to a capture that has written - 55
/// its blobs but not yet stored the manifest that references them. - 56
const GC_GRACE: std::time::Duration = std::time::Duration::from_secs(60); - 57
/// Fan-out width for the blob store's directory prefix, so one directory - 58
/// never holds more than ~1/256th of all blobs. - 59
const BLOB_PREFIX_LEN: usize = 2; - 60
- 61
/// One captured file. Content lives in the blob store keyed by `hash`; - 62
/// `size`/`mtime_ns` are the fast-path signature the next capture compares - 63
/// against to decide whether it can reuse `hash` without reading the file. - 64
#[derive(Debug, Clone, Serialize, Deserialize)] - 65
pub struct ManifestEntry { - 66
pub rel_path: String, - 67
pub hash: String, - 68
pub size: u64, - 69
pub mtime_ns: u64, - 70
} - 71
- 72
#[derive(Debug, Clone, Serialize, Deserialize)] - 73
pub struct Manifest { - 74
pub seq: u32, - 75
pub session_id: String, - 76
pub created_at: chrono::DateTime<chrono::Utc>, - 77
pub label: String, - 78
/// Files whose content is stored (in the blob store) and will be - 79
/// rewritten on restore. - 80
pub files: Vec<ManifestEntry>, - 81
/// Every regular-file path the capture walk observed, including files - 82
/// whose content was NOT stored (oversized, unreadable, secret, - 83
/// gitignored). Restore only deletes files absent from this list. - 84
pub observed: Vec<String>, - 85
} - 86
- 87
/// How much of a [`capture`] call was served from the previous manifest - 88
/// versus freshly read from disk. Exists so callers (and tests) can - 89
/// observe the incremental fast path directly rather than through timing - 90
/// alone, which is flaky under load. - 91
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] - 92
pub struct CaptureStats { - 93
pub files_observed: usize, - 94
pub files_reused: usize, - 95
pub files_read: usize, - 96
} - 97
- 98
fn is_ignored(rel: &Path) -> bool { - 99
rel.components().any(|c| { - 100
matches!( - 101
c, - 102
std::path::Component::Normal(name) if IGNORED_DIRS - 103
.contains(&name.to_string_lossy().as_ref()) - 104
) - 105
}) || rel - 106
.file_name() - 107
.and_then(|n| n.to_str()) - 108
.map(|n| IGNORED_RUNTIME_FILES.contains(&n)) - 109
.unwrap_or(false) - 110
} - 111
- 112
/// Paths that must never be captured into checkpoints nor deleted by a - 113
/// rewind, regardless of what any .gitignore says. - 114
fn is_secret_path(rel: &Path) -> bool { - 115
let Some(name) = rel.file_name().and_then(|n| n.to_str()) else { - 116
return true; - 117
}; - 118
let lower = name.to_ascii_lowercase(); - 119
lower == ".env" - 120
|| lower.starts_with(".env.") - 121
|| lower.ends_with(".pem") - 122
|| lower.ends_with(".key") - 123
|| lower.starts_with("id_rsa") - 124
|| lower.starts_with("id_ed25519") - 125
|| lower == "credentials.json" - 126
} - 127
- 128
// --------------------------------------------------------------------------- - 129
// Minimal .gitignore support (subset): root + nested .gitignore files, - 130
// last matching rule wins, negation (`!pat`) supported. Patterns without - 131
// '/' match against the file name anywhere below their directory; patterns - 132
// with '/' match against the path relative to their directory. - 133
// --------------------------------------------------------------------------- - 134
- 135
#[derive(Debug, Clone)] - 136
struct IgnoreRule { - 137
/// Directory containing the .gitignore this rule came from, relative - 138
/// to the capture root ("." for the root). - 139
base: PathBuf, - 140
negate: bool, - 141
dir_only: bool, - 142
matcher: globset::GlobSet, - 143
} - 144
- 145
#[derive(Debug, Default)] - 146
struct IgnoreRules { - 147
rules: Vec<IgnoreRule>, - 148
} - 149
- 150
impl IgnoreRules { - 151
fn load_dir(&mut self, root: &Path, dir_rel: &Path) { - 152
let file = root.join(dir_rel).join(".gitignore"); - 153
let Ok(text) = std::fs::read_to_string(&file) else { - 154
return; - 155
}; - 156
for raw in text.lines() { - 157
let line = raw.trim_end_matches(['\r', ' ']).trim(); - 158
if line.is_empty() || line.starts_with('#') { - 159
continue; - 160
} - 161
let (negate, line) = match line.strip_prefix('!') { - 162
Some(rest) => (true, rest), - 163
None => (false, line), - 164
}; - 165
if line.is_empty() { - 166
continue; - 167
} - 168
let (dir_only, line) = match line.strip_suffix('/') { - 169
Some(rest) => (true, rest), - 170
None => (false, line), - 171
}; - 172
// Anchored when it contains a '/' anywhere (leading slash just - 173
// anchors to the rule's directory). - 174
let anchored = line.trim_start_matches('/').contains('/'); - 175
let pattern_text = line.trim_start_matches('/'); - 176
let mut gb = globset::GlobBuilder::new(pattern_text); - 177
gb.literal_separator(anchored); - 178
let Ok(glob) = gb.build() else { continue }; - 179
let Ok(matcher) = globset::GlobSetBuilder::new().add(glob).build() else { - 180
continue; - 181
}; - 182
self.rules.push(IgnoreRule { - 183
// "" (not "."): strip_prefix(".") never matches plain - 184
// relative paths, which would silently disable every - 185
// root-level rule. - 186
base: if dir_rel.as_os_str() == "." { - 187
PathBuf::new() - 188
} else { - 189
dir_rel.to_path_buf() - 190
}, - 191
negate, - 192
dir_only, - 193
matcher, - 194
}); - 195
} - 196
} - 197
- 198
fn is_ignored(&self, rel: &Path, is_dir: bool) -> bool { - 199
let mut ignored = false; - 200
// Shallowest ancestor first: a dir-only rule like `secrets/` - 201
// prunes everything beneath it. Deeper matches (and later rules) - 202
// override, mirroring gitignore's last-match-wins. - 203
let ancestors: Vec<&Path> = rel.ancestors().collect(); - 204
for candidate in ancestors.iter().rev() { - 205
if candidate.as_os_str().is_empty() { - 206
continue; - 207
} - 208
let candidate_is_dir = *candidate != rel || is_dir; - 209
for rule in &self.rules { - 210
let Ok(sub) = candidate.strip_prefix(&rule.base) else { - 211
continue; - 212
}; - 213
if sub.as_os_str().is_empty() { - 214
continue; - 215
} - 216
let name_hit = sub - 217
.file_name() - 218
.and_then(|n| n.to_str()) - 219
.map(|n| rule.matcher.is_match(n)) - 220
.unwrap_or(false); - 221
let path_hit = sub - 222
.to_str() - 223
.map(|p| rule.matcher.is_match(p)) - 224
.unwrap_or(false); - 225
if (name_hit || path_hit) && (candidate_is_dir || !rule.dir_only) { - 226
ignored = !rule.negate; - 227
} - 228
} - 229
} - 230
ignored - 231
} - 232
} - 233
- 234
// --------------------------------------------------------------------------- - 235
// Content-addressed blob store, shared by every manifest under a sessions - 236
// home. - 237
// --------------------------------------------------------------------------- - 238
- 239
fn checkpoint_root(sessions_home: &Path) -> PathBuf { - 240
sessions_home.join("checkpoints") - 241
} - 242
- 243
/// "blobs" is a reserved session id: a real session id is `uuid_like()` - 244
/// generated, so the collision this would take is not worth guarding - 245
/// further, matching how `.git`/`.vak`/etc. are already reserved names - 246
/// elsewhere in this file. - 247
fn manifest_dir(sessions_home: &Path, session_id: &str) -> PathBuf { - 248
checkpoint_root(sessions_home).join(session_id) - 249
} - 250
- 251
fn blobs_dir(sessions_home: &Path) -> PathBuf { - 252
checkpoint_root(sessions_home).join("blobs") - 253
} - 254
- 255
fn blob_path(sessions_home: &Path, hash: &str) -> PathBuf { - 256
let split = BLOB_PREFIX_LEN.min(hash.len()); - 257
let (prefix, rest) = hash.split_at(split); - 258
blobs_dir(sessions_home).join(prefix).join(rest) - 259
} - 260
- 261
fn hash_bytes(content: &[u8]) -> String { - 262
let mut hasher = Sha256::new(); - 263
hasher.update(content); - 264
format!("{:x}", hasher.finalize()) - 265
} - 266
- 267
fn mtime_nanos(meta: &std::fs::Metadata) -> Option<u64> { - 268
let modified = meta.modified().ok()?; - 269
let since_epoch = modified.duration_since(std::time::UNIX_EPOCH).ok()?; - 270
u64::try_from(since_epoch.as_nanos()).ok() - 271
} - 272
- 273
/// Writes `content` under `hash` if not already present. Idempotent: the - 274
/// hash is the content, so a lost race between two writers rewrites the - 275
/// same bytes, and the atomic rename means a reader never observes a - 276
/// partial blob. - 277
fn write_blob(sessions_home: &Path, hash: &str, content: &[u8]) -> std::io::Result<()> { - 278
let path = blob_path(sessions_home, hash); - 279
if path.is_file() { - 280
return Ok(()); - 281
} - 282
if let Some(parent) = path.parent() { - 283
std::fs::create_dir_all(parent)?; - 284
} - 285
let tmp = path.with_extension(format!( - 286
"tmp-{}-{}", - 287
std::process::id(), - 288
std::time::SystemTime::now() - 289
.duration_since(std::time::UNIX_EPOCH) - 290
.map(|d| d.as_nanos()) - 291
.unwrap_or_default() - 292
)); - 293
std::fs::write(&tmp, content)?; - 294
std::fs::rename(&tmp, &path)?; - 295
Ok(()) - 296
} - 297
- 298
fn read_blob(sessions_home: &Path, hash: &str) -> std::io::Result<Vec<u8>> { - 299
std::fs::read(blob_path(sessions_home, hash)) - 300
} - 301
- 302
/// Every stored sequence number for `session_id`, ascending. Reads only - 303
/// the manifest directory's file names -- never opens or parses a - 304
/// manifest -- so this is cheap even with the full `MAX_STORED_CHECKPOINTS` - 305
/// present. - 306
fn list_seqs(sessions_home: &Path, session_id: &str) -> std::io::Result<Vec<u32>> { - 307
let dir = manifest_dir(sessions_home, session_id); - 308
let entries = match std::fs::read_dir(&dir) { - 309
Ok(rd) => rd, - 310
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - 311
Err(e) => return Err(e), - 312
}; - 313
let mut seqs: Vec<u32> = entries - 314
.flatten() - 315
.filter_map(|e| { - 316
let name = e.file_name().to_string_lossy().into_owned(); - 317
if name.starts_with('.') || !name.ends_with(".json") { - 318
return None; - 319
} - 320
name.trim_end_matches(".json").parse::<u32>().ok() - 321
}) - 322
.collect(); - 323
seqs.sort_unstable(); - 324
Ok(seqs) - 325
} - 326
- 327
/// The next checkpoint sequence number for `session_id`. Unlike `list`, - 328
/// this never opens a manifest file -- it is safe to call on every turn. - 329
pub fn next_seq(sessions_home: &Path, session_id: &str) -> u32 { - 330
list_seqs(sessions_home, session_id) - 331
.ok() - 332
.and_then(|seqs| seqs.last().map(|s| s + 1)) - 333
.unwrap_or(0) - 334
} - 335
- 336
/// The most recently stored manifest for `session_id`, if any. This is the - 337
/// baseline [`capture`] diffs against for its incremental fast path. - 338
fn latest_manifest(sessions_home: &Path, session_id: &str) -> Option<Manifest> { - 339
let seq = *list_seqs(sessions_home, session_id).ok()?.last()?; - 340
load(sessions_home, session_id, seq).ok() - 341
} - 342
- 343
/// Captures every regular file under `cwd` (bounded), sorted by path. - 344
/// `observed` records every walked path even when its content was - 345
/// skipped. A file whose (size, mtime) match its entry in the previous - 346
/// manifest for `session_id` reuses that entry's hash instead of being - 347
/// re-read; everything else is read, hashed, and written to the blob - 348
/// store. - 349
pub fn capture( - 350
cwd: &Path, - 351
sessions_home: &Path, - 352
session_id: &str, - 353
seq: u32, - 354
label: &str, - 355
) -> std::io::Result<(Manifest, CaptureStats)> { - 356
let canonical_root = cwd.canonicalize().unwrap_or_else(|_| cwd.into()); - 357
let mut ignores = IgnoreRules::default(); - 358
ignores.load_dir(cwd, Path::new(".")); - 359
- 360
let previous = latest_manifest(sessions_home, session_id); - 361
let prev_index: HashMap<&str, &ManifestEntry> = previous - 362
.as_ref() - 363
.map(|m| m.files.iter().map(|e| (e.rel_path.as_str(), e)).collect()) - 364
.unwrap_or_default(); - 365
- 366
let mut files = Vec::new(); - 367
let mut observed: Vec<String> = Vec::new(); - 368
let mut total = 0usize; - 369
let mut stats = CaptureStats::default(); - 370
for entry in WalkDir::new(cwd) - 371
.follow_links(false) - 372
.into_iter() - 373
.filter_entry(|e| { - 374
e.path() - 375
.file_name() - 376
.map(|f| !IGNORED_DIRS.contains(&f.to_string_lossy().as_ref())) - 377
.unwrap_or(true) - 378
}) - 379
.flatten() - 380
{ - 381
let rel_rooted = match entry.path().strip_prefix(cwd) { - 382
Ok(r) => r, - 383
Err(_) => continue, - 384
}; - 385
if entry.file_type().is_dir() { - 386
if !ignores.is_ignored(rel_rooted, true) && !rel_rooted.as_os_str().is_empty() { - 387
ignores.load_dir(cwd, rel_rooted); - 388
} - 389
continue; - 390
} - 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
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.