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
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ Its tests run against real repositories made with `git` in a temporary folder
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`,
`diff.rs` and `merge.rs` (a conflicted file) draw the views, and `theme.rs` has the colours.
`diff.rs`, `merge.rs` (a conflicted file) and `rebase.rs` (an interactive rebase) 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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ GitCraft is young. This is what it does today; [ROADMAP.md](ROADMAP.md) has what
- **Discard**: throw away the changes to a file, a hunk or single lines, after you confirm.
- **Branches and tags**: create, check out, merge, rebase, delete, from the sidebar or a right click.
- **Commits from the graph**: check out, cherry-pick, revert, reset (soft, mixed, hard), copy the SHA.
- **Interactive rebase**: right-click a commit to rebase onto it, and put the commits after it in
a new order, squash or fix them up into the one below, or drop them, before you start.
- **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.
Expand Down
6 changes: 4 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,13 @@ drive it the way a user does, and keeps every benchmark as fast as before.
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
- [x] Interactive rebase, part 1: from a commit's menu, the commits after it in a list, newest
first; move them up and down, pick, squash, fix up or drop each, then start

## Next

1. **Interactive rebase**: reorder, squash, fix up, reword and drop commits, by dragging in the
graph.
1. **Interactive rebase, part 2**: reword a commit with a new message, and reorder by dragging,
in the list and in the graph.
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.
Expand Down
69 changes: 65 additions & 4 deletions 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::conflict::{Conflicts, Pick};
use gitcraft_core::diff::{self, FileDiff, Source};
use gitcraft_core::git::Op;
use gitcraft_core::git::{Op, Step};
use gitcraft_core::repo::{Commit, History, Repo, Underway};
use gitcraft_core::status::{self, Change, FileChange, Status};
use gitcraft_core::undo::{self, Undo};
Expand Down Expand Up @@ -44,6 +44,12 @@ pub enum Action {
Pick(Pick),
/// Show a conflict of the file being resolved.
GoTo(usize),
/// Put together an interactive rebase onto a commit, in the centre.
Plan(String),
/// Set what the rebase does with a commit of the plan.
Step(usize, Step),
/// Move a commit of the plan to another place.
Shift(usize, usize),
/// Ask for a name first.
Prompt(Prompt),
/// Ask whether to go ahead first.
Expand Down Expand Up @@ -176,6 +182,31 @@ impl Merge {
}
}

/// An interactive rebase being put together in the centre.
#[derive(Debug)]
pub struct Plan {
/// The commit to rebase onto.
pub onto: String,
/// The commits it takes along, as git listed them.
pub listed: Vec<ObjectId>,
/// Each commit with what to do with it, in the new order, newest first like the graph.
pub steps: Vec<(Step, ObjectId)>,
}

impl Plan {
fn new(onto: String, listed: Vec<ObjectId>) -> Self {
Self { steps: listed.iter().rev().map(|&id| (Step::Pick, id)).collect(), onto, listed }
}

/// The rebase, unless the oldest commit it keeps would have nothing to meld into.
pub fn op(&self) -> Option<Op> {
let todo: Vec<_> = self.steps.iter().rev().map(|&(step, id)| (step, id.to_string())).collect();
let first = todo.iter().map(|(step, _)| *step).find(|&step| step != Step::Drop);
let melds = matches!(first, Some(Step::Squash | Step::Fixup));
(!melds).then(|| Op::RebaseInteractive { onto: self.onto.clone(), todo })
}
}

/// Commits read so far, so scrolling back costs nothing.
#[derive(Debug, Default)]
pub struct Commits(HashMap<ObjectId, Commit>);
Expand Down Expand Up @@ -220,6 +251,8 @@ pub struct Workspace {
pub open_file: Option<OpenFile>,
/// The conflicted file in the centre, if any.
pub merge: Option<Merge>,
/// The interactive rebase in the centre, if any.
pub plan: Option<Plan>,
/// The commit summary being written.
pub summary: String,
/// The rest of the commit message.
Expand Down Expand Up @@ -258,6 +291,7 @@ impl Workspace {
files: None,
open_file: None,
merge: None,
plan: None,
summary: String::new(),
description: String::new(),
amend: false,
Expand Down Expand Up @@ -314,18 +348,30 @@ impl Workspace {
}
Action::Open(source) => match diff::read(&self.repo.git, &source) {
Ok(diff) => {
self.merge = None;
(self.merge, self.plan) = (None, 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),
Ok(conflicts) => {
(self.merge, self.open_file, self.plan) = (Some(Merge::new(path, conflicts)), None, None);
}
Err(error) => self.error = Some(error.to_string()),
},
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::Plan(onto) => match self.repo.git.rebase_plan(&onto) {
Ok(listed) if listed.is_empty() => self.error = Some(format!("Nothing to rebase onto {onto:.7}")),
Ok(listed) => (self.plan, self.open_file, self.merge) = (Some(Plan::new(onto, listed)), None, None),
Err(error) => self.error = Some(error.to_string()),
},
Action::Step(row, step) => self.plan.iter_mut().for_each(|plan| plan.steps[row].0 = step),
Action::Shift(from, to) => self.plan.iter_mut().for_each(|plan| {
let step = plan.steps.remove(from);
plan.steps.insert(to, step);
}),
Action::CloseFile => (self.open_file, self.merge, self.plan) = (None, None, None),
Action::ToggleLine(hunk, line) => {
if let Some(file) = &mut self.open_file
&& !file.lines.remove(&(hunk, line))
Expand Down Expand Up @@ -454,6 +500,21 @@ impl Workspace {
Some(Err(error)) => (self.merge, self.error) = (None, Some(error.to_string())),
}
}
// A rebase that started is done with its plan, also when it stopped at a conflict.
if matches!(op, Some(Op::RebaseInteractive { .. }))
&& (self.error.is_none() || self.snapshot.underway.is_some())
{
self.plan = None;
}
if let Some(plan) = &mut self.plan {
// Commits changed outside start the plan again, so that the rebase never leaves one out.
match self.repo.git.rebase_plan(&plan.onto) {
Ok(listed) if listed == plan.listed => {}
Ok(listed) if listed.is_empty() => self.plan = None,
Ok(listed) => *plan = Plan::new(std::mem::take(&mut plan.onto), listed),
Err(error) => (self.plan, 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
1 change: 1 addition & 0 deletions crates/app/src/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ pub fn commit_menu(ui: &mut Ui, sha: &str, labels: &[Ref], current: Option<&str>
item(ui, "Create tag here…", Action::Prompt(Prompt::Tag { at: sha.to_owned() }));
item(ui, "Cherry-pick commit", Action::Run(Op::CherryPick(sha.to_owned())));
item(ui, "Revert commit", Action::Run(Op::Revert(sha.to_owned())));
item(ui, "Interactive rebase onto this commit…", Action::Plan(sha.to_owned()));
ui.menu_button("Reset current branch to this commit", |ui| {
for (text, mode) in [
("Soft: keep all changes", ResetMode::Soft),
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 @@ -6,6 +6,7 @@ mod details;
mod diff;
mod graph;
mod merge;
mod rebase;
mod sidebar;
mod start;
#[cfg(test)]
Expand Down
71 changes: 71 additions & 0 deletions crates/app/src/rebase.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//! An interactive rebase being put together: the commits it takes along, newest first like the
//! graph, each with what to do with it.

use eframe::egui::{self, Button, Key, RichText, Role, Ui, WidgetInfo};
use gitcraft_core::git::Step;

use crate::app::{Action, Workspace};
use crate::theme;

/// Shows the plan of the workspace.
pub fn show(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec<Action>) {
let idle = ws.busy().is_none();
let Workspace { plan: Some(plan), commits, repo, .. } = ws else { return };
let count = plan.steps.len();
ui.horizontal(|ui| {
ui.strong(format!("Interactive rebase onto {:.7}", plan.onto));
ui.label(theme::count(count, "commit"));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(Key::Escape)) {
actions.push(Action::CloseFile);
}
let op = plan.op();
let start = ui.add_enabled(idle && op.is_some(), Button::new("Start rebase"));
if start.on_disabled_hover_text("The oldest commit it keeps has nothing below it to meld into").clicked()
&& let Some(op) = op
{
actions.push(Action::Run(op));
}
});
});
ui.weak("Squash and fixup meld a commit into the one below it. Squash keeps both messages, fixup that of the one below.");
ui.separator();
egui::ScrollArea::vertical().auto_shrink(false).show(ui, |ui| {
for (row, &(step, id)) in plan.steps.iter().enumerate() {
let summary = match commits.get(repo, id) {
Ok(commit) => commit.summary().to_owned(),
Err(error) => error.to_string(),
};
ui.horizontal(|ui| {
for (arrow, way, to) in [("↑", "up", row.checked_sub(1)), ("↓", "down", Some(row + 1))] {
let to = to.filter(|&to| to < count);
let button = ui.add_enabled(to.is_some(), Button::new(RichText::new(arrow).monospace()).small());
button.widget_info(|| {
WidgetInfo::labeled(Role::Button, to.is_some(), format!("Move {summary} {way}"))
});
if button.clicked()
&& let Some(to) = to
{
actions.push(Action::Shift(row, to));
}
}
for choice in Step::ALL {
let (name, chosen) = (theme::capital(choice.word()), step == choice);
let button = ui.add(Button::selectable(chosen, &name));
button
.widget_info(|| WidgetInfo::selected(Role::Button, true, chosen, format!("{name} {summary}")));
if button.clicked() {
actions.push(Action::Step(row, choice));
}
}
ui.monospace(RichText::new(&id.to_string()[..7]).weak());
let text = RichText::new(&summary);
ui.label(match step {
Step::Pick => text,
Step::Squash | Step::Fixup => text.weak(),
Step::Drop => text.strikethrough().weak(),
});
});
}
});
}
68 changes: 68 additions & 0 deletions crates/app/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -709,3 +709,71 @@ fn a_conflicted_file_changed_outside_starts_again() {
});
assert!(harness.state().workspace().unwrap().merge.is_none());
}

/// Commits a file of its own for each name, so that the commits go in any order.
fn add_files(git: &Git, names: &[&str]) {
for name in names {
fs::write(git.dir().join(name), name).unwrap();
git.apply(&Op::Stage(vec![(*name).into()])).unwrap();
git.apply(&Op::Commit { message: format!("Add {name}"), amend: false }).unwrap();
}
}

#[test]
fn reorder_squash_and_drop_commits_in_an_interactive_rebase() {
let (_dir, git) = repo();
add_files(&git, &["one", "two", "three"]);
let mut harness = app(Some(git.dir()));
menu(&mut harness, "First commit", &["Interactive rebase onto this commit…"]);
harness.get_by_label(&format!("Interactive rebase onto {}", &rev(&git, "HEAD~3")[..7]));
assert!(harness.get_by_label("Move Add three up").accesskit_node().is_disabled());

harness.get_by_label("Squash Add one").click();
settle(&mut harness);
let start = |harness: &Harness<'_, App>| !harness.get_by_label("Start rebase").accesskit_node().is_disabled();
assert!(!start(&harness), "the oldest commit has nothing to meld into");
harness.get_by_label("Drop Add one").click();
settle(&mut harness);
harness.get_by_label("Squash Add two").click();
settle(&mut harness);
assert!(!start(&harness), "nor has the oldest one it keeps");

harness.get_by_label("Pick Add two").click();
harness.get_by_label("Pick Add one").click();
harness.get_by_label("Move Add one up").click();
settle(&mut harness);
harness.get_by_label("Move Add one up").click();
settle(&mut harness);
// Newest first: one, three, two.
harness.get_by_label("Squash Add one").click();
harness.get_by_label("Drop Add two").click();
settle(&mut harness);
assert!(start(&harness));
harness.get_by_label("Start rebase").click();
settle(&mut harness);
assert_eq!(git.run(&["log", "--format=%B|"]).unwrap(), "Add three\n\nAdd one\n|\nFirst commit\n|\n");
assert_eq!(git.run(&["ls-files"]).unwrap(), "file.txt\none\nthree\n");
assert!(harness.state().workspace().unwrap().plan.is_none());
assert_eq!(harness.state().workspace().unwrap().error, None);
}

#[test]
fn a_rebase_plan_starts_again_when_its_commits_change_outside() {
let (_dir, git) = repo();
add_files(&git, &["one"]);
let mut harness = app(Some(git.dir()));
menu(&mut harness, "First commit", &["Interactive rebase onto this commit…"]);
harness.get_by_label("Drop Add one").click();
settle(&mut harness);

add_files(&git, &["two"]);
wait_until(&mut harness, "the new commit", |harness| harness.query_by_label("Drop Add two").is_some());
harness.get_by_label("2 commits");
harness.get_by_label("Start rebase").click();
settle(&mut harness);
assert_eq!(log(&git), "Add two\nAdd one\nFirst commit\n", "nothing was dropped");

menu(&mut harness, "Add two", &["Interactive rebase onto this commit…"]);
assert!(harness.state().workspace().unwrap().plan.is_none());
harness.get_by_label_contains("Nothing to rebase onto");
}
6 changes: 6 additions & 0 deletions crates/app/src/theme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ pub fn count(n: usize, thing: &str) -> String {
format!("{n} {thing}{}", if n == 1 { "" } else { "s" })
}

/// A word with a capital first letter, like "Rebase".
pub fn capital(word: &str) -> String {
let (first, rest) = word.split_at(word.chars().next().map_or(0, char::len_utf8));
format!("{}{rest}", first.to_uppercase())
}

/// Seconds since 1970, now.
pub fn now() -> i64 {
std::time::SystemTime::now()
Expand Down
7 changes: 4 additions & 3 deletions crates/app/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, merge, sidebar, theme};
use crate::{details, diff, graph, merge, rebase, sidebar, theme};

const RECENT: usize = 10;

Expand Down Expand Up @@ -196,6 +196,8 @@ fn workspace(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec<Action>) {
egui::CentralPanel::default().show(ui, |ui| {
if let Some(merge) = &mut ws.merge {
merge::show(ui, merge, actions);
} else if ws.plan.is_some() {
rebase::show(ui, ws, actions);
} else if let Some(file) = &ws.open_file {
diff::show(ui, file, actions);
} else {
Expand Down Expand Up @@ -267,8 +269,7 @@ fn underway(ui: &mut Ui, ws: &Workspace, actions: &mut Vec<Action>) {
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.strong(format!("{} in progress", theme::capital(command)));
ui.weak(&underway.message);
if conflicts > 0 {
ui.colored_label(theme::ERROR, theme::count(conflicts, "conflicted file"));
Expand Down
Loading
Loading