Skip to content

Commit 72c7105

Browse files
committed
Discard with support for whole hunks.
Whole chunks match the worktree change completely.
1 parent 437b1fe commit 72c7105

File tree

15 files changed

+522
-54
lines changed

15 files changed

+522
-54
lines changed

crates/but-cli/src/command/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ pub(crate) fn discard_change(
151151
debug_print(but_workspace::discard_workspace_changes(
152152
&repo,
153153
Some(spec.into()),
154+
UI_CONTEXT_LINES,
154155
)?)
155156
}
156157

crates/but-core/src/diff/mod.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,19 @@ impl ModeFlags {
8080
}
8181
}
8282

83+
impl ModeFlags {
84+
/// Returns `true` if this instance indicates a type-change.
85+
/// The only reason this isn't the case is if the executable bit changed.
86+
pub fn is_typechange(&self) -> bool {
87+
match self {
88+
ModeFlags::ExecutableBitAdded | ModeFlags::ExecutableBitRemoved => false,
89+
ModeFlags::TypeChangeFileToLink
90+
| ModeFlags::TypeChangeLinkToFile
91+
| ModeFlags::TypeChange => true,
92+
}
93+
}
94+
}
95+
8396
#[cfg(test)]
8497
mod tests {
8598
mod flags {

crates/but-core/src/diff/worktree.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -805,7 +805,7 @@ impl TreeChange {
805805
let mut diff_filter = crate::unified_diff::filter_from_state(
806806
repo,
807807
self.status.state(),
808-
gix::diff::blob::pipeline::Mode::ToGitUnlessBinaryToTextIsPresent,
808+
UnifiedDiff::CONVERSION_MODE,
809809
)?;
810810
self.unified_diff_with_filter(repo, context_lines, &mut diff_filter)
811811
}

crates/but-workspace/src/commit_engine/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use gix::prelude::ObjectIdExt as _;
1313
use gix::refs::transaction::PreviousValue;
1414
use serde::{Deserialize, Serialize};
1515

16-
mod tree;
16+
pub(crate) mod tree;
1717
use tree::{CreateTreeOutcome, create_tree};
1818

1919
pub(crate) mod index;
@@ -94,7 +94,7 @@ pub struct DiffSpec {
9494
}
9595

9696
/// The header of a hunk that represents a change to a file.
97-
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97+
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
9898
#[serde(rename_all = "camelCase")]
9999
pub struct HunkHeader {
100100
/// The 1-based line number at which the previous version of the file started.

crates/but-workspace/src/commit_engine/tree.rs

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@ use crate::commit_engine::{
22
Destination, DiffSpec, HunkHeader, MoveSourceCommit, RejectionReason, apply_hunks,
33
};
44
use anyhow::bail;
5-
use bstr::ByteSlice;
5+
use bstr::{BStr, ByteSlice};
66
use but_core::{RepositoryExt, UnifiedDiff};
77
use gix::filter::plumbing::pipeline::convert::ToGitOutcome;
88
use gix::merge::tree::TreatAsUnresolved;
99
use gix::object::tree::EntryKind;
1010
use gix::prelude::ObjectIdExt;
1111
use std::io::Read;
12+
use std::path::Path;
1213

1314
/// Additional information about the outcome of a [`create_tree()`] call.
1415
#[derive(Debug)]
@@ -222,6 +223,7 @@ fn apply_worktree_changes<'repo>(
222223
gix::diff::blob::pipeline::Mode::ToGitUnlessBinaryToTextIsPresent,
223224
"BUG: if this changes, the uses of worktree filters need a review"
224225
);
226+
// TODO(perf): avoid computing the unified diff here, we only need hunks with, usually with zero context.
225227
let UnifiedDiff::Patch { hunks } =
226228
worktree_change.unified_diff_with_filter(repo, context_lines, &mut diff_filter)?
227229
else {
@@ -281,22 +283,13 @@ fn apply_worktree_changes<'repo>(
281283
continue;
282284
};
283285

284-
current_worktree.clear();
285-
let to_git = pipeline.convert_to_git(
286-
std::fs::File::open(path)?,
287-
&gix::path::from_bstr(base_rela_path),
286+
worktree_file_to_git_in_buf(
287+
&mut current_worktree,
288+
base_rela_path,
289+
&path,
290+
&mut pipeline,
288291
&index,
289292
)?;
290-
match to_git {
291-
ToGitOutcome::Unchanged(mut file) => {
292-
file.read_to_end(&mut current_worktree)?;
293-
}
294-
ToGitOutcome::Process(mut stream) => {
295-
stream.read_to_end(&mut current_worktree)?;
296-
}
297-
ToGitOutcome::Buffer(buf) => current_worktree.extend_from_slice(buf),
298-
};
299-
300293
let worktree_hunks: Vec<HunkHeader> = hunks.into_iter().map(Into::into).collect();
301294
for selected_hunk in &change_request.hunk_headers {
302295
if !worktree_hunks.contains(selected_hunk)
@@ -327,3 +320,28 @@ fn apply_worktree_changes<'repo>(
327320
let maybe_new_tree = (actual_base_tree != altered_base_tree_id).then_some(altered_base_tree_id);
328321
Ok((maybe_new_tree, actual_base_tree))
329322
}
323+
324+
pub(crate) fn worktree_file_to_git_in_buf(
325+
buf: &mut Vec<u8>,
326+
rela_path: &BStr,
327+
path: &Path,
328+
pipeline: &mut gix::filter::Pipeline<'_>,
329+
index: &gix::index::State,
330+
) -> anyhow::Result<()> {
331+
buf.clear();
332+
let to_git = pipeline.convert_to_git(
333+
std::fs::File::open(path)?,
334+
&gix::path::from_bstr(rela_path),
335+
index,
336+
)?;
337+
match to_git {
338+
ToGitOutcome::Unchanged(mut file) => {
339+
file.read_to_end(buf)?;
340+
}
341+
ToGitOutcome::Process(mut stream) => {
342+
stream.read_to_end(buf)?;
343+
}
344+
ToGitOutcome::Buffer(buf2) => buf.extend_from_slice(buf2),
345+
};
346+
Ok(())
347+
}

crates/but-workspace/src/discard/function.rs

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1-
use crate::discard::{DiscardSpec, file};
2-
use anyhow::Context;
1+
use crate::discard::{DiscardSpec, file, hunk};
2+
use anyhow::{Context, bail};
33
use bstr::ByteSlice;
44
use but_core::{ChangeState, TreeStatus};
55

66
/// Discard the given `changes` in the worktree of `repo`. If a change could not be matched with an actual worktree change, for
7-
/// instance due to a race, that's not an error, instead it will be returned in the result Vec.
7+
/// instance due to a race, that's not an error, instead it will be returned in the result Vec, along with all hunks that couldn't
8+
/// be matched.
89
/// The returned Vec is typically empty, meaning that all `changes` could be discarded.
910
///
11+
/// `context_lines` is the amount of context lines we should assume when obtaining hunks of worktree changes to match against
12+
/// the ones we have specified in the hunks contained within `changes`.
13+
///
1014
/// Discarding a change is really more of an 'undo' of a change as it will restore the previous state to the desired extent - Git
11-
/// doesn't have a notion of this.
15+
/// doesn't have a notion of this on a whole-file basis.
1216
///
1317
/// Each of the `changes` will be matched against actual worktree changes to make this operation as safe as possible, after all, it
1418
/// discards changes without recovery.
@@ -19,6 +23,7 @@ use but_core::{ChangeState, TreeStatus};
1923
pub fn discard_workspace_changes(
2024
repo: &gix::Repository,
2125
changes: impl IntoIterator<Item = DiscardSpec>,
26+
context_lines: u32,
2227
) -> anyhow::Result<Vec<DiscardSpec>> {
2328
let wt_changes = but_core::diff::worktree_changes(repo)?;
2429
let mut dropped = Vec::new();
@@ -30,7 +35,7 @@ pub fn discard_workspace_changes(
3035
let mut path_check = gix::status::plumbing::SymlinkCheck::new(
3136
repo.workdir().context("non-bare repository")?.into(),
3237
);
33-
for spec in changes {
38+
for mut spec in changes {
3439
let Some(wt_change) = wt_changes.changes.iter().find(|c| {
3540
c.path == spec.path
3641
&& c.previous_path() == spec.previous_path.as_ref().map(|p| p.as_bstr())
@@ -115,7 +120,43 @@ pub fn discard_workspace_changes(
115120
}
116121
}
117122
} else {
118-
todo!("hunk-based undo")
123+
match wt_change.status {
124+
TreeStatus::Addition { .. } | TreeStatus::Deletion { .. } => {
125+
bail!(
126+
"Deletions or additions aren't well-defined for hunk-based operations - use the whole-file mode instead: '{}'",
127+
wt_change.path
128+
)
129+
}
130+
TreeStatus::Modification {
131+
previous_state,
132+
flags,
133+
..
134+
}
135+
| TreeStatus::Rename {
136+
previous_state,
137+
flags,
138+
..
139+
} => {
140+
if flags.is_some_and(|f| f.is_typechange()) {
141+
bail!(
142+
"Type-changed items can't be discard by hunks - use the whole-file mode isntead"
143+
)
144+
}
145+
hunk::restore_state_to_worktree(
146+
wt_change,
147+
previous_state,
148+
&mut spec.hunk_headers,
149+
&mut path_check,
150+
&mut pipeline,
151+
&index,
152+
context_lines,
153+
)?;
154+
if !spec.hunk_headers.is_empty() {
155+
dropped.push(spec);
156+
continue;
157+
}
158+
}
159+
}
119160
}
120161
}
121162

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
use crate::commit_engine::tree::worktree_file_to_git_in_buf;
2+
use crate::commit_engine::{HunkHeader, apply_hunks};
3+
use anyhow::bail;
4+
use bstr::ByteSlice;
5+
use but_core::{ChangeState, TreeChange, UnifiedDiff};
6+
use gix::filter::plumbing::driver::apply::{Delay, MaybeDelayed};
7+
use gix::filter::plumbing::pipeline::convert::ToWorktreeOutcome;
8+
use gix::prelude::ObjectIdExt;
9+
10+
/// Discard `hunks_to_discard` in the resource at `wt_change`, whose previous version is `previous_state` and is expected to
11+
/// be tracked and readable from the object database. We will always read what's currently on disk as the current version
12+
/// and overwrite it.
13+
///
14+
/// The general idea is to rebuild the file, but without the `hunks_to_discard`, write it into the worktree replacing
15+
/// the previous file. `hunks_to_discard` are hunks based on a diff of what's in Git.
16+
///
17+
/// Note that an index update isn't necessary, as for the purpose of the application it might as well not exist due to the
18+
/// way our worktree-changes function ignores the index entirely.
19+
///
20+
/// Note that `hunks_to_discard` that weren't present in the actual set of worktree changes will remain, so when everything
21+
/// worked `hunks_to_discard` will be empty.
22+
pub fn restore_state_to_worktree(
23+
wt_change: &TreeChange,
24+
previous_state: ChangeState,
25+
hunks_to_discard: &mut Vec<HunkHeader>,
26+
path_check: &mut gix::status::plumbing::SymlinkCheck,
27+
pipeline: &mut gix::filter::Pipeline<'_>,
28+
index: &gix::index::State,
29+
context_lines: u32,
30+
) -> anyhow::Result<()> {
31+
let repo = pipeline.repo;
32+
let state_in_worktree = ChangeState {
33+
id: repo.object_hash().null(),
34+
kind: previous_state.kind,
35+
};
36+
let mut diff_filter = but_core::unified_diff::filter_from_state(
37+
repo,
38+
Some(state_in_worktree),
39+
UnifiedDiff::CONVERSION_MODE,
40+
)?;
41+
let UnifiedDiff::Patch { hunks } =
42+
wt_change.unified_diff_with_filter(repo, context_lines, &mut diff_filter)?
43+
else {
44+
bail!("Couldn't obtain diff for worktree changes.")
45+
};
46+
47+
let prev_len = hunks_to_discard.len();
48+
let hunks_to_keep: Vec<HunkHeader> = hunks
49+
.into_iter()
50+
.map(Into::into)
51+
.filter(|hunk| {
52+
match hunks_to_discard
53+
.iter()
54+
.enumerate()
55+
.find_map(|(idx, hunk_to_discard)| (hunk_to_discard == hunk).then_some(idx))
56+
{
57+
None => true,
58+
Some(idx_to_remove) => {
59+
hunks_to_discard.remove(idx_to_remove);
60+
false
61+
}
62+
}
63+
})
64+
.collect();
65+
if prev_len == hunks_to_discard.len() {
66+
// We want to keep everything, so nothing has to be done.
67+
return Ok(());
68+
}
69+
70+
let old = previous_state.id.attach(repo).object()?.detach().data;
71+
let mut new = repo.empty_reusable_buffer();
72+
let rela_path = wt_change.path.as_bstr();
73+
let worktree_path = path_check.verified_path_allow_nonexisting(rela_path)?;
74+
worktree_file_to_git_in_buf(&mut new, rela_path, &worktree_path, pipeline, index)?;
75+
76+
let base_with_patches = apply_hunks(old.as_bstr(), new.as_bstr(), &hunks_to_keep)?;
77+
78+
let to_worktree = pipeline.convert_to_worktree(&base_with_patches, rela_path, Delay::Forbid)?;
79+
match to_worktree {
80+
ToWorktreeOutcome::Unchanged(buf) => {
81+
std::fs::write(&worktree_path, buf)?;
82+
}
83+
ToWorktreeOutcome::Buffer(buf) => {
84+
std::fs::write(&worktree_path, buf)?;
85+
}
86+
ToWorktreeOutcome::Process(MaybeDelayed::Immediate(mut stream)) => {
87+
let mut file = std::fs::File::create(&worktree_path)?;
88+
std::io::copy(&mut stream, &mut file)?;
89+
}
90+
ToWorktreeOutcome::Process(MaybeDelayed::Delayed(_)) => unreachable!("disabled"),
91+
}
92+
Ok(())
93+
}

crates/but-workspace/src/discard/mod.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
use crate::commit_engine::DiffSpec;
44
use gix::object::tree::EntryKind;
5-
use std::ops::Deref;
5+
use std::ops::{Deref, DerefMut};
66
use std::path::{Path, PathBuf};
77

88
/// A specification of what should be discarded, either changes to the whole file, or a portion of it.
@@ -24,6 +24,12 @@ impl Deref for DiscardSpec {
2424
}
2525
}
2626

27+
impl DerefMut for DiscardSpec {
28+
fn deref_mut(&mut self) -> &mut Self::Target {
29+
&mut self.0
30+
}
31+
}
32+
2733
impl From<DiscardSpec> for DiffSpec {
2834
fn from(value: DiscardSpec) -> Self {
2935
value.0
@@ -38,6 +44,7 @@ pub mod ui {
3844
}
3945

4046
mod file;
47+
mod hunk;
4148

4249
#[cfg(unix)]
4350
fn locked_resource_at(

crates/but-workspace/tests/fixtures/generated-archives/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,5 @@
2121
/all-file-types-renamed-and-overwriting-existing.tar
2222
/move-directory-into-sibling-file.tar
2323
/merge-with-two-branches-conflict.tar
24+
/deletion-addition-untracked.tar
25+
/mixed-hunk-modifications.tar
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#!/usr/bin/env bash
2+
3+
### Description
4+
# Provide a deleted file, an added one, and an untracked one.
5+
set -eu -o pipefail
6+
7+
git init
8+
echo content >to-be-deleted
9+
echo content2 >to-be-deleted-in-index
10+
git add . && git commit -m "init"
11+
12+
echo new >added-to-index && git add added-to-index
13+
echo untracked >untracked
14+
rm to-be-deleted
15+
git rm to-be-deleted-in-index
16+

0 commit comments

Comments
 (0)