crates/kernel/ents-anchor/src/projection.rs
projection.rshistorycomment on this file
| 1 | //! Read-time projection of an [`Anchor`](crate::Anchor) onto another |
| 2 | //! commit: an exact tree diff when the anchor's own commit still exists, |
| 3 | //! degrading to fuzzy context matching once it is gone. |
| 4 | //! |
| 5 | //! Spec coverage: `anchor.projection`, `anchor.fuzzy-fallback`. |
| 6 | //! |
| 7 | //! Projection is a two-point tree diff, not a history walk: [`project_exact`] |
| 8 | //! compares the anchor commit's tree directly against the target commit's |
| 9 | //! tree, so it works whether the target is a descendant, an ancestor, or |
| 10 | //! unrelated history. Blame answers the backwards question (which commit |
| 11 | //! introduced a line); the forward question asked here needs only the diff. |
| 12 | |
| 13 | use gix::ObjectId; |
| 14 | use gix::bstr::ByteSlice as _; |
| 15 | use gix::diff::blob::{Algorithm, Diff, InternedInput}; |
| 16 | use gix::diff::tree_with_rewrites::Change; |
| 17 | |
| 18 | use crate::anchor::{Anchor, CONTEXT_MARGIN, LineRange}; |
| 19 | use crate::error::{Error, Result}; |
| 20 | use crate::util::{commit_at, read_blob, resolve_commit}; |
| 21 | |
| 22 | /// Where an [`Anchor`] sits on a target commit, as computed by [`project`]. |
| 23 | /// |
| 24 | /// # Examples |
| 25 | /// |
| 26 | /// ``` |
| 27 | /// use ents_anchor::Projection; |
| 28 | /// |
| 29 | /// let outcome = Projection::Outdated { path: "src/lib.rs".to_owned() }; |
| 30 | /// assert!(matches!(outcome, Projection::Outdated { .. })); |
| 31 | /// ``` |
| 32 | // @relation(anchor.projection, scope=file) |
| 33 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 34 | pub enum Projection { |
| 35 | /// The target tree holds the anchor's exact blob at its exact path; the |
| 36 | /// anchor applies unchanged. |
| 37 | Current, |
| 38 | /// The file moved and/or its content shifted, but the anchored region |
| 39 | /// itself is intact — the anchor now applies at `path` and `lines`. |
| 40 | Relocated { |
| 41 | /// The anchored file's path in the target tree. |
| 42 | path: String, |
| 43 | /// The anchored lines mapped into the target blob, or `None` for a |
| 44 | /// whole-file anchor. |
| 45 | lines: Option<LineRange>, |
| 46 | }, |
| 47 | /// The file survives at `path` but the anchored lines were edited (or |
| 48 | /// the entry is no longer a regular file); the anchor no longer maps |
| 49 | /// cleanly. |
| 50 | Outdated { |
| 51 | /// The anchored file's path in the target tree. |
| 52 | path: String, |
| 53 | }, |
| 54 | /// The anchored file does not exist in the target tree. |
| 55 | Deleted, |
| 56 | } |
| 57 | |
| 58 | impl Projection { |
| 59 | /// The outcome's canonical lowercase keyword -- the porcelain grammar's |
| 60 | /// own vocabulary (`current`, `relocated`, `outdated`, `deleted`), shared |
| 61 | /// by every surface that names an outcome without narrating its payload. |
| 62 | #[must_use] |
| 63 | pub fn label(&self) -> &'static str { |
| 64 | match self { |
| 65 | Self::Current => "current", |
| 66 | Self::Relocated { .. } => "relocated", |
| 67 | Self::Outdated { .. } => "outdated", |
| 68 | Self::Deleted => "deleted", |
| 69 | } |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | /// Project `anchor` onto `target` (a revision in `repo`), degrading to |
| 74 | /// [`project_from_context`] once `anchor`'s own commit has been garbage |
| 75 | /// collected (`anchor.fuzzy-fallback`) — the one entry point most callers |
| 76 | /// need; [`project_exact`] and [`project_from_context`] are exposed |
| 77 | /// separately for callers that need to distinguish an exact projection from |
| 78 | /// an approximate one. |
| 79 | /// |
| 80 | /// Never mutates `anchor`: every outcome, including [`Projection::Outdated`] |
| 81 | /// and [`Projection::Deleted`], is a fresh [`Projection`] value, and the |
| 82 | /// anchor itself remains displayable regardless of the outcome |
| 83 | /// (`anchor.fuzzy-fallback`). |
| 84 | /// |
| 85 | /// # Examples |
| 86 | /// |
| 87 | /// ``` |
| 88 | /// # let dir = tempfile::tempdir().expect("tempdir"); |
| 89 | /// # std::process::Command::new("git").arg("init").arg("-q").arg(dir.path()).status().unwrap(); |
| 90 | /// # std::fs::write(dir.path().join("file.txt"), "a\nb\nc\n").unwrap(); |
| 91 | /// # std::process::Command::new("git").arg("-C").arg(dir.path()).args(["add", "-A"]).status().unwrap(); |
| 92 | /// # std::process::Command::new("git").arg("-C").arg(dir.path()) |
| 93 | /// # .args(["-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "-m", "one"]) |
| 94 | /// # .status().unwrap(); |
| 95 | /// let repo = gix::open(dir.path()).expect("open"); |
| 96 | /// let anchor = ents_anchor::capture(&repo, "HEAD", "file.txt", None).expect("capture"); |
| 97 | /// assert_eq!(ents_anchor::project(&repo, &anchor, "HEAD").unwrap(), ents_anchor::Projection::Current); |
| 98 | /// ``` |
| 99 | // @relation(anchor.projection, anchor.fuzzy-fallback, scope=function) |
| 100 | pub fn project(repo: &gix::Repository, anchor: &Anchor, target: &str) -> Result<Projection> { |
| 101 | match project_exact(repo, anchor, target) { |
| 102 | Err(Error::AnchorCommitMissing(_)) => project_from_context(repo, anchor, target), |
| 103 | other => other, |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | /// Project `anchor` onto `target` by diffing `anchor`'s own commit tree |
| 108 | /// against `target`'s, with rename tracking, and mapping the line range |
| 109 | /// through the blob diff's hunks — shifted past edits that land entirely |
| 110 | /// outside it, [`Projection::Outdated`] when an edit touches it. |
| 111 | /// |
| 112 | /// Fails with [`Error::AnchorCommitMissing`] when `anchor`'s commit no |
| 113 | /// longer exists (it is retained on a best-effort basis only, |
| 114 | /// `anchor.retention`); [`project`] catches exactly this and retries with |
| 115 | /// [`project_from_context`], which needs no commit at all. |
| 116 | // @relation(anchor.projection, scope=function) |
| 117 | pub fn project_exact(repo: &gix::Repository, anchor: &Anchor, target: &str) -> Result<Projection> { |
| 118 | let anchor_blob = anchor.blob(); |
| 119 | let anchor_commit_id = anchor.commit(); |
| 120 | let target_commit = resolve_commit(repo, target)?; |
| 121 | let target_tree = target_commit |
| 122 | .tree() |
| 123 | .map_err(|error| Error::Object(error.to_string()))?; |
| 124 | |
| 125 | if let Some(entry) = target_tree |
| 126 | .lookup_entry_by_path(&anchor.path) |
| 127 | .map_err(|error| Error::Object(error.to_string()))? |
| 128 | && entry.mode().is_blob() |
| 129 | && entry.object_id() == anchor_blob |
| 130 | { |
| 131 | return Ok(Projection::Current); |
| 132 | } |
| 133 | |
| 134 | if !repo.has_object(anchor_commit_id) { |
| 135 | return Err(Error::AnchorCommitMissing(anchor_commit_id)); |
| 136 | } |
| 137 | let anchor_commit = commit_at(repo, anchor_commit_id)?; |
| 138 | let anchor_tree = anchor_commit |
| 139 | .tree() |
| 140 | .map_err(|error| Error::Object(error.to_string()))?; |
| 141 | // Rename tracking is pinned to git's defaults (50% similarity, no |
| 142 | // copies) rather than read from repository configuration, so a |
| 143 | // projection is the same answer everywhere the repository is checked |
| 144 | // out. |
| 145 | let options = gix::diff::Options::default().with_rewrites(Some(gix::diff::Rewrites::default())); |
| 146 | let changes = repo |
| 147 | .diff_tree_to_tree(Some(&anchor_tree), Some(&target_tree), options) |
| 148 | .map_err(|error| Error::Diff(error.to_string()))?; |
| 149 | |
| 150 | // Find where the anchored path went: its old-side location is |
| 151 | // `location` for a deletion or modification and `source_location` for a |
| 152 | // rename. |
| 153 | let mut destination: Option<(String, ObjectId, bool)> = None; |
| 154 | for change in changes { |
| 155 | match change { |
| 156 | Change::Deletion { location, .. } if location.as_bytes() == anchor.path.as_bytes() => { |
| 157 | return Ok(Projection::Deleted); |
| 158 | } |
| 159 | Change::Modification { |
| 160 | location, |
| 161 | id, |
| 162 | entry_mode, |
| 163 | .. |
| 164 | } if location.as_bytes() == anchor.path.as_bytes() => { |
| 165 | destination = Some((anchor.path.clone(), id, entry_mode.is_blob())); |
| 166 | break; |
| 167 | } |
| 168 | Change::Rewrite { |
| 169 | source_location, |
| 170 | location, |
| 171 | id, |
| 172 | entry_mode, |
| 173 | copy: false, |
| 174 | .. |
| 175 | } if source_location.as_bytes() == anchor.path.as_bytes() => { |
| 176 | destination = Some(( |
| 177 | location.to_str_lossy().into_owned(), |
| 178 | id, |
| 179 | entry_mode.is_blob(), |
| 180 | )); |
| 181 | break; |
| 182 | } |
| 183 | _ => {} |
| 184 | } |
| 185 | } |
| 186 | let Some((path, blob, is_blob)) = destination else { |
| 187 | // The diff never touched the path, yet the fast path did not |
| 188 | // match: the anchor's blob is not what its own commit holds there, |
| 189 | // so the anchor itself is broken. |
| 190 | return Err(Error::MissingPath { |
| 191 | commit: anchor_commit_id, |
| 192 | path: anchor.path.clone(), |
| 193 | }); |
| 194 | }; |
| 195 | if !is_blob { |
| 196 | return Ok(Projection::Outdated { path }); |
| 197 | } |
| 198 | if blob == anchor_blob { |
| 199 | // A pure rename: the content is byte-identical, so every line is |
| 200 | // exactly where it was. |
| 201 | return Ok(Projection::Relocated { |
| 202 | path, |
| 203 | lines: anchor.lines, |
| 204 | }); |
| 205 | } |
| 206 | let lines = match anchor.lines { |
| 207 | None => None, |
| 208 | Some(range) => { |
| 209 | let new = read_blob(repo, blob)?; |
| 210 | match map_range(&anchor.content, &new, range) { |
| 211 | Some(mapped) => Some(mapped), |
| 212 | None => return Ok(Projection::Outdated { path }), |
| 213 | } |
| 214 | } |
| 215 | }; |
| 216 | Ok(Projection::Relocated { path, lines }) |
| 217 | } |
| 218 | |
| 219 | /// Project `anchor` onto `target` by fuzzy-matching `anchor`'s retained |
| 220 | /// `context` (`anchor.retention`) against `target`'s version of |
| 221 | /// `anchor.path`, for use once `anchor`'s commit no longer exists and |
| 222 | /// [`project_exact`] can no longer diff against its tree. |
| 223 | /// |
| 224 | /// Looks up `anchor.path` in `target`'s tree directly (no rename tracking is |
| 225 | /// possible without the anchor commit's tree, so a genuine rename reports |
| 226 | /// [`Projection::Deleted`] here, same as a real deletion); a whole-file |
| 227 | /// anchor (`anchor.lines` is `None`) survives any edit at that path, same as |
| 228 | /// [`project_exact`]. For a line-range anchor, every contiguous window of |
| 229 | /// the target file's lines the same length as `context` is scored by how |
| 230 | /// many lines match `context` exactly; the best-scoring window (at least |
| 231 | /// half its lines matching) is accepted and the anchored sub-range is mapped |
| 232 | /// back through the same margin [`crate::capture`] used to build `context`. |
| 233 | /// No match clearing that bar reports [`Projection::Outdated`], the same as |
| 234 | /// an unrecoverable edit would under [`project_exact`]. |
| 235 | // @relation(anchor.fuzzy-fallback, scope=function) |
| 236 | pub fn project_from_context( |
| 237 | repo: &gix::Repository, |
| 238 | anchor: &Anchor, |
| 239 | target: &str, |
| 240 | ) -> Result<Projection> { |
| 241 | let target_commit = resolve_commit(repo, target)?; |
| 242 | let target_tree = target_commit |
| 243 | .tree() |
| 244 | .map_err(|error| Error::Object(error.to_string()))?; |
| 245 | let Some(entry) = target_tree |
| 246 | .lookup_entry_by_path(&anchor.path) |
| 247 | .map_err(|error| Error::Object(error.to_string()))? |
| 248 | else { |
| 249 | return Ok(Projection::Deleted); |
| 250 | }; |
| 251 | if !entry.mode().is_blob() { |
| 252 | return Ok(Projection::Outdated { |
| 253 | path: anchor.path.clone(), |
| 254 | }); |
| 255 | } |
| 256 | let Some(range) = anchor.lines else { |
| 257 | return Ok(Projection::Relocated { |
| 258 | path: anchor.path.clone(), |
| 259 | lines: None, |
| 260 | }); |
| 261 | }; |
| 262 | |
| 263 | let data = read_blob(repo, entry.object_id())?; |
| 264 | let target_lines: Vec<&[u8]> = data.lines_with_terminator().collect(); |
| 265 | let context_lines: Vec<&[u8]> = anchor.context.lines_with_terminator().collect(); |
| 266 | let window = context_lines.len(); |
| 267 | if window == 0 || window > target_lines.len() { |
| 268 | return Ok(Projection::Outdated { |
| 269 | path: anchor.path.clone(), |
| 270 | }); |
| 271 | } |
| 272 | |
| 273 | let mut best: Option<(usize, usize)> = None; |
| 274 | for (start, slice) in target_lines.windows(window).enumerate() { |
| 275 | let score = slice |
| 276 | .iter() |
| 277 | .zip(context_lines.iter()) |
| 278 | .filter(|(have, want)| have == want) |
| 279 | .count(); |
| 280 | if best.is_none_or(|(_start, best_score)| score > best_score) { |
| 281 | best = Some((start, score)); |
| 282 | } |
| 283 | } |
| 284 | // Require at least half the window's lines to match exactly, so an |
| 285 | // unrelated coincidence of blank or near-empty lines is not mistaken |
| 286 | // for the anchored region having relocated there. |
| 287 | let Some((start, _score)) = best.filter(|(_start, score)| { |
| 288 | score |
| 289 | .checked_mul(2) |
| 290 | .is_some_and(|doubled| doubled >= window) |
| 291 | }) else { |
| 292 | return Ok(Projection::Outdated { |
| 293 | path: anchor.path.clone(), |
| 294 | }); |
| 295 | }; |
| 296 | |
| 297 | let margin_before = CONTEXT_MARGIN.min(range.start.saturating_sub(1)); |
| 298 | let range_len = range.end.saturating_sub(range.start).saturating_add(1); |
| 299 | let Ok(start) = u64::try_from(start) else { |
| 300 | return Ok(Projection::Outdated { |
| 301 | path: anchor.path.clone(), |
| 302 | }); |
| 303 | }; |
| 304 | let mapped_start = start.saturating_add(margin_before).saturating_add(1); |
| 305 | let mapped_end = mapped_start.saturating_add(range_len).saturating_sub(1); |
| 306 | Ok(Projection::Relocated { |
| 307 | path: anchor.path.clone(), |
| 308 | lines: Some(LineRange { |
| 309 | start: mapped_start, |
| 310 | end: mapped_end, |
| 311 | }), |
| 312 | }) |
| 313 | } |
| 314 | |
| 315 | /// Project `anchor` onto the working tree (`anchor.working-tree`): diff the |
| 316 | /// anchored blob — always available, it is embedded (`anchor.retention`) — |
| 317 | /// against the path's current on-disk bytes, or against `buffer` standing |
| 318 | /// in for them (`lens.working-tree`'s unsaved-editor-buffer case), and |
| 319 | /// report the same four outcomes as [`project`]. |
| 320 | /// |
| 321 | /// There is no commit on the target side to diff trees against, so rename |
| 322 | /// following degrades exactly as [`project_from_context`]'s does |
| 323 | /// (`anchor.working-tree`): only `anchor.path` itself is consulted, and a |
| 324 | /// file that moved on disk reports [`Projection::Deleted`] the same as a |
| 325 | /// removed one. The line mapping itself never degrades: the embedded |
| 326 | /// content makes the exact blob diff [`project_exact`] uses available even |
| 327 | /// when the anchor's own commit is long gone. |
| 328 | /// |
| 329 | /// # Examples |
| 330 | /// |
| 331 | /// ``` |
| 332 | /// # let dir = tempfile::tempdir().expect("tempdir"); |
| 333 | /// # std::process::Command::new("git").arg("init").arg("-q").arg(dir.path()).status().unwrap(); |
| 334 | /// # std::fs::write(dir.path().join("file.txt"), "a\nb\nc\n").unwrap(); |
| 335 | /// # std::process::Command::new("git").arg("-C").arg(dir.path()).args(["add", "-A"]).status().unwrap(); |
| 336 | /// # std::process::Command::new("git").arg("-C").arg(dir.path()) |
| 337 | /// # .args(["-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "-m", "one"]) |
| 338 | /// # .status().unwrap(); |
| 339 | /// use ents_anchor::{LineRange, Projection}; |
| 340 | /// |
| 341 | /// let repo = gix::open(dir.path()).expect("open"); |
| 342 | /// let anchor = ents_anchor::capture(&repo, "HEAD", "file.txt", Some(LineRange { start: 2, end: 2 })) |
| 343 | /// .expect("capture"); |
| 344 | /// |
| 345 | /// // Dirty the working tree above the anchored line: the anchor relocates, |
| 346 | /// // no commit involved on the target side. |
| 347 | /// std::fs::write(dir.path().join("file.txt"), "inserted\na\nb\nc\n").unwrap(); |
| 348 | /// assert_eq!( |
| 349 | /// ents_anchor::project_worktree(&repo, &anchor, None).expect("project"), |
| 350 | /// Projection::Relocated { |
| 351 | /// path: "file.txt".to_owned(), |
| 352 | /// lines: Some(LineRange { start: 3, end: 3 }), |
| 353 | /// } |
| 354 | /// ); |
| 355 | /// |
| 356 | /// // A caller-supplied buffer stands in for the on-disk bytes. |
| 357 | /// assert_eq!( |
| 358 | /// ents_anchor::project_worktree(&repo, &anchor, Some(b"a\nb\nc\n")).expect("project"), |
| 359 | /// Projection::Current |
| 360 | /// ); |
| 361 | /// ``` |
| 362 | // @relation(anchor.working-tree, scope=function) |
| 363 | pub fn project_worktree( |
| 364 | repo: &gix::Repository, |
| 365 | anchor: &Anchor, |
| 366 | buffer: Option<&[u8]>, |
| 367 | ) -> Result<Projection> { |
| 368 | let outdated = || { |
| 369 | Ok(Projection::Outdated { |
| 370 | path: anchor.path.clone(), |
| 371 | }) |
| 372 | }; |
| 373 | let owned; |
| 374 | let bytes: &[u8] = match buffer { |
| 375 | Some(bytes) => bytes, |
| 376 | None => { |
| 377 | let workdir = repo.workdir().ok_or(Error::NoWorkingTree)?; |
| 378 | let file = workdir.join(&anchor.path); |
| 379 | let Ok(metadata) = std::fs::metadata(&file) else { |
| 380 | return Ok(Projection::Deleted); |
| 381 | }; |
| 382 | if !metadata.is_file() { |
| 383 | // The entry is no longer a regular file — the same |
| 384 | // taxonomy row `project_exact` reports for a mode change. |
| 385 | return outdated(); |
| 386 | } |
| 387 | owned = std::fs::read(&file).map_err(|error| Error::Object(error.to_string()))?; |
| 388 | &owned |
| 389 | } |
| 390 | }; |
| 391 | if bytes == anchor.content.as_slice() { |
| 392 | // Byte equality is blob-id equality: the exact anchored blob still |
| 393 | // sits at the anchored path. |
| 394 | return Ok(Projection::Current); |
| 395 | } |
| 396 | let Some(range) = anchor.lines else { |
| 397 | return Ok(Projection::Relocated { |
| 398 | path: anchor.path.clone(), |
| 399 | lines: None, |
| 400 | }); |
| 401 | }; |
| 402 | match map_range(&anchor.content, bytes, range) { |
| 403 | Some(lines) => Ok(Projection::Relocated { |
| 404 | path: anchor.path.clone(), |
| 405 | lines: Some(lines), |
| 406 | }), |
| 407 | None => outdated(), |
| 408 | } |
| 409 | } |
| 410 | |
| 411 | /// Map the 1-based inclusive `range` from `old`'s lines to `new`'s by |
| 412 | /// walking the diff's hunks in order: a hunk entirely above the range |
| 413 | /// shifts it by the hunk's growth, a hunk entirely below is ignored, and any |
| 414 | /// hunk touching the range — including an insertion strictly inside it — |
| 415 | /// means the anchored region itself changed, reported as `None` (outdated) |
| 416 | /// rather than guessed at. |
| 417 | // @relation(anchor.projection, scope=function) |
| 418 | fn map_range(old: &[u8], new: &[u8], range: LineRange) -> Option<LineRange> { |
| 419 | // Work in 0-based half-open line coordinates, as the hunks do. |
| 420 | // Everything stays unsigned: the shift is tallied as lines added and |
| 421 | // lines removed above the range, and any overflow is an honest `None` |
| 422 | // (outdated) via the checked arithmetic rather than a saturated wrong |
| 423 | // answer. |
| 424 | let start = range.start.checked_sub(1)?; |
| 425 | let end = range.end; |
| 426 | if end <= start { |
| 427 | return None; |
| 428 | } |
| 429 | let input = InternedInput::new(old, new); |
| 430 | if end > u64::try_from(input.before.len()).ok()? { |
| 431 | return None; |
| 432 | } |
| 433 | let diff = Diff::compute(Algorithm::Histogram, &input); |
| 434 | let mut added: u64 = 0; |
| 435 | let mut removed: u64 = 0; |
| 436 | for hunk in diff.hunks() { |
| 437 | let before_start = u64::from(hunk.before.start); |
| 438 | let before_end = u64::from(hunk.before.end); |
| 439 | if before_end <= start { |
| 440 | removed = removed.checked_add(before_end.checked_sub(before_start)?)?; |
| 441 | added = added |
| 442 | .checked_add(u64::from(hunk.after.end).checked_sub(u64::from(hunk.after.start))?)?; |
| 443 | } else if before_start >= end { |
| 444 | break; |
| 445 | } else { |
| 446 | return None; |
| 447 | } |
| 448 | } |
| 449 | let map = |line: u64| line.checked_add(added)?.checked_sub(removed); |
| 450 | Some(LineRange { |
| 451 | start: map(start)?.checked_add(1)?, |
| 452 | end: map(end)?, |
| 453 | }) |
| 454 | } |
| 455 | |
| 456 | #[cfg(test)] |
| 457 | mod tests { |
| 458 | #![allow( |
| 459 | clippy::unwrap_used, |
| 460 | clippy::arithmetic_side_effects, |
| 461 | reason = "unit test; property inputs are bounded well below overflow" |
| 462 | )] |
| 463 | |
| 464 | use rstest::rstest; |
| 465 | |
| 466 | use super::*; |
| 467 | use crate::anchor::capture; |
| 468 | use crate::fixture::{commit_all, numbered, repo}; |
| 469 | |
| 470 | fn range(start: u64, end: u64) -> Option<LineRange> { |
| 471 | Some(LineRange { start, end }) |
| 472 | } |
| 473 | |
| 474 | #[rstest] |
| 475 | #[case::current(Projection::Current, "current")] |
| 476 | #[case::relocated(Projection::Relocated { path: "f".to_owned(), lines: None }, "relocated")] |
| 477 | #[case::outdated(Projection::Outdated { path: "f".to_owned() }, "outdated")] |
| 478 | #[case::deleted(Projection::Deleted, "deleted")] |
| 479 | // @relation(anchor.projection, scope=function, role=Verifies) |
| 480 | fn label_is_the_porcelain_keyword(#[case] projection: Projection, #[case] expected: &str) { |
| 481 | assert_eq!(projection.label(), expected); |
| 482 | } |
| 483 | |
| 484 | /// One post-capture edit per taxonomy row of |
| 485 | /// [`projection_reports_the_spec_outcomes`]. |
| 486 | #[derive(Debug, Clone, Copy)] |
| 487 | enum Mutation { |
| 488 | TouchOtherFile, |
| 489 | PrependTwoLines, |
| 490 | EditLineFive, |
| 491 | Rename, |
| 492 | RenameAndPrependOneLine, |
| 493 | Delete, |
| 494 | } |
| 495 | |
| 496 | impl Mutation { |
| 497 | fn apply(self, dir: &std::path::Path) { |
| 498 | let file = dir.join("file.txt"); |
| 499 | match self { |
| 500 | Self::TouchOtherFile => { |
| 501 | std::fs::write(dir.join("other.txt"), "unrelated\n").unwrap(); |
| 502 | } |
| 503 | Self::PrependTwoLines => { |
| 504 | std::fs::write(&file, format!("added a\nadded b\n{}", numbered(1..=10))) |
| 505 | .unwrap(); |
| 506 | } |
| 507 | Self::EditLineFive => { |
| 508 | let edited = numbered(1..=10).replace("line 5\n", "line five\n"); |
| 509 | std::fs::write(&file, edited).unwrap(); |
| 510 | } |
| 511 | Self::Rename => { |
| 512 | std::fs::rename(&file, dir.join("moved.txt")).unwrap(); |
| 513 | } |
| 514 | Self::RenameAndPrependOneLine => { |
| 515 | std::fs::remove_file(&file).unwrap(); |
| 516 | std::fs::write( |
| 517 | dir.join("moved.txt"), |
| 518 | format!("added a\n{}", numbered(1..=10)), |
| 519 | ) |
| 520 | .unwrap(); |
| 521 | } |
| 522 | Self::Delete => { |
| 523 | std::fs::remove_file(&file).unwrap(); |
| 524 | std::fs::write(dir.join("unrelated.txt"), "different content\n").unwrap(); |
| 525 | } |
| 526 | } |
| 527 | } |
| 528 | } |
| 529 | |
| 530 | /// `anchor.projection`'s outcome taxonomy, enumerated over the |
| 531 | /// scenarios that select each outcome: unchanged (current), an edit |
| 532 | /// above the range (relocated: shifted), an edit inside the range |
| 533 | /// (outdated), a pure rename (relocated: same lines), a rename with an |
| 534 | /// edit above (relocated: new path and shifted lines), a deletion |
| 535 | /// (deleted), and a whole-file anchor surviving a modification |
| 536 | /// (relocated: no lines). |
| 537 | #[rstest] |
| 538 | #[case::unchanged_is_current(Mutation::TouchOtherFile, range(3, 4), Projection::Current)] |
| 539 | #[case::edit_above_shifts( |
| 540 | Mutation::PrependTwoLines, |
| 541 | range(5, 6), |
| 542 | Projection::Relocated { path: "file.txt".to_owned(), lines: range(7, 8) } |
| 543 | )] |
| 544 | #[case::edit_inside_outdates( |
| 545 | Mutation::EditLineFive, |
| 546 | range(5, 6), |
| 547 | Projection::Outdated { path: "file.txt".to_owned() } |
| 548 | )] |
| 549 | #[case::pure_rename_relocates( |
| 550 | Mutation::Rename, |
| 551 | range(3, 4), |
| 552 | Projection::Relocated { path: "moved.txt".to_owned(), lines: range(3, 4) } |
| 553 | )] |
| 554 | #[case::rename_with_edit_above( |
| 555 | Mutation::RenameAndPrependOneLine, |
| 556 | range(5, 6), |
| 557 | Projection::Relocated { path: "moved.txt".to_owned(), lines: range(6, 7) } |
| 558 | )] |
| 559 | #[case::deletion_is_deleted(Mutation::Delete, range(3, 4), Projection::Deleted)] |
| 560 | #[case::whole_file_survives_an_edit( |
| 561 | Mutation::EditLineFive, |
| 562 | None, |
| 563 | Projection::Relocated { path: "file.txt".to_owned(), lines: None } |
| 564 | )] |
| 565 | // @relation(anchor.projection, scope=function, role=Verifies) |
| 566 | fn projection_reports_the_spec_outcomes( |
| 567 | #[case] mutation: Mutation, |
| 568 | #[case] lines: Option<LineRange>, |
| 569 | #[case] expected: Projection, |
| 570 | ) { |
| 571 | let dir = repo(); |
| 572 | std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); |
| 573 | commit_all(dir.path(), "one"); |
| 574 | let git_repo = gix::open(dir.path()).unwrap(); |
| 575 | let anchor = capture(&git_repo, "HEAD", "file.txt", lines).unwrap(); |
| 576 | |
| 577 | mutation.apply(dir.path()); |
| 578 | commit_all(dir.path(), "two"); |
| 579 | |
| 580 | // Re-open: the first handle predates commit two. |
| 581 | let git_repo = gix::open(dir.path()).unwrap(); |
| 582 | assert_eq!(project_exact(&git_repo, &anchor, "HEAD").unwrap(), expected); |
| 583 | // The umbrella entry point gives the identical answer while the |
| 584 | // anchor commit exists. |
| 585 | assert_eq!(project(&git_repo, &anchor, "HEAD").unwrap(), expected); |
| 586 | } |
| 587 | |
| 588 | // @relation(anchor.projection, scope=function, role=Verifies) |
| 589 | #[test] |
| 590 | fn projection_works_backwards_onto_an_ancestor() { |
| 591 | let dir = repo(); |
| 592 | std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); |
| 593 | commit_all(dir.path(), "one"); |
| 594 | let git_repo = gix::open(dir.path()).unwrap(); |
| 595 | let old = git_repo.head_id().unwrap().detach().to_string(); |
| 596 | |
| 597 | let edited = format!("added a\n{}", numbered(1..=10)); |
| 598 | std::fs::write(dir.path().join("file.txt"), edited).unwrap(); |
| 599 | commit_all(dir.path(), "two"); |
| 600 | let git_repo = gix::open(dir.path()).unwrap(); |
| 601 | let anchor = capture(&git_repo, "HEAD", "file.txt", range(6, 7)).unwrap(); |
| 602 | |
| 603 | assert_eq!( |
| 604 | project_exact(&git_repo, &anchor, &old).unwrap(), |
| 605 | Projection::Relocated { |
| 606 | path: "file.txt".to_owned(), |
| 607 | lines: range(5, 6), |
| 608 | } |
| 609 | ); |
| 610 | } |
| 611 | |
| 612 | proptest::proptest! { |
| 613 | /// Projection stability under content perturbation |
| 614 | /// (`anchor.projection`): inserting lines strictly above the |
| 615 | /// anchored range shifts it by exactly the insertion count, and |
| 616 | /// appending lines strictly below leaves it untouched — for any |
| 617 | /// file size, range, and insertion size. |
| 618 | // @relation(anchor.projection, scope=function, role=Verifies) |
| 619 | #[test] |
| 620 | fn map_range_shifts_past_outside_edits_and_only_outside_edits( |
| 621 | file_len in 1u64..200, |
| 622 | range_start in 1u64..200, |
| 623 | range_len in 0u64..20, |
| 624 | inserted in 1u64..50, |
| 625 | ) { |
| 626 | proptest::prop_assume!(range_start + range_len <= file_len); |
| 627 | let range = LineRange { start: range_start, end: range_start + range_len }; |
| 628 | let old: String = (1..=file_len).map(|n| format!("line {n}\n")).collect(); |
| 629 | |
| 630 | // Insert `inserted` distinct lines at the very top. Even for a |
| 631 | // range starting at line 1 this touches no anchored line — the |
| 632 | // insertion hunk ends where the range begins — so it must |
| 633 | // shift, never outdate. |
| 634 | let above: String = (0..inserted) |
| 635 | .map(|n| format!("inserted {n}\n")) |
| 636 | .chain((1..=file_len).map(|n| format!("line {n}\n"))) |
| 637 | .collect(); |
| 638 | proptest::prop_assert_eq!( |
| 639 | map_range(old.as_bytes(), above.as_bytes(), range), |
| 640 | Some(LineRange { start: range.start + inserted, end: range.end + inserted }) |
| 641 | ); |
| 642 | |
| 643 | // Append strictly below the range: the range must not move. |
| 644 | let below: String = (1..=file_len) |
| 645 | .map(|n| format!("line {n}\n")) |
| 646 | .chain((0..inserted).map(|n| format!("appended {n}\n"))) |
| 647 | .collect(); |
| 648 | proptest::prop_assert_eq!( |
| 649 | map_range(old.as_bytes(), below.as_bytes(), range), |
| 650 | Some(range) |
| 651 | ); |
| 652 | } |
| 653 | } |
| 654 | |
| 655 | // @relation(anchor.projection, scope=function, role=Verifies) |
| 656 | #[test] |
| 657 | fn map_range_handles_edges() { |
| 658 | let old = b"a\nb\nc\nd\n".as_slice(); |
| 659 | // An insertion exactly at the range start shifts it; one exactly |
| 660 | // at its end leaves it alone. |
| 661 | let above = b"x\na\nb\nc\nd\n".as_slice(); |
| 662 | assert_eq!( |
| 663 | map_range(old, above, LineRange { start: 2, end: 3 }), |
| 664 | Some(LineRange { start: 3, end: 4 }) |
| 665 | ); |
| 666 | // An insertion strictly inside the range outdates it. |
| 667 | let inside = b"a\nb\nx\nc\nd\n".as_slice(); |
| 668 | assert_eq!(map_range(old, inside, LineRange { start: 2, end: 3 }), None); |
| 669 | // A range past the end of the old file cannot map. |
| 670 | assert_eq!(map_range(old, old, LineRange { start: 4, end: 9 }), None); |
| 671 | } |
| 672 | |
| 673 | /// A copy of `anchor` whose recorded commit is a made-up id that was |
| 674 | /// never written to the repository — standing in for "gc'd away" |
| 675 | /// without actually having to run gc in a unit test; `has_object` |
| 676 | /// answers `false` either way. |
| 677 | fn with_missing_commit(anchor: &Anchor) -> Anchor { |
| 678 | let mut forged = anchor.clone(); |
| 679 | let fake = gix::ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567").unwrap(); |
| 680 | forged.commit.copy_from_slice(fake.as_slice()); |
| 681 | forged |
| 682 | } |
| 683 | |
| 684 | // @relation(anchor.fuzzy-fallback, scope=function, role=Verifies) |
| 685 | #[test] |
| 686 | fn project_exact_reports_the_anchor_commit_as_missing_and_project_degrades() { |
| 687 | let dir = repo(); |
| 688 | std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); |
| 689 | commit_all(dir.path(), "one"); |
| 690 | let git_repo = gix::open(dir.path()).unwrap(); |
| 691 | let anchor = capture(&git_repo, "HEAD", "file.txt", range(5, 6)).unwrap(); |
| 692 | |
| 693 | let edited = format!("added a\nadded b\n{}", numbered(1..=10)); |
| 694 | std::fs::write(dir.path().join("file.txt"), edited).unwrap(); |
| 695 | commit_all(dir.path(), "two"); |
| 696 | let git_repo = gix::open(dir.path()).unwrap(); |
| 697 | |
| 698 | let anchor = with_missing_commit(&anchor); |
| 699 | assert!(matches!( |
| 700 | project_exact(&git_repo, &anchor, "HEAD"), |
| 701 | Err(Error::AnchorCommitMissing(_)) |
| 702 | )); |
| 703 | // The umbrella entry point degrades to the context fallback |
| 704 | // instead of failing (`anchor.fuzzy-fallback`), and recovers the |
| 705 | // same relocation the exact path would have found. |
| 706 | assert_eq!( |
| 707 | project(&git_repo, &anchor, "HEAD").unwrap(), |
| 708 | Projection::Relocated { |
| 709 | path: "file.txt".to_owned(), |
| 710 | lines: range(7, 8), |
| 711 | } |
| 712 | ); |
| 713 | } |
| 714 | |
| 715 | // @relation(anchor.fuzzy-fallback, scope=function, role=Verifies) |
| 716 | #[test] |
| 717 | fn project_from_context_relocates_across_an_edit_above_the_range() { |
| 718 | let dir = repo(); |
| 719 | std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); |
| 720 | commit_all(dir.path(), "one"); |
| 721 | let git_repo = gix::open(dir.path()).unwrap(); |
| 722 | let anchor = capture(&git_repo, "HEAD", "file.txt", range(5, 6)).unwrap(); |
| 723 | |
| 724 | let edited = format!("added a\nadded b\n{}", numbered(1..=10)); |
| 725 | std::fs::write(dir.path().join("file.txt"), edited).unwrap(); |
| 726 | commit_all(dir.path(), "two"); |
| 727 | let git_repo = gix::open(dir.path()).unwrap(); |
| 728 | |
| 729 | // Same answer `project_exact` would give, but derived with no |
| 730 | // reference at all to the anchor's own (still very much present) |
| 731 | // commit — exercising the exact code path that stands in once it |
| 732 | // is gone. |
| 733 | assert_eq!( |
| 734 | project_from_context(&git_repo, &anchor, "HEAD").unwrap(), |
| 735 | Projection::Relocated { |
| 736 | path: "file.txt".to_owned(), |
| 737 | lines: range(7, 8), |
| 738 | } |
| 739 | ); |
| 740 | } |
| 741 | |
| 742 | // @relation(anchor.fuzzy-fallback, scope=function, role=Verifies) |
| 743 | #[test] |
| 744 | fn project_from_context_reports_outdated_when_no_window_matches_well() { |
| 745 | let dir = repo(); |
| 746 | std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); |
| 747 | commit_all(dir.path(), "one"); |
| 748 | let git_repo = gix::open(dir.path()).unwrap(); |
| 749 | let anchor = capture(&git_repo, "HEAD", "file.txt", range(5, 6)).unwrap(); |
| 750 | |
| 751 | // A wholesale rewrite leaves nothing resembling the captured |
| 752 | // neighborhood anywhere in the file. |
| 753 | std::fs::write(dir.path().join("file.txt"), "totally\nunrelated\ncontent\n").unwrap(); |
| 754 | commit_all(dir.path(), "two"); |
| 755 | let git_repo = gix::open(dir.path()).unwrap(); |
| 756 | |
| 757 | assert_eq!( |
| 758 | project_from_context(&git_repo, &anchor, "HEAD").unwrap(), |
| 759 | Projection::Outdated { |
| 760 | path: "file.txt".to_owned(), |
| 761 | } |
| 762 | ); |
| 763 | } |
| 764 | |
| 765 | // @relation(anchor.fuzzy-fallback, scope=function, role=Verifies) |
| 766 | #[test] |
| 767 | fn project_from_context_reports_deleted() { |
| 768 | let dir = repo(); |
| 769 | std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); |
| 770 | commit_all(dir.path(), "one"); |
| 771 | let git_repo = gix::open(dir.path()).unwrap(); |
| 772 | let anchor = capture(&git_repo, "HEAD", "file.txt", range(5, 6)).unwrap(); |
| 773 | |
| 774 | std::fs::remove_file(dir.path().join("file.txt")).unwrap(); |
| 775 | std::fs::write(dir.path().join("unrelated.txt"), "different\n").unwrap(); |
| 776 | commit_all(dir.path(), "two"); |
| 777 | let git_repo = gix::open(dir.path()).unwrap(); |
| 778 | |
| 779 | assert_eq!( |
| 780 | project_from_context(&git_repo, &anchor, "HEAD").unwrap(), |
| 781 | Projection::Deleted |
| 782 | ); |
| 783 | } |
| 784 | |
| 785 | /// One *uncommitted* working-tree edit per taxonomy row of |
| 786 | /// [`project_worktree_reports_the_spec_outcomes`] — the same rows the |
| 787 | /// commit-target table enumerates, minus rename following, which the |
| 788 | /// working tree deliberately degrades (`anchor.working-tree`). |
| 789 | #[derive(Debug, Clone, Copy)] |
| 790 | enum DirtyMutation { |
| 791 | None, |
| 792 | PrependTwoLines, |
| 793 | EditLineFive, |
| 794 | Delete, |
| 795 | ReplaceWithDirectory, |
| 796 | } |
| 797 | |
| 798 | impl DirtyMutation { |
| 799 | fn apply(self, dir: &std::path::Path) { |
| 800 | let file = dir.join("file.txt"); |
| 801 | match self { |
| 802 | Self::None => {} |
| 803 | Self::PrependTwoLines => { |
| 804 | std::fs::write(&file, format!("added a\nadded b\n{}", numbered(1..=10))) |
| 805 | .unwrap(); |
| 806 | } |
| 807 | Self::EditLineFive => { |
| 808 | let edited = numbered(1..=10).replace("line 5\n", "line five\n"); |
| 809 | std::fs::write(&file, edited).unwrap(); |
| 810 | } |
| 811 | Self::Delete => { |
| 812 | std::fs::remove_file(&file).unwrap(); |
| 813 | } |
| 814 | Self::ReplaceWithDirectory => { |
| 815 | std::fs::remove_file(&file).unwrap(); |
| 816 | std::fs::create_dir(&file).unwrap(); |
| 817 | } |
| 818 | } |
| 819 | } |
| 820 | } |
| 821 | |
| 822 | /// `anchor.working-tree`'s projection target: the four |
| 823 | /// `anchor.projection` outcomes recovered against a dirty working |
| 824 | /// tree, with no commit on the target side. |
| 825 | #[rstest] |
| 826 | #[case::unchanged_is_current(DirtyMutation::None, range(3, 4), Projection::Current)] |
| 827 | #[case::edit_above_shifts( |
| 828 | DirtyMutation::PrependTwoLines, |
| 829 | range(5, 6), |
| 830 | Projection::Relocated { path: "file.txt".to_owned(), lines: range(7, 8) } |
| 831 | )] |
| 832 | #[case::edit_inside_outdates( |
| 833 | DirtyMutation::EditLineFive, |
| 834 | range(5, 6), |
| 835 | Projection::Outdated { path: "file.txt".to_owned() } |
| 836 | )] |
| 837 | #[case::deletion_is_deleted(DirtyMutation::Delete, range(3, 4), Projection::Deleted)] |
| 838 | #[case::not_a_regular_file_outdates( |
| 839 | DirtyMutation::ReplaceWithDirectory, |
| 840 | range(3, 4), |
| 841 | Projection::Outdated { path: "file.txt".to_owned() } |
| 842 | )] |
| 843 | #[case::whole_file_survives_an_edit( |
| 844 | DirtyMutation::EditLineFive, |
| 845 | None, |
| 846 | Projection::Relocated { path: "file.txt".to_owned(), lines: None } |
| 847 | )] |
| 848 | // @relation(anchor.working-tree, scope=function, role=Verifies) |
| 849 | fn project_worktree_reports_the_spec_outcomes( |
| 850 | #[case] mutation: DirtyMutation, |
| 851 | #[case] lines: Option<LineRange>, |
| 852 | #[case] expected: Projection, |
| 853 | ) { |
| 854 | let dir = repo(); |
| 855 | std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); |
| 856 | commit_all(dir.path(), "one"); |
| 857 | let git_repo = gix::open(dir.path()).unwrap(); |
| 858 | let anchor = capture(&git_repo, "HEAD", "file.txt", lines).unwrap(); |
| 859 | |
| 860 | // Dirty the working tree only: nothing is committed, so only the |
| 861 | // on-disk bytes can produce these outcomes. |
| 862 | mutation.apply(dir.path()); |
| 863 | assert_eq!( |
| 864 | project_worktree(&git_repo, &anchor, None).unwrap(), |
| 865 | expected |
| 866 | ); |
| 867 | } |
| 868 | |
| 869 | /// A caller-supplied buffer stands in for the on-disk bytes |
| 870 | /// (`anchor.working-tree`): the projection follows the buffer, not the |
| 871 | /// file — even when the file is gone entirely. |
| 872 | // @relation(anchor.working-tree, scope=function, role=Verifies) |
| 873 | #[test] |
| 874 | fn project_worktree_prefers_a_caller_supplied_buffer_over_the_disk() { |
| 875 | let dir = repo(); |
| 876 | std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); |
| 877 | commit_all(dir.path(), "one"); |
| 878 | let git_repo = gix::open(dir.path()).unwrap(); |
| 879 | let anchor = capture(&git_repo, "HEAD", "file.txt", range(5, 6)).unwrap(); |
| 880 | |
| 881 | std::fs::remove_file(dir.path().join("file.txt")).unwrap(); |
| 882 | let buffer = format!("added a\nadded b\n{}", numbered(1..=10)); |
| 883 | assert_eq!( |
| 884 | project_worktree(&git_repo, &anchor, Some(buffer.as_bytes())).unwrap(), |
| 885 | Projection::Relocated { |
| 886 | path: "file.txt".to_owned(), |
| 887 | lines: range(7, 8), |
| 888 | } |
| 889 | ); |
| 890 | // Without the buffer, the same call reads the (deleted) disk state. |
| 891 | assert_eq!( |
| 892 | project_worktree(&git_repo, &anchor, None).unwrap(), |
| 893 | Projection::Deleted |
| 894 | ); |
| 895 | } |
| 896 | |
| 897 | /// A working-tree projection also works for an anchor that was itself |
| 898 | /// captured from the working tree and whose bytes were never committed |
| 899 | /// anywhere: the embedded content is the diff's old side, no commit |
| 900 | /// participates (`anchor.working-tree`). |
| 901 | // @relation(anchor.working-tree, scope=function, role=Verifies) |
| 902 | #[test] |
| 903 | fn project_worktree_needs_no_commit_on_either_side() { |
| 904 | let dir = repo(); |
| 905 | std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); |
| 906 | commit_all(dir.path(), "one"); |
| 907 | let dirty = numbered(1..=10).replace("line 9\n", "line nine\n"); |
| 908 | std::fs::write(dir.path().join("file.txt"), &dirty).unwrap(); |
| 909 | let git_repo = gix::open(dir.path()).unwrap(); |
| 910 | let anchor = crate::capture_worktree(&git_repo, "file.txt", range(5, 6)).unwrap(); |
| 911 | |
| 912 | assert_eq!( |
| 913 | project_worktree(&git_repo, &anchor, None).unwrap(), |
| 914 | Projection::Current |
| 915 | ); |
| 916 | std::fs::write(dir.path().join("file.txt"), format!("added a\n{dirty}")).unwrap(); |
| 917 | assert_eq!( |
| 918 | project_worktree(&git_repo, &anchor, None).unwrap(), |
| 919 | Projection::Relocated { |
| 920 | path: "file.txt".to_owned(), |
| 921 | lines: range(6, 7), |
| 922 | } |
| 923 | ); |
| 924 | } |
| 925 | |
| 926 | // @relation(anchor.fuzzy-fallback, scope=function, role=Verifies) |
| 927 | #[test] |
| 928 | fn project_from_context_of_a_whole_file_anchor_survives_any_edit() { |
| 929 | let dir = repo(); |
| 930 | std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap(); |
| 931 | commit_all(dir.path(), "one"); |
| 932 | let git_repo = gix::open(dir.path()).unwrap(); |
| 933 | let anchor = capture(&git_repo, "HEAD", "file.txt", None).unwrap(); |
| 934 | |
| 935 | let edited = numbered(1..=10).replace("line 5\n", "line five\n"); |
| 936 | std::fs::write(dir.path().join("file.txt"), edited).unwrap(); |
| 937 | commit_all(dir.path(), "two"); |
| 938 | let git_repo = gix::open(dir.path()).unwrap(); |
| 939 | |
| 940 | assert_eq!( |
| 941 | project_from_context(&git_repo, &anchor, "HEAD").unwrap(), |
| 942 | Projection::Relocated { |
| 943 | path: "file.txt".to_owned(), |
| 944 | lines: None, |
| 945 | } |
| 946 | ); |
| 947 | } |
| 948 | } |