Skip to main content

gkit_core/
stmb.rs

1//! `stmb` — "switch to main branch": finish a feature branch by returning to the
2//! base/integration branch, updating it, and deleting the (merged) feature branch.
3//!
4//! Port of the zsh `stmb`, with **proper, safe branch handling**: base is resolved
5//! (not hardcoded `dev`), and the feature branch is **deleted only when stmb has
6//! positively verified it is merged into base** — by reachability (merge-commit /
7//! fast-forward) *or* by patch-id equivalence (squash / rebase merge, where the
8//! commit hash changed but the changes are already in base). When it can't verify,
9//! it **refuses** and tells you to discard the branch yourself with `git branch -D`,
10//! rather than offering a blunt force flag that trains people to always pass it.
11
12use crate::git::Git;
13use std::path::Path;
14
15/// Why stmb will (or won't) delete a feature branch — the verdict behind the
16/// human-readable line stmb prints before acting. Computed purely from repo state.
17#[derive(Debug, PartialEq, Eq)]
18pub enum MergeStatus {
19    /// The feature tip is an ancestor of base — a normal merge-commit or
20    /// fast-forward merge. Safe to delete; `git branch -d` agrees.
21    Reachable,
22    /// The tip is *not* reachable from base, but every commit on the branch has an
23    /// equivalent patch already in base (squash- or rebase-merged). Safe to delete;
24    /// `git branch -d` would wrongly refuse, so `-D` is used after this verdict.
25    Content,
26    /// `unique` commit(s) on the branch have no equivalent in base — genuine
27    /// unmerged work. stmb refuses to delete it.
28    Unmerged { unique: u64 },
29    /// stmb could not determine the answer (a git error / unparseable output).
30    /// **Fail-closed**: treated like unmerged — refuse, never delete vacuously.
31    Unknown(String),
32}
33
34impl MergeStatus {
35    /// True only when stmb has *verified* the branch is merged (safe to delete).
36    pub fn is_merged(&self) -> bool {
37        matches!(self, MergeStatus::Reachable | MergeStatus::Content)
38    }
39
40    /// A readable "why" for the given branch/base — the reason half of the line
41    /// stmb prints before it deletes (or declines to delete) the branch.
42    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
61/// Decide whether `feature` is merged into `base`. Reachability first (catches
62/// merge-commit / fast-forward merges, where the tip lands in base's history),
63/// then patch-id equivalence (catches squash / rebase merges, where the commit
64/// hash changed but the diff is already in base). **Fail-closed**: any git error
65/// yields [`MergeStatus::Unknown`], never a vacuous "merged".
66pub fn merge_status(git: &dyn Git, dir: &Path, base: &str, feature: &str) -> MergeStatus {
67    // 1. Reachability: is the feature tip an ancestor of base?
68    if git
69        .run(dir, &["merge-base", "--is-ancestor", feature, base])
70        .success
71    {
72        return MergeStatus::Reachable;
73    }
74    // 2. Patch-id equivalence: count commits on `feature` that are NOT in `base`
75    //    *by content*. `--cherry-pick` drops commit pairs with an equal patch-id;
76    //    `--right-only` keeps just the `feature` side, so the count is the branch's
77    //    genuinely-unique work. Zero ⇒ everything is already in base (squashed).
78    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/// The decided plan, computed purely from repo state.
100#[derive(Debug, PartialEq, Eq)]
101pub struct Plan {
102    pub base: String,
103    /// Feature branch to delete after switching; `None` when already on base.
104    pub delete_feature: Option<String>,
105}
106
107/// Decide what `stmb` should do. `current` is the current branch (`None` = detached).
108/// Refuses states that aren't safe to auto-handle.
109pub 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        // Not an ancestor (squash-merged), but rev-list finds 0 unique commits.
148        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        // rev-list errors -> Unknown (refuse), never a vacuous "merged".
174        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}