use anyhow::{anyhow, Result};
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::time::Duration;
fn git_timeout() -> Duration {
std::env::var("CONFER_GIT_TIMEOUT_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.filter(|n| *n > 0)
.map_or(Duration::from_secs(60), Duration::from_secs)
}
fn op_deadline() -> std::time::Instant {
let secs = std::env::var("CONFER_OP_BUDGET_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.filter(|n| *n > 0)
.unwrap_or(45);
std::time::Instant::now() + Duration::from_secs(secs)
}
fn base(root: &Path) -> Command {
let mut c = Command::new("git");
c.arg("-C").arg(root);
c
}
fn run(root: &Path, args: &[&str]) -> Result<Output> {
run_to(root, args, git_timeout())
}
fn is_lock_contention(out: &Output) -> bool {
if out.status.success() {
return false;
}
let e = String::from_utf8_lossy(&out.stderr);
e.contains("index.lock") || e.contains("config.lock") || e.contains("could not lock config")
}
fn run_to(root: &Path, args: &[&str], timeout: Duration) -> Result<Output> {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
let out = run_once(root, args, timeout)?;
if !is_lock_contention(&out) || std::time::Instant::now() >= deadline {
return Ok(out);
}
std::thread::sleep(Duration::from_millis(40 + jitter_ms(80)));
}
}
fn run_once(root: &Path, args: &[&str], timeout: Duration) -> Result<Output> {
let mut cmd = base(root);
cmd.args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
let child = cmd.spawn().map_err(|e| anyhow!("spawn git: {e}"))?;
let pid = child.id();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(child.wait_with_output());
});
match rx.recv_timeout(timeout) {
Ok(r) => Ok(r?),
Err(_) => {
let _ = Command::new("kill").arg("-9").arg(pid.to_string()).status();
Err(anyhow!("git {} timed out after {}s (killed)", args.join(" "), timeout.as_secs()))
}
}
}
pub fn output(root: &Path, args: &[&str]) -> Result<Output> {
run(root, args)
}
pub fn output_stdin(root: &Path, args: &[&str], input: &str) -> Result<Output> {
use std::io::Write;
let mut cmd = base(root);
cmd.args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| anyhow!("spawn git: {e}"))?;
let mut stdin = child.stdin.take().ok_or_else(|| anyhow!("no stdin"))?;
let inp = input.to_string();
std::thread::spawn(move || {
let _ = stdin.write_all(inp.as_bytes());
});
let pid = child.id();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(child.wait_with_output());
});
match rx.recv_timeout(git_timeout()) {
Ok(r) => Ok(r?),
Err(_) => {
let _ = Command::new("kill").arg("-9").arg(pid.to_string()).status();
Err(anyhow!("git {} timed out after {}s (killed)", args.join(" "), git_timeout().as_secs()))
}
}
}
pub fn check(root: &Path, args: &[&str]) -> Result<()> {
let o = run(root, args)?;
if o.status.success() {
Ok(())
} else {
Err(anyhow!(
"git {}: {}",
args.join(" "),
String::from_utf8_lossy(&o.stderr).trim()
))
}
}
pub struct Lock {
_file: std::fs::File, }
fn jitter_ms(cap: u64) -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| (d.subsec_nanos() as u64) % cap.max(1))
.unwrap_or(0)
}
fn acquire_lock(root: &Path) -> Result<Lock> {
acquire_lock_until(root, None)
}
fn acquire_lock_until(root: &Path, overall: Option<std::time::Instant>) -> Result<Lock> {
use fs2::FileExt;
use std::io::{Seek, Write};
let p = root.join(".confer").join("gitlock");
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file = std::fs::OpenOptions::new().create(true).read(true).write(true).open(&p)?;
let budget = std::env::var("CONFER_LOCK_BUDGET_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.filter(|n| *n > 0)
.unwrap_or(30);
let own = std::time::Instant::now() + Duration::from_secs(budget);
let deadline = overall.map_or(own, |o| own.min(o));
loop {
match file.try_lock_exclusive() {
Ok(()) => {
let _ = file.set_len(0);
let _ = file.seek(std::io::SeekFrom::Start(0));
let _ = writeln!(file, "{}", std::process::id());
return Ok(Lock { _file: file });
}
Err(_) if std::time::Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(30 + jitter_ms(70)));
}
Err(_) => {
return Err(anyhow!(
"clone busy — could not acquire {} within the time budget (another confer op holds it)",
p.display()
));
}
}
}
}
pub fn lock(root: &Path) -> Result<Lock> {
acquire_lock(root)
}
fn rev_count(root: &Path, range: &str) -> usize {
output(root, &["rev-list", "--count", range])
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0)
}
pub fn head(root: &Path) -> Result<String> {
let o = output(root, &["rev-parse", "HEAD"])?;
if o.status.success() {
Ok(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
Err(anyhow!("no HEAD commit yet"))
}
}
fn merge_base(root: &Path, a: &str, b: &str) -> Option<String> {
let o = output(root, &["merge-base", a, b]).ok()?;
if !o.status.success() {
return None;
}
let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
(!s.is_empty()).then_some(s)
}
pub fn cursor_anchor(root: &Path) -> Option<String> {
merge_base(root, "@{u}", "HEAD").or_else(|| head(root).ok())
}
pub fn added_message_files(root: &Path, since: Option<&str>) -> Result<Vec<PathBuf>> {
let collect = |range: &str| -> Option<Vec<PathBuf>> {
let o = output(
root,
&[
"log",
"--reverse",
"--diff-filter=A",
"--name-only",
"--format=",
range,
"--",
"threads",
],
)
.ok()?;
if !o.status.success() {
return None;
}
let mut seen = std::collections::HashSet::new();
let mut files = Vec::new();
for line in String::from_utf8_lossy(&o.stdout).lines() {
let line = line.trim();
if line.starts_with("threads/") && line.ends_with(".md") && seen.insert(line.to_string())
{
files.push(root.join(line));
}
}
Some(files)
};
if let Some(c) = since {
let short = &c[..c.len().min(10)];
if is_ancestor(root, c, "HEAD") {
if let Some(files) = collect(&format!("{c}..HEAD")) {
return Ok(files);
}
} else if let Some(base) = merge_base(root, c, "HEAD") {
eprintln!("confer: cursor {short} diverged from HEAD (force-push/re-clone?); re-reading from its merge-base");
return Ok(collect(&format!("{base}..HEAD")).unwrap_or_default());
}
eprintln!("confer: cursor {short} is unknown (history reset/pruned); re-reading full store");
}
Ok(collect("HEAD").unwrap_or_default())
}
fn is_ancestor(root: &Path, a: &str, b: &str) -> bool {
output(root, &["merge-base", "--is-ancestor", a, b])
.map(|o| o.status.success())
.unwrap_or(false)
}
fn has_upstream(root: &Path) -> bool {
output(
root,
&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
)
.map(|o| o.status.success())
.unwrap_or(false)
}
pub enum Committed {
Synced,
DeferredLocal,
}
pub fn commit_and_sync(root: &Path, role: &str, file: &Path, msg: &str, sign: bool) -> Result<Committed> {
let deadline = op_deadline();
let fetched = fetch_unlocked(root, deadline);
let _lock = acquire_lock_until(root, Some(deadline))?; let file_s = file.to_string_lossy().to_string();
check(root, &["add", file_s.as_str()])?;
let name_cfg = format!("user.name={role}");
let email_cfg = format!("user.email={role}@confer.local");
let mut args: Vec<&str> = vec!["-c", name_cfg.as_str(), "-c", email_cfg.as_str()];
if !sign {
args.extend(["-c", "commit.gpgsign=false"]);
}
args.extend(["commit", "-q", "-m", msg]);
check(root, &args)?;
match reconcile_push(root, fetched, Some(deadline)) {
Ok(_) => Ok(Committed::Synced),
Err(_) => Ok(Committed::DeferredLocal),
}
}
pub fn integrate(root: &Path) -> Result<SyncResult> {
let deadline = op_deadline();
let fetched = fetch_unlocked(root, deadline);
let _lock = acquire_lock_until(root, Some(deadline))?;
reconcile_push(root, fetched, Some(deadline))
}
fn fetch_unlocked(root: &Path, deadline: std::time::Instant) -> bool {
for attempt in 0..2 {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
break;
}
if run_to(root, &["fetch", "--quiet"], remaining.min(Duration::from_secs(15)))
.map(|o| o.status.success())
.unwrap_or(false)
{
return true;
}
if attempt == 0 && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(150 + jitter_ms(250)));
}
}
false
}
#[allow(dead_code)] pub struct SyncResult {
pub fetched: bool,
pub pushed: usize,
}
fn reconcile_push(root: &Path, fetched: bool, overall: Option<std::time::Instant>) -> Result<SyncResult> {
if !has_upstream(root) {
return Ok(SyncResult { fetched, pushed: 0 });
}
let sg = |args: &[&str]| run_to(root, args, Duration::from_secs(15));
let budget = std::env::var("CONFER_SYNC_BUDGET_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.filter(|n| *n > 0)
.map_or(Duration::from_secs(25), Duration::from_secs);
let started = std::time::Instant::now();
let end = overall.map_or(started + budget, |o| (started + budget).min(o));
let mut attempt = 0u32;
let deferred = loop {
let behind = rev_count(root, "HEAD..@{u}");
let ahead = rev_count(root, "@{u}..HEAD");
if behind > 0 {
if ahead == 0 {
let m = sg(&["merge", "--ff-only", "--quiet", "@{u}"])?;
if !m.status.success() {
return Err(anyhow!("ff-merge failed: {}", String::from_utf8_lossy(&m.stderr).trim()));
}
} else {
let r = sg(&["rebase", "@{u}"])?;
if !r.status.success() {
let _ = sg(&["rebase", "--abort"]);
return Err(anyhow!(
"rebase onto upstream failed (aborted; resolve manually): {}",
String::from_utf8_lossy(&r.stderr).trim()
));
}
}
}
let ahead_now = rev_count(root, "@{u}..HEAD");
if ahead_now == 0 {
return Ok(SyncResult { fetched, pushed: 0 });
}
let p = sg(&["push", "--quiet"])?;
if p.status.success() {
return Ok(SyncResult { fetched, pushed: ahead_now });
}
let last = String::from_utf8_lossy(&p.stderr).trim().to_string();
if std::time::Instant::now() >= end {
break last;
}
let base = (100u64 << attempt.min(4)).min(1500); std::thread::sleep(Duration::from_millis(base + jitter_ms(150)));
let _ = sg(&["fetch", "--quiet"]);
attempt += 1;
};
Err(anyhow!(
"hub busy — push contended for {}s ({} tries); committed locally, will sync on the next confer command ({deferred})",
started.elapsed().as_secs(),
attempt + 1
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
static SEQ: AtomicU32 = AtomicU32::new(0);
fn tmp() -> PathBuf {
let n = SEQ.fetch_add(1, Ordering::SeqCst);
let p = std::env::temp_dir().join(format!("confer-git-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
p
}
fn git(root: &Path, args: &[&str]) {
let ok = Command::new("git")
.arg("-C")
.arg(root)
.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 in {}", root.display());
}
fn rev(root: &Path, spec: &str) -> String {
let o = Command::new("git").arg("-C").arg(root).args(["rev-parse", spec]).output().unwrap();
String::from_utf8_lossy(&o.stdout).trim().to_string()
}
fn commit(root: &Path, name: &str) -> String {
let d = root.join("threads").join("general");
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join(format!("{name}.md")), format!("---\ntype: note\n---\n{name}")).unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-q", "-m", name]);
rev(root, "HEAD")
}
fn clone_of(hub: &Path) -> PathBuf {
let dst = tmp();
let ok = Command::new("git")
.args(["clone", "-q", &hub.to_string_lossy(), &dst.to_string_lossy()])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
assert!(ok, "clone failed");
dst
}
fn hub_and_clone() -> (PathBuf, PathBuf, String) {
let hub = tmp();
git(&hub, &["init", "--bare", "-q", "-b", "main"]);
let seed = tmp();
git(&seed, &["init", "-q", "-b", "main"]);
let m0 = commit(&seed, "m0");
git(&seed, &["remote", "add", "origin", &hub.to_string_lossy()]);
git(&seed, &["push", "-q", "-u", "origin", "main"]);
(hub.clone(), clone_of(&hub), m0)
}
fn names(files: &[PathBuf]) -> Vec<String> {
let mut v: Vec<String> =
files.iter().map(|p| p.file_name().unwrap().to_string_lossy().into_owned()).collect();
v.sort();
v
}
#[test]
fn transient_index_lock_is_retried_not_failed() {
let r = tmp();
git(&r, &["init", "-q", "-b", "main"]);
std::fs::write(r.join("f.txt"), "hi").unwrap();
let lock = r.join(".git").join("index.lock");
std::fs::write(&lock, "").unwrap();
let lock2 = lock.clone();
let releaser = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(250));
let _ = std::fs::remove_file(&lock2);
});
let res = check(&r, &["add", "f.txt"]);
releaser.join().unwrap();
assert!(res.is_ok(), "add should retry past a transient index.lock, got {res:?}");
}
#[test]
fn cursor_anchor_no_upstream_falls_back_to_head() {
let r = tmp();
git(&r, &["init", "-q", "-b", "main"]);
let h = commit(&r, "a");
assert_eq!(cursor_anchor(&r), Some(h)); }
#[test]
fn cursor_anchor_synced_is_head() {
let (_hub, work, _m0) = hub_and_clone();
assert_eq!(cursor_anchor(&work), Some(rev(&work, "HEAD")));
}
#[test]
fn cursor_anchor_diverged_is_merge_base_not_head_nor_upstream() {
let (hub, work, m0) = hub_and_clone();
let other = clone_of(&hub);
let m1 = commit(&other, "m1");
git(&other, &["push", "-q", "origin", "main"]);
let c1 = commit(&work, "c1");
git(&work, &["fetch", "-q", "origin"]);
let anchor = cursor_anchor(&work).unwrap();
assert_eq!(anchor, m0, "anchor must be the merge-base");
assert_ne!(anchor, c1, "not local HEAD (would be orphaned by rebase)");
assert_ne!(anchor, m1, "not the upstream tip (would skip the un-integrated peer commit)");
}
#[test]
fn cursor_anchor_after_rebase_push_is_stable_head() {
let (hub, work, _m0) = hub_and_clone();
let other = clone_of(&hub);
commit(&other, "m1");
git(&other, &["push", "-q", "origin", "main"]);
commit(&work, "c1");
git(&work, &["fetch", "-q", "origin"]);
git(&work, &["rebase", "-q", "origin/main"]);
git(&work, &["push", "-q", "origin", "main"]);
let head = rev(&work, "HEAD");
assert_eq!(cursor_anchor(&work), Some(head.clone())); assert_eq!(rev(&work, "@{u}"), head);
}
#[test]
fn added_files_incremental_and_full() {
let r = tmp();
git(&r, &["init", "-q", "-b", "main"]);
let a = commit(&r, "a");
commit(&r, "b");
assert_eq!(names(&added_message_files(&r, Some(&a)).unwrap()), vec!["b.md"]);
assert_eq!(names(&added_message_files(&r, None).unwrap()), vec!["a.md", "b.md"]);
assert!(added_message_files(&r, Some(&rev(&r, "HEAD"))).unwrap().is_empty());
}
#[test]
fn added_files_unknown_cursor_falls_back_to_full_not_empty() {
let r = tmp();
git(&r, &["init", "-q", "-b", "main"]);
commit(&r, "a");
commit(&r, "b");
let bogus = "0".repeat(40);
assert_eq!(names(&added_message_files(&r, Some(&bogus)).unwrap()), vec!["a.md", "b.md"]);
}
#[test]
fn added_files_diverged_cursor_uses_merge_base_not_full_nor_orphan() {
let r = tmp();
git(&r, &["init", "-q", "-b", "main"]);
let base = commit(&r, "base");
let x = commit(&r, "x");
git(&r, &["reset", "--hard", "-q", &base]);
commit(&r, "y");
commit(&r, "z");
let got = names(&added_message_files(&r, Some(&x)).unwrap());
assert!(got.contains(&"y.md".into()) && got.contains(&"z.md".into()), "emits the divergent tail");
assert!(!got.contains(&"base.md".into()), "merge-base excludes the shared base");
assert!(!got.contains(&"x.md".into()), "the orphaned branch is not replayed");
}
#[test]
fn gitlock_records_pid_persists_and_reacquires() {
let r = tmp();
std::fs::create_dir_all(r.join(".confer")).unwrap();
let p = r.join(".confer").join("gitlock");
let lock = acquire_lock(&r).unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap().trim(), std::process::id().to_string());
drop(lock);
assert!(p.exists(), "the lock file persists after release");
let _relock = acquire_lock(&r).expect("re-acquire after release must not wedge");
}
}