1use std::collections::{BTreeSet, HashSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use thiserror::Error;
8
9use crate::config::ConfigSet;
10use crate::hash::hash_object;
11use crate::index::MODE_REGULAR;
12use crate::objects::{HashAlgo, ObjectId, ObjectKind};
13use crate::odb::Odb;
14use crate::parallel::ThreadPolicy;
15
16use super::core::{
17 apply_rename_detection_to_diff_entries, format_mode, mode_from_metadata,
18 read_worktree_bytes_for_mode, zero_oid, DiffEntry, DiffStatus,
19};
20use super::options::DiffOptions;
21use super::patch_log::{write_patch, PatchTextOptions};
22use super::structured::{structured_file_diff, SideBytes};
23use super::targets::FileDiff;
24use crate::repo::{InitOptions, Repository};
25
26const S_IFMT: u32 = 0o170000;
27const S_IFREG: u32 = 0o100000;
28
29#[derive(Debug, Error, Clone, PartialEq, Eq)]
31#[non_exhaustive]
32pub enum NoIndexError {
33 #[error("Could not access '{path}'")]
35 PathNotAccessible {
36 path: String,
38 },
39 #[error("sha-1 hash collision while hashing no-index diff input")]
41 HashCollision,
42}
43
44impl From<NoIndexError> for crate::error::Error {
45 fn from(value: NoIndexError) -> Self {
46 Self::Message(value.to_string())
47 }
48}
49
50pub fn diff_paths(a: &Path, b: &Path, opts: &DiffOptions) -> Result<Vec<FileDiff>, NoIndexError> {
61 let temp = tempfile::tempdir().map_err(|e| NoIndexError::PathNotAccessible {
62 path: e.to_string(),
63 })?;
64 let odb = Odb::new(temp.path());
65 let entries = no_index_prepared_entries(a, b, opts, &odb)?;
66 if entries.is_empty() {
67 return Ok(Vec::new());
68 }
69 let diff_options = opts
70 .clone()
71 .cap_word_level_inline_for_changed_files(entries.len());
72 Ok(entries
73 .into_iter()
74 .filter_map(|entry| file_diff_from_entry(&odb, &entry, diff_options.clone()))
75 .collect())
76}
77
78pub fn write_paths_patch<W: std::io::Write>(
86 a: &Path,
87 b: &Path,
88 opts: &DiffOptions,
89 out: &mut W,
90) -> Result<bool, NoIndexError> {
91 let temp = tempfile::tempdir().map_err(|e| NoIndexError::PathNotAccessible {
92 path: e.to_string(),
93 })?;
94 let repo = Repository::init(temp.path(), &InitOptions::new().with_bare(true)).map_err(|e| {
95 NoIndexError::PathNotAccessible {
96 path: e.to_string(),
97 }
98 })?;
99 let entries = no_index_prepared_entries(a, b, opts, repo.odb())?;
100 if entries.is_empty() {
101 return Ok(false);
102 }
103 let patch_opts = PatchTextOptions::from_repo(&repo, opts.clone()).map_err(|e| {
104 NoIndexError::PathNotAccessible {
105 path: e.to_string(),
106 }
107 })?;
108 write_patch(&repo, &entries, &patch_opts, out).map_err(|e| {
109 NoIndexError::PathNotAccessible {
110 path: e.to_string(),
111 }
112 })?;
113 Ok(true)
114}
115
116fn no_index_prepared_entries(
117 a: &Path,
118 b: &Path,
119 opts: &DiffOptions,
120 odb: &Odb,
121) -> Result<Vec<DiffEntry>, NoIndexError> {
122 let (left, right) = fixup_paths(a, b)?;
123 let mut entries = Vec::new();
124 queue_diff(left.as_deref(), right.as_deref(), false, &mut entries)?;
125 if entries.is_empty() {
126 return Ok(entries);
127 }
128
129 let config = ConfigSet::default();
130 let thread_policy = ThreadPolicy::default();
131 let rename_threshold = opts.renames.threshold_percent().unwrap_or(101);
132 store_changed_blobs(odb, &mut entries)?;
133 if rename_threshold < 100 {
134 apply_rename_detection_to_diff_entries(
135 odb,
136 entries,
137 None,
138 rename_threshold,
139 &config,
140 &thread_policy,
141 )
142 .map_err(|e| NoIndexError::PathNotAccessible {
143 path: e.to_string(),
144 })
145 } else {
146 Ok(entries)
147 }
148}
149
150fn file_diff_from_entry(odb: &Odb, entry: &DiffEntry, opts: DiffOptions) -> Option<FileDiff> {
151 let path_unchanged = entry.old_path == entry.new_path;
152 if entry.old_oid == entry.new_oid
153 && entry.old_mode == entry.new_mode
154 && path_unchanged
155 && !matches!(entry.status, DiffStatus::Renamed | DiffStatus::Copied)
156 {
157 return None;
158 }
159 let (old_bytes, old_bin) = side_bytes_for_entry(odb, entry, Side::Old)?;
160 let (new_bytes, new_bin) = side_bytes_for_entry(odb, entry, Side::New)?;
161 let binary = old_bin || new_bin;
162 let path = entry
163 .new_path
164 .clone()
165 .or_else(|| entry.old_path.clone())
166 .unwrap_or_default();
167 let old_path = entry
168 .old_path
169 .clone()
170 .filter(|op| entry.new_path.as_ref() != Some(op));
171 let structured = if binary {
172 super::structured::StructuredFileDiff {
173 old_mode: mode_display(&entry.old_mode),
174 new_mode: mode_display(&entry.new_mode),
175 old_encoding_lossy: old_bytes.encoding_lossy,
176 new_encoding_lossy: new_bytes.encoding_lossy,
177 hunks: Vec::new(),
178 }
179 } else {
180 structured_file_diff(
181 &entry.old_mode,
182 &entry.new_mode,
183 &old_bytes,
184 &new_bytes,
185 opts,
186 )
187 };
188 Some(FileDiff {
189 path,
190 old_path,
191 status: entry.status,
192 old_mode: mode_display(&entry.old_mode),
193 new_mode: mode_display(&entry.new_mode),
194 old_oid: entry.old_oid,
195 new_oid: entry.new_oid,
196 binary,
197 content_changed: entry.old_oid != entry.new_oid,
198 old_encoding_lossy: structured.old_encoding_lossy,
199 new_encoding_lossy: structured.new_encoding_lossy,
200 hunks: structured.hunks,
201 })
202}
203
204enum Side {
205 Old,
206 New,
207}
208
209fn side_bytes_for_entry(odb: &Odb, entry: &DiffEntry, side: Side) -> Option<(SideBytes, bool)> {
210 let (mode, oid) = match side {
211 Side::Old => (&entry.old_mode, entry.old_oid),
212 Side::New => (&entry.new_mode, entry.new_oid),
213 };
214 if *mode == "000000" {
215 return Some((SideBytes::from_bytes(Vec::new()), false));
216 }
217 let data = odb.read(&oid).ok()?.data;
218 Some(bytes_for_diff(&data))
219}
220
221fn bytes_for_diff(data: &[u8]) -> (SideBytes, bool) {
222 let binary = crate::merge_file::is_binary(data);
223 (SideBytes::from_bytes(data.to_vec()), binary)
224}
225
226fn mode_display(mode: &str) -> Option<String> {
227 if mode == "000000" {
228 None
229 } else {
230 Some(mode.to_owned())
231 }
232}
233
234fn store_changed_blobs(odb: &Odb, entries: &mut [DiffEntry]) -> Result<(), NoIndexError> {
236 for entry in entries {
237 if entry.old_mode != "000000" && !entry.old_oid.is_zero() {
238 if let Some(path) = entry.old_path.as_deref() {
239 write_blob_if_missing(odb, path, &entry.old_mode, &mut entry.old_oid)?;
240 }
241 }
242 if entry.new_mode != "000000" && !entry.new_oid.is_zero() {
243 if let Some(path) = entry.new_path.as_deref() {
244 write_blob_if_missing(odb, path, &entry.new_mode, &mut entry.new_oid)?;
245 }
246 }
247 }
248 Ok(())
249}
250
251fn write_blob_if_missing(
252 odb: &Odb,
253 path: &str,
254 mode: &str,
255 oid: &mut ObjectId,
256) -> Result<(), NoIndexError> {
257 if !oid.is_zero() && odb.read(oid).is_ok() {
258 return Ok(());
259 }
260 *oid = hash_side_at_path(path, mode)?;
261 let mode_u = u32::from_str_radix(mode, 8).unwrap_or(MODE_REGULAR);
262 let data = read_worktree_bytes_for_mode(Path::new(path), mode_u).map_err(|_| {
263 NoIndexError::PathNotAccessible {
264 path: path.to_owned(),
265 }
266 })?;
267 let _ = odb.write(ObjectKind::Blob, &data);
268 Ok(())
269}
270
271fn hash_side_at_path(path: &str, mode: &str) -> Result<ObjectId, NoIndexError> {
272 let mode_u = u32::from_str_radix(mode, 8).unwrap_or(MODE_REGULAR);
273 let data = read_worktree_bytes_for_mode(Path::new(path), mode_u).map_err(|_| {
274 NoIndexError::PathNotAccessible {
275 path: path.to_owned(),
276 }
277 })?;
278 hash_blob(&data)
279}
280
281fn hash_blob(data: &[u8]) -> Result<ObjectId, NoIndexError> {
282 hash_object(HashAlgo::Sha1, ObjectKind::Blob, data).map_err(|e| match e {
283 crate::error::Error::HashCollision { .. } => NoIndexError::HashCollision,
284 other => panic!("unexpected error hashing no-index blob: {other}"),
285 })
286}
287
288fn read_side_bytes(path: &Path, mode: u32) -> Result<Vec<u8>, NoIndexError> {
289 read_worktree_bytes_for_mode(path, mode).map_err(|_| NoIndexError::PathNotAccessible {
290 path: path.display().to_string(),
291 })
292}
293
294fn worktree_content_equal(
295 left: &Path,
296 right: &Path,
297 mode_left: u32,
298 mode_right: u32,
299) -> Result<bool, NoIndexError> {
300 let (ls, rs) = (
301 fs::metadata(left).map(|m| m.len()).ok(),
302 fs::metadata(right).map(|m| m.len()).ok(),
303 );
304 if ls != rs {
305 return Ok(false);
306 }
307 let old_bytes = read_side_bytes(left, mode_left)?;
308 let new_bytes = read_side_bytes(right, mode_right)?;
309 Ok(old_bytes == new_bytes)
310}
311
312fn oids_for_pair(
314 left: Option<&Path>,
315 right: Option<&Path>,
316 mode_left: u32,
317 mode_right: u32,
318) -> Result<(ObjectId, ObjectId), NoIndexError> {
319 match (left, right) {
320 (Some(l), Some(r)) if mode_left != 0 && mode_right != 0 => {
321 let old_bytes = read_side_bytes(l, mode_left)?;
322 let new_bytes = read_side_bytes(r, mode_right)?;
323 let old_oid = hash_blob(&old_bytes)?;
324 let new_oid = if old_bytes == new_bytes {
325 old_oid
326 } else {
327 hash_blob(&new_bytes)?
328 };
329 Ok((old_oid, new_oid))
330 }
331 (Some(l), _) if mode_left != 0 => {
332 let old_bytes = read_side_bytes(l, mode_left)?;
333 Ok((hash_blob(&old_bytes)?, zero_oid()))
334 }
335 (_, Some(r)) if mode_right != 0 => {
336 let new_bytes = read_side_bytes(r, mode_right)?;
337 Ok((zero_oid(), hash_blob(&new_bytes)?))
338 }
339 _ => Ok((zero_oid(), zero_oid())),
340 }
341}
342
343fn fixup_paths(a: &Path, b: &Path) -> Result<(Option<PathBuf>, Option<PathBuf>), NoIndexError> {
344 let meta_a = fs::symlink_metadata(a).ok();
345 let meta_b = fs::symlink_metadata(b).ok();
346 if meta_a.is_none() && meta_b.is_none() {
347 return Err(NoIndexError::PathNotAccessible {
348 path: a.display().to_string(),
349 });
350 }
351 let is_dir_a = meta_a.as_ref().is_some_and(|m| m.is_dir());
352 let is_dir_b = meta_b.as_ref().is_some_and(|m| m.is_dir());
353 if is_dir_a && is_dir_b {
354 return Ok((Some(a.to_path_buf()), Some(b.to_path_buf())));
355 }
356 if is_dir_a {
357 let mut joined = a.to_path_buf();
358 append_basename(&mut joined, b);
359 return Ok((Some(joined), Some(b.to_path_buf())));
360 }
361 if is_dir_b {
362 let mut joined = b.to_path_buf();
363 append_basename(&mut joined, a);
364 return Ok((Some(a.to_path_buf()), Some(joined)));
365 }
366 Ok((Some(a.to_path_buf()), Some(b.to_path_buf())))
367}
368
369fn append_basename(dir: &mut PathBuf, file: &Path) {
370 let name = file.file_name().unwrap_or_default();
371 if name.is_empty() {
372 return;
373 }
374 strip_trailing_slashes_preserve_root(dir);
375 dir.push(name);
376}
377
378fn strip_trailing_slashes_preserve_root(path: &mut PathBuf) {
380 let raw = path.as_os_str().to_string_lossy();
381 let trimmed = raw.trim_end_matches('/');
382 if trimmed.is_empty() {
383 *path = PathBuf::from("/");
384 } else {
385 *path = PathBuf::from(trimmed);
386 }
387}
388
389fn queue_diff(
390 left: Option<&Path>,
391 right: Option<&Path>,
392 recursing: bool,
393 out: &mut Vec<DiffEntry>,
394) -> Result<(), NoIndexError> {
395 let (mode_left, mode_right) = (path_mode(left, recursing)?, path_mode(right, recursing)?);
396 let dir_left = path_is_dir(left);
397 let dir_right = path_is_dir(right);
398
399 if mode_left != 0 && mode_right != 0 && dir_left != dir_right {
400 if dir_left {
401 emit_pair(
402 out,
403 None,
404 right,
405 0,
406 mode_right,
407 right.map(path_display_string),
408 )?;
409 return queue_diff(left, None, true, out);
410 }
411 emit_pair(out, left, None, mode_left, 0, left.map(path_display_string))?;
412 return queue_diff(None, right, true, out);
413 }
414
415 if dir_left || dir_right {
416 diff_directories(left, right, out)
417 } else {
418 emit_pair(
419 out,
420 left,
421 right,
422 mode_left,
423 mode_right,
424 left.or(right).map(path_display_string),
425 )?;
426 Ok(())
427 }
428}
429
430fn path_is_dir(path: Option<&Path>) -> bool {
431 path.and_then(|p| fs::symlink_metadata(p).ok())
432 .is_some_and(|m| m.is_dir())
433}
434
435fn diff_directories(
436 left: Option<&Path>,
437 right: Option<&Path>,
438 out: &mut Vec<DiffEntry>,
439) -> Result<(), NoIndexError> {
440 let left_names = read_dir_names(left)?;
441 let right_names = read_dir_names(right)?;
442 let left_set: HashSet<_> = left_names.iter().cloned().collect();
443 let right_set: HashSet<_> = right_names.iter().cloned().collect();
444 let names: BTreeSet<_> = left_names
445 .iter()
446 .chain(right_names.iter())
447 .cloned()
448 .collect();
449 for name in names {
450 let l = left
451 .filter(|_| left_set.contains(&name))
452 .map(|p| p.join(&name));
453 let r = right
454 .filter(|_| right_set.contains(&name))
455 .map(|p| p.join(&name));
456 queue_diff(l.as_deref(), r.as_deref(), true, out)?;
457 }
458 Ok(())
459}
460
461fn read_dir_names(path: Option<&Path>) -> Result<Vec<String>, NoIndexError> {
462 let Some(path) = path else {
463 return Ok(Vec::new());
464 };
465 let mut names = Vec::new();
466 let read_dir = fs::read_dir(path).map_err(|_| NoIndexError::PathNotAccessible {
467 path: path.display().to_string(),
468 })?;
469 for entry in read_dir {
470 let entry = entry.map_err(|_| NoIndexError::PathNotAccessible {
471 path: path.display().to_string(),
472 })?;
473 let name = entry.file_name().to_string_lossy().into_owned();
474 if name == "." || name == ".." {
475 continue;
476 }
477 names.push(name);
478 }
479 names.sort();
480 Ok(names)
481}
482
483fn emit_pair(
484 out: &mut Vec<DiffEntry>,
485 left: Option<&Path>,
486 right: Option<&Path>,
487 mode_left: u32,
488 mode_right: u32,
489 display: Option<String>,
490) -> Result<(), NoIndexError> {
491 let (status, old_path, new_path) = match (left, right) {
492 (Some(_l), Some(_r)) if mode_left == 0 || mode_right == 0 => {
493 if mode_left == 0 {
494 (DiffStatus::Added, None, display.clone())
495 } else {
496 (DiffStatus::Deleted, display.clone(), None)
497 }
498 }
499 (Some(l), None) => (DiffStatus::Deleted, Some(path_display_string(l)), None),
500 (None, Some(r)) => (DiffStatus::Added, None, Some(path_display_string(r))),
501 (Some(l), Some(r)) => {
502 let lp = path_display_string(l);
503 let rp = path_display_string(r);
504 let status = if file_kind(mode_left) != file_kind(mode_right) {
505 DiffStatus::TypeChanged
506 } else {
507 DiffStatus::Modified
508 };
509 (status, Some(lp), Some(rp))
510 }
511 (None, None) => return Ok(()),
512 };
513
514 let old_mode = if mode_left == 0 {
515 "000000".to_owned()
516 } else {
517 format_mode(mode_left)
518 };
519 let new_mode = if mode_right == 0 {
520 "000000".to_owned()
521 } else {
522 format_mode(mode_right)
523 };
524
525 if let (Some(l), Some(r)) = (left, right) {
526 if mode_left != 0
527 && mode_right != 0
528 && old_mode == new_mode
529 && worktree_content_equal(l, r, mode_left, mode_right)?
530 {
531 return Ok(());
532 }
533 }
534
535 let (old_oid, new_oid) = oids_for_pair(left, right, mode_left, mode_right)?;
536
537 if old_oid == new_oid && old_mode == new_mode {
538 return Ok(());
539 }
540
541 out.push(DiffEntry {
542 status,
543 old_path,
544 new_path,
545 old_mode,
546 new_mode,
547 old_oid,
548 new_oid,
549 score: None,
550 });
551 Ok(())
552}
553
554fn file_kind(mode: u32) -> u32 {
555 if mode & S_IFMT != 0 {
556 mode & S_IFMT
557 } else {
558 S_IFREG
559 }
560}
561
562fn path_mode(path: Option<&Path>, recursing: bool) -> Result<u32, NoIndexError> {
563 let Some(path) = path else {
564 return Ok(0);
565 };
566 if !recursing && path.as_os_str().is_empty() {
567 return Ok(0);
568 }
569 match fs::symlink_metadata(path) {
570 Ok(meta) => Ok(mode_from_metadata(&meta)),
571 Err(_) if recursing => Ok(0),
572 Err(_) => Err(NoIndexError::PathNotAccessible {
573 path: path.display().to_string(),
574 }),
575 }
576}
577
578fn path_display_string(path: &Path) -> String {
579 path.to_string_lossy().into_owned()
580}