use crate::git::Git;
use std::path::Path;
#[derive(Debug, PartialEq, Eq)]
pub enum MergeStatus {
Reachable,
Content,
Unmerged { unique: u64 },
Unknown(String),
}
impl MergeStatus {
pub fn is_merged(&self) -> bool {
matches!(self, MergeStatus::Reachable | MergeStatus::Content)
}
pub fn reason(&self, feature: &str, base: &str) -> String {
match self {
MergeStatus::Reachable => format!(
"'{feature}' is fully merged into {base} (its commits are in {base}'s history)"
),
MergeStatus::Content => format!(
"'{feature}' has no commits missing from {base} — its changes are already in \
{base} (squash/rebase-merged)"
),
MergeStatus::Unmerged { unique } => {
format!("'{feature}' has {unique} commit(s) not present in {base} (by content)")
}
MergeStatus::Unknown(why) => {
format!("could not verify whether '{feature}' is merged into {base}: {why}")
}
}
}
}
pub fn merge_status(git: &dyn Git, dir: &Path, base: &str, feature: &str) -> MergeStatus {
if git
.run(dir, &["merge-base", "--is-ancestor", feature, base])
.success
{
return MergeStatus::Reachable;
}
let range = format!("{base}...{feature}");
let out = git.run(
dir,
&[
"rev-list",
"--count",
"--cherry-pick",
"--right-only",
&range,
],
);
if !out.success {
return MergeStatus::Unknown(format!("git rev-list failed: {}", out.stderr.trim()));
}
match out.trimmed().parse::<u64>() {
Ok(0) => MergeStatus::Content,
Ok(unique) => MergeStatus::Unmerged { unique },
Err(_) => MergeStatus::Unknown(format!("unparseable rev-list output: {:?}", out.trimmed())),
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct Plan {
pub base: String,
pub delete_feature: Option<String>,
}
pub fn plan(current: Option<&str>, base: &str, dirty: bool) -> Result<Plan, String> {
if dirty {
return Err("working tree has uncommitted changes — commit or stash before stmb".into());
}
match current {
None => Err("detached HEAD — checkout a branch before stmb".into()),
Some(cur) if cur == base => Ok(Plan {
base: base.to_string(),
delete_feature: None,
}),
Some(cur) => Ok(Plan {
base: base.to_string(),
delete_feature: Some(cur.to_string()),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::test_support::FakeGit;
use std::path::Path;
fn d() -> &'static Path {
Path::new("r")
}
#[test]
fn merge_status_reachable_when_ancestor() {
let g = FakeGit::new().ok("merge-base --is-ancestor feat main", "");
assert_eq!(
merge_status(&g, d(), "main", "feat"),
MergeStatus::Reachable
);
}
#[test]
fn merge_status_content_when_no_unique_patches() {
let g = FakeGit::new()
.fail("merge-base --is-ancestor feat main")
.ok(
"rev-list --count --cherry-pick --right-only main...feat",
"0",
);
assert_eq!(merge_status(&g, d(), "main", "feat"), MergeStatus::Content);
}
#[test]
fn merge_status_unmerged_counts_unique_commits() {
let g = FakeGit::new()
.fail("merge-base --is-ancestor feat main")
.ok(
"rev-list --count --cherry-pick --right-only main...feat",
"2",
);
assert_eq!(
merge_status(&g, d(), "main", "feat"),
MergeStatus::Unmerged { unique: 2 }
);
}
#[test]
fn merge_status_unknown_is_fail_closed_on_git_error() {
let g = FakeGit::new().fail("merge-base --is-ancestor feat main");
let s = merge_status(&g, d(), "main", "feat");
assert!(matches!(s, MergeStatus::Unknown(_)));
assert!(!s.is_merged());
}
#[test]
fn reason_is_readable_per_verdict() {
assert!(MergeStatus::Reachable
.reason("feat", "main")
.contains("merged into main"));
assert!(MergeStatus::Content
.reason("feat", "main")
.contains("squash/rebase-merged"));
assert!(MergeStatus::Unmerged { unique: 3 }
.reason("feat", "main")
.contains("3 commit(s) not present in main"));
assert!(MergeStatus::Unknown("boom".into())
.reason("feat", "main")
.contains("could not verify"));
}
#[test]
fn refuses_dirty_tree() {
assert!(plan(Some("feat"), "dev", true)
.unwrap_err()
.contains("uncommitted"));
}
#[test]
fn refuses_detached() {
assert!(plan(None, "dev", false).unwrap_err().contains("detached"));
}
#[test]
fn on_base_deletes_nothing() {
assert_eq!(
plan(Some("dev"), "dev", false).unwrap(),
Plan {
base: "dev".into(),
delete_feature: None
}
);
}
#[test]
fn on_feature_deletes_it() {
assert_eq!(
plan(Some("feat-x"), "dev", false).unwrap(),
Plan {
base: "dev".into(),
delete_feature: Some("feat-x".into())
}
);
}
}