use std::path::{Path, PathBuf};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::thread;
use crate::domain::Repo;
use crate::git;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Divergence {
pub reference: String,
pub ahead: u32,
pub behind: u32,
}
impl Divergence {
pub fn is_level(&self) -> bool {
self.ahead == 0 && self.behind == 0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoStatus {
pub repo_id: i64,
pub name: String,
pub path: PathBuf,
pub branch: Option<String>,
pub upstream: Option<Divergence>,
pub default: Option<Divergence>,
pub dirty: usize,
pub trouble: Option<String>,
}
impl RepoStatus {
fn blank(repo: &Repo) -> Self {
Self {
repo_id: repo.id,
name: repo.name.clone(),
path: repo.path.clone(),
branch: None,
upstream: None,
default: None,
dirty: 0,
trouble: None,
}
}
}
pub fn status(repo: &Repo) -> RepoStatus {
let mut row = RepoStatus::blank(repo);
let path = repo.path.as_path();
if !git::is_repo(path) {
row.trouble = Some(if path.exists() {
"not a git repo any more".into()
} else {
"gone from disk".into()
});
return row;
}
row.branch = git::head_branch(path).ok().flatten();
match git::status(path) {
Ok(entries) => row.dirty = entries.len(),
Err(err) => row.trouble = Some(format!("could not read status: {err}")),
}
let upstream = git::upstream(path).ok().flatten();
if let Some(reference) = &upstream
&& let Ok(Some((ahead, behind))) = git::ahead_behind(path, "HEAD", reference)
{
row.upstream = Some(Divergence {
reference: reference.clone(),
ahead,
behind,
});
}
if let Some(reference) = default_ref(path)
&& upstream.as_deref() != Some(reference.as_str())
&& let Ok(Some((ahead, behind))) = git::ahead_behind(path, "HEAD", &reference)
{
row.default = Some(Divergence {
reference,
ahead,
behind,
});
}
row
}
fn default_ref(repo: &Path) -> Option<String> {
let default = git::default_branch(repo).ok()?;
let remote = format!("origin/{default}");
match git::ref_exists(repo, &remote) {
Ok(true) => Some(remote),
_ => Some(default),
}
}
#[derive(Debug, Clone)]
pub enum Job {
Refresh(Vec<Repo>),
Fetch(Repo),
Pull(Repo),
}
impl Job {
pub fn doing(&self) -> String {
match self {
Self::Refresh(repos) => format!("reading {} repos", repos.len()),
Self::Fetch(repo) => format!("fetching {}", repo.name),
Self::Pull(repo) => format!("pulling {}", repo.name),
}
}
pub fn did(&self) -> Option<String> {
match self {
Self::Refresh(_) => None,
Self::Fetch(repo) => Some(format!("fetched {}", repo.name)),
Self::Pull(repo) => Some(format!("pulled {}", repo.name)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Update {
Row(Box<RepoStatus>),
Says(String),
Done,
}
pub fn spawn(job: Job) -> Receiver<Update> {
let (tx, rx) = channel();
thread::spawn(move || run(job, &tx));
rx
}
pub fn run(job: Job, tx: &Sender<Update>) {
match &job {
Job::Refresh(repos) => {
for repo in repos {
if tx.send(Update::Row(Box::new(status(repo)))).is_err() {
return;
}
}
}
Job::Fetch(repo) | Job::Pull(repo) => {
let outcome = match &job {
Job::Pull(_) => git::pull(&repo.path),
_ => git::fetch(&repo.path),
};
if let Err(err) = outcome {
let _ = tx.send(Update::Says(format!("{}: {}", repo.name, brief(&err))));
}
if tx.send(Update::Row(Box::new(status(repo)))).is_err() {
return;
}
}
}
let _ = tx.send(Update::Done);
}
fn brief(err: &git::Error) -> String {
let text = err.to_string();
let last = text
.lines()
.rev()
.map(str::trim)
.find(|line| !line.is_empty());
crate::tui::first_words(last.unwrap_or("failed"), 12)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::testing::init_repo;
use chrono::{TimeZone, Utc};
use std::process::Command;
use tempfile::TempDir;
fn repo_at(path: &Path, id: i64) -> Repo {
let now = Utc.timestamp_opt(0, 0).unwrap();
Repo {
id,
path: path.to_path_buf(),
name: path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
ignored: false,
discovered_at: now,
last_seen_at: now,
}
}
fn git_in(repo: &Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.expect("git");
assert!(out.status.success(), "git {args:?}: {out:?}");
}
fn commit(repo: &Path, name: &str) {
std::fs::write(repo.join(name), name).unwrap();
git_in(repo, &["add", "."]);
git_in(repo, &["commit", "-q", "-m", name]);
}
fn origin_and_clone(tmp: &TempDir) -> (PathBuf, PathBuf) {
let origin = tmp.path().join("origin");
init_repo(&origin, "main");
let clone = tmp.path().join("clone");
let out = Command::new("git")
.args(["clone", "-q"])
.arg(&origin)
.arg(&clone)
.output()
.expect("git clone");
assert!(out.status.success(), "clone failed: {out:?}");
git_in(&clone, &["config", "user.email", "test@marver.invalid"]);
git_in(&clone, &["config", "user.name", "marver tests"]);
(origin, clone)
}
#[test]
fn a_fresh_clone_is_level_with_its_upstream() {
let tmp = TempDir::new().unwrap();
let (_, clone) = origin_and_clone(&tmp);
let row = status(&repo_at(&clone, 1));
assert_eq!(row.branch.as_deref(), Some("main"));
let upstream = row.upstream.expect("a clone tracks its origin");
assert_eq!(upstream.reference, "origin/main");
assert!(upstream.is_level(), "{upstream:?}");
assert_eq!(row.dirty, 0);
assert_eq!(row.trouble, None);
}
#[test]
fn the_default_column_is_empty_when_it_would_repeat_the_upstream() {
let tmp = TempDir::new().unwrap();
let (_, clone) = origin_and_clone(&tmp);
let row = status(&repo_at(&clone, 1));
assert!(row.upstream.is_some());
assert_eq!(row.default, None, "{row:?}");
}
#[test]
fn ahead_and_behind_are_counted_separately() {
let tmp = TempDir::new().unwrap();
let (origin, clone) = origin_and_clone(&tmp);
commit(&origin, "theirs-one");
commit(&origin, "theirs-two");
commit(&clone, "ours");
git_in(&clone, &["fetch", "-q"]);
let upstream = status(&repo_at(&clone, 1)).upstream.expect("tracking");
assert_eq!((upstream.ahead, upstream.behind), (1, 2));
assert!(!upstream.is_level());
}
#[test]
fn a_feature_branch_is_measured_against_the_default_too() {
let tmp = TempDir::new().unwrap();
let (origin, clone) = origin_and_clone(&tmp);
commit(&origin, "theirs");
git_in(&clone, &["fetch", "-q"]);
git_in(&clone, &["checkout", "-q", "-b", "feature"]);
commit(&clone, "mine");
let row = status(&repo_at(&clone, 1));
assert_eq!(row.branch.as_deref(), Some("feature"));
assert_eq!(row.upstream, None, "a new branch tracks nothing yet");
let default = row.default.expect("still comparable to main");
assert_eq!(default.reference, "origin/main");
assert_eq!((default.ahead, default.behind), (1, 1));
}
#[test]
fn uncommitted_files_are_counted() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("solo");
init_repo(&repo, "main");
std::fs::write(repo.join("README.md"), "changed").unwrap();
std::fs::write(repo.join("new.txt"), "untracked").unwrap();
assert_eq!(status(&repo_at(&repo, 1)).dirty, 2);
}
#[test]
fn a_repo_with_no_remote_is_compared_against_its_local_default() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("solo");
init_repo(&repo, "main");
git_in(&repo, &["checkout", "-q", "-b", "work"]);
commit(&repo, "one");
let row = status(&repo_at(&repo, 1));
assert_eq!(row.upstream, None);
let default = row.default.expect("main is still there to compare with");
assert_eq!(default.reference, "main", "no origin to prefer");
assert_eq!((default.ahead, default.behind), (1, 0));
}
#[test]
fn a_detached_head_has_no_branch_and_no_trouble() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("solo");
init_repo(&repo, "main");
commit(&repo, "two");
git_in(&repo, &["checkout", "-q", "HEAD~1"]);
let row = status(&repo_at(&repo, 1));
assert_eq!(row.branch, None);
assert_eq!(row.trouble, None);
assert_eq!(row.dirty, 0);
}
#[test]
fn a_repo_that_has_gone_still_gets_a_row() {
let tmp = TempDir::new().unwrap();
let row = status(&repo_at(&tmp.path().join("was-here"), 7));
assert_eq!(row.repo_id, 7);
assert_eq!(row.name, "was-here");
assert_eq!(row.trouble.as_deref(), Some("gone from disk"));
}
#[test]
fn refresh_reports_a_row_each_and_then_stops() {
let tmp = TempDir::new().unwrap();
let one = tmp.path().join("one");
let two = tmp.path().join("two");
init_repo(&one, "main");
init_repo(&two, "main");
let (tx, rx) = channel();
run(Job::Refresh(vec![repo_at(&one, 1), repo_at(&two, 2)]), &tx);
drop(tx);
let updates: Vec<_> = rx.iter().collect();
assert_eq!(updates.len(), 3, "two rows and a done: {updates:?}");
assert_eq!(updates[2], Update::Done);
assert!(matches!(&updates[0], Update::Row(row) if row.repo_id == 1));
assert!(matches!(&updates[1], Update::Row(row) if row.repo_id == 2));
}
#[test]
fn fetching_updates_the_counts_without_moving_the_branch() {
let tmp = TempDir::new().unwrap();
let (origin, clone) = origin_and_clone(&tmp);
commit(&origin, "theirs");
let before = status(&repo_at(&clone, 1)).upstream.expect("tracking");
assert!(before.is_level(), "nothing fetched yet: {before:?}");
let (tx, rx) = channel();
run(Job::Fetch(repo_at(&clone, 1)), &tx);
drop(tx);
let after = rows(rx).pop().expect("a row").upstream.expect("tracking");
assert_eq!((after.ahead, after.behind), (0, 1), "it is now behind");
assert_eq!(
git::head_branch(&clone).unwrap().as_deref(),
Some("main"),
"fetch moves no branch"
);
}
#[test]
fn pulling_fast_forwards_and_the_row_says_so() {
let tmp = TempDir::new().unwrap();
let (origin, clone) = origin_and_clone(&tmp);
commit(&origin, "theirs");
let (tx, rx) = channel();
run(Job::Pull(repo_at(&clone, 1)), &tx);
drop(tx);
let row = rows(rx).pop().expect("a row");
assert!(row.upstream.expect("tracking").is_level(), "caught up");
assert!(clone.join("theirs").exists(), "the work arrived");
}
#[test]
fn a_pull_that_cannot_fast_forward_is_refused_and_reported() {
let tmp = TempDir::new().unwrap();
let (origin, clone) = origin_and_clone(&tmp);
commit(&origin, "theirs");
commit(&clone, "ours");
let (tx, rx) = channel();
run(Job::Pull(repo_at(&clone, 1)), &tx);
drop(tx);
let updates: Vec<_> = rx.iter().collect();
assert!(
updates.iter().any(|u| matches!(u, Update::Says(_))),
"the refusal must be reported: {updates:?}"
);
assert!(
updates
.iter()
.any(|u| matches!(u, Update::Row(row) if row.upstream.is_some())),
"and the row read anyway: {updates:?}"
);
assert!(clone.join("ours").exists(), "local work is untouched");
}
#[test]
fn a_job_stops_when_the_screen_it_was_for_has_gone() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("one");
init_repo(&repo, "main");
let (tx, rx) = channel();
drop(rx);
run(Job::Refresh(vec![repo_at(&repo, 1)]), &tx);
}
fn rows(rx: Receiver<Update>) -> Vec<RepoStatus> {
rx.iter()
.filter_map(|update| match update {
Update::Row(row) => Some(*row),
_ => None,
})
.collect()
}
}