diff --git a/README.md b/README.md index b98b9bd..9846e8a 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ with that one. coloured lanes, branch and tag labels and the uncommitted changes on top. Arrow keys walk it. - **Commits**: message, author, parents and the files they changed, with the diff of each file. - **Staging**: stage or unstage files, whole hunks or single lines, then commit or amend. +- **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. - **Remotes**: fetch, pull and push, with how far ahead and behind you are. diff --git a/ROADMAP.md b/ROADMAP.md index 4b542e8..011bfe3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,27 +22,27 @@ drive it the way a user does, and keeps every benchmark as fast as before. - [x] Clone and init from the start screen, with progress - [x] Tabs: several repositories open at once, remembered between runs - [x] Live refresh: watch the working tree and refs, and reload by itself +- [x] Discard changes per file, hunk or line, after a confirmation ## Next -1. **Discard** changes per file, hunk or line. -2. **Confirm destructive actions**: hard reset, deleting a branch with unmerged commits, force push. -3. **Undo and redo** of the last action, through the reflog. -4. **Conflicts**: list conflicted files, resolve them in a three-way view (ours, theirs, result), +1. **Confirm destructive actions**: hard reset, deleting a branch with unmerged commits, force push. +2. **Undo and redo** of the last action, through the reflog. +3. **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. -5. **Interactive rebase**: reorder, squash, fix up, reword and drop commits, by dragging in the +4. **Interactive rebase**: reorder, squash, fix up, reword and drop commits, by dragging in the graph. -6. **Credentials**: ask for a username, password or passphrase when git needs one. -7. **Push and pull options**: force with lease, push tags, pick the remote, pull with rebase. -8. **Remotes**: add, edit and remove. -9. **Search the graph** by message, author or SHA, and jump to any branch or tag. -10. **File history and blame.** -11. **Better diffs**: side by side, word-level changes, syntax colouring, images. -12. **Keyboard**: shortcuts for everything, and a command palette. -13. **Settings**: light and dark theme, font size, date format, default pull mode. -14. **Graph polish**: resizable columns, compact lanes, author avatars (optional, never by default +5. **Credentials**: ask for a username, password or passphrase when git needs one. +6. **Push and pull options**: force with lease, push tags, pick the remote, pull with rebase. +7. **Remotes**: add, edit and remove. +8. **Search the graph** by message, author or SHA, and jump to any branch or tag. +9. **File history and blame.** +10. **Better diffs**: side by side, word-level changes, syntax colouring, images. +11. **Keyboard**: shortcuts for everything, and a command palette. +12. **Settings**: light and dark theme, font size, date format, default pull mode. +13. **Graph polish**: resizable columns, compact lanes, author avatars (optional, never by default over the network). -15. **Packaging**: installers with the icon for Windows (MSI), macOS (.app in a .dmg) and Linux +14. **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 9291394..2e007be 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -38,6 +38,8 @@ pub enum Action { ToggleLine(usize, usize), /// Ask for a name first. Prompt(Prompt), + /// Ask whether to go ahead first. + Confirm(Confirm), /// Open a repository in a tab of its own, or show its tab if it is open. OpenRepo(PathBuf), /// Show a tab, or the start screen for `None`. @@ -80,6 +82,17 @@ impl Prompt { } } +/// An operation that throws work away, waiting for the user to go ahead. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Confirm { + /// What will be lost, as a question. + pub question: String, + /// The button that goes ahead. + pub verb: &'static str, + /// The operation. + pub op: Op, +} + /// Everything read in the background after a change. #[derive(Debug, Default)] pub struct Snapshot { @@ -158,6 +171,8 @@ pub struct Workspace { pub amend: bool, /// A name being asked for. pub prompt: Option<(Prompt, String)>, + /// An operation waiting for the user to go ahead. + pub confirm: Option, /// The last thing that went wrong. pub error: Option, /// A graph row to scroll to. @@ -187,6 +202,7 @@ impl Workspace { description: String::new(), amend: false, prompt: None, + confirm: None, error: None, scroll_to: None, visible: 0..0, @@ -248,6 +264,7 @@ impl Workspace { } } Action::Prompt(prompt) => self.prompt = Some((prompt, String::new())), + Action::Confirm(confirm) => self.confirm = Some(confirm), Action::OpenRepo(_) | Action::ShowTab(_) | Action::CloseTab(_) => unreachable!("the app handles tabs"), } } @@ -340,7 +357,15 @@ impl Workspace { } } 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; + if !matches!(file.source, Source::Commit { .. }) && !entries.iter().any(|e| e.path == file.source.path()) { + self.open_file = None; + return; + } + // The picked lines stay picked as long as the diff stays the same. match diff::read(&self.repo.git, &file.source) { + Ok(diff) if diff == file.diff => {} Ok(diff) => (file.diff, file.lines) = (diff, BTreeSet::new()), Err(error) => self.error = Some(error.to_string()), } diff --git a/crates/app/src/details.rs b/crates/app/src/details.rs index 59cc7b2..64f8216 100644 --- a/crates/app/src/details.rs +++ b/crates/app/src/details.rs @@ -1,12 +1,12 @@ //! The panel on the right: the uncommitted changes with the commit form, or a commit. -use eframe::egui::{self, Button, Layout, RichText, TextEdit, Ui}; +use eframe::egui::{self, Button, Layout, RichText, Role, TextEdit, Ui, WidgetInfo}; use gitcraft_core::ObjectId; use gitcraft_core::diff::Source; use gitcraft_core::git::Op; use gitcraft_core::status::Change; -use crate::app::{Action, Selection, Workspace}; +use crate::app::{Action, Confirm, Selection, Workspace}; use crate::theme; /// Shows the panel. @@ -20,16 +20,16 @@ pub fn show(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec) { } } -/// One changed file: its kind of change, its path, and a button. -fn file(ui: &mut Ui, change: Change, path: &str, open: bool, button: Option<&str>) -> (bool, bool) { +/// One changed file: its kind of change, its path, and `buttons` on the right, right to left. +/// Returns whether the path was clicked. +fn file(ui: &mut Ui, change: Change, path: &str, open: bool, buttons: impl FnOnce(&mut Ui)) -> bool { let (letter, colour) = theme::change(change); ui.horizontal(|ui| { ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { - let pressed = button.is_some_and(|text| ui.small_button(text).clicked()); + buttons(ui); ui.with_layout(Layout::left_to_right(egui::Align::Center), |ui| { ui.label(RichText::new(letter).monospace().strong().color(colour)); - let clicked = ui.add(Button::selectable(open, path).truncate()).clicked(); - (clicked, pressed) + ui.add(Button::selectable(open, path).truncate()).clicked() }) .inner }) @@ -38,6 +38,16 @@ fn file(ui: &mut Ui, change: Change, path: &str, open: bool, button: Option<&str .inner } +/// Throwing away the unstaged changes to a file, or the whole file when git does not track it. +fn discard(change: Change, path: &str) -> Confirm { + if change == Change::Untracked { + let question = format!("Delete {path}? Git does not track it."); + return Confirm { question, verb: "Delete", op: Op::DeleteUntracked(vec![path.into()]) }; + } + let question = format!("Discard the unstaged changes to {path}?"); + Confirm { question, verb: "Discard", op: Op::Discard(vec![path.into()]) } +} + fn changes(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec) { let open = ws.open_file.as_ref().map(|f| &f.source); let entries = &ws.snapshot.status.entries; @@ -59,10 +69,20 @@ fn changes(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec) { } else { Source::Unstaged(path.into()) }; - let (clicked, stage) = file(ui, change, path, open == Some(&source), Some("Stage")); - if stage { - actions.push(Action::Run(Op::Stage(vec![path.into()]))); - } else if clicked { + let clicked = file(ui, change, path, open == Some(&source), |ui| { + 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))); + } + } + }); + if clicked { actions.push(Action::Open(source)); } } @@ -77,10 +97,12 @@ fn changes(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec) { egui::ScrollArea::vertical().id_salt("staged").max_height(half).auto_shrink([false, true]).show(ui, |ui| { for &(change, path) in &staged { let source = Source::Staged(path.into()); - let (clicked, unstage) = file(ui, change, path, open == Some(&source), Some("Unstage")); - if unstage { - actions.push(Action::Run(Op::Unstage(vec![path.into()]))); - } else if clicked { + let clicked = file(ui, change, path, open == Some(&source), |ui| { + if ui.small_button("Unstage").clicked() { + actions.push(Action::Run(Op::Unstage(vec![path.into()]))); + } + }); + if clicked { actions.push(Action::Open(source)); } } @@ -151,7 +173,7 @@ fn commit(ui: &mut Ui, ws: &mut Workspace, id: ObjectId, actions: &mut Vec &'static [&'static str] { + match source { + Source::Unstaged(_) => &["Stage", "Discard"], + Source::Staged(_) => &["Unstage"], + Source::Untracked(_) | Source::Commit { .. } => &[], + } +} + +/// Doing `verb` with the `selected` lines, which `what` names. +fn act(file: &OpenFile, verb: &'static str, what: &str, selected: &dyn Fn(usize, usize) -> bool) -> Action { + // Unstaging and discarding both start from the lines as they are now, and take them out. + let reverse = verb != "Stage"; + let patch = file.diff.patch(selected, reverse); + if verb == "Discard" { + let question = format!("Discard {what} of {}?", file.source.path()); + return Action::Confirm(Confirm { question, verb, op: Op::DiscardLines(patch) }); + } + Action::Run(Op::ApplyToIndex { patch, reverse }) +} + /// Shows the open file. pub fn show(ui: &mut Ui, file: &OpenFile, actions: &mut Vec) { - let reverse = matches!(file.source, Source::Staged(_)); - let stageable = matches!(file.source, Source::Unstaged(_) | Source::Staged(_)); - let verb = if reverse { "Unstage" } else { "Stage" }; - let run = |selected: &dyn Fn(usize, usize) -> bool| { - Action::Run(Op::ApplyToIndex { patch: file.diff.patch(selected, reverse), reverse }) - }; + let verbs = verbs(&file.source); + let picked = |h, l| file.lines.contains(&(h, l)); + let lines = theme::count(file.lines.len(), "line"); ui.horizontal(|ui| { ui.strong(file.source.path()); ui.weak(match file.source { @@ -30,9 +48,10 @@ pub fn show(ui: &mut Ui, file: &OpenFile, actions: &mut Vec) { if ui.button("Close").clicked() || ui.input(|i| i.key_pressed(Key::Escape)) { actions.push(Action::CloseFile); } - if stageable && !file.lines.is_empty() && ui.button(format!("{verb} {} lines", file.lines.len())).clicked() - { - actions.push(run(&|h, l| file.lines.contains(&(h, l)))); + for verb in verbs { + if !file.lines.is_empty() && ui.button(format!("{verb} {lines}")).clicked() { + actions.push(act(file, verb, &lines, &picked)); + } } }); }); @@ -68,22 +87,21 @@ pub fn show(ui: &mut Ui, file: &OpenFile, actions: &mut Vec) { font.clone(), Color32::GRAY, ); - if stageable { - let button = Rect::from_min_size(rect.right_top() - Vec2::X * 110.0, Vec2::new(104.0, LINE)); + for (verb, x) in verbs.iter().zip([110.0, 220.0]) { + let button = Rect::from_min_size(rect.right_top() - Vec2::X * x, Vec2::new(104.0, LINE)); if ui.put(button, egui::Button::new(RichText::new(format!("{verb} hunk")).small())).clicked() { - actions.push(run(&|hh, _| hh == h)); + actions.push(act(file, verb, "this hunk", &|hh, _| hh == h)); } } continue; }; let line = &hunk.lines[l]; - let picked = file.lines.contains(&(h, l)); let (background, marker) = match line.kind { LineKind::Added => (theme::ADDED, "+"), LineKind::Removed => (theme::REMOVED, "-"), LineKind::Context | LineKind::NoNewline => (Color32::TRANSPARENT, " "), }; - painter.rect_filled(rect, 0.0, if picked { theme::PICKED } else { background }); + painter.rect_filled(rect, 0.0, if picked(h, l) { theme::PICKED } else { background }); let number = |n: Option| n.map(|n| n.to_string()).unwrap_or_default(); let at = |x: f32| rect.left_center() + Vec2::X * x; painter.text(at(NUMBER - 6.0), Align2::RIGHT_CENTER, number(line.old), font.clone(), Color32::DARK_GRAY); @@ -97,7 +115,7 @@ pub fn show(ui: &mut Ui, file: &OpenFile, actions: &mut Vec) { let text = format!("{marker} {}", line.text.trim_end_matches('\r')); painter.text(at(2.0 * NUMBER + 6.0), Align2::LEFT_CENTER, text, font.clone(), ui.visuals().text_color()); let changed = matches!(line.kind, LineKind::Added | LineKind::Removed); - if stageable && changed && response.clicked() { + if !verbs.is_empty() && changed && response.clicked() { actions.push(Action::ToggleLine(h, l)); } } diff --git a/crates/app/src/tests.rs b/crates/app/src/tests.rs index 9f1d183..23d04ab 100644 --- a/crates/app/src/tests.rs +++ b/crates/app/src/tests.rs @@ -20,7 +20,12 @@ fn repo() -> (TempDir, Git) { let dir = tempfile::tempdir().unwrap(); let git = Git::new(fs::canonicalize(dir.path()).unwrap()); git.run(&["init", "-q", "-b", "main"]).unwrap(); - for (key, value) in [("user.name", "Tess Ter"), ("user.email", "tess@example.com"), ("commit.gpgsign", "false")] { + for (key, value) in [ + ("user.name", "Tess Ter"), + ("user.email", "tess@example.com"), + ("commit.gpgsign", "false"), + ("core.autocrlf", "false"), + ] { git.run(&["config", key, value]).unwrap(); } commit(&git, "First commit"); @@ -48,16 +53,16 @@ fn run(app: App) -> Harness<'static, App> { harness } -/// Steps the app until its background job is done and the frame is stable. +/// Steps the app until its background job is done and the frame is stable, also when the +/// watcher starts a reload in the meantime (macOS reports changes late). fn settle(harness: &mut Harness<'_, App>) { let start = Instant::now(); harness.step(); - while busy(harness.state()) { + while busy(harness.state()) || harness.try_run().is_err() { assert!(start.elapsed() < Duration::from_secs(30), "the app stays busy"); std::thread::sleep(Duration::from_millis(5)); harness.step(); } - harness.run(); } /// Steps the app until `shown`, as the watcher wakes it. @@ -196,6 +201,93 @@ fn stage_and_commit_a_change() { assert!(harness.query_by_label("Uncommitted changes").is_none()); } +fn read(git: &Git, path: &str) -> String { + fs::read_to_string(git.dir().join(path)).unwrap() +} + +#[test] +fn discard_a_changed_file_once_confirmed() { + let (_dir, git) = repo(); + fs::write(git.dir().join("file.txt"), "changed").unwrap(); + let mut harness = app(Some(git.dir())); + harness.get_by_label("Uncommitted changes").click(); + settle(&mut harness); + + harness.get_by_label("Discard file.txt").click(); + settle(&mut harness); + harness.get_by_label("Discard the unstaged changes to file.txt?"); + harness.get_by_label("Cancel").click(); + settle(&mut harness); + assert_eq!(read(&git, "file.txt"), "changed"); + + harness.get_by_label("Discard file.txt").click(); + settle(&mut harness); + harness.get_by_label("Discard").click(); + settle(&mut harness); + assert_eq!(read(&git, "file.txt"), "First commit"); + assert!(harness.query_by_label("Uncommitted changes").is_none()); +} + +#[test] +fn discarding_an_open_untracked_file_deletes_it_and_closes_it() { + let (_dir, git) = repo(); + fs::write(git.dir().join("new.txt"), "new").unwrap(); + let mut harness = app(Some(git.dir())); + harness.get_by_label("Uncommitted changes").click(); + settle(&mut harness); + harness.get_by_label("new.txt").click(); + settle(&mut harness); + assert!(harness.state().workspace().unwrap().open_file.is_some()); + + harness.get_by_label("Discard new.txt").click(); + settle(&mut harness); + harness.get_by_label("Delete").click(); + settle(&mut harness); + assert!(!git.dir().join("new.txt").exists()); + let ws = harness.state().workspace().unwrap(); + assert!(ws.open_file.is_none(), "the diff of the deleted file closes"); + assert_eq!(ws.error, None); +} + +#[test] +fn discard_single_lines_and_hunks() { + let (_dir, git) = repo(); + fs::write(git.dir().join("file.txt"), "one\ntwo\nthree\nfour\n").unwrap(); + 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); + + // Pick the line "four", as a click on it does. + let file = harness.state_mut().workspace_mut().unwrap().open_file.as_mut().unwrap(); + let four = file.diff.hunks[0].lines.iter().position(|l| l.text == "four").unwrap(); + file.lines.insert((0, four)); + // A reload that changes nothing keeps the pick. + harness.get_by_label("Refresh").click(); + settle(&mut harness); + harness.get_by_label("Discard 1 line").click(); + settle(&mut harness); + harness.get_by_label("Discard").click(); + settle(&mut harness); + assert_eq!(read(&git, "file.txt"), "one\ntwo\nthree\n"); + + // Escape closes the question and nothing else. + harness.get_by_label("Discard hunk").click(); + settle(&mut harness); + harness.key_press(Key::Escape); + settle(&mut harness); + assert!(harness.query_by_label("Discard").is_none()); + assert!(harness.state().workspace().unwrap().open_file.is_some()); + + harness.get_by_label("Discard hunk").click(); + settle(&mut harness); + harness.get_by_label("Discard").click(); + settle(&mut harness); + assert_eq!(read(&git, "file.txt"), "First commit"); + assert!(harness.query_by_label("Uncommitted changes").is_none()); +} + #[test] fn changes_made_outside_show_up_by_themselves() { let (_dir, git) = repo(); diff --git a/crates/app/src/ui.rs b/crates/app/src/ui.rs index fc707e1..76348db 100644 --- a/crates/app/src/ui.rs +++ b/crates/app/src/ui.rs @@ -173,6 +173,9 @@ pub fn pick_folder(title: &str) -> Option { } fn workspace(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec) { + // Dialogs come first, so that Escape closes them and nothing behind them. + prompt(ui.ctx(), ws, actions); + confirm(ui.ctx(), ws, actions); egui::Panel::top("toolbar").show(ui, |ui| toolbar(ui, ws, actions)); if let Some(error) = ws.error.clone() { egui::Panel::bottom("error").show(ui, |ui| { @@ -190,7 +193,6 @@ fn workspace(ui: &mut Ui, ws: &mut Workspace, actions: &mut Vec) { Some(file) => diff::show(ui, file, actions), None => graph::show(ui, ws, actions), }); - prompt(ui.ctx(), ws, actions); } fn toolbar(ui: &mut Ui, ws: &Workspace, actions: &mut Vec) { @@ -259,3 +261,23 @@ fn prompt(ctx: &egui::Context, ws: &mut Workspace, actions: &mut Vec) { ws.prompt = None; } } + +fn confirm(ctx: &egui::Context, ws: &mut Workspace, actions: &mut Vec) { + let Some(confirm) = &ws.confirm else { return }; + let mut done = false; + let response = egui::Modal::new(egui::Id::unique("confirm")).show(ctx, |ui| { + ui.strong(&confirm.question); + ui.label("This cannot be undone."); + ui.horizontal(|ui| { + let verb = Button::new(egui::RichText::new(confirm.verb).color(theme::ERROR)); + if ui.add_enabled(ws.busy().is_none(), verb).clicked() { + actions.push(Action::Run(confirm.op.clone())); + done = true; + } + done |= ui.button("Cancel").clicked(); + }); + }); + if done || response.should_close() { + ws.confirm = None; + } +} diff --git a/crates/core/src/git.rs b/crates/core/src/git.rs index 5d84a45..47dc7aa 100644 --- a/crates/core/src/git.rs +++ b/crates/core/src/git.rs @@ -166,6 +166,9 @@ pub enum Op { /// Take the patch out of the index instead. reverse: bool, }, + /// Take a patch out of the working tree, putting those lines back the way the index has them. + /// The patch is built by [`crate::diff::FileDiff::patch`] with `reverse`. + DiscardLines(String), /// Commit the index. Commit { /// The full message. @@ -249,6 +252,7 @@ impl Op { Self::ApplyToIndex { reverse, .. } => { (&["apply", "--cached", "--recount"], [flag(*reverse, "--reverse"), vec!["-"]].concat()) } + Self::DiscardLines(_) => (&["apply", "--recount", "--reverse", "-"], vec![]), Self::Commit { amend, .. } => (&["commit", "-q", "--cleanup=strip", "-F", "-"], flag(*amend, "--amend")), Self::Checkout(target) => (&["checkout", "-q"], vec![target]), Self::Branch { name, at } => (&["checkout", "-q", "-b"], vec![name, at]), @@ -281,7 +285,7 @@ impl Op { pub fn input(&self) -> Option<&[u8]> { match self { Self::Commit { message, .. } => Some(message.as_bytes()), - Self::ApplyToIndex { patch, .. } => Some(patch.as_bytes()), + Self::ApplyToIndex { patch, .. } | Self::DiscardLines(patch) => Some(patch.as_bytes()), _ => None, } } diff --git a/crates/core/tests/repo.rs b/crates/core/tests/repo.rs index 71c5a44..e8ca645 100644 --- a/crates/core/tests/repo.rs +++ b/crates/core/tests/repo.rs @@ -169,6 +169,27 @@ fn stage_and_unstage_single_lines() { assert_eq!(changed(Source::Unstaged("f.txt".into())), ["2", "TWO", "4", "FOUR"]); } +#[test] +fn discard_single_lines_from_the_working_tree() { + let (_dir, git) = repo(); + commit(&git, "f.txt", "1\n2\n3\n4\n5\n", "Numbers"); + write(&git, "f.txt", "1\nTWO\n3\n4\n5\n"); + git.apply(&Op::Stage(vec!["f.txt".into()])).unwrap(); + write(&git, "f.txt", "1\nTWO\n3\nFOUR\n5\nsix"); + + let discard = |text: &str| { + let unstaged = diff::read(&git, &Source::Unstaged("f.txt".into())).unwrap(); + git.apply(&Op::DiscardLines(unstaged.patch(|h, i| unstaged.hunks[h].lines[i].text == text, true))).unwrap(); + fs::read_to_string(git.dir().join("f.txt")).unwrap() + }; + // A discarded removal comes back, a discarded addition goes. + assert_eq!(discard("4"), "1\nTWO\n3\n4\nFOUR\n5\nsix"); + assert_eq!(discard("FOUR"), "1\nTWO\n3\n4\n5\nsix"); + assert_eq!(discard("six"), "1\nTWO\n3\n4\n5\n"); + // What was staged stays staged. + assert_eq!(status(&git), [("f.txt".into(), Some(Change::Modified), None)]); +} + #[test] fn diffs_of_untracked_files_and_commits() { let (_dir, git) = repo();