Skip to main content

grit_lib/
operation.rs

1//! Shared conflict classification and in-progress operation vocabulary.
2//!
3//! Status, merge, pick, revert, rebase, pull and stash all report conflicts with the same
4//! [`Conflict`] and [`ConflictKind`]. The kind is read from which unmerged index stages a
5//! path has (the same classification as the `u` lines of `git status --porcelain=v2`), so a
6//! conflict looks the same whether it comes from an operation's outcome or from
7//! [`status`](crate::porcelain::status::status) afterwards. Only a tree-merge result can add
8//! [`ConflictKind::RenameRename`] and the directory rename kinds
9//! ([`ConflictKind::DirectoryRenameMoved`], [`ConflictKind::DirectoryRenameSplit`] and
10//! [`ConflictKind::DirectoryRenameBlocked`]), which need information the index does not keep.
11//!
12//! ```rust
13//! use grit_lib::operation::{conflict_kind_from_stagemask, ConflictKind};
14//!
15//! // Stage 1 (base) and stage 3 (theirs) only: we deleted the path, they kept it.
16//! assert_eq!(
17//!     conflict_kind_from_stagemask(0b101),
18//!     Some(ConflictKind::DeletedByUs)
19//! );
20//! assert_eq!(ConflictKind::DeletedByUs.as_str(), "deleted_by_us");
21//! ```
22
23use std::collections::{BTreeMap, HashMap};
24
25use crate::diff::merge_trees::{DirectoryRenameConflict, TreeMergeOutput};
26use crate::index::Index;
27
28pub use crate::state::{
29    operation_alternatives, operation_next_steps, operation_next_steps_empty, status_operation,
30    InProgressOperation, NextStep, Operation,
31};
32
33/// How the two sides of a merge collided on one path.
34///
35/// "Us" is the side checked out when the operation started (index stage 2, `HEAD`); "them"
36/// is the side being brought in (stage 3): the merged branch, the picked or reverted commit,
37/// or the commit a rebase is replaying. Variants are ordered as `grit status` lists them.
38#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
39#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
40#[cfg_attr(
41    feature = "serde-models",
42    serde(tag = "kind", rename_all = "snake_case")
43)]
44pub enum ConflictKind {
45    /// Both sides changed the file (stages 1, 2 and 3; porcelain `UU`).
46    BothModified,
47    /// Both sides added the path with different content (stages 2 and 3; `AA`).
48    BothAdded,
49    /// Both sides removed the path (stage 1 only; `DD`).
50    BothDeleted,
51    /// Only our side has the path (stage 2 only; `AU`).
52    AddedByUs,
53    /// Only their side has the path (stage 3 only; `UA`).
54    AddedByThem,
55    /// We deleted the path and they changed it (stages 1 and 3; `DU`).
56    DeletedByUs,
57    /// We changed the path and they deleted it (stages 1 and 2; `UD`).
58    DeletedByThem,
59    /// Both sides renamed the same base file, to different paths.
60    ///
61    /// Reported once for each of the three paths involved; [`Conflict::path`] says which one.
62    /// Only [`conflicts_from_tree_merge`] produces it: the index alone shows the three paths
63    /// as [`Self::BothDeleted`], [`Self::AddedByUs`] and [`Self::AddedByThem`]. The paths are
64    /// boxed so every other kind stays small in the long conflict lists `status` builds.
65    RenameRename(Box<RenamedPaths>),
66    /// A file one side added inside a directory the other side renamed was moved into the new
67    /// directory, and left unmerged at the adding side's stage so the user confirms the move.
68    ///
69    /// [`Conflict::path`] is the new location. Only [`conflicts_from_tree_merge`] produces it;
70    /// the index shows the path as [`Self::AddedByUs`] or [`Self::AddedByThem`] (or by its
71    /// stages, when the file also has content conflicts).
72    DirectoryRenameMoved(Box<MovedPath>),
73    /// One side spread a directory's files over several directories with no clear winner, so
74    /// files the other side added to it were left in place. [`Conflict::path`] is the directory;
75    /// no index entry is unmerged.
76    DirectoryRenameSplit,
77    /// Files were not moved into a renamed directory because their destination is taken.
78    /// [`Conflict::path`] is the destination; no index entry is unmerged.
79    DirectoryRenameBlocked(Box<BlockedMove>),
80}
81
82/// Where a [`ConflictKind::DirectoryRenameMoved`] file came from.
83#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
84#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
85pub struct MovedPath {
86    /// Path the adding side gave the file, inside the renamed directory's old name.
87    pub original_path: String,
88}
89
90/// The files a [`ConflictKind::DirectoryRenameBlocked`] directory rename did not move.
91#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
92#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
93pub struct BlockedMove {
94    /// New files that would have moved to the destination; they stay where they were added.
95    pub sources: Vec<String>,
96    /// `true` when the adding side already has a file or directory at the destination; `false`
97    /// when several new files would land on it.
98    pub existing: bool,
99}
100
101/// The three names in a [`ConflictKind::RenameRename`] conflict.
102#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
103#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
104pub struct RenamedPaths {
105    /// Path of the file in the merge base (left at stage 1).
106    pub base_path: String,
107    /// Name our side gave it (stage 2).
108    pub ours_path: String,
109    /// Name their side gave it (stage 3).
110    pub theirs_path: String,
111}
112
113impl ConflictKind {
114    /// Stable snake_case name for JSON and scripting.
115    ///
116    /// One of `both_modified`, `both_added`, `both_deleted`, `added_by_us`, `added_by_them`,
117    /// `deleted_by_us`, `deleted_by_them`, `rename_rename`, `directory_rename_moved`,
118    /// `directory_rename_split` or `directory_rename_blocked`.
119    #[must_use]
120    pub fn as_str(&self) -> &'static str {
121        match self {
122            Self::BothModified => "both_modified",
123            Self::BothAdded => "both_added",
124            Self::BothDeleted => "both_deleted",
125            Self::AddedByUs => "added_by_us",
126            Self::AddedByThem => "added_by_them",
127            Self::DeletedByUs => "deleted_by_us",
128            Self::DeletedByThem => "deleted_by_them",
129            Self::RenameRename(_) => "rename_rename",
130            Self::DirectoryRenameMoved(_) => "directory_rename_moved",
131            Self::DirectoryRenameSplit => "directory_rename_split",
132            Self::DirectoryRenameBlocked(_) => "directory_rename_blocked",
133        }
134    }
135}
136
137/// One conflicted path, as reported by status and by every operation that can stop on
138/// conflicts.
139#[derive(Debug, Clone, PartialEq, Eq, Hash)]
140#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
141pub struct Conflict {
142    /// Repository-relative path.
143    pub path: String,
144    /// How the two sides collided on [`Self::path`].
145    #[cfg_attr(feature = "serde-models", serde(flatten))]
146    pub kind: ConflictKind,
147}
148
149/// Classify a path from the set of unmerged index stages it has.
150///
151/// `stagemask` has bit 0 set for stage 1 (base), bit 1 for stage 2 (ours) and bit 2 for
152/// stage 3 (theirs).
153///
154/// # Returns
155///
156/// The [`ConflictKind`] for that combination, or `None` when `stagemask` has no stage bit
157/// set or has bits above stage 3 (such a path is not unmerged).
158#[must_use]
159pub fn conflict_kind_from_stagemask(stagemask: u8) -> Option<ConflictKind> {
160    Some(match stagemask {
161        0b001 => ConflictKind::BothDeleted,
162        0b010 => ConflictKind::AddedByUs,
163        0b011 => ConflictKind::DeletedByThem,
164        0b100 => ConflictKind::AddedByThem,
165        0b101 => ConflictKind::DeletedByUs,
166        0b110 => ConflictKind::BothAdded,
167        0b111 => ConflictKind::BothModified,
168        _ => return None,
169    })
170}
171
172/// Classify every unmerged path in `index`.
173///
174/// # Returns
175///
176/// One [`Conflict`] per path with an entry at stage 1, 2 or 3, in index (byte) order. Paths
177/// use [`crate::repo_relpath::storage_string`], so non-UTF-8 paths round-trip.
178#[must_use]
179pub fn conflicts_from_index(index: &Index) -> Vec<Conflict> {
180    let mut masks: BTreeMap<&[u8], u8> = BTreeMap::new();
181    for entry in index.entries() {
182        let stage = entry.stage();
183        if (1..=3).contains(&stage) {
184            *masks.entry(entry.path.as_slice()).or_default() |= 1 << (stage - 1);
185        }
186    }
187    masks
188        .into_iter()
189        .filter_map(|(path, mask)| {
190            Some(Conflict {
191                path: crate::repo_relpath::storage_string(path),
192                kind: conflict_kind_from_stagemask(mask)?,
193            })
194        })
195        .collect()
196}
197
198/// Collect conflicted paths and kinds from a three-way tree merge result.
199///
200/// Paths of a [`TreeMergeOutput::rename_conflicts`] entry are reported as
201/// [`ConflictKind::RenameRename`], and each [`TreeMergeOutput::directory_rename_conflicts`]
202/// entry as the matching directory rename kind at its [`DirectoryRenameConflict::path`]; every
203/// other path is classified by its unmerged stages, like [`conflicts_from_index`]. A path that
204/// only has conflict-marker content and no unmerged stages is [`ConflictKind::BothModified`].
205/// The result is sorted by path bytes, and paths use [`crate::repo_relpath::storage_string`]
206/// (as [`conflicts_from_index`] does), so non-UTF-8 paths round-trip.
207#[must_use]
208pub fn conflicts_from_tree_merge(merged: &TreeMergeOutput) -> Vec<Conflict> {
209    use crate::repo_relpath::storage_string;
210
211    let mut known_kinds: HashMap<&[u8], ConflictKind> = HashMap::new();
212    let mut conflicts: Vec<(&[u8], ConflictKind)> = Vec::new();
213    for conflict in &merged.directory_rename_conflicts {
214        let path = conflict.path();
215        match conflict {
216            DirectoryRenameConflict::Moved { from, .. } => {
217                known_kinds.insert(
218                    path,
219                    ConflictKind::DirectoryRenameMoved(Box::new(MovedPath {
220                        original_path: storage_string(from),
221                    })),
222                );
223            }
224            DirectoryRenameConflict::Split { .. } => {
225                conflicts.push((path, ConflictKind::DirectoryRenameSplit));
226            }
227            DirectoryRenameConflict::Blocked {
228                sources, existing, ..
229            } => conflicts.push((
230                path,
231                ConflictKind::DirectoryRenameBlocked(Box::new(BlockedMove {
232                    sources: sources.iter().map(|s| storage_string(s)).collect(),
233                    existing: *existing,
234                })),
235            )),
236        }
237    }
238    for rr in &merged.rename_conflicts {
239        let kind = ConflictKind::RenameRename(Box::new(RenamedPaths {
240            base_path: storage_string(&rr.base_path),
241            ours_path: storage_string(&rr.ours_path),
242            theirs_path: storage_string(&rr.theirs_path),
243        }));
244        for path in [&rr.base_path, &rr.ours_path, &rr.theirs_path] {
245            known_kinds.insert(path.as_slice(), kind.clone());
246        }
247    }
248    for path in tree_merge_conflict_paths(merged) {
249        let kind = known_kinds.get(path).cloned().unwrap_or_else(|| {
250            let mask = (1..=3u8)
251                .filter(|&stage| merged.index.get(path, stage).is_some())
252                .fold(0u8, |mask, stage| mask | 1 << (stage - 1));
253            conflict_kind_from_stagemask(mask).unwrap_or(ConflictKind::BothModified)
254        });
255        conflicts.push((path, kind));
256    }
257    conflicts.sort_by(|a, b| a.0.cmp(b.0));
258    conflicts
259        .into_iter()
260        .map(|(path, kind)| Conflict {
261            path: storage_string(path),
262            kind,
263        })
264        .collect()
265}
266
267/// Whether `merged` has textual conflict blobs, unmerged index stages or directory renames
268/// that need the user's attention.
269#[must_use]
270pub fn tree_merge_has_conflicts(merged: &TreeMergeOutput) -> bool {
271    !merged.conflict_content.is_empty()
272        || !merged.directory_rename_conflicts.is_empty()
273        || merged.index.entries().iter().any(|e| e.stage() != 0)
274}
275
276/// Render [`NextStep`] values as `grit` command lines (skips steps with no CLI yet).
277#[must_use]
278pub fn next_step_grit_commands(steps: &[NextStep]) -> Vec<String> {
279    steps.iter().filter_map(NextStep::grit_command).collect()
280}
281
282/// Conflicted paths of a tree merge (marker blobs and unmerged stages), sorted and deduplicated.
283fn tree_merge_conflict_paths(merged: &TreeMergeOutput) -> Vec<&[u8]> {
284    let mut paths: Vec<&[u8]> = merged
285        .conflict_content
286        .keys()
287        .map(Vec::as_slice)
288        .chain(
289            merged
290                .index
291                .entries()
292                .iter()
293                .filter(|e| e.stage() != 0)
294                .map(|e| e.path.as_slice()),
295        )
296        .collect();
297    paths.sort_unstable();
298    paths.dedup();
299    paths
300}
301
302#[cfg(test)]
303mod tests {
304    #![allow(clippy::unwrap_used)]
305
306    use super::*;
307    use crate::index::{Index, IndexEntry};
308    use crate::merge_trees::TreeMergeOutput;
309    use std::collections::{BTreeMap, HashSet};
310
311    fn index_entry(path: &str, stage: u8) -> IndexEntry {
312        let mut entry = IndexEntry {
313            ctime_sec: 0,
314            ctime_nsec: 0,
315            mtime_sec: 0,
316            mtime_nsec: 0,
317            dev: 0,
318            ino: 0,
319            mode: crate::index::MODE_REGULAR,
320            uid: 0,
321            gid: 0,
322            size: 0,
323            oid: crate::diff::zero_oid(),
324            flags: 0,
325            flags_extended: None,
326            path: path.as_bytes().to_vec(),
327            base_index_pos: 0,
328        };
329        entry.set_stage(stage);
330        entry
331    }
332
333    #[test]
334    fn conflict_kind_from_stagemask_covers_every_unmerged_combination() {
335        let cases = [
336            (0b001, ConflictKind::BothDeleted),
337            (0b010, ConflictKind::AddedByUs),
338            (0b011, ConflictKind::DeletedByThem),
339            (0b100, ConflictKind::AddedByThem),
340            (0b101, ConflictKind::DeletedByUs),
341            (0b110, ConflictKind::BothAdded),
342            (0b111, ConflictKind::BothModified),
343        ];
344        for (mask, kind) in cases {
345            assert_eq!(conflict_kind_from_stagemask(mask), Some(kind), "{mask:#b}");
346        }
347    }
348
349    #[test]
350    fn conflict_kind_from_stagemask_rejects_masks_without_unmerged_stages() {
351        assert_eq!(conflict_kind_from_stagemask(0), None);
352        assert_eq!(conflict_kind_from_stagemask(0b1000), None);
353        assert_eq!(conflict_kind_from_stagemask(0xff), None);
354    }
355
356    #[test]
357    fn conflict_kind_as_str_is_stable_snake_case() {
358        let cases = [
359            (ConflictKind::BothModified, "both_modified"),
360            (ConflictKind::BothAdded, "both_added"),
361            (ConflictKind::BothDeleted, "both_deleted"),
362            (ConflictKind::AddedByUs, "added_by_us"),
363            (ConflictKind::AddedByThem, "added_by_them"),
364            (ConflictKind::DeletedByUs, "deleted_by_us"),
365            (ConflictKind::DeletedByThem, "deleted_by_them"),
366            (
367                ConflictKind::RenameRename(Box::new(RenamedPaths {
368                    base_path: "a".into(),
369                    ours_path: "b".into(),
370                    theirs_path: "c".into(),
371                })),
372                "rename_rename",
373            ),
374        ];
375        for (kind, name) in cases {
376            assert_eq!(kind.as_str(), name);
377        }
378    }
379
380    #[test]
381    fn conflict_kind_stays_two_words() {
382        assert!(std::mem::size_of::<ConflictKind>() <= 2 * std::mem::size_of::<usize>());
383    }
384
385    #[test]
386    fn conflicts_from_index_groups_stages_per_path_in_byte_order() {
387        let mut index = Index::new();
388        index.add_or_replace(index_entry("z", 1));
389        index.add_or_replace(index_entry("z", 3));
390        index.add_or_replace(index_entry("clean", 0));
391        index.add_or_replace(index_entry("a", 2));
392        index.add_or_replace(index_entry("a", 3));
393        let conflicts = conflicts_from_index(&index);
394        assert_eq!(
395            conflicts,
396            vec![
397                Conflict {
398                    path: "a".into(),
399                    kind: ConflictKind::BothAdded,
400                },
401                Conflict {
402                    path: "z".into(),
403                    kind: ConflictKind::DeletedByUs,
404                },
405            ]
406        );
407    }
408
409    #[test]
410    fn conflicts_from_index_non_utf8_path_uses_storage_string() {
411        let mut index = Index::new();
412        for stage in 1..=3 {
413            let mut entry = index_entry("", stage);
414            entry.path = vec![0xff, b'a'];
415            index.add_or_replace(entry);
416        }
417        let conflicts = conflicts_from_index(&index);
418        assert_eq!(conflicts.len(), 1);
419        assert_eq!(conflicts[0].kind, ConflictKind::BothModified);
420        assert_eq!(
421            crate::repo_relpath::storage_bytes(&conflicts[0].path).as_ref(),
422            [0xff, b'a']
423        );
424    }
425
426    #[test]
427    fn conflicts_from_tree_merge_reports_rename_rename_on_all_three_paths() {
428        let mut index = Index::new();
429        index.add_or_replace(index_entry("a", 1));
430        index.add_or_replace(index_entry("b", 2));
431        index.add_or_replace(index_entry("c", 3));
432        index.add_or_replace(index_entry("d", 1));
433        index.add_or_replace(index_entry("d", 2));
434        let merged = TreeMergeOutput {
435            index,
436            conflict_content: BTreeMap::new(),
437            result_tree: crate::diff::zero_oid(),
438            touched_paths: HashSet::new(),
439            rename_conflicts: vec![crate::merge_trees::RenameRenameConflict {
440                base_path: b"a".to_vec(),
441                ours_path: b"b".to_vec(),
442                theirs_path: b"c".to_vec(),
443            }],
444            directory_rename_conflicts: Vec::new(),
445        };
446        let kind = ConflictKind::RenameRename(Box::new(RenamedPaths {
447            base_path: "a".into(),
448            ours_path: "b".into(),
449            theirs_path: "c".into(),
450        }));
451        let conflicts = conflicts_from_tree_merge(&merged);
452        let got: Vec<(&str, &ConflictKind)> = conflicts
453            .iter()
454            .map(|c| (c.path.as_str(), &c.kind))
455            .collect();
456        assert_eq!(
457            got,
458            vec![
459                ("a", &kind),
460                ("b", &kind),
461                ("c", &kind),
462                ("d", &ConflictKind::DeletedByThem),
463            ]
464        );
465    }
466
467    #[test]
468    fn conflicts_from_tree_merge_keeps_non_utf8_directory_rename_paths() {
469        use crate::merge_trees::{DirectoryRenameConflict, MergeSide};
470        use crate::repo_relpath::storage_string;
471
472        let from = b"a/n\xff".to_vec();
473        let to = b"b/n\xff".to_vec();
474        let mut row = index_entry("x", 3);
475        row.path = to.clone();
476        let mut index = Index::new();
477        index.add_or_replace(row);
478        let merged = TreeMergeOutput {
479            index,
480            conflict_content: BTreeMap::new(),
481            result_tree: crate::diff::zero_oid(),
482            touched_paths: HashSet::new(),
483            rename_conflicts: Vec::new(),
484            directory_rename_conflicts: vec![
485                DirectoryRenameConflict::Moved {
486                    from: from.clone(),
487                    to: to.clone(),
488                    added_by: MergeSide::Theirs,
489                },
490                DirectoryRenameConflict::Split {
491                    dir: b"c\xfe".to_vec(),
492                    renamed_by: MergeSide::Ours,
493                },
494            ],
495        };
496        assert_eq!(
497            conflicts_from_tree_merge(&merged),
498            vec![
499                Conflict {
500                    path: storage_string(&to),
501                    kind: ConflictKind::DirectoryRenameMoved(Box::new(MovedPath {
502                        original_path: storage_string(&from),
503                    })),
504                },
505                Conflict {
506                    path: storage_string(b"c\xfe"),
507                    kind: ConflictKind::DirectoryRenameSplit,
508                },
509            ]
510        );
511    }
512
513    #[test]
514    fn conflicts_from_tree_merge_unions_markers_and_unmerged_index() {
515        let mut index = Index::new();
516        index.add_or_replace(index_entry("b.txt", 1));
517        index.add_or_replace(index_entry("b.txt", 2));
518        let mut conflict_content = BTreeMap::new();
519        conflict_content.insert(b"a.txt".to_vec(), crate::diff::zero_oid());
520        let merged = TreeMergeOutput {
521            index,
522            conflict_content,
523            result_tree: crate::diff::zero_oid(),
524            touched_paths: HashSet::new(),
525            rename_conflicts: Vec::new(),
526            directory_rename_conflicts: Vec::new(),
527        };
528        let conflicts = conflicts_from_tree_merge(&merged);
529        assert_eq!(conflicts.len(), 2);
530        assert_eq!(conflicts[0].path, "a.txt");
531        assert_eq!(conflicts[0].kind, ConflictKind::BothModified);
532        assert_eq!(conflicts[1].path, "b.txt");
533        assert_eq!(conflicts[1].kind, ConflictKind::DeletedByThem);
534    }
535
536    #[test]
537    fn next_step_grit_command_for_each_operation() {
538        use crate::state::InProgressOperation;
539
540        let cases: &[(NextStep, &str)] = &[
541            (
542                NextStep::Abort(InProgressOperation::Merge),
543                "grit merge --abort",
544            ),
545            (
546                NextStep::Continue(InProgressOperation::Merge),
547                "grit merge --continue",
548            ),
549            (
550                NextStep::Abort(InProgressOperation::CherryPick),
551                "grit pick --abort",
552            ),
553            (
554                NextStep::Continue(InProgressOperation::CherryPick),
555                "grit pick --continue",
556            ),
557            (
558                NextStep::Skip(InProgressOperation::CherryPick),
559                "grit pick --skip",
560            ),
561            (
562                NextStep::Abort(InProgressOperation::Revert),
563                "grit revert --abort",
564            ),
565            (
566                NextStep::Continue(InProgressOperation::Revert),
567                "grit revert --continue",
568            ),
569            (
570                NextStep::Skip(InProgressOperation::Revert),
571                "grit revert --skip",
572            ),
573        ];
574        for (step, expected) in cases {
575            assert_eq!(step.grit_command().as_deref(), Some(*expected), "{step:?}");
576            assert_eq!(step.command().as_deref(), Some(*expected), "{step:?}");
577            assert_eq!(step.git_command(), None, "{step:?}");
578        }
579    }
580
581    #[test]
582    fn continue_with_git_renders_only_a_git_command() {
583        use crate::state::InProgressOperation;
584
585        let cases: &[(InProgressOperation, Option<&str>)] = &[
586            (InProgressOperation::Merge, Some("git merge --continue")),
587            (InProgressOperation::Rebase, Some("git rebase --continue")),
588            (
589                InProgressOperation::RebaseInteractive,
590                Some("git rebase --continue"),
591            ),
592            (
593                InProgressOperation::CherryPick,
594                Some("git cherry-pick --continue"),
595            ),
596            (InProgressOperation::Revert, Some("git revert --continue")),
597            (InProgressOperation::Am, Some("git am --continue")),
598            (InProgressOperation::Bisect, None),
599        ];
600        for (kind, expected) in cases {
601            let step = NextStep::ContinueWithGit(*kind);
602            assert_eq!(step.grit_command(), None, "{step:?}");
603            assert_eq!(step.git_command().as_deref(), *expected, "{step:?}");
604            assert_eq!(step.command().as_deref(), *expected, "{step:?}");
605        }
606        assert_eq!(
607            NextStep::Abort(InProgressOperation::RebaseInteractive)
608                .command()
609                .as_deref(),
610            Some("grit rebase --abort")
611        );
612    }
613}