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}