use crate::git::{SEP, git_capture, git_capture_raw};
use crate::tui::highlight::{Blob, DiffContext};
use std::collections::{HashMap, HashSet};
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::Sender;
use std::thread;
const BATCH: usize = 64;
pub enum Batch {
Branches {
seq: u64,
rows: Vec<Branch>,
done: bool,
},
Commits {
seq: u64,
rows: Vec<Commit>,
done: bool,
},
Files {
seq: u64,
rows: Vec<FileEntry>,
done: bool,
},
RemoteTags {
seq: u64,
answer: RemoteTags,
},
TagBase {
seq: u64,
prev: Option<String>,
},
}
pub struct Branch {
pub is_head: bool,
pub remote: bool,
pub name: String,
pub refname: String,
pub rel: String,
pub author: String,
pub has_upstream: bool,
pub upstream: String,
pub track: String,
}
pub struct Commit {
pub hash: String,
pub short: String,
pub date: String,
pub committer: String,
pub subject: String,
}
pub struct FileEntry {
pub status: char,
pub path: String,
}
pub struct Tag {
pub name: String,
pub date: String,
pub annotated: bool,
pub object: String,
pub commit: String,
pub subject: String,
pub state: TagState,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum TagState {
Unknown,
Local,
Pushed,
Differs,
Remote,
}
#[derive(Default)]
pub struct RemoteTag {
pub object: String,
pub commit: String,
}
pub enum RemoteTags {
NoRemote,
Unreachable(String),
Answered {
remote: String,
tags: HashMap<String, RemoteTag>,
fresh: bool,
},
}
pub fn load_unpushed(repo: &str) -> HashSet<String> {
git_capture(repo, &["rev-list", "--branches", "--not", "--remotes"])
.map(|out| out.lines().map(str::to_string).collect())
.unwrap_or_default()
}
pub fn first_commit(repo: &str, extra: &[&str]) -> String {
let mut args = vec!["log", "-n", "1", "--pretty=format:%H"];
args.extend_from_slice(extra);
git_capture(repo, &args).unwrap_or_default()
}
fn parse_commit(line: &str) -> Option<Commit> {
let mut f = line.split(SEP);
Some(Commit {
hash: f.next()?.to_string(),
short: f.next()?.to_string(),
date: f.next()?.to_string(),
committer: f.next()?.to_string(),
subject: f.next().unwrap_or("").to_string(),
})
}
fn branch_format() -> String {
format!(
"--format=%(HEAD){SEP}%(refname){SEP}%(committerdate:relative){SEP}%(authorname){SEP}%(upstream){SEP}%(upstream:track)"
)
}
fn parse_branch(line: &str) -> Option<Branch> {
let mut f = line.split(SEP);
let head = f.next()?;
let refname = f.next()?;
let rel = f.next().unwrap_or("").to_string();
let author = f.next().unwrap_or("").to_string();
let upstream = f.next().unwrap_or("");
let track = f.next().unwrap_or("").to_string();
if refname.ends_with("/HEAD") {
return None;
}
let name = refname
.strip_prefix("refs/heads/")
.or_else(|| refname.strip_prefix("refs/remotes/"))?;
Some(Branch {
is_head: head.trim() == "*",
remote: refname.starts_with("refs/remotes/"),
name: name.to_string(),
refname: refname.to_string(),
rel,
author,
has_upstream: !upstream.is_empty(),
upstream: upstream
.strip_prefix("refs/remotes/")
.unwrap_or(upstream)
.to_string(),
track,
})
}
pub fn load_branches(repo: &str) -> Vec<Branch> {
let fmt = branch_format();
git_capture(
repo,
&[
"for-each-ref",
"--sort=-committerdate",
&fmt,
"refs/heads",
"refs/remotes",
],
)
.map(|out| out.lines().filter_map(parse_branch).collect())
.unwrap_or_default()
}
pub fn stream_branches(repo: String, seq: u64, latest: Arc<AtomicU64>, tx: Sender<Batch>) {
thread::spawn(move || {
let fmt = branch_format();
let args = [
"for-each-ref",
"--sort=-committerdate",
fmt.as_str(),
"refs/heads",
"refs/remotes",
];
stream_rows(
&repo,
&args,
seq,
&latest,
&tx,
parse_branch,
|seq, rows, done| Batch::Branches { seq, rows, done },
);
});
}
fn stream_rows<T, P, W>(
repo: &str,
args: &[&str],
seq: u64,
latest: &Arc<AtomicU64>,
tx: &Sender<Batch>,
parse: P,
wrap: W,
) where
P: Fn(&str) -> Option<T>,
W: Fn(u64, Vec<T>, bool) -> Batch,
{
let spawned = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn();
let Ok(mut child) = spawned else {
let _ = tx.send(wrap(seq, Vec::new(), true));
return;
};
let Some(out) = child.stdout.take() else {
let _ = child.wait();
return;
};
let mut rows: Vec<T> = Vec::with_capacity(BATCH);
let mut whole = true;
for line in BufReader::new(out).lines().map_while(Result::ok) {
if latest.load(Ordering::Relaxed) != seq {
whole = false;
break;
}
if let Some(row) = parse(&line) {
rows.push(row);
}
if rows.len() >= BATCH {
let batch = std::mem::take(&mut rows);
if tx.send(wrap(seq, batch, false)).is_err() {
whole = false;
break;
}
rows.reserve(BATCH);
}
}
if !whole {
let _ = child.kill();
}
let _ = child.wait();
if whole {
let _ = tx.send(wrap(seq, rows, true));
}
}
pub fn stream_commits(
repo: String,
args: Vec<String>,
limit: usize,
seq: u64,
latest: Arc<AtomicU64>,
tx: Sender<Batch>,
) {
thread::spawn(move || log_rows(&repo, &args, limit, seq, &latest, &tx));
}
pub fn stream_tag_commits(
repo: String,
rev: String,
limit: usize,
seq: u64,
latest: Arc<AtomicU64>,
tx: Sender<Batch>,
) {
thread::spawn(move || {
let parent = format!("{rev}^");
let before = git_capture(&repo, &["describe", "--tags", "--abbrev=0", &parent]);
let mut args = vec![rev];
if let Some(prev) = &before {
args.push(format!("^refs/tags/{prev}"));
}
let _ = tx.send(Batch::TagBase { seq, prev: before });
log_rows(&repo, &args, limit, seq, &latest, &tx);
});
}
fn log_rows(
repo: &str,
args: &[String],
limit: usize,
seq: u64,
latest: &Arc<AtomicU64>,
tx: &Sender<Batch>,
) {
let n = limit.to_string();
let fmt = format!("--pretty=format:%H{SEP}%h{SEP}%ad{SEP}%cn{SEP}%s");
let mut argv = vec![
"log",
"-n",
n.as_str(),
"--date=format:%Y-%m-%d %H:%M",
fmt.as_str(),
];
argv.extend(args.iter().map(String::as_str));
stream_rows(
repo,
&argv,
seq,
latest,
tx,
parse_commit,
|seq, rows, done| Batch::Commits { seq, rows, done },
);
}
pub fn load_tags(repo: &str) -> Vec<Tag> {
let fmt = format!(
"--format=%(refname){SEP}%(creatordate:format:%Y-%m-%d %H:%M){SEP}%(objecttype){SEP}%(objectname){SEP}%(*objectname){SEP}%(contents:subject)"
);
let args = [
"for-each-ref",
"--sort=-v:refname",
"--sort=-creatordate",
&fmt,
"refs/tags",
];
git_capture(repo, &args)
.map(|out| out.lines().filter_map(parse_tag).collect())
.unwrap_or_default()
}
fn parse_tag(line: &str) -> Option<Tag> {
let mut f = line.split(SEP);
let name = f.next()?.strip_prefix("refs/tags/")?.to_string();
let date = f.next()?.to_string();
let annotated = f.next()? == "tag";
let object = f.next()?.to_string();
let peeled = f.next()?;
let commit = if peeled.is_empty() {
object.clone()
} else {
peeled.to_string()
};
Some(Tag {
name,
date,
annotated,
object,
commit,
subject: f.next().unwrap_or("").to_string(),
state: TagState::Unknown,
})
}
pub fn tag_remote(repo: &str) -> Option<String> {
let remotes = git_capture(repo, &["remote"]).unwrap_or_default();
remotes
.lines()
.find(|r| *r == "origin")
.or_else(|| remotes.lines().next())
.map(str::to_string)
}
pub fn tag_rule(remote: &str) -> String {
format!("+refs/tags/*:refs/remote-tags/{remote}/*")
}
pub fn tracks_tags(repo: &str, remote: &str) -> bool {
let key = format!("remote.{remote}.fetch");
git_capture(repo, &["config", "--get-all", &key])
.is_some_and(|rules| rules.lines().any(|r| r == tag_rule(remote)))
}
pub fn tag_copy(repo: &str, remote: &str) -> HashMap<String, RemoteTag> {
let prefix = format!("refs/remote-tags/{remote}/");
let fmt = format!("--format=%(refname){SEP}%(objectname){SEP}%(*objectname)");
git_capture(repo, &["for-each-ref", &fmt, &prefix])
.map(|out| {
out.lines()
.filter_map(|line| {
let mut f = line.split(SEP);
let name = f.next()?.strip_prefix(&prefix)?.to_string();
let object = f.next()?.to_string();
let peeled = f.next().unwrap_or("");
let commit = if peeled.is_empty() {
object.clone()
} else {
peeled.to_string()
};
Some((name, RemoteTag { object, commit }))
})
.collect()
})
.unwrap_or_default()
}
pub fn stream_remote_tags(
repo: String,
remote: Option<String>,
tracked: bool,
seq: u64,
tx: Sender<Batch>,
) {
thread::spawn(move || {
let answer = match remote {
None => RemoteTags::NoRemote,
Some(remote) if tracked => refresh_copy(&repo, remote),
Some(remote) => ask(&repo, remote),
};
let _ = tx.send(Batch::RemoteTags { seq, answer });
});
}
fn refresh_copy(repo: &str, remote: String) -> RemoteTags {
let rule = tag_rule(&remote);
let fresh = unattended_git(repo)
.args(["fetch", "--no-tags", "--prune", &remote, &rule])
.stdout(Stdio::null())
.status()
.is_ok_and(|s| s.success());
RemoteTags::Answered {
tags: tag_copy(repo, &remote),
remote,
fresh,
}
}
fn ask(repo: &str, remote: String) -> RemoteTags {
let out = unattended_git(repo)
.args(["ls-remote", "--tags", &remote])
.output();
match out {
Ok(out) if out.status.success() => RemoteTags::Answered {
remote,
tags: parse_ls_remote(&String::from_utf8_lossy(&out.stdout)),
fresh: true,
},
_ => RemoteTags::Unreachable(remote),
}
}
pub fn fetch_prune(repo: &str) -> Result<(), String> {
let mut cmd = unattended_git(repo);
cmd.args(["fetch", "--all", "--prune", "--quiet"])
.stdout(Stdio::null())
.stderr(Stdio::piped());
let out = cmd
.output()
.map_err(|e| format!("could not run git: {e}"))?;
if out.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
pub fn fast_forward(repo: &str) -> Result<Option<usize>, String> {
if git_capture(repo, &["rev-parse", "--abbrev-ref", "@{u}"]).is_none() {
return Ok(None);
}
let before = git_capture(repo, &["rev-parse", "HEAD"]).unwrap_or_default();
let (ok, out) = crate::git::git_run(repo, &["merge", "--ff-only", "--quiet", "@{u}"]);
if !ok {
return Err(out);
}
let moved = git_capture(repo, &["rev-list", "--count", &format!("{before}..HEAD")])
.and_then(|n| n.parse().ok())
.unwrap_or(0);
Ok(Some(moved))
}
pub fn push_delete(repo: &str, remote: &str, refname: &str) -> Result<(), String> {
let mut cmd = unattended_git(repo);
cmd.args(["push", remote, &format!(":{refname}")])
.stdout(Stdio::null())
.stderr(Stdio::piped());
let out = cmd
.output()
.map_err(|e| format!("could not run git: {e}"))?;
if out.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
pub fn default_branch(repo: &str, remote: &str) -> Option<String> {
let head = format!("refs/remotes/{remote}/HEAD");
let target = git_capture(repo, &["symbolic-ref", "--short", &head])?;
target
.strip_prefix(&format!("{remote}/"))
.map(str::to_string)
}
pub fn trunk(repo: &str, remote: &str) -> Option<String> {
let names = default_branch(repo, remote)
.into_iter()
.chain(["main".to_string(), "master".to_string()]);
names
.map(|n| format!("refs/remotes/{remote}/{n}"))
.find(|r| git_capture(repo, &["rev-parse", "--verify", "-q", r]).is_some())
}
pub fn landed(repo: &str, branch: &str, trunk: &str) -> bool {
let tip = format!("refs/heads/{branch}");
let merged = git_capture(repo, &["merge-tree", "--write-tree", trunk, &tip]);
let tree = format!("{trunk}^{{tree}}");
let current = git_capture(repo, &["rev-parse", &tree]);
matches!((merged, current), (Some(m), Some(c)) if m.lines().next() == Some(c.as_str()))
}
pub fn unique_commits(repo: &str, branch: &str) -> usize {
let tip = format!("refs/heads/{branch}");
git_capture(repo, &["rev-list", "--count", &tip, "--not", "--remotes"])
.and_then(|n| n.parse().ok())
.unwrap_or(0)
}
fn parse_ls_remote(out: &str) -> HashMap<String, RemoteTag> {
let mut tags: HashMap<String, RemoteTag> = HashMap::new();
for line in out.lines() {
let Some((sha, name)) = line
.split_once('\t')
.and_then(|(sha, r)| Some((sha, r.strip_prefix("refs/tags/")?)))
else {
continue;
};
let (name, peeled) = match name.strip_suffix("^{}") {
Some(name) => (name, true),
None => (name, false),
};
let tag = tags.entry(name.to_string()).or_default();
if peeled {
tag.commit = sha.to_string();
} else {
tag.object = sha.to_string();
if tag.commit.is_empty() {
tag.commit = sha.to_string();
}
}
}
tags
}
fn unattended_git(repo: &str) -> Command {
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(repo)
.env("GIT_TERMINAL_PROMPT", "0")
.stdin(Stdio::null())
.stderr(Stdio::null());
if std::env::var_os("GIT_SSH").is_none() {
let ssh = std::env::var("GIT_SSH_COMMAND")
.ok()
.or_else(|| git_capture(repo, &["config", "core.sshCommand"]))
.unwrap_or_else(|| "ssh".to_string());
cmd.env(
"GIT_SSH_COMMAND",
format!("{ssh} -o BatchMode=yes -o ConnectTimeout=10"),
);
}
cmd
}
pub fn stream_files(repo: String, hash: String, paths: Vec<String>, seq: u64, tx: Sender<Batch>) {
thread::spawn(move || {
let spec: Vec<&str> = paths.iter().map(String::as_str).collect();
let rows = load_files(&repo, &hash, &spec);
let _ = tx.send(Batch::Files {
seq,
rows,
done: true,
});
});
}
const FIRST_PARENT: &str = "--diff-merges=first-parent";
pub fn load_files(repo: &str, hash: &str, pathspec: &[&str]) -> Vec<FileEntry> {
let mut args = vec!["show", "--name-status", "--format=", FIRST_PARENT, hash];
if !pathspec.is_empty() {
args.push("--");
args.extend_from_slice(pathspec);
}
git_capture(repo, &args)
.map(|out| {
out.lines()
.filter(|l| !l.is_empty())
.filter_map(|line| {
let mut parts = line.split('\t');
let status = parts.next()?.chars().next()?;
let path = parts.next_back()?.to_string();
Some(FileEntry { status, path })
})
.collect()
})
.unwrap_or_default()
}
pub fn load_diff_raw(repo: &str, hash: &str, path: &str) -> String {
git_capture(repo, &["show", "--format=", FIRST_PARENT, hash, "--", path]).unwrap_or_default()
}
pub fn load_blob(repo: &str, rev: &str, path: &str) -> Option<String> {
git_capture_raw(repo, &["show", &format!("{rev}:{path}")])
}
pub fn commit_diff_ctx(repo: &str, hash: &str, path: &str) -> DiffContext {
let side = |rev: String| -> Blob {
let (repo, path) = (repo.to_string(), path.to_string());
Box::new(move || load_blob(&repo, &rev, &path))
};
DiffContext {
old: Some(side(format!("{hash}^"))),
new: Some(side(hash.to_string())),
}
}
#[cfg(test)]
mod tests {
use super::{RemoteTags, ask, landed, load_branches, load_diff_raw, load_files, load_tags};
use super::{tag_copy, tag_rule};
use crate::git::git_capture;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
struct TempRepo(PathBuf);
impl TempRepo {
fn new(tag: &str) -> TempRepo {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let dir = std::env::temp_dir().join(format!("sluuz-{tag}-{stamp}"));
fs::create_dir_all(&dir).expect("temp dir");
let repo = TempRepo(dir);
repo.git(&["init", "-q"]);
repo
}
fn path(&self) -> &str {
self.0.to_str().expect("utf-8 temp path")
}
fn git(&self, args: &[&str]) {
git_capture(self.path(), args).unwrap_or_else(|| panic!("git {args:?}"));
}
fn as_author(&self, args: &[&str]) {
let mut all = vec![
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"-c",
"commit.gpgsign=false",
];
all.extend_from_slice(args);
self.git(&all);
}
fn commit(&self, file: &str, text: &str, msg: &str) {
fs::write(self.0.join(file), text).expect("write fixture");
self.git(&["add", file]);
self.as_author(&["commit", "-q", "-m", msg]);
}
}
impl Drop for TempRepo {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn every_file_a_merge_lists_has_a_diff_to_show() {
let repo = TempRepo::new("merge");
repo.commit("f.py", "a\nb\nc\nd\ne\nf\ng\n", "base");
repo.git(&["checkout", "-q", "-b", "side"]);
repo.commit("f.py", "A\nb\nc\nd\ne\nf\ng\n", "edit the top");
repo.git(&["checkout", "-q", "-"]);
repo.commit("f.py", "a\nb\nc\nd\ne\nf\nG\n", "edit the bottom");
repo.as_author(&["merge", "-q", "--no-ff", "--no-edit", "side"]);
let files = load_files(repo.path(), "HEAD", &[]);
assert!(
!files.is_empty(),
"the merge brought a change in, so it lists a file"
);
for file in files {
assert!(
load_diff_raw(repo.path(), "HEAD", &file.path).contains("@@"),
"`{}` is listed for the merge but has no hunk to show",
file.path
);
}
}
#[test]
fn a_squash_merged_branch_counts_as_landed_and_new_work_does_not() {
let repo = TempRepo::new("landed");
repo.commit("base.txt", "base\n", "base");
repo.git(&["branch", "-M", "main"]);
repo.git(&["checkout", "-q", "-b", "feature"]);
repo.commit("a.txt", "a\n", "add a");
repo.commit("b.txt", "b\n", "add b");
repo.git(&["checkout", "-q", "main"]);
repo.git(&["merge", "-q", "--squash", "feature"]);
repo.as_author(&["commit", "-q", "-m", "feature, squashed"]);
repo.commit("later.txt", "later\n", "main moves on");
assert!(
landed(repo.path(), "feature", "main"),
"a squash gives feature's changes new ids on main, yet every one of them is there"
);
repo.git(&["checkout", "-q", "feature"]);
repo.commit("c.txt", "c\n", "work main never got");
assert!(
!landed(repo.path(), "feature", "main"),
"a commit main does not have means deleting feature would lose it"
);
}
#[test]
fn an_annotated_tag_is_compared_by_its_object_and_logged_by_its_commit() {
let repo = TempRepo::new("tags");
repo.commit("f.txt", "f\n", "tagged");
repo.as_author(&["tag", "-a", "v1", "-m", "annotated"]);
repo.git(&["tag", "v0"]);
repo.git(&["fetch", "-q", ".", &tag_rule("self")]);
let head = git_capture(repo.path(), &["rev-parse", "HEAD"]).expect("HEAD");
let RemoteTags::Answered { tags: asked, .. } = ask(repo.path(), ".".to_string()) else {
panic!("`ls-remote` against the repo itself did not answer");
};
let copy = tag_copy(repo.path(), "self");
let local = load_tags(repo.path());
assert_eq!(local.len(), 2, "both tags should be read");
for tag in &local {
assert_eq!(
tag.commit, head,
"`{}` should log from its commit",
tag.name
);
assert_eq!(
tag.annotated,
tag.object != tag.commit,
"`{}`: only an annotated tag names an object other than its commit",
tag.name
);
for (reader, remote) in [("ls-remote", &asked), ("git's copy", ©)] {
let theirs = remote
.get(&tag.name)
.unwrap_or_else(|| panic!("{reader} is missing `{}`", tag.name));
assert_eq!(
(&theirs.object, &theirs.commit),
(&tag.object, &tag.commit),
"{reader} reads `{}` differently from the clone, so an identical tag would mark as differing",
tag.name
);
}
}
}
#[test]
fn a_branch_and_a_tag_sharing_a_name_each_keep_it() {
let repo = TempRepo::new("shared-name");
repo.commit("f.txt", "f\n", "base");
repo.git(&["branch", "v1"]);
repo.git(&["tag", "v1"]);
let branches: Vec<String> = load_branches(repo.path())
.into_iter()
.map(|b| b.name)
.collect();
assert!(
branches.iter().any(|b| b == "v1"),
"the branch should read as `v1`, which `git branch -D` accepts, got {branches:?}"
);
let tags: Vec<String> = load_tags(repo.path()).into_iter().map(|t| t.name).collect();
assert_eq!(tags, ["v1"], "the tag should read as `v1`");
}
}