|
| 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