use std::collections::HashSet;
use std::path::Path;
use crate::session_manager::worktree_safety::{git_command, git_stdout};
const MAX_CANDIDATES: usize = 24;
const MAX_PATHSPEC: usize = 256;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContentOnBase {
Landed {
at: Option<String>,
},
Undone {
at: String,
paths: Vec<String>,
searched: usize,
candidates: usize,
},
Residual {
paths: Vec<String>,
searched: usize,
candidates: usize,
},
Conflicted {
paths: Vec<String>,
searched: usize,
candidates: usize,
},
}
impl ContentOnBase {
pub fn is_landed(&self) -> bool {
matches!(self, Self::Landed { .. })
}
pub fn describe(&self, base: &str) -> String {
match self {
Self::Landed { at: None } => format!("HEAD is an ancestor of {base}"),
Self::Landed { at: Some(at) } => format!(
"HEAD's content landed on {base} at `{at}`: merging HEAD into that commit \
changes no file, and HEAD holds that commit's own change on every file \
HEAD's commits touched"
),
Self::Undone {
at,
paths,
searched,
candidates,
} => format!(
"merging HEAD into {base} changes no file, but HEAD is not on {base} and no \
longer holds all of what landed at `{at}` — {} file(s) HEAD's own commits \
touched ({}) either lack part of that commit's own change, or differ from that \
commit where its change never reached them: a revert or deletion made after a \
landing, or a change on those files HEAD never had; {}",
paths.len(),
name_paths(paths),
history_clause(base, *searched, *candidates)
),
Self::Residual {
paths,
searched,
candidates,
} => format!(
"merging HEAD into {base} would still change {} file(s) ({}), and {}",
paths.len(),
name_paths(paths),
history_clause(base, *searched, *candidates)
),
Self::Conflicted {
paths,
searched,
candidates,
} => format!(
"merging HEAD into {base} conflicts in {} file(s) ({}), and {}",
paths.len(),
name_paths(paths),
history_clause(base, *searched, *candidates)
),
}
}
}
fn history_clause(base: &str, searched: usize, candidates: usize) -> String {
if candidates > searched {
format!(
"none of the oldest {searched} of {candidates} commit(s) on {base} since HEAD forked \
holds HEAD's content in both directions, nor does its tip; the newer {} were not \
searched",
candidates - searched
)
} else {
format!(
"none of the {searched} commit(s) on {base} since HEAD forked holds HEAD's content \
in both directions, nor does its tip"
)
}
}
fn name_paths(paths: &[String]) -> String {
let mut named: Vec<String> = paths.iter().take(5).map(|p| format!("`{p}`")).collect();
if paths.len() > 5 {
named.push(format!("and {} more", paths.len() - 5));
}
named.join(", ")
}
pub fn content_on_base(dir: &Path, base: &str) -> Result<ContentOnBase, String> {
let base = base.trim();
if base.is_empty() {
return Err("no base ref was supplied to judge this worktree's content against".into());
}
let tip = merge_probe(dir, base)?;
if tip.is_noop() && head_is_ancestor_of(dir, base)? {
return Ok(ContentOnBase::Landed { at: None });
}
let walk = landed_in_history(dir, base, tip.is_noop())?;
if let Some(at) = walk.at {
return Ok(ContentOnBase::Landed { at: Some(at) });
}
let (searched, candidates) = (walk.searched, walk.candidates);
match tip {
MergeProbe::Clean(paths) if paths.is_empty() => match walk.undone {
Some((at, paths)) => Ok(ContentOnBase::Undone {
at,
paths,
searched,
candidates,
}),
None => Err(format!(
"merging HEAD into {base} changes no file and HEAD is not an ancestor of it, \
but no commit on {base} whose own change touches a file HEAD changed holds \
HEAD's content, so nothing proves it landed"
)),
},
MergeProbe::Clean(paths) => Ok(ContentOnBase::Residual {
paths,
searched,
candidates,
}),
MergeProbe::Conflicted(paths) => Ok(ContentOnBase::Conflicted {
paths,
searched,
candidates,
}),
}
}
fn head_is_ancestor_of(dir: &Path, base: &str) -> Result<bool, String> {
let out = git_command(dir, &["merge-base", "--is-ancestor", "HEAD", base])
.output()
.map_err(|e| format!("`git merge-base --is-ancestor HEAD {base}` could not be run: {e}"))?;
match out.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
_ => Err(format!(
"`git merge-base --is-ancestor HEAD {base}` could not answer ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
)),
}
}
enum MergeProbe {
Clean(Vec<String>),
Conflicted(Vec<String>),
}
impl MergeProbe {
fn is_noop(&self) -> bool {
matches!(self, Self::Clean(paths) if paths.is_empty())
}
}
fn merge_probe(dir: &Path, base: &str) -> Result<MergeProbe, String> {
merge_in_memory(dir, None, base, "HEAD")
}
fn merge_in_memory(
dir: &Path,
merge_base: Option<&str>,
ours: &str,
theirs: &str,
) -> Result<MergeProbe, String> {
let mut args = vec!["merge-tree", "--write-tree", "--name-only"];
if let Some(merge_base) = merge_base {
args.extend(["--merge-base", merge_base]);
}
args.extend([ours, theirs]);
let shown = format!("git {}", args.join(" "));
let out = git_command(dir, &args)
.output()
.map_err(|e| format!("`{shown}` could not be run: {e}"))?;
let stdout = String::from_utf8_lossy(&out.stdout);
let mut lines = stdout.lines();
let tree = lines
.next()
.map(str::trim)
.filter(|t| t.len() >= 40 && t.bytes().all(|b| b.is_ascii_hexdigit()));
match (out.status.code(), tree) {
(Some(0), Some(tree)) => {
let residue = git_stdout(
dir,
&[
"diff",
"--name-only",
"--no-renames",
"--ignore-submodules=none",
ours,
tree,
],
)
.map_err(|e| format!("`git diff --name-only {ours} <merged tree>` failed: {e}"))?;
Ok(MergeProbe::Clean(non_empty_lines(&residue)))
}
(Some(1), Some(_)) => Ok(MergeProbe::Conflicted(
lines
.take_while(|l| !l.trim().is_empty())
.map(|l| l.trim().to_string())
.collect(),
)),
_ => {
let stderr = String::from_utf8_lossy(&out.stderr);
let stderr = stderr.trim();
Err(format!(
"`{shown}` could not answer ({}, not a merge conflict): {}",
out.status,
if stderr.is_empty() {
"git wrote nothing to stderr"
} else {
stderr
}
))
}
}
}
struct HistoryWalk {
at: Option<String>,
searched: usize,
candidates: usize,
undone: Option<(String, Vec<String>)>,
}
struct BranchPaths {
changed: HashSet<String>,
touched: HashSet<String>,
}
enum Judged {
Ineligible,
Lands,
Lacks(Vec<String>),
}
fn landed_in_history(
dir: &Path,
base: &str,
tip_forward_noop: bool,
) -> Result<HistoryWalk, String> {
let spec = format!("{base}^{{commit}}");
let tip = git_stdout(dir, &["rev-parse", "--verify", "--quiet", &spec])
.map_err(|e| format!("the tip commit of {base} could not be resolved: {e}"))?;
let tip = tip.trim().to_string();
if tip.is_empty() {
return Err(format!("{base} does not name a commit"));
}
let mut walk = HistoryWalk {
at: None,
searched: 0,
candidates: 0,
undone: None,
};
let fork = git_stdout(dir, &["merge-base", base, "HEAD"])
.map_err(|e| format!("the fork point of HEAD and {base} could not be found: {e}"))?;
let fork = fork.trim();
let branch = BranchPaths {
changed: diff_names(dir, fork, "HEAD")?,
touched: branch_touched_paths(dir, fork)?,
};
let mut tip_undone = None;
if tip_forward_noop {
match judge(dir, &tip, &branch)? {
Judged::Ineligible => {}
Judged::Lands => {
walk.at = Some(tip);
return Ok(walk);
}
Judged::Lacks(lacked) => tip_undone = Some((tip.clone(), lacked)),
}
}
let changed = git_stdout(
dir,
&[
"diff",
"--name-only",
"-z",
"--ignore-submodules=none",
fork,
"HEAD",
],
)
.map_err(|e| format!("the paths HEAD changed since {fork} could not be listed: {e}"))?;
let paths: Vec<&str> = changed
.split('\0')
.filter(|p| !p.is_empty())
.take(MAX_PATHSPEC)
.collect();
if paths.is_empty() {
walk.undone = tip_undone;
return Ok(walk);
}
let range = format!("{fork}..{base}");
let mut args = vec!["rev-list", "--first-parent", "--reverse", &range, "--"];
args.extend(paths);
let out = git_command(dir, &args)
.env("GIT_LITERAL_PATHSPECS", "1")
.output()
.map_err(|e| format!("`git rev-list {range}` could not be run: {e}"))?;
if !out.status.success() {
return Err(format!(
"`git rev-list {range}` failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
));
}
let listed = non_empty_lines(&String::from_utf8_lossy(&out.stdout));
walk.candidates = listed.len();
for candidate in listed.into_iter().take(MAX_CANDIDATES) {
walk.searched += 1;
if candidate == tip {
continue; }
if !merge_probe(dir, &candidate)?.is_noop() {
continue;
}
match judge(dir, &candidate, &branch)? {
Judged::Ineligible => {}
Judged::Lands => {
walk.at = Some(candidate);
return Ok(walk);
}
Judged::Lacks(lacked) if walk.undone.is_none() => {
walk.undone = Some((candidate, lacked));
}
Judged::Lacks(_) => {}
}
}
walk.undone = walk.undone.take().or(tip_undone);
Ok(walk)
}
fn judge(dir: &Path, commit: &str, branch: &BranchPaths) -> Result<Judged, String> {
let spec = format!("{commit}^1^{{commit}}");
let parent = git_stdout(dir, &["rev-parse", "--verify", "--quiet", &spec])
.map_err(|e| format!("the first parent of `{commit}` could not be resolved: {e}"))?;
let parent = parent.trim();
if parent.is_empty() {
return Err(format!("`{commit}` has no first parent to diff it against"));
}
let patch = diff_names(dir, parent, commit)?;
if patch.is_disjoint(&branch.changed) {
return Ok(Judged::Ineligible);
}
let differs = diff_names(dir, commit, "HEAD")?;
let mut uncovered: Vec<String> = branch
.touched
.iter()
.filter(|p| differs.contains(*p) && !patch.contains(*p))
.cloned()
.collect();
if !uncovered.is_empty() {
uncovered.sort();
return Ok(Judged::Lacks(uncovered));
}
Ok(match lacked_patch(dir, parent, commit, &branch.touched)? {
None => Judged::Lands,
Some(lacked) => Judged::Lacks(lacked),
})
}
fn lacked_patch(
dir: &Path,
parent: &str,
commit: &str,
touched: &HashSet<String>,
) -> Result<Option<Vec<String>>, String> {
let residue = match merge_in_memory(dir, Some(parent), "HEAD", commit)? {
MergeProbe::Conflicted(paths) => return Ok(Some(paths)),
MergeProbe::Clean(paths) => paths,
};
let lacked: Vec<String> = residue
.into_iter()
.filter(|p| touched.contains(p))
.collect();
Ok((!lacked.is_empty()).then_some(lacked))
}
fn diff_names(dir: &Path, from: &str, to: &str) -> Result<HashSet<String>, String> {
let out = git_stdout(
dir,
&[
"diff",
"--name-only",
"--no-renames",
"--ignore-submodules=none",
from,
to,
],
)
.map_err(|e| {
format!("the paths that differ between {from} and {to} could not be listed: {e}")
})?;
Ok(non_empty_lines(&out).into_iter().collect())
}
fn branch_touched_paths(dir: &Path, fork: &str) -> Result<HashSet<String>, String> {
let range = format!("{fork}..HEAD");
let out = git_stdout(
dir,
&[
"log",
"--format=",
"--name-only",
"--no-renames",
"--ignore-submodules=none",
"-m",
&range,
],
)
.map_err(|e| format!("the paths the commits in {range} touched could not be listed: {e}"))?;
Ok(non_empty_lines(&out).into_iter().collect())
}
fn non_empty_lines(text: &str) -> Vec<String> {
text.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect()
}
#[cfg(test)]
#[path = "worktree_landed_history_tests.rs"]
mod worktree_landed_history_tests;