use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant, SystemTime};
use amont_runtime::{config, git};
pub const KEY_FETCH: &str = "amont.agent.fetch";
pub const FETCH_BUDGET_SECS: u64 = 5;
pub const FETCH_FRESH_SECS: u64 = 600;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Fetch {
Done,
Skipped,
Failed,
}
#[derive(Debug, Clone)]
pub struct Drift {
pub repo: String,
pub branch: Option<String>,
pub base: String,
pub behind: u32,
pub ahead: u32,
pub newest: String,
pub fetched: Fetch,
}
pub fn measure(cwd: &Path, from: &str) -> Option<Drift> {
let top = git::stdout_in(cwd, &["rev-parse", "--show-toplevel"])?;
let remote = remote_of(cwd)?;
let base = default_base(cwd, &remote)?;
let fetched = refresh(cwd, &remote, &base);
let counts = git::stdout_in(
cwd,
&[
"rev-list",
"--left-right",
"--count",
&format!("{from}...{base}"),
],
)?;
let mut parts = counts.split_whitespace();
let ahead: u32 = parts.next()?.parse().ok()?;
let behind: u32 = parts.next()?.parse().ok()?;
let newest = git::stdout_in(cwd, &["log", "-1", "--format=%h %s (%cr)", &base])?;
let branch = if from == "HEAD" {
git::stdout_in(cwd, &["symbolic-ref", "-q", "--short", "HEAD"])
} else {
None
};
let repo = Path::new(&top)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "-".to_string());
Some(Drift {
repo,
branch,
base,
behind,
ahead,
newest,
fetched,
})
}
fn remote_of(cwd: &Path) -> Option<String> {
if let Some(r) = git::stdout_in(cwd, &["config", "--get", "checkout.defaultRemote"]) {
if !r.is_empty() {
return Some(r);
}
}
let listed = git::stdout_in(cwd, &["remote"])?;
let remotes: Vec<&str> = listed
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
if remotes.contains(&"origin") {
return Some("origin".to_string());
}
match remotes.as_slice() {
[only] => Some((*only).to_string()),
_ => None,
}
}
fn default_base(cwd: &Path, remote: &str) -> Option<String> {
let head_ref = format!("refs/remotes/{remote}/HEAD");
if let Some(head) = git::stdout_in(cwd, &["symbolic-ref", "-q", "--short", &head_ref]) {
if !head.is_empty() {
return Some(head);
}
}
for name in ["main", "master"] {
let full = format!("refs/remotes/{remote}/{name}");
if git::succeeds_in(cwd, &["rev-parse", "-q", "--verify", &full]) {
return Some(format!("{remote}/{name}"));
}
}
None
}
pub fn refresh(cwd: &Path, remote: &str, base: &str) -> Fetch {
if !config::boolean_or(KEY_FETCH, true) {
return Fetch::Skipped;
}
if fetched_recently(cwd) {
return Fetch::Skipped;
}
let Some(branch) = base.strip_prefix(&format!("{remote}/")) else {
return Fetch::Skipped;
};
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(cwd)
.args([
"fetch",
"--quiet",
"--no-tags",
"--no-recurse-submodules",
remote,
branch,
])
.env("GIT_TERMINAL_PROMPT", "0")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let Ok(mut child) = cmd.spawn() else {
return Fetch::Failed;
};
let deadline = Instant::now() + Duration::from_secs(FETCH_BUDGET_SECS);
loop {
match child.try_wait() {
Ok(Some(s)) if s.success() => return Fetch::Done,
Ok(Some(_)) | Err(_) => return Fetch::Failed,
Ok(None) => {}
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Fetch::Failed;
}
std::thread::sleep(Duration::from_millis(25));
}
}
fn fetched_recently(cwd: &Path) -> bool {
let Some(path) = git::stdout_in(cwd, &["rev-parse", "--git-path", "FETCH_HEAD"]) else {
return false;
};
let path = if Path::new(&path).is_absolute() {
std::path::PathBuf::from(path)
} else {
cwd.join(path)
};
let Ok(meta) = std::fs::metadata(&path) else {
return false;
};
let Ok(modified) = meta.modified() else {
return false;
};
SystemTime::now()
.duration_since(modified)
.map(|age| age.as_secs() < FETCH_FRESH_SECS)
.unwrap_or(false)
}
pub fn notice(d: &Drift) -> String {
let where_ = match &d.branch {
Some(b) => format!("this checkout of {} (branch {b})", d.repo),
None => format!("this checkout of {}", d.repo),
};
let commits = if d.behind == 1 { "commit" } else { "commits" };
let mut text = format!(
"{where_} is {} {commits} behind {}; newest there: {}. \
Work that seems missing here may already exist on {} — \
`git log HEAD..{} --oneline` lists it — and a branch or worktree \
started from HEAD inherits the gap; one started from {} does not.",
d.behind, d.base, d.newest, d.base, d.base, d.base
);
if d.ahead > 0 {
text.push_str(&format!(
" ({} local {} not on {}.)",
d.ahead,
if d.ahead == 1 {
"commit is"
} else {
"commits are"
},
d.base
));
}
if d.fetched == Fetch::Failed {
text.push_str(&format!(
" {} is as of the last successful fetch; fetching just now did not \
complete within {FETCH_BUDGET_SECS}s.",
d.base
));
}
text
}
#[cfg(test)]
mod tests {
use super::*;
fn drift() -> Drift {
Drift {
repo: "thing".into(),
branch: Some("main".into()),
base: "origin/main".into(),
behind: 8,
ahead: 0,
newest: "d3b2ed5 chore(release): 1.16.0 (3 days ago)".into(),
fetched: Fetch::Done,
}
}
#[test]
fn the_notice_states_the_distance_and_the_newest_commit() {
let n = notice(&drift());
assert!(n.contains("8 commits behind origin/main"), "{n}");
assert!(n.contains("d3b2ed5"), "{n}");
assert!(n.contains("git log HEAD..origin/main"), "{n}");
assert!(!n.contains("fetch"), "a clean fetch is not mentioned: {n}");
}
#[test]
fn a_failed_fetch_is_disclosed() {
let d = Drift {
fetched: Fetch::Failed,
..drift()
};
assert!(notice(&d).contains("last successful fetch"));
}
#[test]
fn singular_when_one_behind() {
let d = Drift {
behind: 1,
..drift()
};
assert!(notice(&d).contains("1 commit behind"));
}
#[test]
fn not_a_repository_is_silence() {
let dir = std::env::temp_dir().join(format!("amont-agent-stale-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
assert!(measure(&dir, "HEAD").is_none());
}
}