use crate::git::{RepoStatus, 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>,
},
Incoming {
seq: u64,
upstream: String,
hashes: HashSet<String>,
},
Synced {
pull: bool,
outcomes: Vec<(String, Result<usize, String>)>,
},
RepoScan {
repos: Vec<RepoStatus>,
},
}
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 refs: Vec<RefLabel>,
pub subject: String,
}
pub struct RefLabel {
pub kind: RefKind,
pub name: String,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum RefKind {
Head,
Detached,
Branch,
Remote,
Tag,
}
impl RefLabel {
pub fn text(&self) -> String {
match self.kind {
RefKind::Head => format!("HEAD -> {}", self.name),
RefKind::Detached => "HEAD".to_string(),
RefKind::Branch | RefKind::Remote => self.name.clone(),
RefKind::Tag => format!("tag: {}", self.name),
}
}
}
pub fn fit_refs(refs: &[RefLabel], room: usize) -> (Vec<(RefKind, String)>, usize) {
let more = |left: usize| {
if left == 0 {
0
} else {
3 + left.to_string().len()
}
};
let mut kept = Vec::new();
let mut used = 3;
for (i, r) in refs.iter().enumerate() {
let text = r.text();
let sep = if i == 0 { 0 } else { 2 };
let len = text.chars().count();
if used + sep + len + more(refs.len() - i - 1) > room {
if i == 0 {
let fit = room.saturating_sub(used + more(refs.len() - 1)).max(2);
let cut: String = text.chars().take(fit - 1).collect();
kept.push((r.kind, format!("{cut}…")));
}
break;
}
used += sep + len;
kept.push((r.kind, text));
}
let hidden = refs.len() - kept.len();
(kept, hidden)
}
pub const DECORATE: &str = "--decorate=full";
pub fn parse_refs(d: &str) -> Vec<RefLabel> {
d.split(", ")
.filter_map(|r| {
let (kind, name) = if let Some(b) = r.strip_prefix("HEAD -> refs/heads/") {
(RefKind::Head, b)
} else if r == "HEAD" {
(RefKind::Detached, r)
} else if let Some(b) = r.strip_prefix("refs/heads/") {
(RefKind::Branch, b)
} else if let Some(t) = r.strip_prefix("tag: refs/tags/") {
(RefKind::Tag, t)
} else if let Some(b) = r.strip_prefix("refs/remotes/")
&& !b.ends_with("/HEAD")
{
(RefKind::Remote, b)
} else {
return None;
};
Some(RefLabel {
kind,
name: name.to_string(),
})
})
.collect()
}
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, args: &[String], branch: Option<&str>) -> String {
let (walk, _) = with_upstream(repo, args, branch);
let mut argv = vec!["log", "-n", "1", "--pretty=format:%H"];
argv.extend(walk.iter().map(String::as_str));
git_capture(repo, &argv).unwrap_or_default()
}
fn with_upstream(
repo: &str,
args: &[String],
branch: Option<&str>,
) -> (Vec<String>, Option<String>) {
let mut walk = args.to_vec();
let Some(branch) = branch else {
return (walk, None);
};
let short = branch.strip_prefix("refs/heads/").unwrap_or(branch);
let mark = format!("{short}@{{upstream}}");
let upstream = git_capture(repo, &["rev-parse", "--symbolic-full-name", &mark]);
if let Some(upstream) = &upstream {
let at = walk.iter().position(|a| a == "--").unwrap_or(walk.len());
walk.insert(at, upstream.clone());
}
(walk, upstream)
}
pub struct CommitDetails {
pub author: String,
pub authored: String,
pub committer: String,
pub committed: String,
pub parents: Vec<String>,
pub message: String,
}
pub fn commit_details(repo: &str, rev: &str) -> Option<CommitDetails> {
let fmt = format!("--format=%an <%ae>{SEP}%ad (%ar){SEP}%cn <%ce>{SEP}%cd (%cr){SEP}%p{SEP}%B");
let out = git_capture(
repo,
&["show", "-s", "--date=format:%Y-%m-%d %H:%M", &fmt, rev],
)?;
let mut f = out.split(SEP);
Some(CommitDetails {
author: f.next()?.to_string(),
authored: f.next()?.to_string(),
committer: f.next()?.to_string(),
committed: f.next()?.to_string(),
parents: f.next()?.split_whitespace().map(str::to_string).collect(),
message: f.next().unwrap_or("").trim().to_string(),
})
}
pub fn tag_details(repo: &str, name: &str) -> Option<(String, String)> {
let fmt = format!("--format=%(taggername) %(taggeremail){SEP}%(contents)");
let tag = format!("refs/tags/{name}");
let out = git_capture(repo, &["for-each-ref", &fmt, &tag])?;
let (tagger, message) = out.split_once(SEP)?;
let tagger = tagger.trim();
(!tagger.is_empty()).then(|| (tagger.to_string(), message.trim().to_string()))
}
pub fn remote_urls(repo: &str) -> Vec<(String, String)> {
let out = git_capture(repo, &["remote", "-v"]).unwrap_or_default();
out.lines()
.filter(|l| l.ends_with("(fetch)"))
.filter_map(|l| {
let mut f = l.split_whitespace();
Some((f.next()?.to_string(), f.next()?.to_string()))
})
.collect()
}
#[derive(Clone)]
pub struct Remote {
pub name: String,
pub url: String,
pub push_url: String,
pub ahead_behind: Option<(usize, usize)>,
}
pub fn load_remotes(repo: &str, head: Option<&str>) -> Vec<Remote> {
let out = git_capture(
repo,
&["config", "--get-regexp", r"^remote\..*\.(url|pushurl)$"],
)
.unwrap_or_default();
let mut remotes = parse_remotes(&out);
for r in &mut remotes {
r.ahead_behind = head.and_then(|b| ahead_behind(repo, &format!("{}/{b}", r.name)));
}
remotes
}
fn parse_remotes(out: &str) -> Vec<Remote> {
let mut remotes: Vec<Remote> = Vec::new();
for line in out.lines() {
let Some((key, url)) = line.split_once(' ') else {
continue;
};
let Some((name, field)) = key.strip_prefix("remote.").and_then(|k| k.rsplit_once('.'))
else {
continue;
};
let at = match remotes.iter().position(|r| r.name == name) {
Some(at) => at,
None => {
remotes.push(Remote {
name: name.to_string(),
url: String::new(),
push_url: String::new(),
ahead_behind: None,
});
remotes.len() - 1
}
};
match field {
"url" => remotes[at].url = url.to_string(),
"pushurl" => remotes[at].push_url = url.to_string(),
_ => {}
}
}
for r in &mut remotes {
if r.push_url.is_empty() {
r.push_url = r.url.clone();
}
}
remotes
}
fn ahead_behind(repo: &str, remote_branch: &str) -> Option<(usize, usize)> {
let range = format!("HEAD...refs/remotes/{remote_branch}");
let out = git_capture(repo, &["rev-list", "--left-right", "--count", &range])?;
let mut f = out.split_whitespace().map(|n| n.parse().ok());
Some((f.next()??, f.next()??))
}
pub fn head_branch(repo: &str) -> Option<String> {
git_capture(repo, &["symbolic-ref", "--quiet", "--short", "HEAD"])
}
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(),
refs: parse_refs(f.next()?),
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>,
branch: Option<String>,
limit: usize,
seq: u64,
latest: Arc<AtomicU64>,
tx: Sender<Batch>,
) {
thread::spawn(move || {
let (walk, upstream) = with_upstream(&repo, &args, branch.as_deref());
if let (Some(branch), Some(upstream)) = (&branch, &upstream) {
let range = format!("{branch}..{upstream}");
let hashes = git_capture(&repo, &["rev-list", &range])
.unwrap_or_default()
.lines()
.map(str::to_string)
.collect();
let upstream = upstream
.strip_prefix("refs/remotes/")
.or_else(|| upstream.strip_prefix("refs/heads/"))
.unwrap_or(upstream)
.to_string();
let _ = tx.send(Batch::Incoming {
seq,
upstream,
hashes,
});
}
log_rows(&repo, &walk, 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}%D{SEP}%s");
let mut argv = vec![
"log",
"-n",
n.as_str(),
"--date=format:%Y-%m-%d %H:%M",
DECORATE,
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);
}
fast_forward_to(repo, "@{u}").map(Some)
}
pub fn fast_forward_to(repo: &str, rev: &str) -> Result<usize, String> {
if let Some(why) = diverged(repo, rev) {
return Err(why);
}
let before = git_capture(repo, &["rev-parse", "HEAD"]).unwrap_or_default();
let (ok, out) = crate::git::git_run(repo, &["merge", "--ff-only", "--quiet", rev]);
if !ok {
let said: Vec<&str> = out.lines().filter(|l| !l.starts_with("hint:")).collect();
return Err(said.join("\n"));
}
Ok(
git_capture(repo, &["rev-list", "--count", &format!("{before}..HEAD")])
.and_then(|n| n.parse().ok())
.unwrap_or(0),
)
}
fn diverged(repo: &str, rev: &str) -> Option<String> {
let counts = git_capture(
repo,
&[
"rev-list",
"--left-right",
"--count",
&format!("HEAD...{rev}"),
],
)?;
let (ahead, behind) = counts.split_once(char::is_whitespace)?;
let (ahead, behind): (usize, usize) = (ahead.trim().parse().ok()?, behind.trim().parse().ok()?);
if ahead == 0 || behind == 0 {
return None;
}
let name =
git_capture(repo, &["rev-parse", "--abbrev-ref", rev]).unwrap_or_else(|| rev.to_string());
let s = |n: usize| if n == 1 { "" } else { "s" };
Some(format!(
"{ahead} commit{} of its own and {behind} new on `{name}`, so it cannot fast-forward: `slu rebase {name}` puts yours on top, `slu reset --hard {name}` drops yours",
s(ahead),
))
}
pub fn fetch_remote(repo: &str, remote: &str) -> Result<(), String> {
run_unattended(repo, &["fetch", "--prune", "--quiet", remote])
}
pub fn push_branch(repo: &str, remote: &str) -> Result<(), String> {
let tracks = git_capture(repo, &["rev-parse", "--abbrev-ref", "@{u}"]).is_some();
let mut args = vec!["push", "--quiet"];
if !tracks {
args.push("-u");
}
args.extend([remote, "HEAD"]);
run_unattended(repo, &args)
}
fn run_unattended(repo: &str, args: &[&str]) -> Result<(), String> {
let out = unattended_git(repo)
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.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 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())
}
const LANDING_SPAN: usize = 100;
const CLOCK_SKEW: i64 = 86_400;
pub fn landed(repo: &str, branch: &str, trunk: &str) -> bool {
let tip = format!("refs/heads/{branch}");
let range = format!("{trunk}...{tip}");
let Some(paths) = git_capture(repo, &["diff", "--name-only", "--no-renames", "-z", &range])
else {
return false;
};
let paths: Vec<&str> = paths.split('\0').filter(|p| !p.is_empty()).collect();
if paths.is_empty() {
return true;
}
let Some(authored) =
git_capture(repo, &["log", "-1", "--format=%at", &tip]).and_then(|t| t.parse::<i64>().ok())
else {
return false;
};
let Some(head) = git_capture(repo, &["log", "-1", "--format=%H %T %ct", trunk]) else {
return false;
};
let since = format!("{tip}..{trunk}");
let mut args = vec!["log", "--first-parent", "--format=%H %T %ct", &since, "--"];
args.extend(paths);
let rows = git_capture(repo, &args).unwrap_or_default();
let rows: Vec<(&str, i64)> = rows
.lines()
.filter(|r| *r != head)
.filter_map(|r| Some((r, r.rsplit(' ').next()?.parse().ok()?)))
.collect();
let after = rows.iter().rev().filter(|(_, t)| *t >= authored);
let skew = rows
.iter()
.filter(|(_, t)| *t < authored && *t >= authored - CLOCK_SKEW);
let candidates: Vec<&str> = std::iter::once(head.as_str())
.chain(after.chain(skew).take(LANDING_SPAN).map(|(r, _)| *r))
.collect();
let feed: String = candidates
.iter()
.filter_map(|c| Some(format!("{} {tip}\n", c.split(' ').next()?)))
.collect();
let Some(out) = merge_tree_batch(repo, &feed) else {
return false;
};
let merges = parse_merge_batch(&out);
merges.len() == candidates.len()
&& merges
.iter()
.zip(&candidates)
.any(|((clean, tree), c)| *clean && c.split(' ').nth(1) == Some(tree))
}
fn merge_tree_batch(repo: &str, feed: &str) -> Option<String> {
use std::io::Write;
let mut child = Command::new("git")
.args([
"-C",
repo,
"merge-tree",
"--write-tree",
"--no-messages",
"--stdin",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
child.stdin.take()?.write_all(feed.as_bytes()).ok()?;
let out = child.wait_with_output().ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).into_owned())
}
fn parse_merge_batch(out: &str) -> Vec<(bool, &str)> {
let fields: Vec<&str> = out.split('\0').collect();
let mut merges = Vec::new();
let mut i = 0;
while i + 1 < fields.len() {
let oid = fields[i + 1];
if matches!(fields[i], "0" | "1")
&& oid.len() >= 40
&& oid.bytes().all(|b| b.is_ascii_hexdigit())
{
merges.push((fields[i] == "1", oid));
i += 2;
} else {
i += 1;
}
}
merges
}
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::parse_remotes;
use super::{DECORATE, RefKind, RefLabel, fit_refs, parse_refs, tag_copy, tag_rule};
use super::{RemoteTags, ask, landed, load_branches, load_diff_raw, load_files, load_tags};
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 a_rebased_branch_counts_as_landed_after_main_rewrites_its_line() {
let repo = TempRepo::new("rebased");
repo.commit("app.txt", "one\ntwo\nthree\n", "base");
repo.git(&["branch", "-M", "main"]);
repo.git(&["checkout", "-q", "-b", "feature"]);
repo.commit("app.txt", "one\nfeature\nthree\n", "feature edits two");
repo.git(&["checkout", "-q", "main"]);
repo.as_author(&["cherry-pick", "feature"]);
repo.commit(
"app.txt",
"one\nrewritten\nthree\n",
"main rewrites it again",
);
assert!(
landed(repo.path(), "feature", "main"),
"main took feature's change under a new id, so the rewrite after it loses nothing"
);
}
#[test]
fn a_deletion_main_edited_over_is_not_landed() {
let repo = TempRepo::new("deleted");
repo.commit("gone.txt", "x\n", "base");
repo.git(&["branch", "-M", "main"]);
repo.git(&["checkout", "-q", "-b", "feature"]);
repo.git(&["rm", "-q", "gone.txt"]);
repo.as_author(&["commit", "-q", "-m", "feature deletes it"]);
repo.git(&["checkout", "-q", "main"]);
repo.commit("gone.txt", "x\ny\n", "main edits it");
assert!(
!landed(repo.path(), "feature", "main"),
"a delete against an edit conflicts yet merges to main's own tree, which must not read as landed"
);
}
#[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`");
}
#[test]
fn a_log_labels_only_branches_remotes_and_tags() {
let repo = TempRepo::new("refs");
repo.commit("f", "a", "one");
repo.git(&["branch", "origin/lookalike"]);
repo.git(&["tag", "v1"]);
repo.git(&["update-ref", "refs/remotes/origin/main", "HEAD"]);
repo.git(&[
"symbolic-ref",
"refs/remotes/origin/HEAD",
"refs/remotes/origin/main",
]);
repo.git(&["update-ref", "refs/remote-tags/origin/v1", "HEAD"]);
repo.git(&["update-ref", "refs/stash", "HEAD"]);
let head = git_capture(repo.path(), &["symbolic-ref", "--short", "HEAD"]).expect("HEAD");
let d = git_capture(repo.path(), &["log", "-1", "--format=%D", DECORATE]).expect("log");
let mut got: Vec<(RefKind, String)> = parse_refs(&d)
.into_iter()
.map(|r| (r.kind, r.name))
.collect();
got.sort_by(|a, b| a.1.cmp(&b.1));
let mut want = vec![
(RefKind::Head, head),
(RefKind::Branch, "origin/lookalike".to_string()),
(RefKind::Remote, "origin/main".to_string()),
(RefKind::Tag, "v1".to_string()),
];
want.sort_by(|a, b| a.1.cmp(&b.1));
assert_eq!(
got, want,
"from `{d}`: the stash, origin/HEAD and refs/remote-tags should be left out"
);
}
#[test]
fn ref_labels_never_run_past_their_room_and_keep_the_first() {
let refs: Vec<RefLabel> = [
(RefKind::Head, "feature/a-rather-long-branch-name"),
(RefKind::Remote, "origin/feature/a-rather-long-branch-name"),
(RefKind::Tag, "v2.2.0-rc.1"),
(RefKind::Branch, "main"),
]
.into_iter()
.map(|(kind, name)| RefLabel {
kind,
name: name.to_string(),
})
.collect();
for room in 12..160 {
let (kept, hidden) = fit_refs(&refs, room);
let drawn = 3
+ kept.iter().map(|(_, t)| t.chars().count()).sum::<usize>()
+ 2 * (kept.len() - 1)
+ if hidden > 0 {
3 + hidden.to_string().len()
} else {
0
};
assert!(
!kept.is_empty(),
"room {room}: the first label went missing"
);
assert_eq!(
kept.len() + hidden,
refs.len(),
"room {room}: labels lost count"
);
assert!(
drawn <= room,
"room {room}: drew {drawn} columns from {kept:?} +{hidden}"
);
for (i, (_, text)) in kept.iter().enumerate().skip(1) {
assert_eq!(
*text,
refs[i].text(),
"room {room}: only the first may be cut"
);
}
}
}
#[test]
fn a_remote_pushing_somewhere_else_keeps_both_urls() {
let out = "remote.origin.url https://example.com/a.git\n\
remote.origin.pushurl git@example.com:a.git\n\
remote.fork.url /srv/fork.git\n";
let remotes = parse_remotes(out);
let names: Vec<&str> = remotes.iter().map(|r| r.name.as_str()).collect();
assert_eq!(
names,
["origin", "fork"],
"one remote per name, in git's order"
);
assert_eq!(remotes[0].url, "https://example.com/a.git");
assert_eq!(remotes[0].push_url, "git@example.com:a.git");
assert_eq!(remotes[1].url, remotes[1].push_url);
}
}