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
10 changes: 6 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 13 additions & 13 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 68 additions & 3 deletions crates/app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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.
Expand Down Expand Up @@ -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<Option<Pick>>,
/// 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<ObjectId, Commit>);
Expand Down Expand Up @@ -177,6 +218,8 @@ pub struct Workspace {
pub files: Option<(ObjectId, Vec<FileChange>)>,
/// The diff in the centre, if any.
pub open_file: Option<OpenFile>,
/// The conflicted file in the centre, if any.
pub merge: Option<Merge>,
/// The commit summary being written.
pub summary: String,
/// The rest of the commit message.
Expand Down Expand Up @@ -214,6 +257,7 @@ impl Workspace {
selection: None,
files: None,
open_file: None,
merge: None,
summary: String::new(),
description: String::new(),
amend: false,
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 10 additions & 4 deletions crates/app/src/details.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Action>) {
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();
Expand All @@ -92,7 +93,8 @@ fn changes(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec<Action>) {
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;
Expand All @@ -113,9 +115,13 @@ fn changes(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec<Action>) {
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),
});
}
}
});
Expand Down
2 changes: 1 addition & 1 deletion crates/app/src/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down
1 change: 1 addition & 0 deletions crates/app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod app;
mod details;
mod diff;
mod graph;
mod merge;
mod sidebar;
mod start;
#[cfg(test)]
Expand Down
Loading
Loading