Skip to content

Commit 4317aef

Browse files
committed
Add V3 support for discarding files
For now, no hunks or hunk-ranges.
1 parent be56afd commit 4317aef

File tree

12 files changed

+509
-3
lines changed

12 files changed

+509
-3
lines changed

crates/but-testsupport/src/lib.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@ pub fn visualize_commit_graph(
9090
Ok(log.stdout.to_str().expect("no illformed UTF-8").to_string())
9191
}
9292

93+
/// Run a condensed status on `repo`.
94+
pub fn git_status(repo: &gix::Repository) -> std::io::Result<String> {
95+
let out = git(repo).args(["status", "--porcelain"]).output()?;
96+
assert!(out.status.success());
97+
Ok(out.stdout.to_str().expect("no illformed UTF-8").to_string())
98+
}
99+
93100
/// Display a Git tree in the style of the `tree` CLI program, but include blob contents and usful Git metadata.
94101
pub fn visualize_tree(tree_id: gix::Id<'_>) -> termtree::Tree<String> {
95102
fn visualize_tree(
@@ -141,3 +148,46 @@ pub fn visualize_tree(tree_id: gix::Id<'_>) -> termtree::Tree<String> {
141148
}
142149
visualize_tree(tree_id.object().unwrap().peel_to_tree().unwrap().id(), None).unwrap()
143150
}
151+
152+
/// Visualize a tree on disk with mode information.
153+
/// For convenience, skip `.git` and don't display the root.
154+
/// As it's intended for tests, this can't be called on Windows were modes don't exist.
155+
#[cfg(unix)]
156+
pub fn visualize_disk_tree_skip_dot_git(root: &Path) -> anyhow::Result<termtree::Tree<String>> {
157+
use std::os::unix::fs::MetadataExt;
158+
fn label(p: &Path, md: &std::fs::Metadata) -> String {
159+
format!(
160+
"{name}:{mode:o}",
161+
name = p.file_name().unwrap().to_str().unwrap(),
162+
mode = md.mode(),
163+
)
164+
}
165+
166+
fn tree(p: &Path, show_label: bool) -> std::io::Result<termtree::Tree<String>> {
167+
let mut cur = termtree::Tree::new(if show_label {
168+
label(p, &p.symlink_metadata()?)
169+
} else {
170+
".".into()
171+
});
172+
173+
let mut entries: Vec<_> = std::fs::read_dir(p)?.filter_map(|e| e.ok()).collect();
174+
entries.sort_by_key(|e| e.file_name());
175+
for entry in entries {
176+
let md = entry.metadata()?;
177+
if md.is_dir() && entry.file_name() != ".git" {
178+
cur.push(tree(&entry.path(), true)?);
179+
} else {
180+
cur.push(termtree::Tree::new(label(&entry.path(), &md)));
181+
}
182+
}
183+
Ok(cur)
184+
}
185+
186+
Ok(tree(root, false)?)
187+
}
188+
189+
/// Windows dummy
190+
#[cfg(not(unix))]
191+
pub fn visualize_disk_tree(root: &Path) -> anyhow::Result<termtree::Tree<String>> {
192+
anyhow::bail!("BUG: must not run on Windows - results won't be desirable");
193+
}

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ pub fn apply_lhs_to_rhs(
5252
&md,
5353
id.into_owned(),
5454
entry_mode,
55+
gix::index::entry::Flags::empty(),
5556
&mut num_sorted_entries,
5657
)?;
5758
}
@@ -72,12 +73,13 @@ pub fn apply_lhs_to_rhs(
7273
// TODO(gix): this could be a platform in Gix which supports these kinds of edits while assuring
7374
// consistency. It could use some tricks to not have worst-case performance like this has.
7475
// It really is index-add that we need.
75-
fn upsert_index_entry(
76+
pub fn upsert_index_entry(
7677
index: &mut gix::index::State,
7778
rela_path: &BStr,
7879
md: &gix::index::fs::Metadata,
7980
id: gix::ObjectId,
8081
mode: gix::index::entry::Mode,
82+
add_flags: gix::index::entry::Flags,
8183
num_sorted_entries: &mut usize,
8284
) -> anyhow::Result<bool> {
8385
use gix::index::entry::Stage;
@@ -100,14 +102,15 @@ fn upsert_index_entry(
100102
// This basically forces it to look closely, bad for performance, but at
101103
// least correct. Usually it fixes itself as well.
102104
entry.stat = Default::default();
105+
entry.flags |= add_flags;
103106
entry.id = id;
104107
entry.mode = mode;
105108
false
106109
} else {
107110
index.dangerously_push_entry(
108111
gix::index::entry::Stat::from_fs(md)?,
109112
id,
110-
gix::index::entry::Flags::empty(),
113+
add_flags,
111114
mode,
112115
rela_path,
113116
);

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ mod tree;
1616
use crate::commit_engine::reference_frame::InferenceMode;
1717
use tree::{CreateTreeOutcome, create_tree};
1818

19-
mod index;
19+
pub(crate) mod index;
2020
/// Utility types
2121
pub mod reference_frame;
2222
mod refs;

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@ fn apply_worktree_changes<'repo>(
263263
entry.object()?.detach().data
264264
} else if entry.mode().is_blob() {
265265
let mut obj_in_git = entry.object()?;
266+
266267
match pipeline.convert_to_worktree(
267268
&obj_in_git.data,
268269
base_rela_path,
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
use crate::discard::DiscardSpec;
2+
use anyhow::{Context, bail};
3+
use bstr::{BStr, ByteSlice};
4+
use but_core::{ChangeState, TreeStatus};
5+
use gix::filter::plumbing::driver::apply::Delay;
6+
use gix::object::tree::EntryKind;
7+
use gix::prelude::ObjectIdExt;
8+
use std::ops::Deref;
9+
use std::path::Path;
10+
11+
/// Discard the given `changes` in the worktree of `repo`. If a change could not be matched with an actual worktree change, for
12+
/// instance due to a race, that's not an error, instead it will be returned in the result Vec.
13+
/// The returned Vec is typically empty, meaning that all `changes` could be discarded.
14+
///
15+
/// Discarding a change is really more of an 'undo' of a change as it will restore the previous state to the desired extent - Git
16+
/// doesn't have a notion of this.
17+
///
18+
/// Each of the `changes` will be matched against actual worktree changes to make this operation as safe as possible, after all, it
19+
/// discards changes without recovery.
20+
///
21+
/// In practice, this is like a selective 'inverse-checkout', as such it must have a lot of the capabilities of checkout, but focussed
22+
/// on just a couple of paths, and with special handling for renamed files, something that `checkout` can't naturally handle
23+
/// as it's only dealing with single file-paths.
24+
pub fn discard_workspace_changes(
25+
repo: &gix::Repository,
26+
changes: impl IntoIterator<Item = DiscardSpec>,
27+
) -> anyhow::Result<Vec<DiscardSpec>> {
28+
let wt_changes = but_core::diff::worktree_changes(repo)?;
29+
let mut dropped = Vec::new();
30+
// Have to go from shared state to mutable one, needs clone, unfortunately.
31+
let mut index = repo.index_or_empty()?.deref().clone();
32+
let initial_entries_len = index.entries().len();
33+
let (mut pipeline, _) = repo.filter_pipeline(Some(repo.empty_tree().id))?;
34+
35+
let mut path_check = gix::status::plumbing::SymlinkCheck::new(
36+
repo.workdir().context("non-bare repository")?.into(),
37+
);
38+
for spec in changes {
39+
let Some(wt_change) = wt_changes.changes.iter().find(|c| {
40+
c.path == spec.path
41+
&& c.previous_path() == spec.previous_path.as_ref().map(|p| p.as_bstr())
42+
}) else {
43+
dropped.push(spec);
44+
continue;
45+
};
46+
47+
if spec.hunk_headers.is_empty() {
48+
match wt_change.status {
49+
TreeStatus::Addition { is_untracked, .. } => {
50+
std::fs::remove_file(
51+
path_check
52+
.verified_path(&gix::path::from_bstr(wt_change.path.as_bstr()))?,
53+
)?;
54+
if !is_untracked {
55+
index::mark_entry_for_deletion(&mut index, wt_change.path.as_bstr());
56+
}
57+
}
58+
TreeStatus::Deletion { previous_state } => {
59+
restore_state_to_worktree(
60+
&mut pipeline,
61+
&mut index,
62+
wt_change.path.as_bstr(),
63+
previous_state,
64+
RestoreMode::Deleted,
65+
&mut path_check,
66+
)?;
67+
}
68+
TreeStatus::Modification { .. } => {}
69+
TreeStatus::Rename { .. } => {}
70+
}
71+
} else {
72+
todo!("hunk-based undo")
73+
}
74+
}
75+
76+
let has_removals_or_updates = index.entries().iter().any(|e| {
77+
e.flags
78+
.intersects(gix::index::entry::Flags::REMOVE | gix::index::entry::Flags::UPDATE)
79+
});
80+
if has_removals_or_updates {
81+
index.remove_tree();
82+
index.remove_resolve_undo();
83+
// This works because we delete entries by marking them, so only additions change the count.
84+
if initial_entries_len != index.entries().len() {
85+
index.sort_entries();
86+
}
87+
index.write(Default::default())?;
88+
}
89+
Ok(dropped)
90+
}
91+
92+
enum RestoreMode {
93+
/// Assume the resource to be restored doesn't exist as it was deleted.
94+
Deleted,
95+
/// A similar resource is in its place that needs to be updated.
96+
Update,
97+
}
98+
99+
/// Restore `state` by writing it into the worktree of `repo`, possibly re-adding or updating the
100+
/// `index` with it so that it matches the worktree.
101+
fn restore_state_to_worktree(
102+
pipeline: &mut gix::filter::Pipeline<'_>,
103+
index: &mut gix::index::State,
104+
rela_path: &BStr,
105+
state: ChangeState,
106+
mode: RestoreMode,
107+
path_check: &mut gix::status::plumbing::SymlinkCheck,
108+
) -> anyhow::Result<()> {
109+
if state.id.is_null() {
110+
bail!(
111+
"Change to discard at '{rela_path}' didn't have a last-known tracked state - this is a bug"
112+
);
113+
}
114+
115+
let mut update_index = |md| -> anyhow::Result<()> {
116+
let mut num_sorted_entries = index.entries().len();
117+
let newly_added = crate::commit_engine::index::upsert_index_entry(
118+
index,
119+
rela_path,
120+
&md,
121+
state.id,
122+
state.kind.into(),
123+
gix::index::entry::Flags::UPDATE,
124+
&mut num_sorted_entries,
125+
)?;
126+
assert_eq!(
127+
num_sorted_entries,
128+
index.entries().len() - usize::from(newly_added),
129+
"BUG: cannot currently discard conflicts - there is no notion of it"
130+
);
131+
Ok(())
132+
};
133+
134+
let repo = pipeline.repo;
135+
let file_path = path_check.verified_path(&gix::path::from_bstr(rela_path))?;
136+
match state.kind {
137+
EntryKind::Blob | EntryKind::BlobExecutable => {
138+
let mut dest_lock_file = locked_resource_at(file_path, state.kind)?;
139+
let obj_in_git = state.id.attach(repo).object()?;
140+
let mut stream =
141+
pipeline.convert_to_worktree(&obj_in_git.data, rela_path, Delay::Forbid)?;
142+
std::io::copy(&mut stream, &mut dest_lock_file)?;
143+
144+
let (file_path, maybe_file) = dest_lock_file.commit()?;
145+
update_index(match maybe_file {
146+
None => gix::index::fs::Metadata::from_path_no_follow(&file_path)?,
147+
Some(file) => gix::index::fs::Metadata::from_file(&file)?,
148+
})?;
149+
}
150+
EntryKind::Link => {
151+
let link_path = file_path;
152+
if let RestoreMode::Update = mode {
153+
std::fs::remove_file(link_path)?;
154+
}
155+
let link_target = state.id.attach(repo).object()?;
156+
let link_target = gix::path::from_bstr(link_target.data.as_bstr());
157+
gix::fs::symlink::create(&link_target, link_path)?;
158+
update_index(gix::index::fs::Metadata::from_path_no_follow(link_path)?)?;
159+
}
160+
EntryKind::Commit => {
161+
if let RestoreMode::Update = mode {
162+
todo!("checkout the desired revision")
163+
} else {
164+
let sm = repo.submodules()?.into_iter().flatten().find_map(|sm| {
165+
let is_active = sm.is_active().ok()?;
166+
is_active
167+
.then(|| {
168+
if sm.path().ok().is_some_and(|sm_path| sm_path == rela_path) {
169+
sm.url().ok()
170+
} else {
171+
None
172+
}
173+
})
174+
.flatten()
175+
});
176+
match sm {
177+
None => {
178+
// A directory is what git creates with `git restore` even if the thing to restore is a submodule.
179+
// We are trying to be better than that if we find a submodule, hoping that this is what users expect.
180+
// We do that as baseline as there is no need to fail here.
181+
}
182+
Some(url) => {
183+
// Must use `git` directly as `gix` isn't 100% compatible, nor is `git2`.
184+
// TODO(compat): use `gix` once it can clone Azure repos, and otherwise also is 99% compatible,
185+
// for better performance.
186+
let output = std::process::Command::new(gix::path::env::exe_invocation())
187+
.current_dir(repo.workdir().context("non-bare repository")?)
188+
.arg("clone")
189+
.arg(gix::path::from_bstring(url.to_bstring()))
190+
.arg(file_path)
191+
.output();
192+
match output {
193+
Ok(out) => {
194+
if !out.status.success() {
195+
tracing::warn!(
196+
"Failed to clone repository from {url} to {}: {}",
197+
file_path.display(),
198+
out.stderr.as_bstr()
199+
)
200+
}
201+
}
202+
Err(err) => {
203+
tracing::warn!("Could not clone submodule into place: {err}");
204+
}
205+
}
206+
}
207+
}
208+
std::fs::create_dir(file_path).or_else(|err| {
209+
if err.kind() == std::io::ErrorKind::AlreadyExists {
210+
Ok(())
211+
} else {
212+
Err(err)
213+
}
214+
})?;
215+
}
216+
update_index(gix::index::fs::Metadata::from_path_no_follow(file_path)?)?;
217+
}
218+
EntryKind::Tree => {
219+
unreachable!("BUG: should not attempt to create a tree")
220+
}
221+
};
222+
Ok(())
223+
}
224+
225+
mod index {
226+
use bstr::BStr;
227+
use gix::index::entry::Stage;
228+
229+
pub fn mark_entry_for_deletion(state: &mut gix::index::State, rela_path: &BStr) {
230+
for stage in [Stage::Unconflicted, Stage::Base, Stage::Ours, Stage::Theirs] {
231+
// TODO(perf): `gix` should offer a way to get the *first* index by path so the
232+
// binary search doesn't have to be repeated.
233+
let Some(entry) = state.entry_mut_by_path_and_stage(rela_path, stage) else {
234+
continue;
235+
};
236+
entry.flags.insert(gix::index::entry::Flags::REMOVE);
237+
}
238+
}
239+
}
240+
241+
#[cfg(unix)]
242+
fn locked_resource_at(path: &Path, kind: EntryKind) -> anyhow::Result<gix::lock::File> {
243+
use std::os::unix::fs::PermissionsExt;
244+
Ok(
245+
gix::lock::File::acquire_to_update_resource_with_permissions(
246+
path,
247+
gix::lock::acquire::Fail::Immediately,
248+
None,
249+
|| std::fs::Permissions::from_mode(kind as u32),
250+
)?,
251+
)
252+
}
253+
254+
#[cfg(windows)]
255+
fn locked_resource_at(path: &Path, _kind: EntryKind) -> anyhow::Result<gix::lock::File> {
256+
Ok(gix::lock::File::acquire_to_update_resource(
257+
path,
258+
gix::lock::acquire::Fail::Immediately,
259+
None,
260+
)?)
261+
}

0 commit comments

Comments
 (0)