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