diff --git a/src/default_config.toml b/src/default_config.toml index 7e1c4a8d10..34e022d43d 100644 --- a/src/default_config.toml +++ b/src/default_config.toml @@ -113,6 +113,7 @@ root.show = ["enter"] root.discard = ["K"] root.stage = ["s"] root.unstage = ["u"] +root.apply = ["a"] root.copy_hash = ["y"] root.help_menu = ["h", "?"] diff --git a/src/git/mod.rs b/src/git/mod.rs index 87f7fab0e8..f92664fd60 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -192,21 +192,110 @@ pub(crate) fn show(repo: &Repository, reference: &str) -> Res { }) } -pub(crate) fn stash_show(repo: &Repository, stash_ref: &str) -> Res { - let text = String::from_utf8_lossy( - &Command::new("git") - .current_dir(repo.workdir().expect("Bare repos unhandled")) - .args(["stash", "show", "-p", stash_ref]) +#[derive(Debug, Clone)] +pub(crate) struct StashDiffs { + pub staged: Diff, + pub unstaged: Diff, + pub untracked: Option, +} + +pub(crate) fn stash_diffs(repo: &Repository, stash_ref: &str) -> Res { + let dir = repo.workdir().expect("Bare repos unhandled"); + + let stash_commit = repo + .revparse_single(stash_ref) + .map_err(Error::GitShowMeta)? + .peel_to_commit() + .map_err(Error::GitShowMeta)?; + + let diff = |from: &str, to: &str, paths: &[std::ffi::OsString]| -> Res { + let mut cmd = Command::new("git"); + cmd.current_dir(dir); + cmd.args(["diff", "--no-ext-diff"]); + cmd.args([from, to]); + if !paths.is_empty() { + cmd.arg("--"); + cmd.args(paths); + } + + let text = + String::from_utf8_lossy(&cmd.output().map_err(Error::GitDiff)?.stdout).into_owned(); + + Ok(Diff { + file_diffs: gitu_diff::Parser::new(&text).parse_diff().unwrap(), + diff_type: DiffType::TreeToTree, + text, + }) + }; + + let show = || -> Res { + let text = String::from_utf8_lossy( + &Command::new("git") + .current_dir(dir) + .args(["stash", "show", "-p", stash_ref]) + .output() + .map_err(Error::GitShow)? + .stdout, + ) + .into_owned(); + + Ok(Diff { + file_diffs: gitu_diff::Parser::new(&text).parse_diff().unwrap(), + diff_type: DiffType::TreeToTree, + text, + }) + }; + + if stash_commit.parent_count() < 2 { + let empty = Diff { + text: String::new(), + diff_type: DiffType::TreeToTree, + file_diffs: vec![], + }; + return Ok(StashDiffs { + staged: empty, + unstaged: show()?, + untracked: None, + }); + } + + let base_ref = format!("{stash_ref}^"); + let index_ref = format!("{stash_ref}^2"); + + let staged = diff(&base_ref, &index_ref, &[])?; + let unstaged = diff(&index_ref, stash_ref, &[])?; + + let untracked = if stash_commit.parent_count() >= 3 { + let untracked_ref = format!("{stash_ref}^3"); + let paths_out = Command::new("git") + .current_dir(dir) + .args([ + "ls-tree", + "-z", + "--name-only", + "-r", + "--full-tree", + &untracked_ref, + ]) .output() .map_err(Error::GitShow)? - .stdout, - ) - .into_owned(); + .stdout; - Ok(Diff { - file_diffs: gitu_diff::Parser::new(&text).parse_diff().unwrap(), - diff_type: DiffType::TreeToTree, - text, + let paths = paths_out + .split(|b| *b == b'\0') + .filter(|p| !p.is_empty()) + .map(|p| std::ffi::OsString::from(String::from_utf8_lossy(p).into_owned())) + .collect::>(); + + Some(diff(&base_ref, &untracked_ref, &paths)?) + } else { + None + }; + + Ok(StashDiffs { + staged, + unstaged, + untracked, }) } diff --git a/src/item_data.rs b/src/item_data.rs index 1bed5a5301..f4da0abc1b 100644 --- a/src/item_data.rs +++ b/src/item_data.rs @@ -87,4 +87,8 @@ pub(crate) enum SectionHeader { Stashes, RecentCommits, Commit(String), + StashRef(String), + StagedChanges(usize), + UnstagedChanges(usize), + UntrackedFiles(usize), } diff --git a/src/items.rs b/src/items.rs index f3b3db0fc8..43b0f06de6 100644 --- a/src/items.rs +++ b/src/items.rs @@ -152,6 +152,10 @@ impl Item { SectionHeader::Stashes => "Stashes".to_string(), SectionHeader::RecentCommits => "Recent commits".to_string(), SectionHeader::Commit(oid) => format!("commit {oid}"), + SectionHeader::StashRef(stash_ref) => stash_ref, + SectionHeader::StagedChanges(count) => format!("Staged changes ({count})"), + SectionHeader::UnstagedChanges(count) => format!("Unstaged changes ({count})"), + SectionHeader::UntrackedFiles(count) => format!("Untracked files ({count})"), }; Line::styled(content, &config.style.section_header) diff --git a/src/ops/apply.rs b/src/ops/apply.rs new file mode 100644 index 0000000000..f41ee62bb9 --- /dev/null +++ b/src/ops/apply.rs @@ -0,0 +1,79 @@ +use super::OpTrait; +use crate::{ + Action, + app::{App, State}, + git::diff::{Diff, PatchMode}, + item_data::ItemData, + term::Term, +}; +use std::{process::Command, rc::Rc}; + +pub(crate) struct Apply; +impl OpTrait for Apply { + fn get_action(&self, target: &ItemData) -> Option { + let action = match target { + ItemData::Stash { stash_ref, .. } => apply_stash(stash_ref.clone()), + ItemData::Delta { diff, file_i } => apply_patch(diff.format_file_patch(*file_i)), + ItemData::Hunk { + diff, + file_i, + hunk_i, + } => apply_patch(diff.format_hunk_patch(*file_i, *hunk_i)), + ItemData::HunkLine { + diff, + file_i, + hunk_i, + line_i, + .. + } => apply_line(diff, *file_i, *hunk_i, *line_i), + _ => return None, + }; + + Some(action) + } + + fn is_target_op(&self) -> bool { + true + } + + fn display(&self, _state: &State) -> String { + "Apply".into() + } +} + +fn apply_stash(stash_ref: String) -> Action { + Rc::new(move |app: &mut App, term: &mut Term| { + let mut cmd = Command::new("git"); + cmd.args(["stash", "apply", "-q"]); + cmd.arg(&stash_ref); + + app.close_menu(); + app.run_cmd(term, &[], cmd) + }) +} + +fn apply_line(diff: &Rc, file_i: usize, hunk_i: usize, line_i: usize) -> Action { + let patch = diff + .format_line_patch(file_i, hunk_i, line_i..(line_i + 1), PatchMode::Normal) + .into_bytes(); + + Rc::new(move |app: &mut App, term: &mut Term| { + let mut cmd = Command::new("git"); + cmd.args(["apply", "--recount"]); + + app.close_menu(); + app.run_cmd(term, &patch, cmd) + }) +} + +fn apply_patch(patch: String) -> Action { + let patch = patch.into_bytes(); + + Rc::new(move |app: &mut App, term: &mut Term| { + let mut cmd = Command::new("git"); + cmd.arg("apply"); + + app.close_menu(); + app.run_cmd(term, &patch, cmd) + }) +} diff --git a/src/ops/mod.rs b/src/ops/mod.rs index da19bc6773..e52046ff81 100644 --- a/src/ops/mod.rs +++ b/src/ops/mod.rs @@ -9,6 +9,7 @@ use crate::{ }; use std::{fmt::Display, rc::Rc}; +pub(crate) mod apply; pub(crate) mod branch; pub(crate) mod commit; pub(crate) mod copy_hash; @@ -100,6 +101,7 @@ pub(crate) enum Op { Unstage, Show, Discard, + Apply, CopyHash, ToggleSection, @@ -187,6 +189,7 @@ impl Op { Op::Show => Box::new(show::Show), Op::Stage => Box::new(stage::Stage), Op::Unstage => Box::new(unstage::Unstage), + Op::Apply => Box::new(apply::Apply), Op::CopyHash => Box::new(copy_hash::CopyHash), Op::AddRemote => Box::new(remote::AddRemote), diff --git a/src/screen/show_stash.rs b/src/screen/show_stash.rs index 34b13be8fe..a387715432 100644 --- a/src/screen/show_stash.rs +++ b/src/screen/show_stash.rs @@ -23,25 +23,70 @@ pub(crate) fn create( size, Box::new(move || { let commit = git::show_summary(repo.as_ref(), &stash_ref)?; - let show = git::stash_show(repo.as_ref(), &stash_ref)?; let details = commit.details.lines(); - Ok(iter::once(Item { - id: hash(["stash_section", &commit.hash]), + let git::StashDiffs { + staged, + unstaged, + untracked, + } = git::stash_diffs(repo.as_ref(), &stash_ref)?; + + let mut out: Vec = Vec::new(); + out.extend(iter::once(Item { + id: hash(["stash_section", &stash_ref]), depth: 0, - data: ItemData::Header(SectionHeader::Commit(commit.hash.clone())), + data: ItemData::Header(SectionHeader::StashRef(stash_ref.clone())), ..Default::default() - }) - .chain(details.into_iter().map(|line| Item { - id: hash(["stash", &commit.hash]), + })); + out.extend(details.into_iter().map(|line| Item { + id: hash(["stash", &stash_ref]), depth: 1, unselectable: true, data: ItemData::Raw(line.to_string()), ..Default::default() - })) - .chain([items::blank_line()]) - .chain(items::create_diff_items(&Rc::new(show), 0, false)) - .collect()) + })); + + let push_diff_section = |out: &mut Vec, header: SectionHeader, diff| { + let diff = Rc::new(diff); + out.extend([ + items::blank_line(), + Item { + id: hash(["stash_diff_section", &commit.hash, &format!("{header:?}")]), + depth: 0, + data: ItemData::Header(header), + ..Default::default() + }, + ]); + out.extend(items::create_diff_items(&diff, 1, false)); + }; + + if !staged.file_diffs.is_empty() { + push_diff_section( + &mut out, + SectionHeader::StagedChanges(staged.file_diffs.len()), + staged, + ); + } + + if !unstaged.file_diffs.is_empty() { + push_diff_section( + &mut out, + SectionHeader::UnstagedChanges(unstaged.file_diffs.len()), + unstaged, + ); + } + + if let Some(untracked) = untracked + && !untracked.file_diffs.is_empty() + { + push_diff_section( + &mut out, + SectionHeader::UntrackedFiles(untracked.file_diffs.len()), + untracked, + ); + } + + Ok(out) }), ) } diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 14ade27f08..f68cafe62e 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -170,11 +170,30 @@ fn show() { fn show_stash() { let ctx = setup_clone!(); - fs::write(ctx.dir.join("file1.txt"), "content").unwrap(); - run(&ctx.dir, &["git", "add", "file1.txt"]); - // Unstaged changes to "file1.txt" - fs::write(ctx.dir.join("file1.txt"), "content\nmodified content").unwrap(); - run(&ctx.dir, &["git", "stash", "save", "firststash"]); + // Unstaged changes should be shown as a diff against a tracked file. + commit(&ctx.dir, "unstaged.txt", ""); + + // Staged changes + fs::write(ctx.dir.join("staged.txt"), "staged\n").unwrap(); + run(&ctx.dir, &["git", "add", "staged.txt"]); + + // Unstaged changes + fs::write(ctx.dir.join("unstaged.txt"), "unstaged\n").unwrap(); + + // Untracked changes + fs::write(ctx.dir.join("untracked.txt"), "untracked\n").unwrap(); + + run( + &ctx.dir, + &[ + "git", + "stash", + "push", + "--include-untracked", + "--message", + "firststash", + ], + ); snapshot!(ctx, "jj"); } @@ -285,7 +304,8 @@ fn hide_untracked() { let mut app = ctx.init_app(); let mut config = app.state.repo.config().unwrap(); - config.set_str("status.showUntrackedFiles", "off").unwrap(); + // Git expects "no|normal|all" here; "off" can error on some versions and break `git status`. + config.set_str("status.showUntrackedFiles", "no").unwrap(); ctx.update(&mut app, keys("g")); insta::assert_snapshot!(ctx.redact_buffer()); diff --git a/src/tests/snapshots/gitu__tests__show_stash.snap b/src/tests/snapshots/gitu__tests__show_stash.snap index 349b11be5f..34fa8982c7 100644 --- a/src/tests/snapshots/gitu__tests__show_stash.snap +++ b/src/tests/snapshots/gitu__tests__show_stash.snap @@ -2,24 +2,24 @@ source: src/tests/mod.rs expression: ctx.redact_buffer() --- - commit 0a076c73d486bf053bb41b602a7d0746a139c61c | + stash@{0} | Author: Author Name | Date: Fri, 16 Feb 2024 11:11:00 +0100 | | On main: firststash | | - added file1.txt | -▌@@ -0,0 +1,2 @@ | -▌+content | -▌+modified content | -▌\ No newline at end of file | - | - | - | - | - | - | - | - | - | -styles_hash: 21974436519d24b8 + Staged changes (1) | + added staged.txt | +▌@@ -0,0 +1 @@ | +▌+staged | + | + Unstaged changes (1) | + modified unstaged.txt | + @@ -0,0 +1 @@ | + +unstaged | + | + Untracked files (1) | + added untracked.txt | + @@ -0,0 +1 @@ | + +untracked | +styles_hash: 657c8e06e0c26076 diff --git a/src/tests/snapshots/gitu__tests__stash__stash_apply_file_as_patch.snap b/src/tests/snapshots/gitu__tests__stash__stash_apply_file_as_patch.snap new file mode 100644 index 0000000000..e5182cf0bb --- /dev/null +++ b/src/tests/snapshots/gitu__tests__stash__stash_apply_file_as_patch.snap @@ -0,0 +1,61 @@ +--- +source: src/tests/stash.rs +expression: out +--- + stash@{0} | + Author: Author Name | + Date: Fri, 16 Feb 2024 11:11:00 +0100 | + | + On main: apply-stash | + | + Unstaged changes (2) | +▌modified file1.txt | +▌@@ -1,5 +1,7 @@ | +▌ one | +▌ two | +▌+two-and-a-half | +▌+two-and-three-quarters | +▌ three | +▌ four | +▌ five | +▌@@ -7,4 +9,4 @@ six | +▌ seven | +────────────────────────────────────────────────────────────────────────────────| +$ git apply | +styles_hash: 92b47e5045c3f2a + +[files before] +--- file1.txt --- +one +two +three +four +five +six +seven +eight +nine +ten +--- file2.txt --- +alpha +beta +gamma + +[files after] +--- file1.txt --- +one +two +two-and-a-half +two-and-three-quarters +three +four +five +six +seven +eight +nine +TEN +--- file2.txt --- +alpha +beta +gamma diff --git a/src/tests/snapshots/gitu__tests__stash__stash_apply_hunk_as_patch.snap b/src/tests/snapshots/gitu__tests__stash__stash_apply_hunk_as_patch.snap new file mode 100644 index 0000000000..7a66471ea4 --- /dev/null +++ b/src/tests/snapshots/gitu__tests__stash__stash_apply_hunk_as_patch.snap @@ -0,0 +1,61 @@ +--- +source: src/tests/stash.rs +expression: out +--- + stash@{0} | + Author: Author Name | + Date: Fri, 16 Feb 2024 11:11:00 +0100 | + | + On main: apply-stash | + | + Unstaged changes (2) | + modified file1.txt | +▌@@ -1,5 +1,7 @@ | +▌ one | +▌ two | +▌+two-and-a-half | +▌+two-and-three-quarters | +▌ three | +▌ four | +▌ five | + @@ -7,4 +9,4 @@ six | + seven | +────────────────────────────────────────────────────────────────────────────────| +$ git apply | +styles_hash: 2a88314975ff98fc + +[files before] +--- file1.txt --- +one +two +three +four +five +six +seven +eight +nine +ten +--- file2.txt --- +alpha +beta +gamma + +[files after] +--- file1.txt --- +one +two +two-and-a-half +two-and-three-quarters +three +four +five +six +seven +eight +nine +ten +--- file2.txt --- +alpha +beta +gamma diff --git a/src/tests/snapshots/gitu__tests__stash__stash_apply_line_as_patch.snap b/src/tests/snapshots/gitu__tests__stash__stash_apply_line_as_patch.snap new file mode 100644 index 0000000000..7f488031f3 --- /dev/null +++ b/src/tests/snapshots/gitu__tests__stash__stash_apply_line_as_patch.snap @@ -0,0 +1,60 @@ +--- +source: src/tests/stash.rs +expression: out +--- + stash@{0} | + Author: Author Name | + Date: Fri, 16 Feb 2024 11:11:00 +0100 | + | + On main: apply-stash | + | + Unstaged changes (2) | + modified file1.txt | + @@ -1,5 +1,7 @@ | + one | + two | +▌+two-and-a-half | + +two-and-three-quarters | + three | + four | + five | + @@ -7,4 +9,4 @@ six | + seven | +────────────────────────────────────────────────────────────────────────────────| +$ git apply --recount | +styles_hash: 6bd2811f25dca59d + +[files before] +--- file1.txt --- +one +two +three +four +five +six +seven +eight +nine +ten +--- file2.txt --- +alpha +beta +gamma + +[files after] +--- file1.txt --- +one +two +two-and-a-half +three +four +five +six +seven +eight +nine +ten +--- file2.txt --- +alpha +beta +gamma diff --git a/src/tests/snapshots/gitu__tests__stash__stash_apply_selected.snap b/src/tests/snapshots/gitu__tests__stash__stash_apply_selected.snap new file mode 100644 index 0000000000..e4d1104fd8 --- /dev/null +++ b/src/tests/snapshots/gitu__tests__stash__stash_apply_selected.snap @@ -0,0 +1,62 @@ +--- +source: src/tests/stash.rs +expression: out +--- + On branch main | + Your branch is ahead of 'origin/main' by 2 commit(s). | + | + Unstaged changes (2) | +▌modified file1.txt | +▌@@ -1,5 +1,7 @@ | +▌ one | +▌ two | +▌+two-and-a-half | +▌+two-and-three-quarters | +▌ three | +▌ four | +▌ five | +▌@@ -7,4 +9,4 @@ six | +▌ seven | +▌ eight | +▌ nine | +▌-ten | +────────────────────────────────────────────────────────────────────────────────| +$ git stash apply -q stash@{0} | +styles_hash: ac8c9cc328d84585 + +[files before] +--- file1.txt --- +one +two +three +four +five +six +seven +eight +nine +ten +--- file2.txt --- +alpha +beta +gamma + +[files after] +--- file1.txt --- +one +two +two-and-a-half +two-and-three-quarters +three +four +five +six +seven +eight +nine +TEN +--- file2.txt --- +alpha +beta +gamma +delta diff --git a/src/tests/stash.rs b/src/tests/stash.rs index e4eaab5a53..99d83a5cfb 100644 --- a/src/tests/stash.rs +++ b/src/tests/stash.rs @@ -1,5 +1,44 @@ use super::*; +fn snapshot_with_files(snapshot_name: &str, mut ctx: TestContext, keys_input: &str) { + let before = [ + ( + "file1.txt", + fs::read_to_string(ctx.dir.join("file1.txt")).unwrap(), + ), + ( + "file2.txt", + fs::read_to_string(ctx.dir.join("file2.txt")).unwrap(), + ), + ]; + + let mut app = ctx.init_app(); + ctx.update(&mut app, keys(keys_input)); + + let after = [ + ( + "file1.txt", + fs::read_to_string(ctx.dir.join("file1.txt")).unwrap(), + ), + ( + "file2.txt", + fs::read_to_string(ctx.dir.join("file2.txt")).unwrap(), + ), + ]; + + let mut out = ctx.redact_buffer(); + out.push_str("\n\n[files before]\n"); + for (name, content) in before { + out.push_str(&format!("--- {name} ---\n{content}")); + } + out.push_str("\n[files after]\n"); + for (name, content) in after { + out.push_str(&format!("--- {name} ---\n{content}")); + } + + insta::assert_snapshot!(snapshot_name, out); +} + fn setup(ctx: TestContext) -> TestContext { fs::write(ctx.dir.join("file-one"), "blahonga\n").unwrap(); fs::write(ctx.dir.join("file-two"), "blahonga\n").unwrap(); @@ -128,3 +167,45 @@ pub(crate) fn stash_drop() { pub(crate) fn stash_drop_default() { snapshot!(setup_two_stashes(setup_clone!()), "zk"); } + +fn setup_stash_for_patch_apply(ctx: TestContext) -> TestContext { + commit( + &ctx.dir, + "file1.txt", + "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n", + ); + commit(&ctx.dir, "file2.txt", "alpha\nbeta\ngamma\n"); + + fs::write(ctx.dir.join("file1.txt"), "one\ntwo\ntwo-and-a-half\ntwo-and-three-quarters\nthree\nfour\nfive\nsix\nseven\neight\nnine\nTEN\n").unwrap(); + fs::write(ctx.dir.join("file2.txt"), "alpha\nbeta\ngamma\ndelta\n").unwrap(); + run(&ctx.dir, &["git", "stash", "save", "apply-stash"]); + ctx +} + +#[test] +pub(crate) fn stash_apply_hunk_as_patch() { + let ctx = setup_stash_for_patch_apply(setup_clone!()); + let snapshot_name = function_name!().rsplit("::").next().unwrap(); + snapshot_with_files(snapshot_name, ctx, "jja"); +} + +#[test] +pub(crate) fn stash_apply_file_as_patch() { + let ctx = setup_stash_for_patch_apply(setup_clone!()); + let snapshot_name = function_name!().rsplit("::").next().unwrap(); + snapshot_with_files(snapshot_name, ctx, "jja"); +} + +#[test] +pub(crate) fn stash_apply_line_as_patch() { + let ctx = setup_stash_for_patch_apply(setup_clone!()); + let snapshot_name = function_name!().rsplit("::").next().unwrap(); + snapshot_with_files(snapshot_name, ctx, "jja"); +} + +#[test] +pub(crate) fn stash_apply_selected() { + let ctx = setup_stash_for_patch_apply(setup_clone!()); + let snapshot_name = function_name!().rsplit("::").next().unwrap(); + snapshot_with_files(snapshot_name, ctx, "jja"); +}