git-ents.gitmain
⌘K
foforge
projection.rs948 lines · 37.8 KB · rusthistorycomment 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
13use gix::ObjectId;
14use gix::bstr::ByteSlice as _;
15use gix::diff::blob::{Algorithm, Diff, InternedInput};
16use gix::diff::tree_with_rewrites::Change;
17
18use crate::anchor::{Anchor, CONTEXT_MARGIN, LineRange};
19use crate::error::{Error, Result};
20use 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)]
34pub 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
58impl 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)
100pub 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)
117pub 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)
236pub 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)
363pub 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)
418fn 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)]
457mod 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}