Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ There are two crates.
**`crates/core` (`gitcraft-core`)** knows nothing about the screen.

- `repo.rs` reads the history, refs and commits with gitoxide, in-process, because the graph needs
millions of lookups.
millions of lookups, and what is under way: a merge, rebase, cherry-pick or revert that stopped.
- `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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ GitCraft is young. This is what it does today; [ROADMAP.md](ROADMAP.md) has what
- **Remotes**: fetch, pull and push, with how far ahead and behind you are. A branch that went its
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.
- **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
Expand Down
7 changes: 5 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,14 @@ drive it the way a user does, and keeps every benchmark as fast as before.
has, force push (with lease, for a branch that went its own way), dropping a stash
- [x] Undo and redo through the reflog: commits, amends, checkouts, merges, pulls, rebases,
cherry-picks, reverts and resets, also those made on the command line, several steps deep
- [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

## Next

1. **Conflicts**: list conflicted files, resolve them in a three-way view (ours, theirs, result),
mark them resolved, and continue or abort a merge, rebase or cherry-pick.
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
graph.
3. **Credentials**: ask for a username, password or passphrase when git needs one.
Expand Down
5 changes: 4 additions & 1 deletion crates/app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use std::time::{Duration, Instant};
use eframe::egui;
use gitcraft_core::diff::{self, FileDiff, Source};
use gitcraft_core::git::Op;
use gitcraft_core::repo::{Commit, History, Repo};
use gitcraft_core::repo::{Commit, History, Repo, Underway};
use gitcraft_core::status::{self, FileChange, Status};
use gitcraft_core::undo::{self, Undo};
use gitcraft_core::watch::Watch;
Expand Down Expand Up @@ -107,6 +107,8 @@ pub struct Snapshot {
pub stashes: Vec<String>,
/// What undo and redo would do.
pub undo: Undo,
/// What stopped half way, if anything.
pub underway: Option<Underway>,
}

impl Snapshot {
Expand All @@ -116,6 +118,7 @@ impl Snapshot {
history: repo.history()?,
undo: undo::read(repo, &status)?,
stashes: status::stashes(&repo.git)?,
underway: repo.underway(),
status,
})
}
Expand Down
48 changes: 40 additions & 8 deletions crates/app/src/details.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,29 @@ fn discard(change: Change, path: &str) -> Confirm {
Confirm { question, verb: "Discard", op: Op::Discard(vec![path.into()]) }
}

/// The buttons of a conflicted file: take the whole file from one of the `sides` (name, commit,
/// whether it has the file), when git says what they are, or mark it resolved as it is now.
fn resolve(ui: &mut Ui, path: &str, sides: Option<[(&str, &str, bool); 2]>, actions: &mut Vec<Action>) {
for (side, from, has) in sides.into_iter().flatten() {
let (op, hint) = if has {
(Op::Restore { from: from.into(), paths: vec![path.into()] }, format!("Take the whole file from {from}"))
} else {
(Op::Remove(vec![path.into()]), format!("Delete the file, as {from} did"))
};
let button = ui.small_button(side).on_hover_text(hint);
let name = format!("Take {} for {path}", side.to_lowercase());
button.widget_info(|| WidgetInfo::labeled(Role::Button, true, &name));
if button.clicked() {
actions.push(Action::Run(op));
}
}
if ui.small_button("Mark resolved").clicked() {
actions.push(Action::Run(Op::Stage(vec![path.into()])));
}
}

fn changes(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec<Action>) {
let underway = ws.snapshot.underway.as_ref();
let open = ws.open_file.as_ref().map(|f| &f.source);
let entries = &ws.snapshot.status.entries;
let unstaged: Vec<_> = entries.iter().filter_map(|e| Some((e.unstaged?, e.path.as_str()))).collect();
Expand All @@ -69,20 +91,30 @@ fn changes(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec<Action>) {
} else {
Source::Unstaged(path.into())
};
let conflicted = matches!(change, Change::Conflicted { .. });
let clicked = file(ui, change, path, open == Some(&source), |ui| {
// A conflicted file has its buttons on a line of their own, below.
if conflicted {
return;
}
if ui.small_button("Stage").clicked() {
actions.push(Action::Run(Op::Stage(vec![path.into()])));
}
// Conflicts are resolved, not discarded.
if change != Change::Conflicted {
let button = ui.small_button("Discard");
button.widget_info(|| WidgetInfo::labeled(Role::Button, true, format!("Discard {path}")));
if button.clicked() {
actions.push(Action::Confirm(discard(change, path)));
}
let button = ui.small_button("Discard");
button.widget_info(|| WidgetInfo::labeled(Role::Button, true, format!("Discard {path}")));
if button.clicked() {
actions.push(Action::Confirm(discard(change, path)));
}
});
if clicked {
if let Change::Conflicted { ours, theirs } = change {
let sides = underway.map(|u| [("Ours", "HEAD", ours), ("Theirs", u.theirs, theirs)]);
ui.horizontal(|ui| {
ui.add_space(16.0);
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));
}
}
Expand Down
71 changes: 70 additions & 1 deletion crates/app/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use std::time::{Duration, Instant};

use eframe::egui::{Key, Role};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use egui_kittest::kittest::{NodeT, Queryable};
use gitcraft_core::git::{Git, Op};
use tempfile::TempDir;

Expand Down Expand Up @@ -569,3 +569,72 @@ fn deleting_a_stash_asks_first() {
settle(&mut harness);
assert_eq!(git.run(&["stash", "list"]).unwrap(), "");
}

/// Makes `main` and `topic` each change file.txt their own way, with `main` checked out.
fn clash(git: &Git) {
git.run(&["branch", "topic"]).unwrap();
commit(git, "On main");
git.apply(&Op::Checkout("topic".into())).unwrap();
commit(git, "On topic");
git.apply(&Op::Checkout("main".into())).unwrap();
}

#[test]
fn take_their_side_of_a_conflict_and_continue_the_merge() {
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("Merge in progress");
harness.get_by_label("1 conflicted file");
assert!(harness.get_by_label("Continue").accesskit_node().is_disabled());
assert!(harness.query_by_label("Skip").is_none(), "a merge has no commit to skip");

harness.get_by_label("Uncommitted changes").click();
settle(&mut harness);
harness.get_by_label("Take theirs for file.txt").click();
settle(&mut harness);
assert_eq!(read(&git, "file.txt"), "On topic");
harness.get_by_label("Continue").click();
settle(&mut harness);
assert_eq!(git.run(&["log", "-1", "--format=%s"]).unwrap(), "Merge branch 'topic'\n");
assert!(harness.query_by_label("Merge in progress").is_none());
}

#[test]
fn mark_a_conflict_resolved_and_continue_the_rebase() {
let (_dir, git) = repo();
clash(&git);
git.apply(&Op::Checkout("topic".into())).unwrap();
assert!(git.apply(&Op::Rebase("main".into())).is_err());
let mut harness = app(Some(git.dir()));
harness.get_by_label("Rebase in progress");
harness.get_by_label("Skip");

fs::write(git.dir().join("file.txt"), "On both").unwrap();
harness.get_by_label("Uncommitted changes").click();
settle(&mut harness);
harness.get_by_label("Mark resolved").click();
settle(&mut harness);
harness.get_by_label("Continue").click();
settle(&mut harness);
assert_eq!(log(&git), "On topic\nOn main\nFirst commit\n");
assert_eq!(read(&git, "file.txt"), "On both");
assert!(harness.query_by_label("Rebase in progress").is_none());
}

#[test]
fn abort_a_cherry_pick_that_stopped_at_a_conflict() {
let (_dir, git) = repo();
clash(&git);
assert!(git.apply(&Op::CherryPick("topic".into())).is_err());
let mut harness = app(Some(git.dir()));
harness.get_by_label("Cherry-pick in progress");
assert_eq!(harness.get_all_by_label("On topic").count(), 2, "the commit in the graph, and above it");

harness.get_by_label("Abort").click();
settle(&mut harness);
assert_eq!(read(&git, "file.txt"), "On main");
assert!(harness.query_by_label("Cherry-pick in progress").is_none());
assert!(harness.query_by_label("Uncommitted changes").is_none());
}
2 changes: 1 addition & 1 deletion crates/app/src/theme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ pub fn change(change: Change) -> (&'static str, Color32) {
Change::Renamed | Change::Copied => ("R", LANES[0]),
Change::TypeChanged => ("T", LANES[1]),
Change::Untracked => ("U", LANES[7]),
Change::Conflicted => ("C", LANES[9]),
Change::Conflicted { .. } => ("C", LANES[9]),
}
}

Expand Down
31 changes: 31 additions & 0 deletions crates/app/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf};
use eframe::egui::{self, Button, Key, Role, Ui, WidgetInfo};
use gitcraft_core::git::Op;
use gitcraft_core::repo::{RefKind, Repo};
use gitcraft_core::status::Change;

use crate::app::{Action, Confirm, Selection, Workspace};
use crate::start::Start;
Expand Down Expand Up @@ -177,6 +178,9 @@ fn workspace(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec<Action>) {
prompt(ui.ctx(), ws, actions);
confirm(ui.ctx(), ws, actions);
egui::Panel::top("toolbar").show(ui, |ui| toolbar(ui, ws, actions));
if ws.snapshot.underway.is_some() {
egui::Panel::top("underway").show(ui, |ui| underway(ui, ws, actions));
}
if let Some(error) = ws.error.clone() {
egui::Panel::bottom("error").show(ui, |ui| {
ui.horizontal(|ui| {
Expand Down Expand Up @@ -251,6 +255,33 @@ fn toolbar(ui: &mut Ui, ws: &Workspace, actions: &mut Vec<Action>) {
});
}

/// What stopped half way, the conflicts left, and the ways on from there.
fn underway(ui: &mut Ui, ws: &Workspace, actions: &mut Vec<Action>) {
let Some(underway) = &ws.snapshot.underway else { return };
let entries = &ws.snapshot.status.entries;
let conflicts = entries.iter().filter(|e| matches!(e.unstaged, Some(Change::Conflicted { .. }))).count();
let (command, idle) = (underway.command, ws.busy().is_none());
ui.horizontal(|ui| {
let (first, rest) = command.split_at(1);
ui.strong(format!("{}{rest} in progress", first.to_uppercase()));
ui.weak(&underway.message);
if conflicts > 0 {
ui.colored_label(theme::ERROR, theme::count(conflicts, "conflicted file"));
}
let go_on = ui.add_enabled(idle && conflicts == 0, Button::new("Continue"));
if go_on.on_disabled_hover_text("Resolve every conflict first").clicked() {
actions.push(Action::Run(Op::Continue(command)));
}
// A merge has no commit of its own to leave out.
let skip = (command != "merge").then_some(("Skip", Op::Skip(command)));
for (verb, op) in skip.into_iter().chain([("Abort", Op::Abort(command))]) {
if ui.add_enabled(idle, Button::new(verb)).clicked() {
actions.push(Action::Run(op));
}
}
});
}

fn prompt(ctx: &egui::Context, ws: &mut Workspace, actions: &mut Vec<Action>) {
let Some((prompt, name)) = &mut ws.prompt else { return };
let mut done = false;
Expand Down
23 changes: 23 additions & 0 deletions crates/core/src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,16 @@ pub enum Op {
Discard(Vec<String>),
/// Delete untracked files.
DeleteUntracked(Vec<String>),
/// Put files in the index and the working tree as a commit has them, which also resolves
/// their conflicts.
Restore {
/// The commit.
from: String,
/// The files.
paths: Vec<String>,
},
/// Delete tracked files from the index and the working tree.
Remove(Vec<String>),
/// Apply a patch to the index, or take it out with `reverse`.
ApplyToIndex {
/// The patch, as built by [`crate::diff::FileDiff::patch`].
Expand Down Expand Up @@ -220,6 +230,12 @@ pub enum Op {
CherryPick(String),
/// Add a commit that undoes another.
Revert(String),
/// Carry on with what stopped half way, given as its command (see [`crate::repo::Underway`]).
Continue(&'static str),
/// Leave out the commit it stopped at, and carry on.
Skip(&'static str),
/// Go back to where it started.
Abort(&'static str),
/// Move the current branch.
Reset {
/// Where to.
Expand Down Expand Up @@ -281,6 +297,10 @@ impl Op {
Self::Unstage(paths) => (&["reset", "-q", "--"], strs(paths)),
Self::Discard(paths) => (&["checkout", "-q", "--"], strs(paths)),
Self::DeleteUntracked(paths) => (&["clean", "-f", "-q", "--"], strs(paths)),
Self::Restore { from, paths } => {
(&["restore", "--staged", "--worktree", "--source"], [vec![from.as_str(), "--"], strs(paths)].concat())
}
Self::Remove(paths) => (&["rm", "-q", "--"], strs(paths)),
Self::ApplyToIndex { reverse, .. } => {
(&["apply", "--cached", "--recount"], [flag(*reverse, "--reverse"), vec!["-"]].concat())
}
Expand All @@ -293,6 +313,9 @@ impl Op {
Self::Rebase(onto) => (&["rebase"], vec![onto]),
Self::CherryPick(commit) => (&["cherry-pick"], vec![commit]),
Self::Revert(commit) => (&["revert", "--no-edit"], vec![commit]),
Self::Continue(command) => (&[], vec![command, "--continue"]),
Self::Skip(command) => (&[], vec![command, "--skip"]),
Self::Abort(command) => (&[], vec![command, "--abort"]),
Self::Reset { to, mode } => (&["reset", "-q"], vec![mode.flag(), to]),
Self::Tag { name, at } => (&["tag"], vec![name, at]),
Self::DeleteTag(name) => (&["tag", "-d"], vec![name]),
Expand Down
29 changes: 29 additions & 0 deletions crates/core/src/repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,36 @@ impl Commit {
}
}

/// A merge, rebase, cherry-pick or revert that stopped half way, mostly at a conflict.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Underway {
/// The command that started it and carries on with it: `merge`, `rebase`, `cherry-pick` or
/// `revert`.
pub command: &'static str,
/// The commit whose changes come in, which git calls theirs; ours is `HEAD`.
pub theirs: &'static str,
/// The first line of the message for the commit it makes next.
pub message: String,
}

impl Repo {
/// What stopped half way, if anything.
pub fn underway(&self) -> Option<Underway> {
use gix::state::InProgress;
let (command, theirs) = match self.gix.state()? {
InProgress::Merge => ("merge", "MERGE_HEAD"),
InProgress::Rebase | InProgress::RebaseInteractive => ("rebase", "REBASE_HEAD"),
InProgress::CherryPick | InProgress::CherryPickSequence => ("cherry-pick", "CHERRY_PICK_HEAD"),
// A revert takes out the changes of a commit, so what comes in is its parent.
InProgress::Revert | InProgress::RevertSequence => ("revert", "REVERT_HEAD^"),
InProgress::ApplyMailbox | InProgress::ApplyMailboxRebase | InProgress::Bisect => return None,
};
// The message is only shown. Without one, as when a rebase stopped to edit a commit, the
// rest works the same.
let message = std::fs::read_to_string(self.gix.path().join("MERGE_MSG")).unwrap_or_default();
Some(Underway { command, theirs, message: message.lines().next().unwrap_or_default().to_owned() })
}

/// Opens the repository that contains `path`.
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
let mut gix = gix::discover(path).map_err(Error::repository)?;
Expand Down
Loading
Loading