use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use crate::scope::HiddenFiles;
use crate::scope::ScanLimits;
use crate::store::PreEditImage;
use crate::store::WorkspaceStore;
use crate::turn::TurnScope;
use crate::turn::capture_turn;
use crate::turn::declare_edits;
use tracing::info;
use tracing::warn;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionStart {
New { tracking_enabled: bool },
Resumed,
}
pub struct SnapshotTracker {
store: WorkspaceStore,
session_id: String,
hidden: HiddenFiles,
limits: ScanLimits,
workspace: PathBuf,
}
impl SnapshotTracker {
pub fn maybe_new(
data_dir: &Path,
workspace: &Path,
session_id: String,
start: SessionStart,
hidden: HiddenFiles,
limits: ScanLimits,
) -> Option<Arc<Self>> {
let store = match WorkspaceStore::open(data_dir, workspace) {
Ok(store) => store,
Err(err) => {
warn!("filesnap: failed to open store, tracking disabled: {err}");
return None;
}
};
let active = match start {
SessionStart::New { tracking_enabled } => tracking_enabled,
SessionStart::Resumed => store.session_exists(&session_id),
};
if !active {
return None;
}
info!("filesnap: tracking enabled for session {session_id}");
Some(Arc::new(Self {
store,
session_id,
hidden,
limits,
workspace: workspace.to_path_buf(),
}))
}
fn scope(&self, cwd: &Path, workspace_roots: &[PathBuf]) -> TurnScope {
TurnScope {
cwd: cwd.to_path_buf(),
roots: if workspace_roots.is_empty() {
vec![self.workspace.clone()]
} else {
workspace_roots.to_vec()
},
hidden: self.hidden,
limits: self.limits,
}
}
pub fn checkpoint_turn_start(&self, turn_id: &str, cwd: &Path, workspace_roots: &[PathBuf]) {
let scope = self.scope(cwd, workspace_roots);
if let Err(err) = capture_turn(&self.store, &self.session_id, turn_id, &scope) {
warn!("filesnap: turn-start checkpoint failed (turn {turn_id}): {err}");
}
}
pub fn attach_pre_edits(
&self,
turn_id: &str,
cwd: &Path,
pre_images: Vec<(PathBuf, PreEditImage)>,
) {
let scope = self.scope(cwd, &[]);
if let Err(err) = declare_edits(&self.store, &self.session_id, turn_id, &scope, pre_images)
{
warn!("filesnap: declare failed (turn {turn_id}): {err}");
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
fn dirs() -> (tempfile::TempDir, tempfile::TempDir) {
(tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap())
}
fn tracker(
home: &tempfile::TempDir,
ws: &tempfile::TempDir,
id: &str,
start: SessionStart,
) -> Option<Arc<SnapshotTracker>> {
SnapshotTracker::maybe_new(
home.path(),
ws.path(),
id.into(),
start,
HiddenFiles::Skip,
ScanLimits::default(),
)
}
fn tracking(
home: &tempfile::TempDir,
ws: &tempfile::TempDir,
id: &str,
) -> Arc<SnapshotTracker> {
tracker(
home,
ws,
id,
SessionStart::New {
tracking_enabled: true,
},
)
.expect("tracking on for a new session")
}
#[test]
fn session_scoped_binding() {
let (home, ws) = dirs();
let marker = |id: &str| {
WorkspaceStore::open(home.path(), ws.path())
.unwrap()
.session_exists(id)
};
assert!(
tracker(
&home,
&ws,
"t1",
SessionStart::New {
tracking_enabled: false
}
)
.is_none()
);
let controller = tracking(&home, &ws, "t1");
assert!(!marker("t1"), "no snapshots yet, so no marker");
std::fs::write(ws.path().join("a.txt"), "x").unwrap();
controller.checkpoint_turn_start("turn-1", ws.path(), &[]);
assert!(marker("t1"));
assert!(tracker(&home, &ws, "t1", SessionStart::Resumed).is_some());
assert!(tracker(&home, &ws, "t2", SessionStart::Resumed).is_none());
}
#[test]
fn a_session_id_does_not_reach_across_workspaces() {
let (home, ws) = dirs();
let other = tempfile::tempdir().unwrap();
std::fs::write(ws.path().join("a.txt"), "x").unwrap();
tracking(&home, &ws, "shared-id").checkpoint_turn_start("turn-1", ws.path(), &[]);
assert!(
WorkspaceStore::open(home.path(), ws.path())
.unwrap()
.session_exists("shared-id")
);
assert!(
!WorkspaceStore::open(home.path(), other.path())
.unwrap()
.session_exists("shared-id"),
"the same id in another workspace is another session, not this one"
);
assert!(
tracker(&home, &other, "shared-id", SessionStart::Resumed).is_none(),
"and resuming it there finds nothing to resume"
);
}
#[test]
fn hidden_entries_are_skipped_unless_edited() {
let (home, ws) = dirs();
let ctl = tracking(&home, &ws, "t1");
std::fs::create_dir_all(ws.path().join(".git")).unwrap();
std::fs::write(ws.path().join(".env"), "SECRET=1").unwrap();
std::fs::create_dir_all(ws.path().join(".github/workflows")).unwrap();
std::fs::write(ws.path().join(".github/workflows/ci.yml"), "on: push").unwrap();
std::fs::write(ws.path().join("src.rs"), "code").unwrap();
ctl.checkpoint_turn_start("turn-1", ws.path(), &[]);
let scanned = ctl.store.tracked_paths("t1").unwrap();
assert!(
scanned.iter().all(|p| !p.contains("/.env")),
"tool state and credentials stay out of snapshots: {scanned:?}"
);
assert!(scanned.iter().all(|p| !p.contains("/.git")));
assert!(scanned.iter().any(|p| p.ends_with("src.rs")));
let workflow = ws.path().join(".github/workflows/ci.yml");
ctl.attach_pre_edits(
"turn-1",
ws.path(),
vec![(
workflow.clone(),
PreEditImage::Existed(b"on: push".to_vec()),
)],
);
assert!(
ctl.store
.tracked_paths("t1")
.unwrap()
.contains(&workflow.to_string_lossy().into_owned()),
"explicitly edited hidden files must remain restorable"
);
}
#[test]
fn a_large_directory_cannot_flood_a_capture() {
let (home, loose) = dirs();
let ctl = tracking(&home, &loose, "t1");
for i in 0..(crate::ScanLimits::default().max_files + 50) {
std::fs::write(loose.path().join(format!("f{i}.txt")), "x").unwrap();
}
ctl.checkpoint_turn_start("turn-1", loose.path(), &[]);
let history = ctl.store.thread_history("t1").unwrap();
assert_eq!(
history[0].1.entries.len(),
crate::ScanLimits::default().max_files,
"no repository here, so only the recency partition contributes"
);
}
#[test]
fn ignored_paths_are_not_captured_through_the_edit_hook() {
let (home, ws) = dirs();
let ctl = tracking(&home, &ws, "t1");
std::fs::create_dir_all(ws.path().join(".git")).unwrap();
std::fs::write(
ws.path().join(crate::scope::SNAPSHOT_IGNORE_FILENAME),
"secrets/**\n",
)
.unwrap();
std::fs::create_dir_all(ws.path().join("secrets")).unwrap();
std::fs::write(ws.path().join("secrets/key.pem"), "private").unwrap();
std::fs::write(ws.path().join("src.rs"), "code").unwrap();
ctl.checkpoint_turn_start("turn-1", ws.path(), &[]);
let secret = ws.path().join("secrets/key.pem");
let tracked = ws.path().join("src.rs");
ctl.attach_pre_edits(
"turn-1",
ws.path(),
vec![
(secret.clone(), PreEditImage::Existed(b"private".to_vec())),
(tracked.clone(), PreEditImage::Existed(b"code".to_vec())),
],
);
ctl.checkpoint_turn_start("turn-2", ws.path(), &[]);
let paths = ctl.store.tracked_paths("t1").unwrap();
assert!(
!paths.contains(&secret.to_string_lossy().into_owned()),
"ignored path must never reach the store, not even via the edit hook: {paths:?}"
);
assert!(paths.contains(&tracked.to_string_lossy().into_owned()));
}
#[test]
fn checkpoint_workspace_and_fallback_modes() {
let (home, ws) = dirs();
let ctl = tracking(&home, &ws, "t1");
std::fs::write(ws.path().join("a.txt"), "alpha").unwrap();
ctl.checkpoint_turn_start("turn-1", ws.path(), &[]);
let history = ctl.store.thread_history("t1").unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].1.entries.len(), 1);
let loose = tempfile::tempdir().unwrap();
std::fs::write(loose.path().join("note.md"), "n1").unwrap();
let ctl2 = tracking(&home, &loose, "t2");
ctl2.checkpoint_turn_start("turn-1", loose.path(), &[]);
let outside = home.path().join("elsewhere.cfg");
ctl2.attach_pre_edits(
"turn-1",
ws.path(),
vec![(outside.clone(), PreEditImage::Existed(b"pre".to_vec()))],
);
std::fs::write(&outside, "post").unwrap();
ctl2.checkpoint_turn_start("turn-2", loose.path(), &[]);
let history = ctl2.store.thread_history("t2").unwrap();
assert_eq!(history.len(), 3);
let outside_key = outside.to_string_lossy().into_owned();
assert!(
!history[0].1.entries.contains_key(&outside_key),
"a path nothing had pointed at yet is simply not observed"
);
let last = &history[2].1;
assert!(
last.entries
.contains_key(&outside.to_string_lossy().into_owned()),
"extras are unioned into later checkpoints"
);
}
#[test]
fn the_edit_hook_filters_before_the_first_capture_has_run() {
let home = tempfile::tempdir().unwrap();
let ws = tempfile::tempdir().unwrap();
std::fs::write(ws.path().join(crate::SNAPSHOT_IGNORE_FILENAME), ".env\n").unwrap();
let secret = ws.path().join(".env");
std::fs::write(&secret, "TOKEN=hunter2").unwrap();
let tracker = SnapshotTracker::maybe_new(
home.path(),
ws.path(),
"s1".into(),
SessionStart::New {
tracking_enabled: true,
},
HiddenFiles::Skip,
ScanLimits::default(),
)
.expect("tracking enabled");
tracker.attach_pre_edits(
"turn-1",
ws.path(),
vec![(
secret.clone(),
PreEditImage::Existed(b"TOKEN=hunter2".to_vec()),
)],
);
let store = crate::WorkspaceStore::open(home.path(), ws.path()).unwrap();
assert!(
!store
.tracked_paths("s1")
.unwrap()
.contains(&secret.to_string_lossy().into_owned()),
"an ignored path entered the store through the edit hook"
);
}
#[test]
fn a_capture_in_one_process_stops_watching_a_path_past_the_window() {
let home = tempfile::tempdir().unwrap();
let ws = tempfile::tempdir().unwrap();
let tracker = SnapshotTracker::maybe_new(
home.path(),
ws.path(),
"s1".into(),
SessionStart::New {
tracking_enabled: true,
},
HiddenFiles::Skip,
ScanLimits::default(),
)
.expect("tracking enabled");
let outside = home.path().join("edited-once.cfg");
std::fs::write(&outside, b"before").unwrap();
tracker.checkpoint_turn_start("turn-0", ws.path(), &[ws.path().to_path_buf()]);
tracker.attach_pre_edits(
"turn-0",
ws.path(),
vec![(outside.clone(), PreEditImage::Existed(b"before".to_vec()))],
);
let store = crate::WorkspaceStore::open(home.path(), ws.path()).unwrap();
let key = outside.to_string_lossy().into_owned();
let captured = |store: &crate::WorkspaceStore| {
store
.latest_manifest("s1")
.unwrap()
.is_some_and(|m| m.entries.contains_key(&key))
};
tracker.checkpoint_turn_start("turn-1", ws.path(), &[ws.path().to_path_buf()]);
assert!(captured(&store), "still inside the window");
for i in 2..=(crate::declared::DECLARED_WINDOW_TURNS + 2) {
tracker.checkpoint_turn_start(
&format!("turn-{i}"),
ws.path(),
&[ws.path().to_path_buf()],
);
}
assert!(
!captured(&store),
"the process's own cache kept an aged-out path alive"
);
}
}