1use crate::git::Git;
13use std::path::Path;
14
15#[derive(Debug, PartialEq, Eq)]
18pub enum MergeStatus {
19 Reachable,
22 Content,
26 Unmerged { unique: u64 },
29 Unknown(String),
32}
33
34impl MergeStatus {
35 pub fn is_merged(&self) -> bool {
37 matches!(self, MergeStatus::Reachable | MergeStatus::Content)
38 }
39
40 pub fn reason(&self, feature: &str, base: &str) -> String {
43 match self {
44 MergeStatus::Reachable => format!(
45 "'{feature}' is fully merged into {base} (its commits are in {base}'s history)"
46 ),
47 MergeStatus::Content => format!(
48 "'{feature}' has no commits missing from {base} — its changes are already in \
49 {base} (squash/rebase-merged)"
50 ),
51 MergeStatus::Unmerged { unique } => {
52 format!("'{feature}' has {unique} commit(s) not present in {base} (by content)")
53 }
54 MergeStatus::Unknown(why) => {
55 format!("could not verify whether '{feature}' is merged into {base}: {why}")
56 }
57 }
58 }
59}
60
61pub fn merge_status(git: &dyn Git, dir: &Path, base: &str, feature: &str) -> MergeStatus {
67 if git
69 .run(dir, &["merge-base", "--is-ancestor", feature, base])
70 .success
71 {
72 return MergeStatus::Reachable;
73 }
74 let range = format!("{base}...{feature}");
79 let out = git.run(
80 dir,
81 &[
82 "rev-list",
83 "--count",
84 "--cherry-pick",
85 "--right-only",
86 &range,
87 ],
88 );
89 if !out.success {
90 return MergeStatus::Unknown(format!("git rev-list failed: {}", out.stderr.trim()));
91 }
92 match out.trimmed().parse::<u64>() {
93 Ok(0) => MergeStatus::Content,
94 Ok(unique) => MergeStatus::Unmerged { unique },
95 Err(_) => MergeStatus::Unknown(format!("unparseable rev-list output: {:?}", out.trimmed())),
96 }
97}
98
99#[derive(Debug, PartialEq, Eq)]
101pub struct Plan {
102 pub base: String,
103 pub delete_feature: Option<String>,
105}
106
107pub fn plan(current: Option<&str>, base: &str, dirty: bool) -> Result<Plan, String> {
110 if dirty {
111 return Err("working tree has uncommitted changes — commit or stash before stmb".into());
112 }
113 match current {
114 None => Err("detached HEAD — checkout a branch before stmb".into()),
115 Some(cur) if cur == base => Ok(Plan {
116 base: base.to_string(),
117 delete_feature: None,
118 }),
119 Some(cur) => Ok(Plan {
120 base: base.to_string(),
121 delete_feature: Some(cur.to_string()),
122 }),
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use crate::git::test_support::FakeGit;
130 use std::path::Path;
131
132 fn d() -> &'static Path {
133 Path::new("r")
134 }
135
136 #[test]
137 fn merge_status_reachable_when_ancestor() {
138 let g = FakeGit::new().ok("merge-base --is-ancestor feat main", "");
139 assert_eq!(
140 merge_status(&g, d(), "main", "feat"),
141 MergeStatus::Reachable
142 );
143 }
144
145 #[test]
146 fn merge_status_content_when_no_unique_patches() {
147 let g = FakeGit::new()
149 .fail("merge-base --is-ancestor feat main")
150 .ok(
151 "rev-list --count --cherry-pick --right-only main...feat",
152 "0",
153 );
154 assert_eq!(merge_status(&g, d(), "main", "feat"), MergeStatus::Content);
155 }
156
157 #[test]
158 fn merge_status_unmerged_counts_unique_commits() {
159 let g = FakeGit::new()
160 .fail("merge-base --is-ancestor feat main")
161 .ok(
162 "rev-list --count --cherry-pick --right-only main...feat",
163 "2",
164 );
165 assert_eq!(
166 merge_status(&g, d(), "main", "feat"),
167 MergeStatus::Unmerged { unique: 2 }
168 );
169 }
170
171 #[test]
172 fn merge_status_unknown_is_fail_closed_on_git_error() {
173 let g = FakeGit::new().fail("merge-base --is-ancestor feat main");
175 let s = merge_status(&g, d(), "main", "feat");
176 assert!(matches!(s, MergeStatus::Unknown(_)));
177 assert!(!s.is_merged());
178 }
179
180 #[test]
181 fn reason_is_readable_per_verdict() {
182 assert!(MergeStatus::Reachable
183 .reason("feat", "main")
184 .contains("merged into main"));
185 assert!(MergeStatus::Content
186 .reason("feat", "main")
187 .contains("squash/rebase-merged"));
188 assert!(MergeStatus::Unmerged { unique: 3 }
189 .reason("feat", "main")
190 .contains("3 commit(s) not present in main"));
191 assert!(MergeStatus::Unknown("boom".into())
192 .reason("feat", "main")
193 .contains("could not verify"));
194 }
195
196 #[test]
197 fn refuses_dirty_tree() {
198 assert!(plan(Some("feat"), "dev", true)
199 .unwrap_err()
200 .contains("uncommitted"));
201 }
202
203 #[test]
204 fn refuses_detached() {
205 assert!(plan(None, "dev", false).unwrap_err().contains("detached"));
206 }
207
208 #[test]
209 fn on_base_deletes_nothing() {
210 assert_eq!(
211 plan(Some("dev"), "dev", false).unwrap(),
212 Plan {
213 base: "dev".into(),
214 delete_feature: None
215 }
216 );
217 }
218
219 #[test]
220 fn on_feature_deletes_it() {
221 assert_eq!(
222 plan(Some("feat-x"), "dev", false).unwrap(),
223 Plan {
224 base: "dev".into(),
225 delete_feature: Some("feat-x".into())
226 }
227 );
228 }
229}