use std::path::Path;
const MAX_COMMITS_LISTED: usize = 20;
const LAST_COMMIT_PREFIX: &str = "Last commit:";
const GIT_CONTEXT_HEADING: &str = "## Git Context";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct SnapshotFreshness {
pub(crate) superseded: bool,
pub(crate) commits_since: Vec<String>,
pub(crate) total_since: usize,
}
pub(crate) fn assess_snapshot(project_dir: &Path, snapshot: &Path) -> SnapshotFreshness {
let Ok(markdown) = std::fs::read_to_string(snapshot) else {
return SnapshotFreshness::default();
};
let Some(commit) = recorded_commit(&markdown) else {
return SnapshotFreshness::default();
};
let Some(since) = commits_since(project_dir, &commit) else {
return SnapshotFreshness::default();
};
let total_since = since.len();
SnapshotFreshness {
superseded: total_since > 0,
commits_since: since.into_iter().take(MAX_COMMITS_LISTED).collect(),
total_since,
}
}
fn recorded_commit(markdown: &str) -> Option<String> {
let mut in_git_context = false;
for line in markdown.lines() {
if line.starts_with("## ") {
in_git_context = line.trim_end() == GIT_CONTEXT_HEADING;
continue;
}
if !in_git_context {
continue;
}
if let Some(rest) = line.strip_prefix(LAST_COMMIT_PREFIX) {
let token = rest.split_whitespace().next()?;
let looks_like_a_commit =
token.len() >= 4 && token.chars().all(|c| c.is_ascii_hexdigit());
return looks_like_a_commit.then(|| token.to_string());
}
}
None
}
fn commits_since(project_dir: &Path, commit: &str) -> Option<Vec<String>> {
let output = std::process::Command::new("git")
.arg("-C")
.arg(project_dir)
.args(["log", "--oneline", "--no-decorate"])
.arg(format!("{commit}..HEAD"))
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(
String::from_utf8_lossy(&output.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn git(dir: &Path, args: &[&str]) {
let status = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("git must be runnable in this fixture");
assert!(status.status.success(), "git {args:?} failed");
}
fn repo_with_snapshot(tmp: &TempDir) -> (std::path::PathBuf, std::path::PathBuf) {
let dir = tmp.path().to_path_buf();
git(&dir, &["init"]);
git(&dir, &["config", "user.email", "t@t.com"]);
git(&dir, &["config", "user.name", "T"]);
std::fs::write(dir.join("a.txt"), b"a").unwrap();
git(&dir, &["add", "."]);
git(&dir, &["commit", "-m", "first"]);
let head = std::process::Command::new("git")
.arg("-C")
.arg(&dir)
.args(["log", "-1", "--format=%h %s"])
.output()
.unwrap();
let last_commit = String::from_utf8_lossy(&head.stdout).trim().to_string();
let snapshot = tmp.path().join("session-x.md");
std::fs::write(
&snapshot,
format!(
"# Session Pause - now\n\n## Summary\nWork.\n\n## Git Context\nBranch: main\n\
Last commit: {last_commit}\nUncommitted changes: clean\n"
),
)
.unwrap();
(dir, snapshot)
}
fn commit_file(dir: &Path, name: &str, subject: &str) {
std::fs::write(dir.join(name), name.as_bytes()).unwrap();
git(dir, &["add", "."]);
git(dir, &["commit", "-m", subject]);
}
#[test]
fn not_superseded_when_the_snapshot_commit_is_head() {
let tmp = TempDir::new().unwrap();
let (dir, snapshot) = repo_with_snapshot(&tmp);
assert_eq!(
assess_snapshot(&dir, &snapshot),
SnapshotFreshness::default(),
"nothing landed after the pause, so the snapshot still describes HEAD"
);
}
#[test]
fn supersedes_when_commits_landed_after_the_snapshot() {
let tmp = TempDir::new().unwrap();
let (dir, snapshot) = repo_with_snapshot(&tmp);
commit_file(
&dir,
"b.txt",
"the work the snapshot still calls in progress",
);
let freshness = assess_snapshot(&dir, &snapshot);
assert!(freshness.superseded, "a commit landed after the pause");
assert_eq!(freshness.total_since, 1);
assert!(
freshness.commits_since[0].ends_with("the work the snapshot still calls in progress"),
"the commit made since must be listed: {:?}",
freshness.commits_since
);
}
#[test]
fn the_commit_list_is_capped_but_the_total_is_not() {
let tmp = TempDir::new().unwrap();
let (dir, snapshot) = repo_with_snapshot(&tmp);
for n in 0..MAX_COMMITS_LISTED + 3 {
commit_file(&dir, &format!("f{n}.txt"), &format!("commit {n}"));
}
let freshness = assess_snapshot(&dir, &snapshot);
assert_eq!(freshness.commits_since.len(), MAX_COMMITS_LISTED);
assert_eq!(freshness.total_since, MAX_COMMITS_LISTED + 3);
}
#[test]
fn an_unknown_commit_is_not_superseded() {
let tmp = TempDir::new().unwrap();
let (dir, _) = repo_with_snapshot(&tmp);
let snapshot = tmp.path().join("orphan.md");
std::fs::write(
&snapshot,
"## Git Context\nLast commit: deadbeef gone with a squash merge\n",
)
.unwrap();
assert_eq!(
assess_snapshot(&dir, &snapshot),
SnapshotFreshness::default(),
"a commit git cannot resolve is 'cannot tell', never 'superseded'"
);
}
#[test]
fn a_snapshot_without_a_git_context_is_not_superseded() {
let tmp = TempDir::new().unwrap();
let (dir, _) = repo_with_snapshot(&tmp);
let snapshot = tmp.path().join("bare.md");
std::fs::write(
&snapshot,
"# Session Pause - now\n\n## Summary\nLast commit: x\n",
)
.unwrap();
assert_eq!(
assess_snapshot(&dir, &snapshot),
SnapshotFreshness::default()
);
assert_eq!(
assess_snapshot(&dir, &tmp.path().join("absent.md")),
SnapshotFreshness::default(),
"an unreadable snapshot answers the same fail-open way"
);
}
#[test]
fn a_non_hex_commit_token_is_rejected() {
assert_eq!(
recorded_commit("## Git Context\nLast commit: HEAD~1 some subject\n"),
None,
"only an abbreviated object id may reach git"
);
assert_eq!(
recorded_commit("## Git Context\nLast commit: abc1234 subject\n").as_deref(),
Some("abc1234")
);
}
}