use crate::{gitcmd, repomap, repos, schema};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Staleness {
Current,
Changed,
Moved,
Reachable,
Offline,
Squashed,
Unpinned,
Unknown,
Landed,
}
impl Staleness {
pub fn label(self) -> &'static str {
match self {
Staleness::Current => "current",
Staleness::Changed => "changed",
Staleness::Moved => "moved",
Staleness::Reachable => "reachable",
Staleness::Offline => "offline",
Staleness::Squashed => "squashed",
Staleness::Unpinned => "unpinned",
Staleness::Unknown => "unknown",
Staleness::Landed => "landed",
}
}
fn badge(self) -> &'static str {
match self {
Staleness::Current => " [current]",
Staleness::Changed => " [⚠ changed since pinned]",
Staleness::Moved => " [⚠ moved/renamed since pinned]",
Staleness::Reachable => " [⚠ reachable — changed since pinned]",
Staleness::Offline => " [⚠ offline — rebased/abandoned]",
Staleness::Squashed => " [⚠ squashed — merged away]",
Staleness::Unpinned => " [⚠ unpinned — legacy HEAD ref]",
Staleness::Unknown => "",
Staleness::Landed => " [patch landed]",
}
}
}
fn is_full_hex(s: &str) -> bool {
(s.len() == 40 || s.len() == 64) && s.chars().all(|c| c.is_ascii_hexdigit())
}
pub fn clone_for(repo_inv: &repos::Repos, repo: &str) -> Option<PathBuf> {
let card_root_sha = repo_inv.get(repo).and_then(|c| c.root_sha.clone());
repomap::resolve(repo, card_root_sha.as_deref())
}
pub fn staleness(
clone: Option<&Path>,
sha: &str,
path: &str,
content_hash: Option<&str>,
) -> Staleness {
if !is_full_hex(sha) {
return Staleness::Unpinned;
}
let (Some(dir), Some(pinned)) = (clone, content_hash) else {
return Staleness::Unknown;
};
match gitcmd::output(dir, &["rev-parse", "--verify", "--quiet", &format!("HEAD:{path}")]) {
Ok(o) if o.status.success() => {
if String::from_utf8_lossy(&o.stdout).trim() == pinned {
Staleness::Current
} else {
Staleness::Changed
}
}
Ok(_) => Staleness::Moved, Err(_) => Staleness::Unknown,
}
}
fn object_present(dir: &Path, sha: &str) -> bool {
gitcmd::output(dir, &["cat-file", "-e", sha]).map(|o| o.status.success()).unwrap_or(false)
}
pub(crate) fn is_ancestor(dir: &Path, a: &str, b: &str) -> bool {
gitcmd::output(dir, &["merge-base", "--is-ancestor", a, b]).map(|o| o.status.success()).unwrap_or(false)
}
fn squash_anchor_reachable(dir: &Path, fork_point: &str, base_ref: &str) -> bool {
is_ancestor(dir, fork_point, base_ref) || is_ancestor(dir, fork_point, &format!("origin/{base_ref}"))
}
pub fn staleness_ex(
clone: Option<&Path>,
sha: &str,
path: &str,
content_hash: Option<&str>,
base_ref: Option<&str>,
fork_point: Option<&str>,
) -> Staleness {
let base = staleness(clone, sha, path, content_hash);
if base != Staleness::Changed {
return base; }
let Some(dir) = clone else { return base }; if !object_present(dir, sha) {
return base; }
if is_ancestor(dir, sha, "HEAD") {
return Staleness::Reachable;
}
match (base_ref, fork_point) {
(Some(b), Some(f)) if squash_anchor_reachable(dir, f, b) => Staleness::Squashed,
_ => Staleness::Offline,
}
}
pub fn patch_staleness(clone: Option<&Path>, path: &str, result_hash: Option<&str>) -> Staleness {
let Some(dir) = clone else { return Staleness::Unknown };
let at_head = gitcmd::output(dir, &["rev-parse", "--verify", "--quiet", &format!("HEAD:{path}")])
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
let landed_at_head = match (result_hash, &at_head) {
(Some(rh), Some(oid)) => oid == rh,
(None, None) => true,
_ => false,
};
if landed_at_head {
return Staleness::Landed;
}
if let Some(rh) = result_hash {
if history_contains_blob_at_path(dir, rh, path) {
return Staleness::Landed;
}
}
Staleness::Unknown
}
fn history_contains_blob_at_path(dir: &Path, oid: &str, path: &str) -> bool {
match gitcmd::output(dir, &["log", "-1", "--format=%H", &format!("--find-object={oid}"), "--", path]) {
Ok(o) if o.status.success() => !String::from_utf8_lossy(&o.stdout).trim().is_empty(),
_ => false,
}
}
pub fn blob_line_count(dir: &Path, sha: &str, path: &str) -> Option<u64> {
let spec = format!("{sha}:{path}");
let szo = gitcmd::output(dir, &["cat-file", "-s", &spec]).ok()?;
if !szo.status.success() {
return None;
}
let size: u64 = String::from_utf8_lossy(&szo.stdout).trim().parse().ok()?;
if size > 2_000_000 {
return None;
}
let o = gitcmd::output(dir, &["cat-file", "-p", &spec]).ok()?;
if !o.status.success() {
return None;
}
Some(String::from_utf8_lossy(&o.stdout).lines().count() as u64)
}
pub fn snippet(
clone: Option<&Path>,
sha: &str,
path: &str,
range: Option<[u64; 2]>,
max_lines: usize,
) -> Option<Vec<(u64, String)>> {
let dir = clone?;
let spec = format!("{sha}:{path}");
let szo = gitcmd::output(dir, &["cat-file", "-s", &spec]).ok()?;
if !szo.status.success() {
return None;
}
let size: u64 = String::from_utf8_lossy(&szo.stdout).trim().parse().ok()?;
if size > 2_000_000 {
return None; }
let o = gitcmd::output(dir, &["cat-file", "-p", &spec]).ok()?;
if !o.status.success() {
return None;
}
let text = String::from_utf8_lossy(&o.stdout);
let all: Vec<&str> = text.lines().collect();
let (start, end) = match range {
Some([a, b]) => (a.max(1), b),
None => (1, all.len() as u64),
};
let mut out = Vec::new();
for (i, line) in all.iter().enumerate() {
let n = i as u64 + 1;
if n < start {
continue;
}
if n > end || out.len() >= max_lines {
break;
}
out.push((n, schema::sanitize_term(line, false)));
}
(!out.is_empty()).then_some(out)
}
pub fn identity_paren(ref_name: Option<&str>, ref_type: Option<&str>, commit_date: Option<&str>) -> String {
let name = ref_name.map(|n| match ref_type {
Some("tag") => format!("tag: {n}"),
_ => n.to_string(),
});
let date = commit_date.map(|d| d.split('T').next().unwrap_or(d).to_string());
match (name, date) {
(Some(n), Some(d)) => format!(" ({n} · {d})"),
(Some(n), None) => format!(" ({n})"),
(None, Some(d)) => format!(" ({d})"),
(None, None) => String::new(),
}
}
fn flag_badges(r: &schema::CodeRef) -> String {
let mut s = String::new();
if r.dirty {
s.push_str(" [dirty]");
}
if r.untracked {
s.push_str(" [untracked]");
}
s
}
pub fn enrich_commit_date(clone: Option<&Path>, sha: &str, stored: Option<&str>) -> Option<String> {
if stored.is_some() {
return stored.map(str::to_string);
}
if !is_full_hex(sha) {
return None;
}
let dir = clone?;
let o = gitcmd::output(dir, &["log", "-1", "--format=%cI", sha]).ok()?;
if !o.status.success() {
return None;
}
let d = String::from_utf8_lossy(&o.stdout).trim().to_string();
(!d.is_empty()).then_some(d)
}
pub fn render_resolved(repo_inv: &repos::Repos, r: &schema::CodeRef, max_lines: usize) -> String {
let clone = clone_for(repo_inv, &r.repo);
let rng = r.range.map(|x| format!("#L{}-{}", x[0], x[1])).unwrap_or_default();
let short: String = if is_full_hex(&r.sha) {
r.sha[..r.sha.len().min(9)].to_string()
} else {
r.sha.clone()
};
let st = staleness_ex(
clone.as_deref(),
&r.sha,
&r.path,
r.content_hash.as_deref(),
r.base_ref.as_deref(),
r.fork_point.as_deref(),
);
let enriched_date = enrich_commit_date(clone.as_deref(), &r.sha, r.commit_date.as_deref());
let mut s = format!(
"⟶ {}:{} @{}{}{}{}{}",
r.repo,
r.path,
short,
identity_paren(r.ref_name.as_deref(), r.ref_type.as_deref(), enriched_date.as_deref()),
rng,
st.badge(),
flag_badges(r)
);
if clone.is_none() {
s.push_str(&format!(
"\n (not cloned here — `confer repos map {} <path>` to see the code)",
r.repo
));
return s;
}
if r.patch {
let applied = patch_staleness(clone.as_deref(), &r.path, r.result_hash.as_deref()) == Staleness::Landed;
s.push_str(&format!("\n ⟳ proposed a change here ({})", if applied { "applied" } else { "open" }));
}
if let Some(lines) = snippet(clone.as_deref(), &r.sha, &r.path, r.range, max_lines) {
for (n, line) in lines {
s.push_str(&format!("\n {n:>5} │ {line}"));
}
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
fn git(dir: &Path, args: &[&str]) {
let ok = Command::new("git")
.arg("-C")
.arg(dir)
.args(["-c", "user.name=t", "-c", "user.email=t@t.local", "-c", "commit.gpgsign=false", "-c", "init.defaultBranch=main"])
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
assert!(ok, "git {args:?} failed");
}
fn repo_with_file(tag: &str, contents: &str) -> (PathBuf, String, String) {
let dir = std::env::temp_dir().join(format!("confer-refcode-{}-{tag}-{}", std::process::id(), std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
git(&dir, &["init", "-q"]);
std::fs::write(dir.join("f.rs"), contents).unwrap();
git(&dir, &["add", "-A"]);
git(&dir, &["commit", "-q", "-m", "c0"]);
let head = String::from_utf8_lossy(
&Command::new("git").arg("-C").arg(&dir).args(["rev-parse", "HEAD"]).output().unwrap().stdout,
)
.trim()
.to_string();
let blob = String::from_utf8_lossy(
&Command::new("git").arg("-C").arg(&dir).args(["rev-parse", "HEAD:f.rs"]).output().unwrap().stdout,
)
.trim()
.to_string();
(dir, head, blob)
}
#[test]
fn staleness_unpinned_for_non_full_hex_sha_regardless_of_clone() {
let (dir, _head, blob) = repo_with_file("unpinned", "a\nb\n");
assert_eq!(staleness(Some(&dir), "HEAD", "f.rs", Some(&blob)), Staleness::Unpinned);
assert_eq!(staleness(None, "abcdef", "f.rs", None), Staleness::Unpinned);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn staleness_unresolved_sha_is_unpinned_like_legacy_head() {
assert_eq!(staleness(None, "unresolved", "f.rs", None), Staleness::Unpinned);
}
#[test]
fn staleness_unknown_without_clone_or_content_hash() {
let (dir, head, blob) = repo_with_file("unknown", "a\nb\n");
assert_eq!(staleness(None, &head, "f.rs", Some(&blob)), Staleness::Unknown);
assert_eq!(staleness(Some(&dir), &head, "f.rs", None), Staleness::Unknown);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn staleness_moved_when_path_gone_at_head() {
let (dir, head, blob) = repo_with_file("moved", "a\nb\n");
assert_eq!(staleness(Some(&dir), &head, "renamed.rs", Some(&blob)), Staleness::Moved);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn staleness_current_vs_changed() {
let (dir, head, blob) = repo_with_file("curchg", "a\nb\n");
assert_eq!(staleness(Some(&dir), &head, "f.rs", Some(&blob)), Staleness::Current);
assert_eq!(staleness(Some(&dir), &head, "f.rs", Some("0000000000000000000000000000000000000000")), Staleness::Changed);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn patch_staleness_fast_path_at_head() {
let (dir, _head, blob) = repo_with_file("patch-fast", "a\nb\n");
assert_eq!(patch_staleness(Some(&dir), "f.rs", Some(&blob)), Staleness::Landed);
assert_eq!(patch_staleness(Some(&dir), "gone.rs", None), Staleness::Landed);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn patch_staleness_durable_fallback_after_a_later_edit() {
let (dir, _head1, landed_blob) = repo_with_file("patch-durable", "landed content\n");
std::fs::write(dir.join("f.rs"), "a later, unrelated edit\n").unwrap();
git(&dir, &["add", "-A"]);
git(&dir, &["commit", "-q", "-m", "c1 supersedes the landed content"]);
assert_eq!(patch_staleness(Some(&dir), "f.rs", Some(&landed_blob)), Staleness::Landed);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn patch_staleness_unknown_when_blob_never_landed() {
let (dir, _head, _blob) = repo_with_file("patch-never", "a\nb\n");
assert_eq!(
patch_staleness(Some(&dir), "f.rs", Some(&"f".repeat(40))),
Staleness::Unknown
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn patch_staleness_unknown_without_a_clone() {
assert_eq!(patch_staleness(None, "f.rs", Some(&"a".repeat(40))), Staleness::Unknown);
}
#[test]
fn snippet_none_without_a_clone() {
assert_eq!(snippet(None, "abc", "f.rs", None, 100), None);
}
#[test]
fn snippet_none_for_missing_object() {
let (dir, head, _blob) = repo_with_file("missingobj", "a\nb\n");
assert_eq!(snippet(Some(&dir), &head, "does-not-exist.rs", None, 100), None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn snippet_size_guard_rejects_oversized_blob() {
let big = "x".repeat(2_100_000); let (dir, head, _blob) = repo_with_file("big", &big);
assert_eq!(snippet(Some(&dir), &head, "f.rs", None, 100), None, "a >2MB blob must degrade to pointer-only, not slurp");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn snippet_range_is_clamped_and_bounded_by_max_lines() {
let (dir, head, _blob) = repo_with_file("range", "one\ntwo\nthree\nfour\nfive\n");
let lines = snippet(Some(&dir), &head, "f.rs", Some([0, 3]), 100).unwrap();
assert_eq!(lines, vec![(1, "one".to_string()), (2, "two".to_string()), (3, "three".to_string())]);
let lines = snippet(Some(&dir), &head, "f.rs", Some([4, 999]), 100).unwrap();
assert_eq!(lines, vec![(4, "four".to_string()), (5, "five".to_string())]);
let lines = snippet(Some(&dir), &head, "f.rs", None, 2).unwrap();
assert_eq!(lines.len(), 2);
assert_eq!(snippet(Some(&dir), &head, "f.rs", Some([50, 60]), 100), None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn render_resolved_hints_map_a_clone_when_unmapped() {
let repo_inv: repos::Repos = Default::default(); let r = schema::CodeRef {
repo: "mylib".into(),
sha: "a".repeat(40),
path: "f.rs".into(),
range: None,
content_hash: None,
ref_name: None,
ref_type: None,
commit_date: None,
dirty: false,
untracked: false,
rev: None,
base_ref: None,
fork_point: None,
patch: false,
result_hash: None,
};
let out = render_resolved(&repo_inv, &r, 50);
assert!(out.contains("not cloned here"), "out: {out}");
assert!(out.contains("confer repos map mylib"), "out: {out}");
assert!(out.contains(&format!("@{}", &"a".repeat(9))), "out: {out}");
}
#[test]
fn render_resolved_keeps_short_sha_unmodified() {
let repo_inv: repos::Repos = Default::default();
let r = schema::CodeRef {
repo: "mylib".into(),
sha: "HEAD".into(),
path: "f.rs".into(),
range: None,
content_hash: None,
ref_name: None,
ref_type: None,
commit_date: None,
dirty: false,
untracked: false,
rev: None,
base_ref: None,
fork_point: None,
patch: false,
result_hash: None,
};
let out = render_resolved(&repo_inv, &r, 50);
assert!(out.contains("@HEAD"), "out: {out}");
assert!(out.contains("unpinned"), "legacy HEAD refs should badge as unpinned: {out}");
}
#[test]
fn staleness_ex_reachable_when_ancestor_but_content_changed() {
let (dir, head1, blob1) = repo_with_file("reachable", "a\nb\n");
std::fs::write(dir.join("f.rs"), "a\nb\nc\n").unwrap();
git(&dir, &["add", "-A"]);
git(&dir, &["commit", "-q", "-m", "c1"]);
assert_eq!(
staleness_ex(Some(&dir), &head1, "f.rs", Some(&blob1), None, None),
Staleness::Reachable
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn staleness_ex_offline_when_rebased_away_and_no_squash_anchor() {
let (dir, old_head, blob) = repo_with_file("offline", "a\nb\n");
std::fs::write(dir.join("f.rs"), "changed\n").unwrap();
git(&dir, &["add", "-A"]);
git(&dir, &["commit", "--amend", "-q", "-m", "rewritten"]);
assert_eq!(
staleness_ex(Some(&dir), &old_head, "f.rs", Some(&blob), None, None),
Staleness::Offline
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn staleness_ex_squashed_when_fork_point_still_reachable_on_base() {
let dir = std::env::temp_dir().join(format!("confer-refcode-squash-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
git(&dir, &["init", "-q", "-b", "main"]);
std::fs::write(dir.join("f.rs"), "base\n").unwrap();
git(&dir, &["add", "-A"]);
git(&dir, &["commit", "-q", "-m", "base"]);
let fork_point = String::from_utf8_lossy(
&Command::new("git").arg("-C").arg(&dir).args(["rev-parse", "HEAD"]).output().unwrap().stdout,
)
.trim()
.to_string();
let blob = String::from_utf8_lossy(
&Command::new("git").arg("-C").arg(&dir).args(["rev-parse", "HEAD:f.rs"]).output().unwrap().stdout,
)
.trim()
.to_string();
git(&dir, &["checkout", "-q", "-b", "feature"]);
std::fs::write(dir.join("f.rs"), "feature work\n").unwrap();
git(&dir, &["add", "-A"]);
git(&dir, &["commit", "-q", "-m", "feature commit"]); let feature_sha = String::from_utf8_lossy(
&Command::new("git").arg("-C").arg(&dir).args(["rev-parse", "HEAD"]).output().unwrap().stdout,
)
.trim()
.to_string();
git(&dir, &["checkout", "-q", "main"]);
git(&dir, &["merge", "-q", "--squash", "feature"]);
git(&dir, &["commit", "-q", "-m", "squashed feature"]);
assert!(!is_ancestor(&dir, &feature_sha, "main"));
assert!(is_ancestor(&dir, &fork_point, "main"), "the fork point must survive the squash");
assert_eq!(
staleness_ex(Some(&dir), &feature_sha, "f.rs", Some(&blob), Some("main"), Some(&fork_point)),
Staleness::Squashed
);
assert_eq!(
staleness_ex(Some(&dir), &feature_sha, "f.rs", Some(&blob), None, None),
Staleness::Offline
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn staleness_ex_falls_back_to_changed_when_object_absent_locally() {
let (dir, _head, blob) = repo_with_file("noobject", "a\nb\n");
std::fs::write(dir.join("f.rs"), "a\nb\nc\n").unwrap();
git(&dir, &["add", "-A"]);
git(&dir, &["commit", "-q", "-m", "c1"]);
let bogus_sha = "f".repeat(40); assert_eq!(
staleness_ex(Some(&dir), &bogus_sha, "f.rs", Some(&blob), None, None),
Staleness::Changed
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn staleness_ex_passes_through_current_moved_unpinned_unknown_untouched() {
let (dir, head, blob) = repo_with_file("passthrough", "a\nb\n");
assert_eq!(staleness_ex(Some(&dir), &head, "f.rs", Some(&blob), None, None), Staleness::Current);
assert_eq!(
staleness_ex(Some(&dir), &head, "renamed.rs", Some(&blob), None, None),
Staleness::Moved
);
assert_eq!(staleness_ex(Some(&dir), "HEAD", "f.rs", Some(&blob), None, None), Staleness::Unpinned);
assert_eq!(staleness_ex(None, &head, "f.rs", Some(&blob), None, None), Staleness::Unknown);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn enrich_commit_date_keeps_a_stored_value_untouched() {
let (dir, head, _blob) = repo_with_file("enrich-stored", "a\n");
assert_eq!(
enrich_commit_date(Some(&dir), &head, Some("2020-01-01T00:00:00Z")),
Some("2020-01-01T00:00:00Z".to_string())
);
}
#[test]
fn enrich_commit_date_derives_from_a_mapped_clone_for_a_full_hex_sha() {
let (dir, head, _blob) = repo_with_file("enrich-derive", "a\n");
let got = enrich_commit_date(Some(&dir), &head, None).expect("should derive a date");
assert!(got.contains('T'), "expected an ISO 8601 date, got {got}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn enrich_commit_date_omits_for_legacy_non_full_hex_sha() {
assert_eq!(enrich_commit_date(None, "HEAD", None), None);
}
#[test]
fn enrich_commit_date_omits_without_a_clone() {
assert_eq!(enrich_commit_date(None, &"a".repeat(40), None), None);
}
}