1use std::{
5 collections::BTreeSet,
6 path::{Path, PathBuf},
7};
8
9use anyhow::{Result, anyhow};
10use objects::{
11 HeddleError, RecoveryDetails,
12 object::{
13 AnnotationStatus, Blob, ChangeId, ContextTarget, DiffKind, EntryType, FileChangeSet,
14 FileMode, SemanticChange, State, Tree, TreeEntry,
15 },
16 store::ObjectStore,
17 worktree::{WorktreeStatus, diff_blobs},
18};
19use repo::{
20 Repository, ResolvePolicy, StateResolveError, StateResolveFailure, resolve_state_for_command,
21};
22#[cfg(feature = "semantic")]
23use semantic::diff::{SemanticDiffOptions, WorktreeStatus as SemanticWorktreeStatus};
24use sley::{EntryKind, Repository as SleyRepository};
25
26use crate::ExecutionContext;
27
28mod patch;
29mod types;
30
31pub use patch::{render_diff_patch, render_diff_patch_bytes, write_diff_patch};
32pub use types::*;
33
34const BINARY_DIFF_ERROR: &str = "binary file";
35
36#[derive(Clone, Debug, Default)]
37struct SemanticDiffResult {
38 changes: Vec<SemanticChange>,
39 file_changes: FileChangeSet,
40}
41
42#[derive(Clone, Debug)]
44pub struct DiffOptions {
45 pub from: Option<String>,
46 pub to: Option<String>,
47 pub semantic: bool,
48 pub stat: bool,
49 pub name_only: bool,
50 pub unified: usize,
51 pub show_context: bool,
52 pub include_patch_text: bool,
56}
57
58impl Default for DiffOptions {
59 fn default() -> Self {
60 Self {
61 from: None,
62 to: None,
63 semantic: false,
64 stat: false,
65 name_only: false,
66 unified: 3,
67 show_context: false,
68 include_patch_text: false,
69 }
70 }
71}
72
73#[derive(Debug)]
75pub struct PlainGitDiffProbe {
76 pub root: PathBuf,
77 pub changes: WorktreeStatus,
78}
79
80pub fn diff(ctx: &ExecutionContext, options: DiffOptions) -> Result<DiffReport> {
82 let repo = ctx.require_repo().map_err(anyhow::Error::new)?;
83 let to = options.to.as_ref();
84 let git_overlay_head_worktree_diff = repo.current_state()?.is_none()
85 && to.is_none()
86 && matches!(options.from.as_deref(), Some("HEAD" | "@"));
87
88 let from_id = if git_overlay_head_worktree_diff {
89 None
90 } else if let Some(ref spec) = options.from {
91 Some(resolve_state_id(repo, spec)?)
92 } else {
93 repo.head()?
94 };
95
96 let from_state = if let Some(id) = from_id {
97 Some(require_resolved_state(repo, &id)?)
98 } else {
99 None
100 };
101
102 let from_tree = if let Some(ref state) = from_state {
103 repo.store().get_tree(&state.tree)?
104 } else {
105 None
106 };
107 let to_state = if let Some(to_spec) = to {
108 let to_id = resolve_state_id(repo, to_spec)?;
109 Some(require_resolved_state(repo, &to_id)?)
110 } else {
111 None
112 };
113 let to_tree = if let Some(ref state) = to_state {
114 repo.store().get_tree(&state.tree)?
115 } else {
116 None
117 };
118 let status_options = ctx.config().worktree_status_options(Some(repo.config()));
119 let from_hash = from_state
120 .as_ref()
121 .map(|state| state.tree)
122 .unwrap_or_else(|| Tree::new().hash());
123
124 let semantic_diff_result = if options.semantic {
125 if let Some(ref to_state) = to_state {
126 Some(run_semantic_diff(repo, &from_hash, &to_state.tree)?)
127 } else {
128 Some(run_semantic_worktree_diff(
129 repo,
130 &from_hash,
131 &status_options,
132 )?)
133 }
134 } else {
135 None
136 };
137
138 let changes: FileChangeSet = if let Some(ref result) = semantic_diff_result {
139 result.file_changes.clone()
140 } else if let Some(ref to_state) = to_state {
141 repo.diff_trees(&from_hash, &to_state.tree)?
142 } else if git_overlay_head_worktree_diff {
143 file_change_set_from_status(&repo.git_overlay_worktree_status()?.unwrap_or_default())
144 } else {
145 let tree = from_tree.clone().unwrap_or_default();
146 file_change_set_from_status(
147 &repo.compare_worktree_cached_with_options(&tree, &status_options)?,
148 )
149 };
150
151 let patch_text_needed = options.include_patch_text;
152 let want_hunks = patch_text_needed || !(options.name_only || options.stat);
153 let file_changes = file_changes_from_change_set(
154 repo,
155 from_tree.as_ref(),
156 to_tree.as_ref(),
157 &changes,
158 &options,
159 want_hunks,
160 patch_text_needed,
161 )?;
162
163 let semantic_changes = semantic_diff_result.map(|result| {
164 result
165 .changes
166 .into_iter()
167 .map(SemanticChangeEntry::from)
168 .collect()
169 });
170
171 let context_state = if options.show_context {
172 if let Some(ref state) = to_state {
173 Some(state.clone())
174 } else if let Some(state) = from_state.clone() {
175 Some(state)
176 } else {
177 repo.current_state()?
178 }
179 } else {
180 None
181 };
182
183 let stats = DiffStats::from_changes(&file_changes, semantic_changes.as_deref());
184 let mut output = DiffReport::with_stats(
185 from_id.map(|id| id.short()),
186 options.to.clone(),
187 file_changes,
188 semantic_changes,
189 context_state
190 .as_ref()
191 .map(|state| collect_file_context(repo, state, &changes))
192 .transpose()?,
193 context_state
194 .as_ref()
195 .map(|state| collect_state_guidance(repo, state))
196 .transpose()?,
197 stats,
198 );
199 output.worktree_mode = options.to.is_none();
200 finalize_diff_report(output, &options)
201}
202
203fn file_changes_from_change_set(
204 repo: &Repository,
205 from_tree: Option<&Tree>,
206 to_tree: Option<&Tree>,
207 changes: &FileChangeSet,
208 options: &DiffOptions,
209 want_hunks: bool,
210 patch_text_needed: bool,
211) -> Result<Vec<FileChange>> {
212 let file_changes: Vec<FileChange> = if options.name_only && !patch_text_needed {
213 changes
214 .iter()
215 .map(|change| {
216 make_status_only_change(
217 Some(repo),
218 from_tree,
219 to_tree,
220 &change.path,
221 &change.kind.to_string(),
222 )
223 })
224 .collect()
225 } else {
226 changes
227 .iter()
228 .map(|change| {
229 let effective_kind = if to_tree.is_none() {
230 worktree_modified_type_change(repo.root(), &change.path, change.kind)
231 .map(|(_, diff_kind)| diff_kind)
232 .unwrap_or(change.kind)
233 } else {
234 change.kind
235 };
236 let diff_result = if let Some(tree) = to_tree {
237 get_state_diff(repo, from_tree, tree, &change.path, &effective_kind)
238 } else {
239 get_worktree_diff(repo, from_tree, &change.path, &effective_kind)
240 };
241 let binary = diff_result.as_ref().err().is_some_and(is_binary_diff_error);
242 let (raw_lines, eol) = match diff_result {
243 Ok((lines, eol)) => (Some(lines), eol),
244 Err(_) => (None, FileEolState::default()),
245 };
246 let (lines, line_counts) = if options.stat && !patch_text_needed {
247 let counts = change_line_counts(raw_lines.as_deref());
248 (None, Some(counts))
249 } else {
250 (
251 raw_lines.map(|lines| unified_hunks(lines, options.unified, &eol)),
252 None,
253 )
254 };
255
256 let kind = effective_kind.to_string();
257 let (old_mode, mode) =
258 change_file_modes(repo, from_tree, to_tree, &change.path, &kind);
259 let symlink = symlink_change_for_paths(
260 repo,
261 from_tree,
262 to_tree,
263 &kind,
264 &change.path,
265 &change.path,
266 old_mode,
267 mode,
268 );
269 FileChange {
270 path: change.path.clone(),
271 kind,
272 binary: binary && symlink.is_none(),
273 lines,
274 line_counts,
275 eol,
276 mode,
277 old_mode,
278 symlink,
279 ..Default::default()
280 }
281 })
282 .collect()
283 };
284 let file_changes = sort_changes_by_path(file_changes);
285 let file_changes = expand_type_changes(
286 repo,
287 from_tree,
288 to_tree,
289 file_changes,
290 want_hunks,
291 options.unified,
292 )?;
293 detect_clear_renames(
294 repo,
295 from_tree,
296 to_tree,
297 file_changes,
298 want_hunks,
299 options.unified,
300 )
301}
302
303pub fn diff_worktree_status(
305 status: &WorktreeStatus,
306 options: &DiffOptions,
307 repo: Option<&Repository>,
308 detect_renames: bool,
309) -> Result<DiffReport> {
310 let want_hunks = options.include_patch_text && repo.is_some();
311 let from_tree = match repo {
312 Some(repo) => head_from_tree(repo)?,
313 None => None,
314 };
315 let changes = file_changes_from_status(
316 status,
317 want_hunks,
318 repo,
319 from_tree.as_ref(),
320 options.unified,
321 );
322 let changes = match repo {
323 Some(repo) => expand_type_changes(
324 repo,
325 from_tree.as_ref(),
326 None,
327 changes,
328 want_hunks,
329 options.unified,
330 )?,
331 None => changes,
332 };
333 let changes = if detect_renames {
334 match repo {
335 Some(repo) => detect_clear_renames(
336 repo,
337 from_tree.as_ref(),
338 None,
339 changes,
340 want_hunks,
341 options.unified,
342 )?,
343 None => changes,
344 }
345 } else {
346 changes
347 };
348 let mut output = DiffReport::new(Some("HEAD".to_string()), None, changes, None, None, None);
349 output.worktree_mode = true;
350 finalize_diff_report(output, options)
351}
352
353pub fn plain_git_head_diff(probe: &PlainGitDiffProbe, options: &DiffOptions) -> Result<DiffReport> {
356 if options.include_patch_text {
357 let changes = plain_git_file_changes_with_hunks(probe, options.unified)?;
358 let mut output = DiffReport::new(Some("HEAD".to_string()), None, changes, None, None, None);
359 output.worktree_mode = true;
360 return finalize_diff_report(output, options);
361 }
362 diff_worktree_status(&probe.changes, options, None, false)
363}
364
365fn finalize_diff_report(mut output: DiffReport, options: &DiffOptions) -> Result<DiffReport> {
366 if options.include_patch_text {
367 populate_patch_text(&mut output);
368 }
369 if options.stat {
370 output.changes = strip_line_hunks(std::mem::take(&mut output.changes));
371 }
372 Ok(output)
373}
374
375fn populate_patch_text(output: &mut DiffReport) {
377 let text = render_diff_patch(output);
378 if !text.is_empty() {
379 output.patch = Some(text);
380 }
381}
382
383fn file_change_set_from_status(status: &WorktreeStatus) -> FileChangeSet {
384 let mut changes = FileChangeSet::with_capacity(status.change_count());
385 for path in &status.modified {
386 changes.push_modified(path.display().to_string());
387 }
388 for path in &status.added {
389 changes.push_added(path.display().to_string());
390 }
391 for path in &status.deleted {
392 changes.push_deleted(path.display().to_string());
393 }
394 changes
395}
396
397fn resolve_state_id(repository: &Repository, spec: &str) -> Result<ChangeId> {
398 resolve_state_for_command(repository, spec, ResolvePolicy::minimal())
399 .map(|resolved| resolved.change_id)
400 .map_err(|error| match error {
401 StateResolveError::Repository(err) => err.into(),
402 StateResolveError::Failure(StateResolveFailure::NotFound { spec }) => {
403 anyhow!(HeddleError::recovery(RecoveryDetails::state_not_found(
404 spec
405 )))
406 }
407 StateResolveError::Failure(other) => anyhow!("{other}"),
408 })
409}
410
411fn require_resolved_state(repo: &Repository, id: &ChangeId) -> Result<State> {
412 repo.store().get_state(id)?.ok_or_else(|| {
413 anyhow!(HeddleError::MissingObject {
414 object_type: "state".to_string(),
415 id: id.to_string_full(),
416 })
417 })
418}
419
420#[cfg(feature = "semantic")]
421fn run_semantic_diff(
422 repo: &Repository,
423 from_tree_hash: &objects::object::ContentHash,
424 to_tree_hash: &objects::object::ContentHash,
425) -> Result<SemanticDiffResult> {
426 let options = SemanticDiffOptions::default();
427 let result =
428 semantic::diff::semantic_diff(repo.store(), from_tree_hash, to_tree_hash, &options)?;
429 Ok(SemanticDiffResult {
430 changes: result.changes,
431 file_changes: result.file_changes,
432 })
433}
434
435#[cfg(not(feature = "semantic"))]
436fn run_semantic_diff(
437 _repo: &Repository,
438 _from_tree_hash: &objects::object::ContentHash,
439 _to_tree_hash: &objects::object::ContentHash,
440) -> Result<SemanticDiffResult> {
441 Err(anyhow!(HeddleError::recovery(
442 RecoveryDetails::feature_unavailable("semantic diff", "semantic")
443 )))
444}
445
446#[cfg(feature = "semantic")]
447fn run_semantic_worktree_diff(
448 repo: &Repository,
449 from_tree_hash: &objects::object::ContentHash,
450 status_options: &repo::WorktreeStatusOptions,
451) -> Result<SemanticDiffResult> {
452 let from_tree = repo.require_tree(from_tree_hash)?;
453 let status = repo.compare_worktree_cached_with_options(&from_tree, status_options)?;
454 let status = SemanticWorktreeStatus {
455 modified: status.modified,
456 added: status.added,
457 deleted: status.deleted,
458 };
459 let options = SemanticDiffOptions::default();
460 let result = semantic::diff::semantic_diff_worktree(
461 repo.store(),
462 from_tree_hash,
463 repo.root(),
464 &status,
465 &options,
466 )?;
467 Ok(SemanticDiffResult {
468 changes: result.changes,
469 file_changes: result.file_changes,
470 })
471}
472
473#[cfg(not(feature = "semantic"))]
474fn run_semantic_worktree_diff(
475 _repo: &Repository,
476 _from_tree_hash: &objects::object::ContentHash,
477 _status_options: &repo::WorktreeStatusOptions,
478) -> Result<SemanticDiffResult> {
479 Err(anyhow!(HeddleError::recovery(
480 RecoveryDetails::feature_unavailable("semantic diff", "semantic")
481 )))
482}
483
484fn sort_changes_by_path(mut changes: Vec<FileChange>) -> Vec<FileChange> {
493 changes.sort_by(|a, b| a.path.cmp(&b.path));
494 changes
495}
496fn plain_git_file_changes_with_hunks(
507 probe: &PlainGitDiffProbe,
508 unified: usize,
509) -> Result<Vec<FileChange>> {
510 let git_repo = SleyRepository::discover(&probe.root)?;
511 let head_has_tree = !git_repo.head()?.is_unborn();
512 let added_set: BTreeSet<&Path> = probe.changes.added.iter().map(PathBuf::as_path).collect();
520 let deleted_set: BTreeSet<&Path> = probe.changes.deleted.iter().map(PathBuf::as_path).collect();
521
522 let mut changes = Vec::with_capacity(probe.changes.change_count());
523 for path in &probe.changes.modified {
524 push_plain_git_modified(
525 &git_repo,
526 head_has_tree,
527 &probe.root,
528 path,
529 unified,
530 &mut changes,
531 )?;
532 }
533 for path in &probe.changes.added {
534 if deleted_set.contains(path.as_path()) {
535 push_plain_git_modified(
539 &git_repo,
540 head_has_tree,
541 &probe.root,
542 path,
543 unified,
544 &mut changes,
545 )?;
546 } else {
547 changes.push(plain_git_file_change(
548 &git_repo,
549 head_has_tree,
550 &probe.root,
551 path,
552 "added",
553 DiffKind::Added,
554 unified,
555 )?);
556 }
557 }
558 for path in &probe.changes.deleted {
559 if added_set.contains(path.as_path()) {
561 continue;
562 }
563 changes.push(plain_git_file_change(
564 &git_repo,
565 head_has_tree,
566 &probe.root,
567 path,
568 "deleted",
569 DiffKind::Deleted,
570 unified,
571 )?);
572 }
573 Ok(changes)
574}
575
576#[allow(clippy::too_many_arguments)]
577fn plain_git_file_change(
578 git_repo: &SleyRepository,
579 head_has_tree: bool,
580 root: &Path,
581 path: &std::path::Path,
582 kind: &str,
583 diff_kind: DiffKind,
584 unified: usize,
585) -> Result<FileChange> {
586 let (old_blob, old_mode) = match (head_has_tree, &diff_kind) {
587 (true, DiffKind::Modified | DiffKind::Deleted) => {
588 match plain_git_lookup_blob_and_mode(git_repo, path)? {
589 Some((blob, mode)) => (Some(blob), Some(mode)),
590 None => (None, None),
591 }
592 }
593 _ => (None, None),
594 };
595 let new_blob = match diff_kind {
596 DiffKind::Added | DiffKind::Modified => {
597 read_worktree_blob_for_diff(&root.join(path)).ok()
601 }
602 _ => None,
603 };
604 let (old_mode_field, mode) = match diff_kind {
609 DiffKind::Added => (None, worktree_file_mode(&root.join(path))),
610 DiffKind::Deleted => (None, old_mode),
611 DiffKind::Modified => (old_mode, worktree_file_mode(&root.join(path))),
612 DiffKind::Unchanged => (None, None),
613 };
614 let (lines, eol, binary) =
615 compute_plain_git_hunks(old_blob.as_ref(), new_blob.as_ref(), &diff_kind, unified);
616 let symlink = symlink_change_from_blobs(
617 kind,
618 old_blob.as_ref(),
619 old_mode_field,
620 new_blob.as_ref(),
621 mode,
622 );
623 Ok(FileChange {
624 path: path.display().to_string(),
625 kind: kind.to_string(),
626 binary: binary && symlink.is_none(),
627 lines,
628 eol,
629 mode,
630 old_mode: old_mode_field,
631 symlink,
632 ..Default::default()
633 })
634}
635
636fn plain_git_lookup_blob_and_mode(
637 git_repo: &SleyRepository,
638 path: &std::path::Path,
639) -> Result<Option<(Blob, FileMode)>> {
640 let tree_path = plain_git_tree_path(path);
641 let Ok(entry) = git_repo.resolve_path("HEAD", &tree_path) else {
642 return Ok(None);
643 };
644 let Some(entry_mode) = entry.mode else {
645 return Ok(None);
646 };
647 let mode = match EntryKind::from_mode(entry_mode) {
648 Some(EntryKind::Symlink) => FileMode::Symlink,
649 Some(EntryKind::BlobExecutable) => FileMode::Executable,
650 Some(EntryKind::Blob) => FileMode::Normal,
651 _ => return Ok(None),
652 };
653 let object = git_repo.read_object(&entry.oid)?;
654 Ok(Some((Blob::new(object.body.clone()), mode)))
655}
656
657fn plain_git_tree_path(path: &std::path::Path) -> String {
658 path.components()
659 .map(|component| component.as_os_str().to_string_lossy())
660 .collect::<Vec<_>>()
661 .join("/")
662}
663
664fn plain_git_old_side_kind(
670 git_repo: &SleyRepository,
671 head_has_tree: bool,
672 path: &std::path::Path,
673) -> Result<SideKind> {
674 if !head_has_tree {
675 return Ok(SideKind::Absent);
676 }
677 let tree_path = plain_git_tree_path(path);
678 let Ok(entry) = git_repo.resolve_path("HEAD", &tree_path) else {
679 return Ok(SideKind::Absent);
680 };
681 Ok(match entry.mode.and_then(EntryKind::from_mode) {
682 Some(EntryKind::Symlink) => SideKind::Symlink,
683 Some(EntryKind::Tree) => SideKind::Dir,
684 _ => SideKind::Regular,
685 })
686}
687
688fn push_plain_git_modified(
700 git_repo: &SleyRepository,
701 head_has_tree: bool,
702 root: &Path,
703 path: &std::path::Path,
704 unified: usize,
705 out: &mut Vec<FileChange>,
706) -> Result<()> {
707 let new_kind = worktree_side_kind(&root.join(path));
708 let old_kind = plain_git_old_side_kind(git_repo, head_has_tree, path)?;
709 if is_type_change(old_kind, new_kind) {
710 out.push(plain_git_file_change(
711 git_repo,
712 head_has_tree,
713 root,
714 path,
715 "deleted",
716 DiffKind::Deleted,
717 unified,
718 )?);
719 if new_kind != SideKind::Dir {
722 out.push(plain_git_file_change(
723 git_repo,
724 head_has_tree,
725 root,
726 path,
727 "added",
728 DiffKind::Added,
729 unified,
730 )?);
731 }
732 } else {
733 out.push(plain_git_file_change(
734 git_repo,
735 head_has_tree,
736 root,
737 path,
738 "modified",
739 DiffKind::Modified,
740 unified,
741 )?);
742 }
743 Ok(())
744}
745
746fn compute_plain_git_hunks(
747 old: Option<&Blob>,
748 new: Option<&Blob>,
749 diff_kind: &DiffKind,
750 unified: usize,
751) -> (Option<Vec<LineDiff>>, FileEolState, bool) {
752 let attempt = || -> Result<(Vec<LineDiff>, FileEolState)> {
753 match diff_kind {
754 DiffKind::Added => {
755 let Some(new) = new else {
756 return Ok((Vec::new(), FileEolState::default()));
757 };
758 ensure_text_diffable(new)?;
759 let eol = eol_for_added(new);
760 Ok((number_lines(blob_lines(new, "+")?), eol))
761 }
762 DiffKind::Deleted => {
763 let Some(old) = old else {
764 return Ok((Vec::new(), FileEolState::default()));
765 };
766 ensure_text_diffable(old)?;
767 let eol = eol_for_deleted(old);
768 Ok((number_lines(blob_lines(old, "-")?), eol))
769 }
770 DiffKind::Modified => match (old, new) {
771 (Some(old), Some(new)) => modified_blob_hunks(old, new),
772 (None, Some(new)) => {
773 ensure_text_diffable(new)?;
774 let eol = eol_for_added(new);
775 Ok((number_lines(blob_lines(new, "+")?), eol))
776 }
777 (Some(old), None) => {
778 ensure_text_diffable(old)?;
779 let eol = eol_for_deleted(old);
780 Ok((number_lines(blob_lines(old, "-")?), eol))
781 }
782 (None, None) => Ok((Vec::new(), FileEolState::default())),
783 },
784 DiffKind::Unchanged => Ok((Vec::new(), FileEolState::default())),
785 }
786 };
787 match attempt() {
788 Ok((lines, eol)) => (Some(unified_hunks(lines, unified, &eol)), eol, false),
789 Err(error) if is_binary_diff_error(&error) => (None, FileEolState::default(), true),
790 Err(_) => (None, FileEolState::default(), false),
791 }
792}
793fn file_changes_from_status(
798 status: &objects::worktree::WorktreeStatus,
799 want_hunks: bool,
800 repo: Option<&Repository>,
801 from_tree: Option<&Tree>,
802 unified: usize,
803) -> Vec<FileChange> {
804 let mut changes = Vec::with_capacity(status.change_count());
805 for path in &status.modified {
806 changes.push(make_status_file_change(
807 path,
808 "modified",
809 DiffKind::Modified,
810 want_hunks,
811 repo,
812 from_tree,
813 unified,
814 ));
815 }
816 for path in &status.added {
817 changes.push(make_status_file_change(
818 path,
819 "added",
820 DiffKind::Added,
821 want_hunks,
822 repo,
823 from_tree,
824 unified,
825 ));
826 }
827 for path in &status.deleted {
828 changes.push(make_status_file_change(
829 path,
830 "deleted",
831 DiffKind::Deleted,
832 want_hunks,
833 repo,
834 from_tree,
835 unified,
836 ));
837 }
838 changes
839}
840
841#[allow(clippy::too_many_arguments)]
842fn make_status_file_change(
843 path: &std::path::Path,
844 kind: &str,
845 diff_kind: DiffKind,
846 want_hunks: bool,
847 repo: Option<&Repository>,
848 from_tree: Option<&Tree>,
849 unified: usize,
850) -> FileChange {
851 let path_str = path.display().to_string();
852 let (kind, diff_kind) = match repo
856 .and_then(|repo| worktree_modified_type_change(repo.root(), &path_str, diff_kind))
857 {
858 Some(reclassified) => reclassified,
859 None => (kind, diff_kind),
860 };
861 match repo {
862 Some(repo) if want_hunks => {
863 build_worktree_change(repo, from_tree, &path_str, kind, diff_kind, unified)
864 }
865 _ => make_status_only_change(repo, from_tree, None, &path_str, kind),
866 }
867}
868
869fn make_status_only_change(
883 repo: Option<&Repository>,
884 from_tree: Option<&Tree>,
885 to_tree: Option<&Tree>,
886 path_str: &str,
887 kind: &str,
888) -> FileChange {
889 let (old_mode, mode) = match repo {
890 Some(repo) => change_file_modes(repo, from_tree, to_tree, path_str, kind),
891 None => (None, None),
892 };
893 FileChange {
894 path: path_str.to_string(),
895 kind: kind.to_string(),
896 mode,
897 old_mode,
898 ..Default::default()
899 }
900}
901
902fn build_worktree_change(
907 repo: &Repository,
908 from_tree: Option<&Tree>,
909 path_str: &str,
910 kind: &str,
911 diff_kind: DiffKind,
912 unified: usize,
913) -> FileChange {
914 let (old_mode, mode) = change_file_modes(repo, from_tree, None, path_str, kind);
915 let (lines, eol, binary) = match get_worktree_diff(repo, from_tree, path_str, &diff_kind) {
916 Ok((raw, eol)) => (Some(unified_hunks(raw, unified, &eol)), eol, false),
917 Err(error) if is_binary_diff_error(&error) => (None, FileEolState::default(), true),
918 Err(_) => (None, FileEolState::default(), false),
923 };
924 let symlink = symlink_change_for_paths(
925 repo, from_tree, None, kind, path_str, path_str, old_mode, mode,
926 );
927 FileChange {
928 path: path_str.to_string(),
929 kind: kind.to_string(),
930 binary: binary && symlink.is_none(),
931 lines,
932 eol,
933 mode,
934 old_mode,
935 symlink,
936 ..Default::default()
937 }
938}
939
940#[derive(Clone, Copy, PartialEq, Eq, Debug)]
942enum SideKind {
943 Absent,
944 Dir,
945 Regular,
947 Symlink,
948}
949
950fn tree_side_kind(repo: &Repository, tree: Option<&Tree>, path: &str) -> Result<SideKind> {
955 let Some(tree) = tree else {
956 return Ok(SideKind::Absent);
957 };
958 if let Some(entry) = find_entry_in_tree(repo, tree, path)? {
959 return Ok(if entry.entry_type() == EntryType::Symlink {
960 SideKind::Symlink
961 } else {
962 SideKind::Regular
963 });
964 }
965 if dir_subtree_in_tree(repo, tree, path)?.is_some() {
966 Ok(SideKind::Dir)
967 } else {
968 Ok(SideKind::Absent)
969 }
970}
971
972fn new_side_kind(repo: &Repository, to_tree: Option<&Tree>, path: &str) -> Result<SideKind> {
975 match to_tree {
976 Some(tree) => tree_side_kind(repo, Some(tree), path),
977 None => Ok(worktree_side_kind(&repo.root().join(path))),
978 }
979}
980
981fn worktree_side_kind(path: &Path) -> SideKind {
985 let Ok(meta) = std::fs::symlink_metadata(path) else {
986 return SideKind::Absent;
987 };
988 if meta.file_type().is_symlink() {
989 SideKind::Symlink
990 } else if meta.is_dir() {
991 SideKind::Dir
992 } else {
993 SideKind::Regular
994 }
995}
996
997fn is_type_change(old: SideKind, new: SideKind) -> bool {
1000 use SideKind::{Dir, Regular, Symlink};
1001 matches!(
1002 (old, new),
1003 (Dir, Regular)
1004 | (Dir, Symlink)
1005 | (Regular, Dir)
1006 | (Symlink, Dir)
1007 | (Regular, Symlink)
1008 | (Symlink, Regular)
1009 )
1010}
1011
1012fn expand_type_changes(
1039 repo: &Repository,
1040 from_tree: Option<&Tree>,
1041 to_tree: Option<&Tree>,
1042 changes: Vec<FileChange>,
1043 want_hunks: bool,
1044 unified: usize,
1045) -> Result<Vec<FileChange>> {
1046 let mut output = Vec::with_capacity(changes.len());
1047 for change in changes {
1048 if change.kind != "modified" {
1049 output.push(change);
1050 continue;
1051 }
1052 let old_kind = tree_side_kind(repo, from_tree, &change.path)?;
1053 let new_kind = new_side_kind(repo, to_tree, &change.path)?;
1054 if !is_type_change(old_kind, new_kind) {
1055 output.push(change);
1056 continue;
1057 }
1058
1059 if old_kind == SideKind::Dir {
1062 if let Some(from_tree) = from_tree
1063 && let Some(subtree) = dir_subtree_in_tree(repo, from_tree, &change.path)?
1064 {
1065 let mut nested = Vec::new();
1066 collect_subtree_blob_paths(repo, &subtree, &change.path, &mut nested)?;
1067 for nested_path in nested {
1068 output.push(make_type_change_part(
1069 repo,
1070 Some(from_tree),
1071 to_tree,
1072 &nested_path,
1073 DiffKind::Deleted,
1074 want_hunks,
1075 unified,
1076 ));
1077 }
1078 }
1079 } else {
1080 output.push(make_type_change_part(
1081 repo,
1082 from_tree,
1083 to_tree,
1084 &change.path,
1085 DiffKind::Deleted,
1086 want_hunks,
1087 unified,
1088 ));
1089 }
1090
1091 if new_kind == SideKind::Dir {
1096 if let Some(to_tree) = to_tree
1097 && let Some(subtree) = dir_subtree_in_tree(repo, to_tree, &change.path)?
1098 {
1099 let mut nested = Vec::new();
1100 collect_subtree_blob_paths(repo, &subtree, &change.path, &mut nested)?;
1101 for nested_path in nested {
1102 output.push(make_type_change_part(
1103 repo,
1104 from_tree,
1105 Some(to_tree),
1106 &nested_path,
1107 DiffKind::Added,
1108 want_hunks,
1109 unified,
1110 ));
1111 }
1112 }
1113 } else {
1114 output.push(make_type_change_part(
1115 repo,
1116 from_tree,
1117 to_tree,
1118 &change.path,
1119 DiffKind::Added,
1120 want_hunks,
1121 unified,
1122 ));
1123 }
1124 }
1125 Ok(output)
1126}
1127
1128fn make_type_change_part(
1129 repo: &Repository,
1130 from_tree: Option<&Tree>,
1131 to_tree: Option<&Tree>,
1132 path_str: &str,
1133 diff_kind: DiffKind,
1134 want_hunks: bool,
1135 unified: usize,
1136) -> FileChange {
1137 let kind = diff_kind.to_string();
1138 if !want_hunks {
1139 return make_status_only_change(Some(repo), from_tree, to_tree, path_str, &kind);
1140 }
1141 match to_tree {
1142 Some(to_tree) => build_state_change(
1143 repo, from_tree, to_tree, path_str, &kind, diff_kind, unified,
1144 ),
1145 None => build_worktree_change(repo, from_tree, path_str, &kind, diff_kind, unified),
1146 }
1147}
1148
1149fn build_state_change(
1153 repo: &Repository,
1154 from_tree: Option<&Tree>,
1155 to_tree: &Tree,
1156 path_str: &str,
1157 kind: &str,
1158 diff_kind: DiffKind,
1159 unified: usize,
1160) -> FileChange {
1161 let (old_mode, mode) = change_file_modes(repo, from_tree, Some(to_tree), path_str, kind);
1162 let (lines, eol, binary) = match get_state_diff(repo, from_tree, to_tree, path_str, &diff_kind)
1163 {
1164 Ok((raw, eol)) => (Some(unified_hunks(raw, unified, &eol)), eol, false),
1165 Err(error) if is_binary_diff_error(&error) => (None, FileEolState::default(), true),
1166 Err(_) => (None, FileEolState::default(), false),
1167 };
1168 let symlink = symlink_change_for_paths(
1169 repo,
1170 from_tree,
1171 Some(to_tree),
1172 kind,
1173 path_str,
1174 path_str,
1175 old_mode,
1176 mode,
1177 );
1178 FileChange {
1179 path: path_str.to_string(),
1180 kind: kind.to_string(),
1181 binary: binary && symlink.is_none(),
1182 lines,
1183 eol,
1184 mode,
1185 old_mode,
1186 symlink,
1187 ..Default::default()
1188 }
1189}
1190
1191fn dir_subtree_in_tree(repo: &Repository, tree: &Tree, path: &str) -> Result<Option<Tree>> {
1195 let mut current = tree.clone();
1196 let mut parts = path.split('/').peekable();
1197 while let Some(name) = parts.next() {
1198 let Some(entry) = current.get(name) else {
1199 return Ok(None);
1200 };
1201 if !entry.is_tree() {
1202 return Ok(None);
1203 }
1204 let Some(hash) = entry.tree_hash() else {
1205 return Ok(None);
1206 };
1207 let Some(subtree) = repo.store().get_tree(&hash)? else {
1208 return Ok(None);
1209 };
1210 if parts.peek().is_none() {
1211 return Ok(Some(subtree));
1212 }
1213 current = subtree;
1214 }
1215 Ok(None)
1216}
1217
1218fn collect_subtree_blob_paths(
1221 repo: &Repository,
1222 subtree: &Tree,
1223 prefix: &str,
1224 out: &mut Vec<String>,
1225) -> Result<()> {
1226 for entry in subtree.entries() {
1227 let child_path = format!("{prefix}/{}", entry.name());
1228 if entry.is_tree() {
1229 if let Some(hash) = entry.tree_hash()
1230 && let Some(nested) = repo.store().get_tree(&hash)?
1231 {
1232 collect_subtree_blob_paths(repo, &nested, &child_path, out)?;
1233 }
1234 } else {
1235 out.push(child_path);
1236 }
1237 }
1238 Ok(())
1239}
1240
1241fn head_from_tree(repo: &Repository) -> Result<Option<Tree>> {
1242 let Some(head_id) = repo.head()? else {
1243 return Ok(None);
1244 };
1245 let Some(state) = repo.store().get_state(&head_id)? else {
1246 return Ok(None);
1247 };
1248 Ok(repo.store().get_tree(&state.tree)?)
1249}
1250
1251pub fn compute_state_diff(
1266 repo: &Repository,
1267 from_change_id: &ChangeId,
1268 to_change_id: &ChangeId,
1269 semantic: bool,
1270 unified: usize,
1271) -> Result<DiffReport> {
1272 let from_state = repo.store().get_state(from_change_id)?;
1273 let from_tree = if let Some(ref state) = from_state {
1274 repo.store().get_tree(&state.tree)?
1275 } else {
1276 None
1277 };
1278
1279 let to_state = require_resolved_state(repo, to_change_id)?;
1280 let to_tree = repo
1281 .store()
1282 .get_tree(&to_state.tree)?
1283 .ok_or_else(|| anyhow!("Tree not found for state {}", to_change_id.short()))?;
1284
1285 let from_hash = from_state
1286 .as_ref()
1287 .map(|s| s.tree)
1288 .unwrap_or_else(|| Tree::new().hash());
1289
1290 let semantic_diff_result: Option<SemanticDiffResult> = if semantic {
1291 Some(run_semantic_diff(repo, &from_hash, &to_state.tree)?)
1292 } else {
1293 None
1294 };
1295
1296 let changes: FileChangeSet = if let Some(ref result) = semantic_diff_result {
1297 result.file_changes.clone()
1298 } else {
1299 repo.diff_trees(&from_hash, &to_state.tree)?
1300 };
1301
1302 let file_changes: Vec<FileChange> = changes
1303 .iter()
1304 .map(|change| {
1305 build_state_change(
1306 repo,
1307 from_tree.as_ref(),
1308 &to_tree,
1309 &change.path,
1310 &change.kind.to_string(),
1311 change.kind,
1312 unified,
1313 )
1314 })
1315 .collect();
1316 let file_changes = sort_changes_by_path(file_changes);
1317 let file_changes = expand_type_changes(
1318 repo,
1319 from_tree.as_ref(),
1320 Some(&to_tree),
1321 file_changes,
1322 true,
1323 unified,
1324 )?;
1325 let file_changes = detect_clear_renames(
1326 repo,
1327 from_tree.as_ref(),
1328 Some(&to_tree),
1329 file_changes,
1330 true,
1331 unified,
1332 )?;
1333
1334 let semantic_changes = semantic_diff_result.map(|r| {
1335 r.changes
1336 .into_iter()
1337 .map(SemanticChangeEntry::from)
1338 .collect()
1339 });
1340
1341 let mut output = DiffReport::new(
1342 Some(from_change_id.short()),
1343 Some(to_change_id.short()),
1344 file_changes,
1345 semantic_changes,
1346 None,
1347 None,
1348 );
1349 populate_patch_text(&mut output);
1350 Ok(output)
1351}
1352
1353pub fn compute_tree_diff(
1360 repo: &Repository,
1361 from_change_id: &ChangeId,
1362 to_tree: &Tree,
1363 to_label: impl Into<String>,
1364 semantic: bool,
1365 unified: usize,
1366) -> Result<DiffReport> {
1367 let from_state = repo.store().get_state(from_change_id)?;
1368 let from_tree = if let Some(ref state) = from_state {
1369 repo.store().get_tree(&state.tree)?
1370 } else {
1371 None
1372 };
1373 let from_hash = from_state
1374 .as_ref()
1375 .map(|s| s.tree)
1376 .unwrap_or_else(|| Tree::new().hash());
1377
1378 let to_hash = repo.store().put_tree(to_tree)?;
1379
1380 let semantic_diff_result: Option<SemanticDiffResult> = if semantic {
1381 Some(run_semantic_diff(repo, &from_hash, &to_hash)?)
1382 } else {
1383 None
1384 };
1385
1386 let changes: FileChangeSet = if let Some(ref result) = semantic_diff_result {
1387 result.file_changes.clone()
1388 } else {
1389 repo.diff_trees(&from_hash, &to_hash)?
1390 };
1391
1392 let file_changes: Vec<FileChange> = changes
1393 .iter()
1394 .map(|change| {
1395 build_state_change(
1396 repo,
1397 from_tree.as_ref(),
1398 to_tree,
1399 &change.path,
1400 &change.kind.to_string(),
1401 change.kind,
1402 unified,
1403 )
1404 })
1405 .collect();
1406 let file_changes = sort_changes_by_path(file_changes);
1407 let file_changes = expand_type_changes(
1408 repo,
1409 from_tree.as_ref(),
1410 Some(to_tree),
1411 file_changes,
1412 true,
1413 unified,
1414 )?;
1415 let file_changes = detect_clear_renames(
1416 repo,
1417 from_tree.as_ref(),
1418 Some(to_tree),
1419 file_changes,
1420 true,
1421 unified,
1422 )?;
1423
1424 let semantic_changes = semantic_diff_result.map(|r| {
1425 r.changes
1426 .into_iter()
1427 .map(SemanticChangeEntry::from)
1428 .collect()
1429 });
1430
1431 let mut output = DiffReport::new(
1432 Some(from_change_id.short()),
1433 Some(to_label.into()),
1434 file_changes,
1435 semantic_changes,
1436 None,
1437 None,
1438 );
1439 populate_patch_text(&mut output);
1440 Ok(output)
1441}
1442
1443fn strip_line_hunks(changes: Vec<FileChange>) -> Vec<FileChange> {
1444 changes
1445 .into_iter()
1446 .map(|mut change| {
1447 change.lines = None;
1448 change
1449 })
1450 .collect()
1451}
1452
1453fn unified_hunks(lines: Vec<LineDiff>, context: usize, eol: &FileEolState) -> Vec<LineDiff> {
1454 if lines.is_empty() {
1455 return lines;
1456 }
1457 if !lines.iter().any(|line| line.prefix != " ") {
1458 if eol.old_has_final_newline == eol.new_has_final_newline {
1466 return lines;
1467 }
1468 return eol_only_tail_hunk(lines, context);
1469 }
1470
1471 let mut ranges = Vec::<(usize, usize)>::new();
1472 let mut cursor = 0usize;
1473 while cursor < lines.len() {
1474 while cursor < lines.len() && lines[cursor].prefix == " " {
1475 cursor += 1;
1476 }
1477 if cursor >= lines.len() {
1478 break;
1479 }
1480
1481 let start = cursor.saturating_sub(context);
1482 while cursor < lines.len() && lines[cursor].prefix != " " {
1483 cursor += 1;
1484 }
1485 let mut end = (cursor + context).min(lines.len());
1486
1487 while cursor < lines.len() && lines[cursor].prefix == " " && cursor < end {
1488 cursor += 1;
1489 }
1490 while cursor < lines.len() && lines[cursor].prefix != " " {
1491 end = (cursor + 1 + context).min(lines.len());
1492 cursor += 1;
1493 }
1494
1495 if let Some((_, previous_end)) = ranges.last_mut()
1496 && start <= *previous_end
1497 {
1498 *previous_end = end;
1499 continue;
1500 }
1501 ranges.push((start, end));
1502 }
1503
1504 let mut output = Vec::new();
1505 for (start, end) in ranges {
1506 let (old_start, old_len, new_start, new_len) = hunk_span(&lines, start, end);
1507 output.push(LineDiff {
1508 prefix: "@".to_string(),
1509 content: format!("@ -{},{} +{},{} @@", old_start, old_len, new_start, new_len),
1510 old_line: None,
1511 new_line: None,
1512 });
1513 output.extend_from_slice(&lines[start..end]);
1521 }
1522 output
1523}
1524
1525fn eol_only_tail_hunk(lines: Vec<LineDiff>, context: usize) -> Vec<LineDiff> {
1532 let end = lines.len();
1533 let start = end.saturating_sub(context + 1);
1534 let (old_start, old_len, new_start, new_len) = hunk_span(&lines, start, end);
1535 let mut output = Vec::with_capacity(end - start + 1);
1536 output.push(LineDiff {
1537 prefix: "@".to_string(),
1538 content: format!("@ -{},{} +{},{} @@", old_start, old_len, new_start, new_len),
1539 old_line: None,
1540 new_line: None,
1541 });
1542 output.extend_from_slice(&lines[start..end]);
1543 output
1544}
1545
1546pub fn trim_added_decorations_for_display(lines: &[LineDiff]) -> Vec<LineDiff> {
1561 let mut output = Vec::with_capacity(lines.len());
1562 let mut body_start = 0usize;
1563 for (index, line) in lines.iter().enumerate() {
1564 if line.prefix == "@" {
1565 if body_start < index {
1566 output.extend(trim_trailing_added_decorations(&lines[body_start..index]));
1567 }
1568 output.push(line.clone());
1569 body_start = index + 1;
1570 }
1571 }
1572 if body_start < lines.len() {
1573 output.extend(trim_trailing_added_decorations(&lines[body_start..]));
1574 }
1575 output
1576}
1577
1578fn trim_trailing_added_decorations(lines: &[LineDiff]) -> Vec<LineDiff> {
1579 let mut trimmed = Vec::with_capacity(lines.len());
1580 let mut index = 0usize;
1581 while index < lines.len() {
1582 if lines[index].prefix == "+"
1583 && is_visual_decoration_line(&lines[index].content)
1584 && let Some(next_context) = next_context_line(lines, index + 1)
1585 && next_context.content == lines[index].content
1586 {
1587 let added_block_has_code = lines[index + 1..next_context.index]
1588 .iter()
1589 .any(|line| line.prefix == "+" && !is_blank_or_visual_decoration(&line.content));
1590 if added_block_has_code {
1591 index += 1;
1592 continue;
1593 }
1594 }
1595 trimmed.push(lines[index].clone());
1596 index += 1;
1597 }
1598 trimmed
1599}
1600
1601struct IndexedLine<'a> {
1602 index: usize,
1603 content: &'a str,
1604}
1605
1606fn next_context_line(lines: &[LineDiff], start: usize) -> Option<IndexedLine<'_>> {
1607 lines[start..]
1608 .iter()
1609 .enumerate()
1610 .find(|(_, line)| line.prefix == " ")
1611 .map(|(offset, line)| IndexedLine {
1612 index: start + offset,
1613 content: &line.content,
1614 })
1615}
1616
1617fn is_blank_or_visual_decoration(line: &str) -> bool {
1618 line.trim().is_empty() || is_visual_decoration_line(line)
1619}
1620
1621fn is_visual_decoration_line(line: &str) -> bool {
1622 let trimmed = line.trim_start();
1623 trimmed.starts_with("#[")
1624 || trimmed.starts_with("#![")
1625 || trimmed.starts_with('@')
1626 || trimmed.starts_with("///")
1627 || trimmed.starts_with("//!")
1628}
1629
1630fn hunk_span(lines: &[LineDiff], start: usize, end: usize) -> (usize, usize, usize, usize) {
1631 let old_before = lines[..start]
1632 .iter()
1633 .filter(|line| line.prefix != "+")
1634 .count();
1635 let new_before = lines[..start]
1636 .iter()
1637 .filter(|line| line.prefix != "-")
1638 .count();
1639 let old_len = lines[start..end]
1640 .iter()
1641 .filter(|line| line.prefix != "+")
1642 .count();
1643 let new_len = lines[start..end]
1644 .iter()
1645 .filter(|line| line.prefix != "-")
1646 .count();
1647
1648 let old_start = if old_len == 0 {
1649 old_before
1650 } else {
1651 old_before + 1
1652 };
1653 let new_start = if new_len == 0 {
1654 new_before
1655 } else {
1656 new_before + 1
1657 };
1658 (old_start, old_len, new_start, new_len)
1659}
1660
1661fn collect_file_context(
1662 repo: &Repository,
1663 state: &State,
1664 changes: &FileChangeSet,
1665) -> Result<Vec<FileContextEntry>> {
1666 let Some(context_root) = &state.context else {
1667 return Ok(Vec::new());
1668 };
1669
1670 let mut entries = Vec::new();
1671 for change in changes {
1672 let target = ContextTarget::file(change.path.clone())?;
1673 let Some(blob) = repo.get_context_blob(context_root, &target)? else {
1674 continue;
1675 };
1676 let annotations = blob
1677 .annotations
1678 .iter()
1679 .filter(|annotation| annotation.status == AnnotationStatus::Active)
1680 .filter_map(|annotation| {
1681 annotation
1682 .current_revision()
1683 .map(|revision| ContextSnippet {
1684 annotation_id: annotation.annotation_id.clone(),
1685 kind: revision.kind.to_string(),
1686 content: summarize_context(&revision.content),
1687 revision_count: annotation.revisions.len(),
1688 })
1689 })
1690 .collect::<Vec<_>>();
1691 if !annotations.is_empty() {
1692 entries.push(FileContextEntry {
1693 path: change.path.clone(),
1694 annotations,
1695 });
1696 }
1697 }
1698 Ok(entries)
1699}
1700
1701fn collect_state_guidance(repo: &Repository, state: &State) -> Result<Vec<ContextSnippet>> {
1702 let Some(context_root) = &state.context else {
1703 return Ok(Vec::new());
1704 };
1705 let target = ContextTarget::state(state.change_id);
1706 let Some(blob) = repo.get_context_blob(context_root, &target)? else {
1707 return Ok(Vec::new());
1708 };
1709 Ok(blob
1710 .annotations
1711 .iter()
1712 .filter(|annotation| annotation.status == AnnotationStatus::Active)
1713 .filter_map(|annotation| {
1714 annotation
1715 .current_revision()
1716 .map(|revision| ContextSnippet {
1717 annotation_id: annotation.annotation_id.clone(),
1718 kind: revision.kind.to_string(),
1719 content: summarize_context(&revision.content),
1720 revision_count: annotation.revisions.len(),
1721 })
1722 })
1723 .collect())
1724}
1725
1726fn summarize_context(content: &str) -> String {
1727 let first_line = content
1728 .lines()
1729 .find(|line| !line.trim().is_empty())
1730 .unwrap_or("");
1731 let char_count = first_line.chars().count();
1732 if char_count <= 88 {
1733 first_line.to_string()
1734 } else {
1735 format!("{}...", first_line.chars().take(85).collect::<String>())
1736 }
1737}
1738
1739fn get_worktree_diff(
1740 repo: &Repository,
1741 from_tree: Option<&Tree>,
1742 path: &str,
1743 kind: &DiffKind,
1744) -> Result<(Vec<LineDiff>, FileEolState)> {
1745 let worktree_path = repo.root().join(path);
1746
1747 match kind {
1748 DiffKind::Added => {
1749 let new_blob = read_worktree_blob_for_diff(&worktree_path)?;
1750 let eol = eol_for_added(&new_blob);
1751 Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
1752 }
1753 DiffKind::Deleted => {
1754 if let Some(tree) = from_tree
1758 && let Some(blob) = find_blob_in_tree(repo, tree, path)?
1759 {
1760 let eol = eol_for_deleted(&blob);
1761 return Ok((number_lines(blob_lines(&blob, "-")?), eol));
1762 }
1763 Ok((vec![], FileEolState::default()))
1764 }
1765 DiffKind::Modified => {
1766 let new_blob = read_worktree_blob_for_diff(&worktree_path)?;
1767
1768 if let Some(tree) = from_tree
1769 && let Some(old_blob) = find_blob_in_tree(repo, tree, path)?
1770 {
1771 return modified_blob_hunks(&old_blob, &new_blob);
1772 }
1773
1774 let eol = eol_for_added(&new_blob);
1775 Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
1776 }
1777 DiffKind::Unchanged => Ok((Vec::new(), FileEolState::default())),
1778 }
1779}
1780
1781fn worktree_modified_type_change(
1797 repo_root: &Path,
1798 path: &str,
1799 diff_kind: DiffKind,
1800) -> Option<(&'static str, DiffKind)> {
1801 if matches!(diff_kind, DiffKind::Modified)
1802 && worktree_side_kind(&repo_root.join(path)) == SideKind::Dir
1803 {
1804 Some(("deleted", DiffKind::Deleted))
1805 } else {
1806 None
1807 }
1808}
1809
1810fn read_worktree_blob_for_diff(path: &std::path::Path) -> Result<Blob> {
1811 let metadata = std::fs::symlink_metadata(path)?;
1812 if metadata.file_type().is_symlink() {
1813 let target = std::fs::read_link(path)?;
1814 return Ok(Blob::new(objects::util::symlink_target_bytes(&target)));
1815 }
1816 Ok(Blob::new(std::fs::read(path)?))
1817}
1818
1819fn is_symlink_mode(mode: Option<FileMode>) -> bool {
1820 matches!(mode, Some(FileMode::Symlink))
1821}
1822
1823fn symlink_sides(kind: &str, old_mode: Option<FileMode>, mode: Option<FileMode>) -> (bool, bool) {
1831 match kind {
1832 "added" => (false, is_symlink_mode(mode)),
1833 "deleted" => (is_symlink_mode(mode), false),
1834 _ => (is_symlink_mode(old_mode), is_symlink_mode(mode)),
1835 }
1836}
1837
1838fn make_symlink_change(old: Option<Vec<u8>>, new: Option<Vec<u8>>) -> Option<SymlinkChange> {
1848 (old.is_some() || new.is_some()).then_some(SymlinkChange { old, new })
1849}
1850
1851fn symlink_change_from_blobs(
1855 kind: &str,
1856 old_blob: Option<&Blob>,
1857 old_mode: Option<FileMode>,
1858 new_blob: Option<&Blob>,
1859 mode: Option<FileMode>,
1860) -> Option<SymlinkChange> {
1861 let (old_is_link, new_is_link) = symlink_sides(kind, old_mode, mode);
1862 let old = old_is_link
1863 .then(|| old_blob.map(|blob| blob.content().to_vec()))
1864 .flatten();
1865 let new = new_is_link
1866 .then(|| new_blob.map(|blob| blob.content().to_vec()))
1867 .flatten();
1868 make_symlink_change(old, new)
1869}
1870
1871#[allow(clippy::too_many_arguments)]
1878fn symlink_change_for_paths(
1879 repo: &Repository,
1880 from_tree: Option<&Tree>,
1881 to_tree: Option<&Tree>,
1882 kind: &str,
1883 old_path: &str,
1884 new_path: &str,
1885 old_mode: Option<FileMode>,
1886 mode: Option<FileMode>,
1887) -> Option<SymlinkChange> {
1888 let (old_is_link, new_is_link) = symlink_sides(kind, old_mode, mode);
1889 let old = old_is_link
1890 .then(|| blob_from_tree(repo, from_tree, old_path).ok().flatten())
1891 .flatten()
1892 .map(|blob| blob.content().to_vec());
1893 let new = new_is_link
1894 .then(|| new_blob_for_rename(repo, to_tree, new_path).ok().flatten())
1895 .flatten()
1896 .map(|blob| blob.content().to_vec());
1897 make_symlink_change(old, new)
1898}
1899fn detect_clear_renames(
1900 repo: &Repository,
1901 from_tree: Option<&Tree>,
1902 to_tree: Option<&Tree>,
1903 changes: Vec<FileChange>,
1904 include_lines: bool,
1905 unified: usize,
1906) -> Result<Vec<FileChange>> {
1907 let deleted = changes
1908 .iter()
1909 .filter(|change| change.kind == "deleted")
1910 .map(|change| change.path.as_str())
1911 .collect::<Vec<_>>();
1912 let added = changes
1913 .iter()
1914 .filter(|change| change.kind == "added")
1915 .map(|change| change.path.as_str())
1916 .collect::<Vec<_>>();
1917 if deleted.is_empty() || added.is_empty() {
1918 return Ok(changes);
1919 }
1920
1921 let deleted_side_modes = changes
1931 .iter()
1932 .filter(|change| change.kind == "deleted")
1933 .map(|change| (change.path.as_str(), change.mode))
1934 .collect::<std::collections::BTreeMap<&str, Option<FileMode>>>();
1935 let added_side_modes = changes
1936 .iter()
1937 .filter(|change| change.kind == "added")
1938 .map(|change| (change.path.as_str(), change.mode))
1939 .collect::<std::collections::BTreeMap<&str, Option<FileMode>>>();
1940
1941 let mut candidates = Vec::new();
1942 for old_path in &deleted {
1943 let Some(old_blob) = blob_from_tree(repo, from_tree, old_path)? else {
1944 continue;
1945 };
1946 for new_path in &added {
1947 if old_path == new_path {
1952 continue;
1953 }
1954 if !rename_mode_compatible(
1961 deleted_side_modes.get(old_path).copied().flatten(),
1962 added_side_modes.get(new_path).copied().flatten(),
1963 ) {
1964 continue;
1965 }
1966 let Some(new_blob) = new_blob_for_rename(repo, to_tree, new_path)? else {
1967 continue;
1968 };
1969 let score = rename_similarity(&old_blob, &new_blob);
1970 if score >= 0.75 {
1971 candidates.push((score, (*old_path).to_string(), (*new_path).to_string()));
1972 }
1973 }
1974 }
1975
1976 candidates.sort_by(|left, right| {
1977 right
1978 .0
1979 .total_cmp(&left.0)
1980 .then_with(|| left.1.cmp(&right.1))
1981 .then_with(|| left.2.cmp(&right.2))
1982 });
1983
1984 let mut used_old = BTreeSet::new();
1985 let mut used_new = BTreeSet::new();
1986 let mut renames: Vec<(String, String, f64)> = Vec::new();
1987 for (score, old_path, new_path) in candidates {
1988 if used_old.insert(old_path.clone()) && used_new.insert(new_path.clone()) {
1989 renames.push((old_path, new_path, score));
1990 }
1991 }
1992 if renames.is_empty() {
1993 return Ok(changes);
1994 }
1995
1996 let rename_by_new = renames
1997 .iter()
1998 .map(|(old_path, new_path, score)| (new_path.as_str(), (old_path.as_str(), *score)))
1999 .collect::<std::collections::BTreeMap<_, _>>();
2000 let removed_old = renames
2001 .iter()
2002 .map(|(old_path, _, _)| old_path.as_str())
2003 .collect::<BTreeSet<_>>();
2004 let deleted_modes = changes
2010 .iter()
2011 .filter(|change| change.kind == "deleted")
2012 .map(|change| (change.path.clone(), change.mode))
2013 .collect::<std::collections::BTreeMap<String, Option<FileMode>>>();
2014
2015 let mut output = Vec::with_capacity(changes.len() - renames.len());
2016 for mut change in changes {
2017 if change.kind == "deleted" && removed_old.contains(change.path.as_str()) {
2018 continue;
2019 }
2020 if change.kind == "added"
2021 && let Some((old_path, score)) = rename_by_new.get(change.path.as_str()).copied()
2022 {
2023 let (lines, eol) = if include_lines {
2024 match rename_lines(repo, from_tree, to_tree, old_path, &change.path, unified) {
2025 Ok(Some((lines, eol))) => (Some(lines), eol),
2026 Ok(None) => (None, FileEolState::default()),
2027 Err(error) if is_binary_diff_error(&error) => {
2028 change.binary = true;
2029 (None, FileEolState::default())
2030 }
2031 Err(error) => return Err(error),
2032 }
2033 } else {
2034 (None, FileEolState::default())
2035 };
2036 change.kind = "renamed".to_string();
2037 change.old_path = Some(old_path.to_string());
2038 change.similarity_score = Some(score);
2039 change.lines = lines;
2040 change.eol = eol;
2041 change.old_mode = deleted_modes.get(old_path).copied().flatten();
2045 change.symlink = symlink_change_for_paths(
2052 repo,
2053 from_tree,
2054 to_tree,
2055 "renamed",
2056 old_path,
2057 &change.path,
2058 change.old_mode,
2059 change.mode,
2060 );
2061 if change.symlink.is_some() {
2062 change.binary = false;
2063 }
2064 change.line_counts = None;
2071 }
2072 output.push(change);
2073 }
2074 Ok(output)
2075}
2076
2077fn rename_lines(
2078 repo: &Repository,
2079 from_tree: Option<&Tree>,
2080 to_tree: Option<&Tree>,
2081 old_path: &str,
2082 new_path: &str,
2083 unified: usize,
2084) -> Result<Option<(Vec<LineDiff>, FileEolState)>> {
2085 let Some(old_blob) = blob_from_tree(repo, from_tree, old_path)? else {
2086 return Ok(None);
2087 };
2088 let Some(new_blob) = new_blob_for_rename(repo, to_tree, new_path)? else {
2089 return Ok(None);
2090 };
2091 ensure_text_diffable(&old_blob)?;
2092 ensure_text_diffable(&new_blob)?;
2093 let eol = eol_for_modified(&old_blob, &new_blob);
2094 let diff = diff_blobs(&old_blob, &new_blob);
2095 let lines = diff
2096 .iter()
2097 .map(|line| LineDiff::new(line.prefix(), line.content()))
2098 .collect();
2099 Ok(Some((
2100 unified_hunks(number_lines(lines), unified, &eol),
2101 eol,
2102 )))
2103}
2104
2105fn blob_from_tree(repo: &Repository, tree: Option<&Tree>, path: &str) -> Result<Option<Blob>> {
2106 let Some(tree) = tree else {
2107 return Ok(None);
2108 };
2109 find_blob_in_tree(repo, tree, path)
2110}
2111
2112fn new_blob_for_rename(
2113 repo: &Repository,
2114 to_tree: Option<&Tree>,
2115 path: &str,
2116) -> Result<Option<Blob>> {
2117 if let Some(tree) = to_tree {
2118 return find_blob_in_tree(repo, tree, path);
2119 }
2120
2121 let worktree_path = repo.root().join(path);
2129 match std::fs::symlink_metadata(&worktree_path) {
2130 Ok(_) => Ok(Some(read_worktree_blob_for_diff(&worktree_path)?)),
2131 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
2132 Err(error) => Err(error.into()),
2133 }
2134}
2135
2136fn rename_mode_compatible(old: Option<FileMode>, new: Option<FileMode>) -> bool {
2146 let is_symlink = |mode: Option<FileMode>| matches!(mode, Some(FileMode::Symlink));
2147 is_symlink(old) == is_symlink(new)
2148}
2149
2150fn rename_similarity(old_blob: &Blob, new_blob: &Blob) -> f64 {
2151 if old_blob.content() == new_blob.content() {
2152 return 1.0;
2153 }
2154 let (Some(old_text), Some(new_text)) = (old_blob.content_str(), new_blob.content_str()) else {
2155 return 0.0;
2156 };
2157 if old_text.chars().any(is_terminal_hostile_control)
2158 || new_text.chars().any(is_terminal_hostile_control)
2159 {
2160 return 0.0;
2161 }
2162 let old_lines = old_text.lines().collect::<Vec<_>>();
2163 let new_lines = new_text.lines().collect::<Vec<_>>();
2164 if old_lines.is_empty() || new_lines.is_empty() {
2165 return 0.0;
2166 }
2167 let shared = lcs_len(&old_lines, &new_lines);
2168 (shared * 2) as f64 / (old_lines.len() + new_lines.len()) as f64
2169}
2170
2171fn lcs_len(left: &[&str], right: &[&str]) -> usize {
2172 let mut previous = vec![0usize; right.len() + 1];
2173 let mut current = vec![0usize; right.len() + 1];
2174 for left_line in left {
2175 for (index, right_line) in right.iter().enumerate() {
2176 current[index + 1] = if left_line == right_line {
2177 previous[index] + 1
2178 } else {
2179 previous[index + 1].max(current[index])
2180 };
2181 }
2182 std::mem::swap(&mut previous, &mut current);
2183 current.fill(0);
2184 }
2185 previous[right.len()]
2186}
2187
2188fn get_state_diff(
2200 repo: &Repository,
2201 from_tree: Option<&Tree>,
2202 to_tree: &Tree,
2203 path: &str,
2204 kind: &DiffKind,
2205) -> Result<(Vec<LineDiff>, FileEolState)> {
2206 match kind {
2207 DiffKind::Added => {
2208 let Some(new_blob) = find_blob_in_tree(repo, to_tree, path)? else {
2209 return Ok((Vec::new(), FileEolState::default()));
2210 };
2211 let eol = eol_for_added(&new_blob);
2212 Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
2213 }
2214 DiffKind::Deleted => {
2215 let Some(tree) = from_tree else {
2216 return Ok((Vec::new(), FileEolState::default()));
2217 };
2218 let Some(old_blob) = find_blob_in_tree(repo, tree, path)? else {
2219 return Ok((Vec::new(), FileEolState::default()));
2220 };
2221 let eol = eol_for_deleted(&old_blob);
2222 Ok((number_lines(blob_lines(&old_blob, "-")?), eol))
2223 }
2224 DiffKind::Modified => {
2225 let Some(new_blob) = find_blob_in_tree(repo, to_tree, path)? else {
2226 return Ok((Vec::new(), FileEolState::default()));
2227 };
2228 if let Some(tree) = from_tree
2229 && let Some(old_blob) = find_blob_in_tree(repo, tree, path)?
2230 {
2231 return modified_blob_hunks(&old_blob, &new_blob);
2232 }
2233 let eol = eol_for_added(&new_blob);
2235 Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
2236 }
2237 DiffKind::Unchanged => Ok((Vec::new(), FileEolState::default())),
2238 }
2239}
2240
2241fn eol_for_added(new_blob: &Blob) -> FileEolState {
2245 let (new_eol, new_count) = blob_eol_meta(new_blob);
2246 FileEolState {
2247 old_has_final_newline: true,
2248 new_has_final_newline: new_eol,
2249 old_line_count: 0,
2250 new_line_count: new_count,
2251 }
2252}
2253
2254fn eol_for_deleted(old_blob: &Blob) -> FileEolState {
2255 let (old_eol, old_count) = blob_eol_meta(old_blob);
2256 FileEolState {
2257 old_has_final_newline: old_eol,
2258 new_has_final_newline: true,
2259 old_line_count: old_count,
2260 new_line_count: 0,
2261 }
2262}
2263
2264fn eol_for_modified(old_blob: &Blob, new_blob: &Blob) -> FileEolState {
2265 let (old_eol, old_count) = blob_eol_meta(old_blob);
2266 let (new_eol, new_count) = blob_eol_meta(new_blob);
2267 FileEolState {
2268 old_has_final_newline: old_eol,
2269 new_has_final_newline: new_eol,
2270 old_line_count: old_count,
2271 new_line_count: new_count,
2272 }
2273}
2274
2275fn blob_eol_meta(blob: &Blob) -> (bool, usize) {
2280 let content = blob.content();
2281 if content.is_empty() {
2282 return (true, 0);
2283 }
2284 let has_eol = content.ends_with(b"\n");
2285 let line_count = blob
2286 .content_str()
2287 .map(|text| text.lines().count())
2288 .unwrap_or(0);
2289 (has_eol, line_count)
2290}
2291
2292fn blob_lines(blob: &Blob, prefix: &str) -> Result<Vec<LineDiff>> {
2293 let text = text_diff_content(blob)?;
2294 Ok(text
2295 .lines()
2296 .map(|line| LineDiff::new(prefix, line))
2297 .collect())
2298}
2299
2300fn modified_blob_hunks(old: &Blob, new: &Blob) -> Result<(Vec<LineDiff>, FileEolState)> {
2314 if old.content() == new.content() {
2315 return Ok((Vec::new(), FileEolState::default()));
2316 }
2317 ensure_text_diffable(old)?;
2318 ensure_text_diffable(new)?;
2319 let eol = eol_for_modified(old, new);
2320 let diff = diff_blobs(old, new);
2321 let lines = diff
2322 .iter()
2323 .map(|l| LineDiff::new(l.prefix(), l.content()))
2324 .collect();
2325 Ok((number_lines(lines), eol))
2326}
2327
2328fn ensure_text_diffable(blob: &Blob) -> Result<()> {
2329 text_diff_content(blob).map(|_| ())
2330}
2331
2332fn text_diff_content(blob: &Blob) -> Result<&str> {
2333 let Some(text) = blob.content_str() else {
2334 return Err(anyhow!(BINARY_DIFF_ERROR));
2335 };
2336 if text.chars().any(is_terminal_hostile_control) {
2337 return Err(anyhow!(BINARY_DIFF_ERROR));
2338 }
2339 Ok(text)
2340}
2341
2342fn is_binary_diff_error(error: &anyhow::Error) -> bool {
2343 error.to_string() == BINARY_DIFF_ERROR
2344}
2345
2346fn is_terminal_hostile_control(ch: char) -> bool {
2347 ch.is_control() && ch != '\n' && ch != '\t'
2348}
2349
2350fn number_lines(lines: Vec<LineDiff>) -> Vec<LineDiff> {
2351 let mut old_line = 1usize;
2352 let mut new_line = 1usize;
2353
2354 lines
2355 .into_iter()
2356 .map(|line| {
2357 let old = if line.prefix != "+" {
2358 let current = Some(old_line);
2359 old_line += 1;
2360 current
2361 } else {
2362 None
2363 };
2364 let new = if line.prefix != "-" {
2365 let current = Some(new_line);
2366 new_line += 1;
2367 current
2368 } else {
2369 None
2370 };
2371 LineDiff::with_lines(line.prefix, line.content, old, new)
2372 })
2373 .collect()
2374}
2375
2376fn find_blob_in_tree(repo: &Repository, tree: &Tree, path: &str) -> Result<Option<Blob>> {
2377 match find_entry_in_tree(repo, tree, path)? {
2378 Some(entry) => match entry.content_hash() {
2379 Some(hash) if entry.is_blob() || entry.is_symlink() => {
2380 Ok(Some(repo.require_blob(&hash)?))
2381 }
2382 _ => Ok(None),
2383 },
2384 None => Ok(None),
2385 }
2386}
2387
2388fn find_entry_in_tree(repo: &Repository, tree: &Tree, path: &str) -> Result<Option<TreeEntry>> {
2396 let parts: Vec<&str> = path.split('/').collect();
2397 find_entry_recursive(repo, tree, &parts)
2398}
2399
2400fn find_entry_recursive(
2401 repo: &Repository,
2402 tree: &Tree,
2403 parts: &[&str],
2404) -> Result<Option<TreeEntry>> {
2405 if parts.is_empty() {
2406 return Ok(None);
2407 }
2408
2409 let name = parts[0];
2410 let entry = match tree.get(name) {
2411 Some(e) => e,
2412 None => return Ok(None),
2413 };
2414
2415 if parts.len() == 1 {
2416 if entry.is_blob() || entry.entry_type() == EntryType::Symlink || entry.is_gitlink() {
2417 return Ok(Some(entry.clone()));
2418 }
2419 } else if entry.is_tree()
2420 && let Some(hash) = entry.tree_hash()
2421 && let Some(subtree) = repo.store().get_tree(&hash)?
2422 {
2423 return find_entry_recursive(repo, &subtree, &parts[1..]);
2424 }
2425
2426 Ok(None)
2427}
2428
2429fn worktree_file_mode(path: &Path) -> Option<FileMode> {
2434 let metadata = std::fs::symlink_metadata(path).ok()?;
2435 if metadata.file_type().is_symlink() {
2436 return Some(FileMode::Symlink);
2437 }
2438 #[cfg(unix)]
2439 {
2440 use std::os::unix::fs::PermissionsExt;
2441 if metadata.permissions().mode() & 0o111 != 0 {
2442 return Some(FileMode::Executable);
2443 }
2444 }
2445 Some(FileMode::Normal)
2446}
2447
2448fn change_file_modes(
2461 repo: &Repository,
2462 from_tree: Option<&Tree>,
2463 to_tree: Option<&Tree>,
2464 path: &str,
2465 kind: &str,
2466) -> (Option<FileMode>, Option<FileMode>) {
2467 let old_side = || {
2468 from_tree
2469 .and_then(|tree| find_entry_in_tree(repo, tree, path).ok().flatten())
2470 .map(|entry| entry.mode())
2471 };
2472 let new_side = || match to_tree {
2473 Some(tree) => find_entry_in_tree(repo, tree, path)
2474 .ok()
2475 .flatten()
2476 .map(|entry| entry.mode()),
2477 None => worktree_file_mode(&repo.root().join(path)),
2478 };
2479 match kind {
2480 "added" => (None, new_side()),
2481 "deleted" => (None, old_side()),
2482 "modified" => (old_side(), new_side()),
2483 _ => (None, None),
2484 }
2485}
2486
2487#[cfg(test)]
2488mod tests {
2489 use super::{
2490 DiffStats, FileChange, FileEolState, LineCounts, LineDiff, change_line_counts,
2491 summarize_context, unified_hunks,
2492 };
2493
2494 fn stat_change(kind: &str, counts: LineCounts) -> FileChange {
2495 FileChange {
2496 path: "notes.txt".to_string(),
2497 kind: kind.to_string(),
2498 line_counts: Some(counts),
2499 ..Default::default()
2500 }
2501 }
2502
2503 #[test]
2510 fn diff_stats_reads_line_counts_when_hunks_dropped() {
2511 let changes = vec![stat_change(
2512 "modified",
2513 LineCounts {
2514 added: 1,
2515 modified: 0,
2516 deleted: 0,
2517 },
2518 )];
2519
2520 let stats = DiffStats::from_changes(&changes, None);
2521
2522 assert_eq!(stats.files_changed, 1);
2523 assert_eq!(stats.additions, 1);
2524 assert_eq!(stats.modifications, 0);
2525 assert_eq!(stats.deletions, 0);
2526 assert_eq!(stats.renames, 0);
2527 }
2528
2529 #[test]
2534 fn diff_stats_treats_zero_line_counts_as_authoritative() {
2535 let changes = vec![stat_change(
2536 "modified",
2537 LineCounts {
2538 added: 0,
2539 modified: 0,
2540 deleted: 0,
2541 },
2542 )];
2543
2544 let stats = DiffStats::from_changes(&changes, None);
2545
2546 assert_eq!(stats.modifications, 0);
2547 assert_eq!(stats.additions, 0);
2548 assert_eq!(stats.deletions, 0);
2549 }
2550
2551 #[test]
2554 fn change_line_counts_pairs_modified_lines() {
2555 let lines = vec![
2556 LineDiff::with_lines("-", "alpha", Some(1), None),
2557 LineDiff::with_lines("+", "alpha-changed", None, Some(1)),
2558 LineDiff::with_lines("+", "fresh", None, Some(2)),
2559 ];
2560 let counts = change_line_counts(Some(&lines));
2561 assert_eq!(counts.modified, 1);
2562 assert_eq!(counts.added, 1);
2563 assert_eq!(counts.deleted, 0);
2564 }
2565
2566 #[test]
2572 fn unified_hunks_keeps_added_decoration_in_canonical_body() {
2573 let lines = vec![
2574 LineDiff::with_lines("+", "#[test]", None, Some(1)),
2575 LineDiff::with_lines("+", "fn added() {}", None, Some(2)),
2576 LineDiff::with_lines(" ", "#[test]", Some(1), Some(3)),
2577 LineDiff::with_lines(" ", "fn existing() {}", Some(2), Some(4)),
2578 ];
2579
2580 let hunk = unified_hunks(lines, 3, &FileEolState::default());
2581
2582 let header = hunk
2583 .iter()
2584 .find(|line| line.prefix == "@")
2585 .expect("hunk should carry an `@@` header");
2586 assert_eq!(
2588 header.content, "@ -1,2 +1,4 @@",
2589 "header counts must match the untrimmed body: {hunk:?}"
2590 );
2591 assert!(
2592 hunk.iter()
2593 .any(|line| line.prefix == "+" && line.content == "#[test]"),
2594 "added decoration line must survive in the canonical body: {hunk:?}"
2595 );
2596 assert!(
2597 hunk.iter()
2598 .any(|line| line.prefix == "+" && line.content == "fn added() {}"),
2599 "added function body should remain: {hunk:?}"
2600 );
2601 }
2602
2603 #[test]
2607 fn display_trim_drops_added_decoration_but_keeps_header() {
2608 use super::trim_added_decorations_for_display;
2609
2610 let lines = vec![
2611 LineDiff::with_lines("+", "#[test]", None, Some(1)),
2612 LineDiff::with_lines("+", "fn added() {}", None, Some(2)),
2613 LineDiff::with_lines(" ", "#[test]", Some(1), Some(3)),
2614 LineDiff::with_lines(" ", "fn existing() {}", Some(2), Some(4)),
2615 ];
2616 let hunk = unified_hunks(lines, 3, &FileEolState::default());
2617
2618 let display = trim_added_decorations_for_display(&hunk);
2619
2620 assert!(
2621 display
2622 .iter()
2623 .filter(|line| line.content == "#[test]")
2624 .all(|line| line.prefix == " "),
2625 "display trim should let existing context own the decoration: {display:?}"
2626 );
2627 assert!(
2628 display
2629 .iter()
2630 .any(|line| line.prefix == "+" && line.content == "fn added() {}"),
2631 "added function body should remain after display trim: {display:?}"
2632 );
2633 assert_eq!(
2634 display
2635 .iter()
2636 .find(|line| line.prefix == "@")
2637 .map(|l| l.content.as_str()),
2638 Some("@ -1,2 +1,4 @@"),
2639 "display trim must not rewrite the `@@` header: {display:?}"
2640 );
2641 }
2642
2643 #[test]
2646 fn summarize_context_truncates_on_char_boundary_not_byte_index() {
2647 let first_line = format!("{}中中", "a".repeat(83));
2648 assert!(first_line.len() > 88);
2649 assert!(!first_line.is_char_boundary(85));
2650
2651 let summary = summarize_context(&format!("{first_line}\nsecond line"));
2652 assert_eq!(summary, first_line);
2653 }
2654
2655 #[test]
2656 fn summarize_context_char_cap_truncates_multibyte_line() {
2657 let first_line = format!("{}中中中", "a".repeat(86));
2658 assert!(first_line.chars().count() > 88);
2659
2660 let summary = summarize_context(&first_line);
2661 let expected = format!("{}...", "a".repeat(85));
2662 assert_eq!(summary, expected);
2663 }
2664
2665 #[test]
2666 fn summarize_context_ascii_truncation_unchanged() {
2667 let line = "b".repeat(90);
2668 let summary = summarize_context(&line);
2669 assert_eq!(summary, format!("{}...", "b".repeat(85)));
2670 }
2671
2672 #[test]
2675 fn minimal_resolve_failure_maps_to_recovery_state_not_found() {
2676 use objects::{RecoveryDetails, error::HeddleError};
2677 use repo::{
2678 ResolvePolicy, StateResolveError, StateResolveFailure, resolve_state_for_command,
2679 };
2680 use tempfile::TempDir;
2681
2682 let temp = TempDir::new().unwrap();
2683 let repo = repo::Repository::init_default(temp.path()).unwrap();
2684 std::fs::write(temp.path().join("a.txt"), "a").unwrap();
2685 repo.snapshot(Some("seed".into()), None).unwrap();
2686
2687 let err = resolve_state_for_command(&repo, "hd-zzzzzzzzzzzz", ResolvePolicy::minimal())
2688 .unwrap_err();
2689 let mapped = match err {
2690 StateResolveError::Failure(StateResolveFailure::NotFound { spec }) => {
2691 HeddleError::recovery(RecoveryDetails::state_not_found(spec))
2692 }
2693 other => panic!("expected not-found failure, got {other:?}"),
2694 };
2695 assert!(matches!(mapped, HeddleError::Recovery(_)));
2696 assert!(
2697 mapped.to_string().contains("State not found"),
2698 "unexpected message: {mapped}"
2699 );
2700 }
2701}