Skip to content
Open
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
1 change: 1 addition & 0 deletions src/default_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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", "?"]
Expand Down
113 changes: 101 additions & 12 deletions src/git/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,21 +192,110 @@ pub(crate) fn show(repo: &Repository, reference: &str) -> Res<Diff> {
})
}

pub(crate) fn stash_show(repo: &Repository, stash_ref: &str) -> Res<Diff> {
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<Diff>,
}

pub(crate) fn stash_diffs(repo: &Repository, stash_ref: &str) -> Res<StashDiffs> {
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<Diff> {
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<Diff> {
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::<Vec<_>>();

Some(diff(&base_ref, &untracked_ref, &paths)?)
} else {
None
};

Ok(StashDiffs {
staged,
unstaged,
untracked,
})
}

Expand Down
4 changes: 4 additions & 0 deletions src/item_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,8 @@ pub(crate) enum SectionHeader {
Stashes,
RecentCommits,
Commit(String),
StashRef(String),
StagedChanges(usize),
UnstagedChanges(usize),
UntrackedFiles(usize),
}
4 changes: 4 additions & 0 deletions src/items.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
79 changes: 79 additions & 0 deletions src/ops/apply.rs
Original file line number Diff line number Diff line change
@@ -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<Action> {
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<Diff>, 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)
})
}
3 changes: 3 additions & 0 deletions src/ops/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -100,6 +101,7 @@ pub(crate) enum Op {
Unstage,
Show,
Discard,
Apply,
CopyHash,

ToggleSection,
Expand Down Expand Up @@ -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),
Expand Down
67 changes: 56 additions & 11 deletions src/screen/show_stash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Item> = 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<Item>, 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)
}),
)
}
32 changes: 26 additions & 6 deletions src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<enter>");
}
Expand Down Expand Up @@ -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());
Expand Down
Loading