Skip to main content

lattice_vcs/
in_flight.rs

1//! Which multi-commit operation the repository is stopped in the
2//! middle of.
3//!
4//! Git records every one of these as a marker in the gitdir and
5//! removes it on `--continue` / `--abort` / `--quit`, so the marker's
6//! presence IS the state. Nothing is cached and nothing is parsed:
7//! a cached flag can go stale behind our back when the user runs git
8//! in a terminal, which for "am I mid-rebase?" is the difference
9//! between offering `--continue` and offering nonsense.
10//!
11//! This lives in `lattice-vcs` rather than in the magit UI because
12//! two consumers need the same answer and had been asking it
13//! separately: the transient menus (to decide whether to offer the
14//! way IN or the ways OUT) and the status buffer's headerline (to say
15//! what the user is in the middle of at all). Two detectors for one
16//! fact is how they drift.
17
18use std::path::Path;
19
20use crate::Repository;
21
22/// A multi-commit operation stopped mid-flight.
23///
24/// Ordered by specificity in [`InFlightOp::detect`]: `git am` and
25/// rebase share the `rebase-apply` directory, so the more specific
26/// marker is tested first.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum InFlightOp {
29    /// A merge stopped with conflicts — `MERGE_HEAD`.
30    Merge,
31    /// A rebase stopped at a conflict or an `edit` stop —
32    /// `rebase-merge/` (the default backend) or `rebase-apply/` (the
33    /// legacy `--apply` backend).
34    Rebase,
35    /// A cherry-pick sequence stopped — `CHERRY_PICK_HEAD`.
36    CherryPick,
37    /// A revert sequence stopped — `REVERT_HEAD`.
38    Revert,
39    /// `git am` stopped applying a patch — `rebase-apply/applying`.
40    ApplyPatch,
41}
42
43impl InFlightOp {
44    /// The headerline alert for this state, in the wording git itself
45    /// uses when it tells the user what to do next.
46    pub fn label(self) -> &'static str {
47        match self {
48            Self::Merge => "MERGING",
49            Self::Rebase => "REBASING",
50            Self::CherryPick => "CHERRY-PICKING",
51            Self::Revert => "REVERTING",
52            Self::ApplyPatch => "APPLYING",
53        }
54    }
55
56    /// Which side of a conflict "us" and "them" name during this
57    /// operation — and they INVERT between the two families.
58    ///
59    /// In a merge, "us" is the branch you are on and "them" is the
60    /// branch being merged in, which is what everyone expects. In a
61    /// rebase, cherry-pick, revert or `am`, git replays your work ONTO
62    /// the other side, so "us" is the *upstream* being replayed onto
63    /// and "them" is *your own commit*.
64    ///
65    /// This is the single most confusing thing about resolving a
66    /// conflict mid-rebase, and it is why the unmerged labels
67    /// ("added by us", "deleted by them") are ambiguous without
68    /// knowing which operation produced them.
69    pub fn ours_is_local(self) -> bool {
70        matches!(self, Self::Merge)
71    }
72
73    /// Every variant, for exhaustive tests and label tables.
74    pub const ALL: [Self; 5] = [
75        Self::Merge,
76        Self::Rebase,
77        Self::CherryPick,
78        Self::Revert,
79        Self::ApplyPatch,
80    ];
81
82    /// Detect the operation in flight, or `None` when the repository
83    /// is idle.
84    ///
85    /// At most one is reported. They are not strictly exclusive in
86    /// git's data model — a rebase that hits a conflict while
87    /// cherry-picking a commit writes `CHERRY_PICK_HEAD` too — so the
88    /// order matters: the *outer* operation is the one the user has to
89    /// finish, and the one whose `--continue` they need.
90    pub fn detect(repo: &Repository) -> Option<Self> {
91        Self::detect_in(repo.gitdir())
92    }
93
94    /// [`Self::detect`] against a gitdir path directly, so it is
95    /// testable without a live repository.
96    pub fn detect_in(gitdir: &Path) -> Option<Self> {
97        // `git am` before rebase: both use `rebase-apply/`, and only
98        // the `applying` file distinguishes them.
99        if gitdir.join("rebase-apply").join("applying").exists() {
100            return Some(Self::ApplyPatch);
101        }
102        // Rebase before cherry-pick/revert: an interactive rebase that
103        // stops on a conflict leaves `CHERRY_PICK_HEAD` behind as an
104        // implementation detail of how it replays commits. Reporting
105        // "CHERRY-PICKING" there would send the user to
106        // `cherry-pick --continue`, which is not what finishes a
107        // rebase.
108        if gitdir.join("rebase-merge").exists() || gitdir.join("rebase-apply").exists() {
109            return Some(Self::Rebase);
110        }
111        if gitdir.join("MERGE_HEAD").exists() {
112            return Some(Self::Merge);
113        }
114        if gitdir.join("CHERRY_PICK_HEAD").exists() {
115            return Some(Self::CherryPick);
116        }
117        if gitdir.join("REVERT_HEAD").exists() {
118            return Some(Self::Revert);
119        }
120        None
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    fn gitdir() -> tempfile::TempDir {
129        tempfile::tempdir().expect("tempdir")
130    }
131
132    fn touch(dir: &Path, rel: &str) {
133        let p = dir.join(rel);
134        if let Some(parent) = p.parent() {
135            std::fs::create_dir_all(parent).expect("mkdir");
136        }
137        std::fs::write(p, b"").expect("write");
138    }
139
140    #[test]
141    fn an_idle_repository_reports_nothing() {
142        let d = gitdir();
143        assert_eq!(InFlightOp::detect_in(d.path()), None);
144    }
145
146    #[test]
147    fn each_marker_is_recognised() {
148        for (marker, expected) in [
149            ("MERGE_HEAD", InFlightOp::Merge),
150            ("CHERRY_PICK_HEAD", InFlightOp::CherryPick),
151            ("REVERT_HEAD", InFlightOp::Revert),
152        ] {
153            let d = gitdir();
154            touch(d.path(), marker);
155            assert_eq!(
156                InFlightOp::detect_in(d.path()),
157                Some(expected),
158                "{marker} means {}",
159                expected.label()
160            );
161        }
162        // Both rebase backends.
163        for dir in ["rebase-merge", "rebase-apply"] {
164            let d = gitdir();
165            std::fs::create_dir_all(d.path().join(dir)).expect("mkdir");
166            assert_eq!(InFlightOp::detect_in(d.path()), Some(InFlightOp::Rebase));
167        }
168    }
169
170    /// `git am` and the legacy rebase backend share `rebase-apply/`;
171    /// only the `applying` file tells them apart, so it is tested
172    /// first.
173    #[test]
174    fn am_is_distinguished_from_the_legacy_rebase_backend() {
175        let d = gitdir();
176        touch(d.path(), "rebase-apply/applying");
177        assert_eq!(
178            InFlightOp::detect_in(d.path()),
179            Some(InFlightOp::ApplyPatch)
180        );
181    }
182
183    /// An interactive rebase that stops on a conflict leaves
184    /// `CHERRY_PICK_HEAD` behind — an implementation detail of how it
185    /// replays commits. Reporting "CHERRY-PICKING" would send the user
186    /// to `cherry-pick --continue`, which does not finish a rebase.
187    #[test]
188    fn a_rebase_carrying_cherry_pick_head_is_still_a_rebase() {
189        let d = gitdir();
190        std::fs::create_dir_all(d.path().join("rebase-merge")).expect("mkdir");
191        touch(d.path(), "CHERRY_PICK_HEAD");
192        assert_eq!(InFlightOp::detect_in(d.path()), Some(InFlightOp::Rebase));
193    }
194
195    /// "us" and "them" invert between a merge and everything that
196    /// replays commits — the single most confusing thing about
197    /// resolving a conflict mid-rebase.
198    #[test]
199    fn ours_is_local_only_for_a_merge() {
200        assert!(InFlightOp::Merge.ours_is_local());
201        for op in [
202            InFlightOp::Rebase,
203            InFlightOp::CherryPick,
204            InFlightOp::Revert,
205            InFlightOp::ApplyPatch,
206        ] {
207            assert!(
208                !op.ours_is_local(),
209                "{} replays onto the other side, so \"us\" is the upstream",
210                op.label()
211            );
212        }
213    }
214
215    #[test]
216    fn every_op_has_a_distinct_label() {
217        let labels: std::collections::HashSet<&str> =
218            InFlightOp::ALL.iter().map(|o| o.label()).collect();
219        assert_eq!(labels.len(), InFlightOp::ALL.len());
220    }
221}