From 1aaca8d0fd997dd15695cb15a07295a2c573d184 Mon Sep 17 00:00:00 2001 From: Lars Jansen Date: Thu, 5 Nov 2026 11:11:35 +0100 Subject: [PATCH] feat: resolve a conflicted file conflict by conflict, with both sides next to each other and the result below --- CONTRIBUTING.md | 10 +- README.md | 6 +- ROADMAP.md | 26 ++--- crates/app/src/app.rs | 71 +++++++++++++- crates/app/src/details.rs | 14 ++- crates/app/src/graph.rs | 2 +- crates/app/src/main.rs | 1 + crates/app/src/merge.rs | 184 ++++++++++++++++++++++++++++++++++++ crates/app/src/tests.rs | 71 ++++++++++++++ crates/app/src/theme.rs | 2 + crates/app/src/ui.rs | 13 ++- crates/core/src/conflict.rs | 183 +++++++++++++++++++++++++++++++++++ crates/core/src/error.rs | 11 +++ crates/core/src/git.rs | 11 +++ crates/core/src/lib.rs | 1 + crates/core/tests/repo.rs | 26 +++++ 16 files changed, 601 insertions(+), 31 deletions(-) create mode 100644 crates/app/src/merge.rs create mode 100644 crates/core/src/conflict.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 95f8398..bbea210 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,11 +52,13 @@ There are two crates. - `graph.rs` orders commits (`git log --date-order`) and lays out the lanes. It keeps a snapshot of the lanes every 256 rows, so it only ever builds the rows on screen. - `git.rs` runs the user's own `git` for everything else. Every change to a repository is one - variant of `Op`, which maps to the arguments for `git`; cloning and creating a repository are - the two functions next to it. Using `git` itself keeps hooks, credentials, signing and config + variant of `Op`, which maps to the arguments for `git` (saving a resolved file writes it + first); cloning and creating a repository are the two functions next to it. Using `git` itself keeps hooks, credentials, signing and config working without code of our own. - `status.rs` and `diff.rs` parse `git status --porcelain=v2` and unified diffs, and build the patches for staging single lines. +- `conflict.rs` splits a conflicted file at the markers git wrote into it, and puts it back + together with ours, theirs or both for each conflict. - `watch.rs` watches the working tree and the refs, and says when something changed that git does not ignore. Reading should not write (`git status` runs without optional locks): a write wakes the watcher again. @@ -74,8 +76,8 @@ Its tests run against real repositories made with `git` in a temporary folder background thread and is followed by a reload, so the window never waits; what the user asks for in the meantime runs next. The tab on screen also reloads by itself when its watcher saw a change. -- `start.rs` (open, clone and create), `ui.rs` (tabs and toolbar), `graph.rs`, `sidebar.rs`, `details.rs` - and `diff.rs` draw the views, and `theme.rs` has the colours. +- `start.rs` (open, clone and create), `ui.rs` (tabs and toolbar), `graph.rs`, `sidebar.rs`, `details.rs`, + `diff.rs` and `merge.rs` (a conflicted file) draw the views, and `theme.rs` has the colours. The app's tests (`crates/app/src/tests.rs`) drive the whole window the way a user does, through the accessibility tree with [egui_kittest](https://crates.io/crates/egui_kittest). That also keeps diff --git a/README.md b/README.md index 87080cf..986a09b 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,10 @@ GitCraft is young. This is what it does today; [ROADMAP.md](ROADMAP.md) has what own way, after an amend or a rebase, is force pushed with a lease, so nobody else's work is lost. - **Stashes**: stash, apply, pop and drop. - **Conflicts**: when a merge, rebase, cherry-pick or revert stops at a conflict, a bar says so and - counts the conflicted files. Take a whole file from your side or theirs (also when one side - deleted it), or edit it and mark it resolved, then continue, skip the commit, or abort. + counts the conflicted files. Open a conflicted file to go through it conflict by conflict, with + your side and theirs next to each other and the result below: take ours, theirs or both, and + save. Or take a whole file from one side (also when one side deleted it), or edit it and mark + it resolved; then continue, skip the commit, or abort. - **Undo and redo**: take back a commit, an amend, a checkout, a merge, a pull, a rebase, a cherry-pick, a revert or a reset, one step at a time, and do it again. It reads git's reflog, so it works for what you did on the command line too. Undoing a commit puts its changes back in the diff --git a/ROADMAP.md b/ROADMAP.md index f21c860..d1491f2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,24 +30,24 @@ drive it the way a user does, and keeps every benchmark as fast as before. - [x] Conflicts, part 1: a bar while a merge, rebase, cherry-pick or revert is stopped, with the number of conflicted files; continue, skip or abort; per file take ours or theirs (also when that side deleted it) or mark it resolved +- [x] Conflicts, part 2: resolve a conflicted file conflict by conflict, with ours and theirs side + by side and the result below; take ours, theirs or both for each conflict, then save ## Next -1. **Conflicts, part 2**: resolve a conflicted file in a three-way view (ours, theirs, result), - conflict by conflict. -2. **Interactive rebase**: reorder, squash, fix up, reword and drop commits, by dragging in the +1. **Interactive rebase**: reorder, squash, fix up, reword and drop commits, by dragging in the graph. -3. **Credentials**: ask for a username, password or passphrase when git needs one. -4. **Push and pull options**: force push on request, push tags, pick the remote, pull with rebase. -5. **Remotes**: add, edit and remove. -6. **Search the graph** by message, author or SHA, and jump to any branch or tag. -7. **File history and blame.** -8. **Better diffs**: side by side, word-level changes, syntax colouring, images. -9. **Keyboard**: shortcuts for everything, and a command palette. -10. **Settings**: light and dark theme, font size, date format, default pull mode. -11. **Graph polish**: resizable columns, compact lanes, author avatars (optional, never by default +2. **Credentials**: ask for a username, password or passphrase when git needs one. +3. **Push and pull options**: force push on request, push tags, pick the remote, pull with rebase. +4. **Remotes**: add, edit and remove. +5. **Search the graph** by message, author or SHA, and jump to any branch or tag. +6. **File history and blame.** +7. **Better diffs**: side by side, word-level changes, syntax colouring, images. +8. **Keyboard**: shortcuts for everything, and a command palette. +9. **Settings**: light and dark theme, font size, date format, default pull mode. +10. **Graph polish**: resizable columns, compact lanes, author avatars (optional, never by default over the network). -12. **Packaging**: installers with the icon for Windows (MSI), macOS (.app in a .dmg) and Linux +11. **Packaging**: installers with the icon for Windows (MSI), macOS (.app in a .dmg) and Linux (AppImage, .deb, Flatpak), signed where the platform expects it. ## After 1.0 diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index 5a6682d..8024775 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -12,10 +12,11 @@ use std::sync::mpsc::{self, Receiver, TryRecvError}; use std::time::{Duration, Instant}; use eframe::egui; +use gitcraft_core::conflict::{Conflicts, Pick}; use gitcraft_core::diff::{self, FileDiff, Source}; use gitcraft_core::git::Op; use gitcraft_core::repo::{Commit, History, Repo, Underway}; -use gitcraft_core::status::{self, FileChange, Status}; +use gitcraft_core::status::{self, Change, FileChange, Status}; use gitcraft_core::undo::{self, Undo}; use gitcraft_core::watch::Watch; use gitcraft_core::{ObjectId, Result}; @@ -37,6 +38,12 @@ pub enum Action { CloseFile, /// Pick or unpick a line of the open diff for staging. ToggleLine(usize, usize), + /// Resolve a conflicted file in the centre, conflict by conflict. + Resolve(String), + /// Keep a side of the conflict on screen, and go on to the next conflict without a pick. + Pick(Pick), + /// Show a conflict of the file being resolved. + GoTo(usize), /// Ask for a name first. Prompt(Prompt), /// Ask whether to go ahead first. @@ -135,6 +142,40 @@ pub struct OpenFile { pub lines: BTreeSet<(usize, usize)>, } +/// A conflicted file being resolved in the centre, conflict by conflict. +#[derive(Debug)] +pub struct Merge { + /// The file. + pub path: String, + /// Its conflicts, as git marked them. + pub conflicts: Conflicts, + /// What to keep of each conflict. + pub picks: Vec>, + /// The conflict on screen. + pub at: usize, + /// Whether to scroll to it. + pub scroll: bool, +} + +impl Merge { + fn new(path: String, conflicts: Conflicts) -> Self { + Self { path, picks: vec![None; conflicts.count()], conflicts, at: 0, scroll: true } + } + + fn go_to(&mut self, at: usize) { + (self.at, self.scroll) = (at, true); + } + + /// Keeps `pick` of the conflict on screen, and goes on to the next conflict without a pick. + fn pick(&mut self, pick: Pick) { + self.picks[self.at] = Some(pick); + let count = self.picks.len(); + if let Some(next) = (1..count).map(|step| (self.at + step) % count).find(|&i| self.picks[i].is_none()) { + self.go_to(next); + } + } +} + /// Commits read so far, so scrolling back costs nothing. #[derive(Debug, Default)] pub struct Commits(HashMap); @@ -177,6 +218,8 @@ pub struct Workspace { pub files: Option<(ObjectId, Vec)>, /// The diff in the centre, if any. pub open_file: Option, + /// The conflicted file in the centre, if any. + pub merge: Option, /// The commit summary being written. pub summary: String, /// The rest of the commit message. @@ -214,6 +257,7 @@ impl Workspace { selection: None, files: None, open_file: None, + merge: None, summary: String::new(), description: String::new(), amend: false, @@ -269,10 +313,19 @@ impl Workspace { self.scroll_to = self.selected_row(); } Action::Open(source) => match diff::read(&self.repo.git, &source) { - Ok(diff) => self.open_file = Some(OpenFile { source, diff, lines: BTreeSet::new() }), + Ok(diff) => { + self.merge = None; + self.open_file = Some(OpenFile { source, diff, lines: BTreeSet::new() }); + } + Err(error) => self.error = Some(error.to_string()), + }, + Action::Resolve(path) => match Conflicts::read(&self.repo.git, &path) { + Ok(conflicts) => (self.merge, self.open_file) = (Some(Merge::new(path, conflicts)), None), Err(error) => self.error = Some(error.to_string()), }, - Action::CloseFile => self.open_file = None, + Action::Pick(pick) => self.merge.iter_mut().for_each(|merge| merge.pick(pick)), + Action::GoTo(at) => self.merge.iter_mut().for_each(|merge| merge.go_to(at)), + Action::CloseFile => (self.open_file, self.merge) = (None, None), Action::ToggleLine(hunk, line) => { if let Some(file) = &mut self.open_file && !file.lines.remove(&(hunk, line)) @@ -389,6 +442,18 @@ impl Workspace { None => self.selection = None, } } + if let Some(merge) = &mut self.merge { + // A file without conflicts left closes; one changed outside starts again. + let entries = &self.snapshot.status.entries; + let conflicted = + entries.iter().any(|e| e.path == merge.path && matches!(e.unstaged, Some(Change::Conflicted { .. }))); + match conflicted.then(|| Conflicts::read(&self.repo.git, &merge.path)) { + None => self.merge = None, + Some(Ok(conflicts)) if conflicts == merge.conflicts => {} + Some(Ok(conflicts)) => *merge = Merge::new(std::mem::take(&mut merge.path), conflicts), + Some(Err(error)) => (self.merge, self.error) = (None, Some(error.to_string())), + } + } if let Some(file) = &mut self.open_file { // A file in the working tree without changes left, or gone altogether, closes. let entries = &self.snapshot.status.entries; diff --git a/crates/app/src/details.rs b/crates/app/src/details.rs index 557704c..1cfdd11 100644 --- a/crates/app/src/details.rs +++ b/crates/app/src/details.rs @@ -72,6 +72,7 @@ fn resolve(ui: &mut Ui, path: &str, sides: Option<[(&str, &str, bool); 2]>, acti fn changes(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec) { let underway = ws.snapshot.underway.as_ref(); let open = ws.open_file.as_ref().map(|f| &f.source); + let merging = ws.merge.as_ref().map(|m| m.path.as_str()); let entries = &ws.snapshot.status.entries; let unstaged: Vec<_> = entries.iter().filter_map(|e| Some((e.unstaged?, e.path.as_str()))).collect(); let staged: Vec<_> = entries.iter().filter_map(|e| Some((e.staged?, e.path.as_str()))).collect(); @@ -92,7 +93,8 @@ fn changes(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec) { Source::Unstaged(path.into()) }; let conflicted = matches!(change, Change::Conflicted { .. }); - let clicked = file(ui, change, path, open == Some(&source), |ui| { + let shown = if conflicted { merging == Some(path) } else { open == Some(&source) }; + let clicked = file(ui, change, path, shown, |ui| { // A conflicted file has its buttons on a line of their own, below. if conflicted { return; @@ -113,9 +115,13 @@ fn changes(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec) { resolve(ui, path, sides, actions); }); } - // `git diff` shows a conflict in a form of its own, which has no view yet. - if clicked && !conflicted { - actions.push(Action::Open(source)); + if clicked { + actions.push(match change { + Change::Conflicted { ours: true, theirs: true } => Action::Resolve(path.into()), + // A file one side deleted has no lines to line up. + Change::Conflicted { .. } => continue, + _ => Action::Open(source), + }); } } }); diff --git a/crates/app/src/graph.rs b/crates/app/src/graph.rs index 2871105..101c4d6 100644 --- a/crates/app/src/graph.rs +++ b/crates/app/src/graph.rs @@ -17,7 +17,7 @@ const AUTHOR: f32 = 140.0; const DATE: f32 = 100.0; /// A count as a distance in points. -fn points(n: usize) -> f32 { +pub fn points(n: usize) -> f32 { f32::from(u16::try_from(n).unwrap_or(u16::MAX)) } diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index d90d460..9f76d38 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -5,6 +5,7 @@ mod app; mod details; mod diff; mod graph; +mod merge; mod sidebar; mod start; #[cfg(test)] diff --git a/crates/app/src/merge.rs b/crates/app/src/merge.rs new file mode 100644 index 0000000..10dc6d0 --- /dev/null +++ b/crates/app/src/merge.rs @@ -0,0 +1,184 @@ +//! A conflicted file, resolved conflict by conflict: our side and theirs next to each other with +//! the conflicts lined up, and the result below them. + +use eframe::egui::{self, Align2, Button, Color32, FontId, Key, Rect, Response, Role, Sense, Ui, Vec2, WidgetInfo}; +use gitcraft_core::conflict::{Part, Pick}; +use gitcraft_core::git::Op; + +use crate::app::{Action, Merge}; +use crate::graph::points; +use crate::theme; + +const LINE: f32 = 18.0; + +/// A row of the sides, or of the result. +#[derive(Debug, Clone, Copy)] +enum Row<'a> { + /// A line both sides agree on. + Same(&'a str), + /// The heading of a conflict; in the result, a conflict without a pick. + Conflict(usize), + /// A line of a conflict on our side and theirs; in the result, a line it keeps, on the left. + Lines(usize, [Option<&'a str>; 2]), +} + +impl Row<'_> { + fn conflict(self) -> Option { + match self { + Self::Same(_) => None, + Self::Conflict(n) | Self::Lines(n, _) => Some(n), + } + } +} + +/// The rows of both sides, or with `result`, of the result. +fn rows(merge: &Merge, result: bool) -> Vec> { + let mut rows = Vec::new(); + let mut n = 0; + for part in &merge.conflicts.parts { + match (part, result) { + (Part::Same(text), _) => rows.extend(text.lines().map(Row::Same)), + (Part::Conflict(sides), true) => match merge.picks[n] { + Some(pick) => { + let kept = sides[pick.sides()].iter().flat_map(|side| side.lines()); + rows.extend(kept.map(|line| Row::Lines(n, [Some(line), None]))); + } + None => rows.push(Row::Conflict(n)), + }, + (Part::Conflict(sides), false) => { + rows.push(Row::Conflict(n)); + let [ours, theirs] = sides.each_ref().map(|side| side.lines().collect::>()); + let lines = (0..ours.len().max(theirs.len())).map(|i| [ours.get(i).copied(), theirs.get(i).copied()]); + rows.extend(lines.map(|lines| Row::Lines(n, lines))); + } + } + n += usize::from(matches!(part, Part::Conflict(_))); + } + rows +} + +/// What was picked for a conflict, in words. +fn picked(pick: Option) -> &'static str { + match pick { + None => "no pick yet", + Some(Pick::Ours) => "ours", + Some(Pick::Theirs) => "theirs", + Some(Pick::Both) => "both, ours first", + } +} + +/// Fills `cell` with `background` and writes `text` in it. +fn paint(ui: &Ui, cell: Rect, text: &str, colour: Color32, background: Color32) { + let painter = ui.painter_at(cell); + painter.rect_filled(cell, 0.0, background); + painter.text(cell.left_center() + Vec2::X * 8.0, Align2::LEFT_CENTER, text, FontId::monospace(12.5), colour); +} + +/// `rows` in a scrolling list at most `height` high, scrolled to conflict `to` when given. +fn list( + ui: &mut Ui, + id: &str, + rows: &[Row], + height: f32, + to: Option, + mut row: impl FnMut(&Ui, Rect, Response, Row), +) { + let mut area = egui::ScrollArea::vertical().id_salt(id).max_height(height).auto_shrink(false); + if let Some(first) = to.and_then(|to| rows.iter().position(|row| row.conflict() == Some(to))) { + area = area.vertical_scroll_offset(points(first.saturating_sub(3)) * LINE); + } + area.show_rows(ui, LINE, rows.len(), |ui, range| { + for &r in &rows[range] { + let (rect, response) = ui.allocate_exact_size(Vec2::new(ui.available_width(), LINE), Sense::click()); + row(ui, rect, response, r); + } + }); +} + +/// Shows the file being resolved. +pub fn show(ui: &mut Ui, merge: &mut Merge, actions: &mut Vec) { + let (count, at) = (merge.picks.len(), merge.at); + let resolved = merge.conflicts.resolve(&merge.picks); + ui.horizontal(|ui| { + ui.strong(&merge.path); + ui.label(format!("Conflict {} of {count}", at + 1)); + for (verb, to) in [("Previous", at + count - 1), ("Next", at + 1)] { + if ui.add_enabled(count > 1, Button::new(verb)).clicked() { + actions.push(Action::GoTo(to % count)); + } + } + ui.separator(); + for (verb, pick) in [("Take ours", Pick::Ours), ("Take theirs", Pick::Theirs), ("Take both", Pick::Both)] { + if ui.add(Button::selectable(merge.picks[at] == Some(pick), verb)).clicked() { + actions.push(Action::Pick(pick)); + } + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Close").clicked() || ui.input(|i| i.key_pressed(Key::Escape)) { + actions.push(Action::CloseFile); + } + let save = ui.add_enabled(resolved.is_some(), Button::new("Save and mark resolved")); + if save.on_disabled_hover_text("Pick a side for every conflict first").clicked() + && let Some(text) = resolved + { + actions.push(Action::Run(Op::Save { path: merge.path.clone(), text })); + } + }); + }); + ui.separator(); + let [ours, theirs] = &merge.conflicts.names; + ui.columns(2, |columns| { + columns[0].strong(format!("Ours: {ours}")); + columns[1].strong(format!("Theirs: {theirs}")); + }); + let to = std::mem::take(&mut merge.scroll).then_some(at); + let visuals = ui.visuals(); + let (text, current, other) = (visuals.text_color(), visuals.selection.bg_fill, visuals.extreme_bg_color); + ui.spacing_mut().item_spacing.y = 0.0; + let half = (ui.available_height() - 2.0 * LINE) / 2.0; + list(ui, "sides", &rows(merge, false), half, to, |ui, rect, response, row| { + let cells = + [Rect::from_min_max(rect.min, rect.center_bottom()), Rect::from_min_max(rect.center_top(), rect.max)]; + match row { + Row::Same(line) => { + for cell in cells { + paint(ui, cell, line, text, Color32::TRANSPARENT); + } + } + Row::Conflict(n) => { + let name = format!("Conflict {}", n + 1); + let background = if n == at { current } else { other }; + paint(ui, rect, &format!("{name}: {}", picked(merge.picks[n])), text, background); + response.widget_info(|| WidgetInfo::labeled(Role::Button, true, &name)); + if response.clicked() { + actions.push(Action::GoTo(n)); + } + } + Row::Lines(n, lines) => { + for (side, (cell, line)) in cells.into_iter().zip(lines).enumerate() { + let background = match merge.picks[n] { + None => theme::CONFLICT, + Some(pick) if pick.sides().contains(&side) => theme::ADDED, + Some(_) => theme::REMOVED, + }; + if let Some(line) = line { + paint(ui, cell, line, text, background); + } + } + } + } + }); + ui.add_space(4.0); + ui.strong("Result"); + ui.add_space(4.0); + list(ui, "result", &rows(merge, true), f32::INFINITY, to, |ui, rect, response, row| match row { + Row::Same(line) => paint(ui, rect, line, text, Color32::TRANSPARENT), + Row::Lines(_, [line, _]) => paint(ui, rect, line.unwrap_or_default(), text, theme::ADDED), + Row::Conflict(n) => { + paint(ui, rect, &format!("Conflict {}: pick a side", n + 1), theme::ERROR, theme::CONFLICT); + if response.clicked() { + actions.push(Action::GoTo(n)); + } + } + }); +} diff --git a/crates/app/src/tests.rs b/crates/app/src/tests.rs index 3b3ff72..0dd6642 100644 --- a/crates/app/src/tests.rs +++ b/crates/app/src/tests.rs @@ -638,3 +638,74 @@ fn abort_a_cherry_pick_that_stopped_at_a_conflict() { assert!(harness.query_by_label("Cherry-pick in progress").is_none()); assert!(harness.query_by_label("Uncommitted changes").is_none()); } + +#[test] +fn resolve_a_file_conflict_by_conflict() { + let (_dir, git) = repo(); + let lines = |second: &str, last: &str| format!("1\n{second}\n3\n4\n5\n6\n7\n{last}\n"); + let save = |text: String, message: &str| { + fs::write(git.dir().join("file.txt"), text).unwrap(); + git.apply(&Op::Stage(vec!["file.txt".into()])).unwrap(); + git.apply(&Op::Commit { message: message.into(), amend: false }).unwrap(); + }; + save(lines("2", "8"), "Eight lines"); + git.run(&["branch", "topic"]).unwrap(); + save(lines("main", "main"), "On main"); + git.apply(&Op::Checkout("topic".into())).unwrap(); + save(lines("topic", "topic"), "On topic"); + git.apply(&Op::Checkout("main".into())).unwrap(); + assert!(git.apply(&Op::Merge("topic".into())).is_err()); + let mut harness = app(Some(git.dir())); + harness.get_by_label("Uncommitted changes").click(); + settle(&mut harness); + harness.get_by_label("file.txt").click(); + settle(&mut harness); + harness.get_by_label("Conflict 1 of 2"); + harness.get_by_label("Ours: HEAD"); + harness.get_by_label("Theirs: topic"); + assert!(harness.get_by_label("Save and mark resolved").accesskit_node().is_disabled()); + + harness.get_by_label("Take theirs").click(); + settle(&mut harness); + harness.get_by_label("Conflict 2 of 2"); + harness.get_by_label("Conflict 1").click(); + settle(&mut harness); + harness.get_by_label("Take ours").click(); + settle(&mut harness); + harness.get_by_label("Conflict 2 of 2"); + assert!(harness.get_by_label("Save and mark resolved").accesskit_node().is_disabled()); + harness.get_by_label("Take both").click(); + settle(&mut harness); + harness.get_by_label("Save and mark resolved").click(); + settle(&mut harness); + assert_eq!(read(&git, "file.txt"), lines("main", "main\ntopic")); + assert!(harness.state().workspace().unwrap().merge.is_none(), "a resolved file closes"); + harness.get_by_label("Continue").click(); + settle(&mut harness); + assert_eq!(git.run(&["log", "-1", "--format=%s"]).unwrap(), "Merge branch 'topic'\n"); +} + +#[test] +fn a_conflicted_file_changed_outside_starts_again() { + let (_dir, git) = repo(); + clash(&git); + assert!(git.apply(&Op::Merge("topic".into())).is_err()); + let mut harness = app(Some(git.dir())); + harness.get_by_label("Uncommitted changes").click(); + settle(&mut harness); + harness.get_by_label("file.txt").click(); + settle(&mut harness); + harness.get_by_label("Take ours").click(); + settle(&mut harness); + let saveable = + |harness: &Harness<'_, App>| !harness.get_by_label("Save and mark resolved").accesskit_node().is_disabled(); + assert!(saveable(&harness)); + + fs::write(git.dir().join("file.txt"), "<<<<<<< HEAD\nEdited\n=======\nOn topic\n>>>>>>> topic\n").unwrap(); + wait_until(&mut harness, "the edited conflict without its pick", |harness| !saveable(harness)); + fs::write(git.dir().join("file.txt"), "On both").unwrap(); + wait_until(&mut harness, "the explanation", |harness| { + harness.query_by_label_contains("file.txt has no conflict markers").is_some() + }); + assert!(harness.state().workspace().unwrap().merge.is_none()); +} diff --git a/crates/app/src/theme.rs b/crates/app/src/theme.rs index 273b2e7..ac85034 100644 --- a/crates/app/src/theme.rs +++ b/crates/app/src/theme.rs @@ -22,6 +22,8 @@ const LANES: [Color32; 10] = [ pub const ADDED: Color32 = Color32::from_rgb(0x1d, 0x3b, 0x2c); /// The background of a removed line. pub const REMOVED: Color32 = Color32::from_rgb(0x45, 0x22, 0x27); +/// The background of a conflict without a pick. +pub const CONFLICT: Color32 = Color32::from_rgb(0x4a, 0x38, 0x1c); /// The background of a line picked for staging. pub const PICKED: Color32 = Color32::from_rgb(0x2b, 0x4a, 0x7a); /// Text for errors. diff --git a/crates/app/src/ui.rs b/crates/app/src/ui.rs index 1817719..f32329c 100644 --- a/crates/app/src/ui.rs +++ b/crates/app/src/ui.rs @@ -9,7 +9,7 @@ use gitcraft_core::status::Change; use crate::app::{Action, Confirm, Selection, Workspace}; use crate::start::Start; -use crate::{details, diff, graph, sidebar, theme}; +use crate::{details, diff, graph, merge, sidebar, theme}; const RECENT: usize = 10; @@ -193,9 +193,14 @@ fn workspace(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec) { } egui::Panel::left("sidebar").resizable(true).default_size(220.0).show(ui, |ui| sidebar::show(ui, ws, actions)); egui::Panel::right("details").resizable(true).default_size(360.0).show(ui, |ui| details::show(ui, ws, actions)); - egui::CentralPanel::default().show(ui, |ui| match &ws.open_file { - Some(file) => diff::show(ui, file, actions), - None => graph::show(ui, ws, actions), + egui::CentralPanel::default().show(ui, |ui| { + if let Some(merge) = &mut ws.merge { + merge::show(ui, merge, actions); + } else if let Some(file) = &ws.open_file { + diff::show(ui, file, actions); + } else { + graph::show(ui, ws, actions); + } }); } diff --git a/crates/core/src/conflict.rs b/crates/core/src/conflict.rs new file mode 100644 index 0000000..2e740e0 --- /dev/null +++ b/crates/core/src/conflict.rs @@ -0,0 +1,183 @@ +//! A file git could not merge: the text both sides agree on and the conflicts git marked in it, +//! put back together with a side picked for each conflict. + +use std::ops::Range; + +use crate::git::Git; +use crate::{Error, Result}; + +/// What to keep of a conflict. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Pick { + /// Our lines. + Ours, + /// Their lines. + Theirs, + /// Our lines, then theirs. + Both, +} + +impl Pick { + /// Which of the sides of a [`Part::Conflict`] it keeps. + pub fn sides(self) -> Range { + match self { + Self::Ours => 0..1, + Self::Theirs => 1..2, + Self::Both => 0..2, + } + } +} + +/// A part of a conflicted file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Part { + /// Text both sides agree on. + Same(String), + /// Lines each side changed its own way: ours, then theirs. + Conflict([String; 2]), +} + +/// A conflicted file, split at the markers git wrote into it. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Conflicts { + /// What git calls our side and theirs, like `HEAD` and the branch coming in. + pub names: [String; 2], + /// The file, in order. + pub parts: Vec, +} + +/// Where a line of a conflicted file belongs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum At { + Same, + Ours, + /// The common ancestor, which `merge.conflictStyle=diff3` shows. It is left out. + Base, + Theirs, +} + +impl Conflicts { + /// Reads the conflicts in `path`, a file in the working tree. + pub fn read(git: &Git, path: &str) -> Result { + let bytes = + std::fs::read(git.dir().join(path)).map_err(|source| Error::File { path: path.to_owned(), source })?; + let text = String::from_utf8(bytes).ok(); + text.as_deref().and_then(Self::parse).ok_or_else(|| Error::NoConflicts(path.to_owned())) + } + + /// Splits `text` at its conflict markers; `None` when it has none, or they do not add up. + pub fn parse(text: &str) -> Option { + let mut file = Self::default(); + let mut at = At::Same; + for line in text.split_inclusive('\n') { + let bare = line.trim_end_matches(['\n', '\r']); + // Seven marker characters, then nothing or a space and the name of the side. + let marker = |marker: &str| { + let name = bare.strip_prefix(marker)?; + (name.is_empty() || name.starts_with(' ')).then(|| name.trim().to_owned()) + }; + if at == At::Same + && let Some(name) = marker("<<<<<<<") + { + file.names[0] = name; + file.parts.push(Part::Conflict(Default::default())); + at = At::Ours; + } else if at == At::Ours && marker("|||||||").is_some() { + at = At::Base; + } else if matches!(at, At::Ours | At::Base) && bare == "=======" { + at = At::Theirs; + } else if at == At::Theirs + && let Some(name) = marker(">>>>>>>") + { + file.names[1] = name; + at = At::Same; + } else { + match (at, file.parts.last_mut()) { + (At::Same, Some(Part::Same(same))) => same.push_str(line), + (At::Same, _) => file.parts.push(Part::Same(line.to_owned())), + (At::Ours, Some(Part::Conflict(sides))) => sides[0].push_str(line), + (At::Theirs, Some(Part::Conflict(sides))) => sides[1].push_str(line), + _ => {} + } + } + } + (at == At::Same && file.count() > 0).then_some(file) + } + + /// How many conflicts there are. + pub fn count(&self) -> usize { + self.parts.iter().filter(|part| matches!(part, Part::Conflict(_))).count() + } + + /// The file with what `picks` keeps of each conflict, in order; `None` while a conflict has + /// no pick. + pub fn resolve(&self, picks: &[Option]) -> Option { + let mut picks = picks.iter(); + let mut text = String::new(); + for part in &self.parts { + match part { + Part::Same(same) => text.push_str(same), + Part::Conflict(sides) => sides[(*picks.next()?)?.sides()].iter().for_each(|side| text.push_str(side)), + } + } + Some(text) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const MERGE: &str = "one\n<<<<<<< HEAD\nours\n=======\ntheirs\nmore\n>>>>>>> topic\ntwo\n"; + + fn conflict(ours: &str, theirs: &str) -> Part { + Part::Conflict([ours.into(), theirs.into()]) + } + + #[test] + fn splits_a_file_at_its_conflict_markers() { + let file = Conflicts::parse(MERGE).unwrap(); + assert_eq!(file.names, ["HEAD".to_owned(), "topic".to_owned()]); + assert_eq!( + file.parts, + [Part::Same("one\n".into()), conflict("ours\n", "theirs\nmore\n"), Part::Same("two\n".into())] + ); + assert_eq!(file.count(), 1); + } + + #[test] + fn leaves_out_the_common_ancestor() { + let diff3 = MERGE.replace("=======", "||||||| base\nancestor\n======="); + assert_eq!(Conflicts::parse(&diff3), Conflicts::parse(MERGE)); + } + + #[test] + fn keeps_line_endings_and_a_missing_last_newline() { + let file = Conflicts::parse("a\r\n<<<<<<< HEAD\r\nb\r\n=======\r\n>>>>>>> x\r\nend").unwrap(); + assert_eq!(file.parts, [Part::Same("a\r\n".into()), conflict("b\r\n", ""), Part::Same("end".into())]); + assert_eq!(file.resolve(&[Some(Pick::Ours)]).unwrap(), "a\r\nb\r\nend"); + assert_eq!(file.resolve(&[Some(Pick::Theirs)]).unwrap(), "a\r\nend"); + } + + #[test] + fn markers_count_only_where_they_belong() { + assert_eq!(Conflicts::parse("no conflicts\n=======\n"), None); + assert_eq!(Conflicts::parse("<<<<<<< HEAD\nnever ends\n=======\n"), None); + assert_eq!(Conflicts::parse("<<<<<<<< eight is not seven\n"), None); + let file = Conflicts::parse("=======\n<<<<<<<\n>>>>>>> x\n=======\n>>>>>>>\n").unwrap(); + assert_eq!(file.parts, [Part::Same("=======\n".into()), conflict(">>>>>>> x\n", "")]); + } + + #[test] + fn resolves_once_every_conflict_has_a_pick() { + let twice = format!("{MERGE}{MERGE}"); + let file = Conflicts::parse(&twice).unwrap(); + assert_eq!(file.count(), 2); + assert_eq!(file.resolve(&[Some(Pick::Ours), None]), None); + assert_eq!(file.resolve(&[Some(Pick::Ours)]), None); + assert_eq!( + file.resolve(&[Some(Pick::Ours), Some(Pick::Both)]).unwrap(), + "one\nours\ntwo\none\nours\ntheirs\nmore\ntwo\n" + ); + } +} diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index 13784fd..d688a4a 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -18,6 +18,17 @@ pub enum Error { /// `git` printed something we do not understand. #[error("unexpected output from git: {0}")] Parse(String), + /// A file in the working tree could not be read or written. + #[error("{path}: {source}")] + File { + /// The file. + path: String, + /// What went wrong. + source: std::io::Error, + }, + /// A conflicted file has no conflict markers to resolve one by one. + #[error("{0} has no conflict markers to resolve: mark it resolved as it is, or take a side")] + NoConflicts(String), /// Watching the repository for changes failed. #[error("could not watch for changes: {0}")] Watch(Box), diff --git a/crates/core/src/git.rs b/crates/core/src/git.rs index a514947..8d811a8 100644 --- a/crates/core/src/git.rs +++ b/crates/core/src/git.rs @@ -31,6 +31,9 @@ impl Git { /// Carries out `op`. pub fn apply(&self, op: &Op) -> Result { + if let Op::Save { path, text } = op { + std::fs::write(self.dir.join(path), text).map_err(|source| Error::File { path: path.clone(), source })?; + } let args = op.args(); let mut command = self.command(&args); if let Op::Undo { mark, .. } = op { @@ -192,6 +195,13 @@ pub enum Op { /// The files. paths: Vec, }, + /// Write a file and add it to the index, which marks its conflicts resolved. + Save { + /// The file. + path: String, + /// What it holds from now on. + text: String, + }, /// Delete tracked files from the index and the working tree. Remove(Vec), /// Apply a patch to the index, or take it out with `reverse`. @@ -300,6 +310,7 @@ impl Op { Self::Restore { from, paths } => { (&["restore", "--staged", "--worktree", "--source"], [vec![from.as_str(), "--"], strs(paths)].concat()) } + Self::Save { path, .. } => (&["add", "--"], vec![path]), Self::Remove(paths) => (&["rm", "-q", "--"], strs(paths)), Self::ApplyToIndex { reverse, .. } => { (&["apply", "--cached", "--recount"], [flag(*reverse, "--reverse"), vec!["-"]].concat()) diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 39977d0..6354811 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -5,6 +5,7 @@ //! `git`, so their config, hooks, credentials and signing apply unchanged. #![warn(missing_docs)] +pub mod conflict; pub mod diff; mod error; pub mod git; diff --git a/crates/core/tests/repo.rs b/crates/core/tests/repo.rs index d48fd73..8443ff2 100644 --- a/crates/core/tests/repo.rs +++ b/crates/core/tests/repo.rs @@ -4,6 +4,7 @@ use std::fs; use std::sync::mpsc::{self, Receiver}; use std::time::Duration; +use gitcraft_core::conflict::{Conflicts, Pick}; use gitcraft_core::diff::{self, FileDiff, LineKind, Source}; use gitcraft_core::git::{self, Git, Op, ResetMode}; use gitcraft_core::repo::{Label, RefKind, Repo, Underway}; @@ -432,6 +433,31 @@ fn resolve_conflicts_and_continue_a_merge() { assert!(!git.dir().join("b.txt").exists()); } +#[test] +fn resolve_a_file_conflict_by_conflict() { + let (_dir, git) = repo(); + let lines = |second: &str, last: &str| format!("1\n{second}\n3\n4\n5\n6\n7\n{last}\n"); + let root = commit(&git, "a.txt", &lines("2", "8"), "Root"); + git.apply(&Op::Branch { name: "topic".into(), at: root }).unwrap(); + commit(&git, "a.txt", &lines("topic", "topic"), "Topic"); + git.apply(&Op::Checkout("main".into())).unwrap(); + commit(&git, "a.txt", &lines("main", "main"), "Main"); + assert!(git.apply(&Op::Merge("topic".into())).is_err()); + + let file = Conflicts::read(&git, "a.txt").unwrap(); + assert_eq!(file.names, ["HEAD".to_owned(), "topic".to_owned()]); + assert_eq!(file.count(), 2); + let text = file.resolve(&[Some(Pick::Theirs), Some(Pick::Both)]).unwrap(); + git.apply(&Op::Save { path: "a.txt".into(), text }).unwrap(); + assert_eq!(read(&git, "a.txt"), lines("topic", "main\ntopic")); + assert_eq!(status(&git), [("a.txt".into(), Some(Change::Modified), None)]); + git.apply(&Op::Continue("merge")).unwrap(); + assert_eq!(underway(&git), None); + + assert!(matches!(Conflicts::read(&git, "a.txt"), Err(Error::NoConflicts(path)) if path == "a.txt")); + assert!(matches!(Conflicts::read(&git, "gone.txt"), Err(Error::File { .. }))); +} + #[test] fn skip_abort_and_continue_a_rebase_a_cherry_pick_and_a_revert() { let (_dir, git) = repo();