1use crate::config::ConfigSet;
7use crate::diff::{
8 diff_index_to_tree, diff_index_to_worktree, diff_tree_to_worktree, diff_trees, DiffEntry,
9 DiffStatus,
10};
11use crate::error::{Error, Result};
12use crate::index::Index;
13use crate::objects::ObjectId;
14use crate::odb::Odb;
15use crate::repo::Repository;
16use crate::rev_parse::{peel_to_tree, resolve_revision};
17use crate::state::resolve_head;
18use std::collections::{BTreeMap, BTreeSet};
19use std::io::{self, BufRead, Write};
20use std::path::{Path, PathBuf};
21use std::process::{Command, Stdio};
22
23#[derive(Debug, Clone, Default)]
25pub struct DifftoolEnv {
26 pub git_diff_tool: Option<String>,
28 pub git_difftool_no_prompt: bool,
30 pub git_difftool_prompt: bool,
32 pub git_mergetool_gui: Option<bool>,
34 pub display: Option<String>,
36}
37
38#[derive(Debug, Clone, Default)]
40pub struct DifftoolOptions {
41 pub gui: Option<bool>,
43 pub dir_diff: bool,
45 pub prompt: Option<bool>,
47 pub trust_exit_code: bool,
49 pub no_trust_exit_code: bool,
51 pub tool: Option<String>,
53 pub extcmd: Option<String>,
55 pub tool_help: bool,
57 pub no_index: bool,
59 pub symlinks: Option<bool>,
61 pub rotate_to: Option<String>,
63 pub skip_to: Option<String>,
65 pub diff_argv: Vec<String>,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct DifftoolResult {
72 pub exit_code: i32,
74}
75
76pub fn parse_difftool_argv(argv: &[String]) -> Result<DifftoolOptions> {
80 let mut opts = DifftoolOptions::default();
81 let mut i = 0;
82 while i < argv.len() {
83 let arg = &argv[i];
84 match arg.as_str() {
85 "-g" | "--gui" => {
86 opts.gui = Some(true);
87 }
88 "--no-gui" => {
89 opts.gui = Some(false);
90 }
91 "-d" | "--dir-diff" => {
92 opts.dir_diff = true;
93 }
94 "-y" | "--no-prompt" => {
95 opts.prompt = Some(false);
96 }
97 "--prompt" => {
98 opts.prompt = Some(true);
99 }
100 "--trust-exit-code" => {
101 opts.trust_exit_code = true;
102 }
103 "--no-trust-exit-code" => {
104 opts.no_trust_exit_code = true;
105 }
106 "--tool-help" => {
107 opts.tool_help = true;
108 }
109 "--no-index" => {
110 opts.no_index = true;
111 opts.diff_argv.push(arg.clone());
112 }
113 "--symlinks" => {
114 opts.symlinks = Some(true);
115 }
116 "--no-symlinks" => {
117 opts.symlinks = Some(false);
118 }
119 "-t" | "--tool" => {
120 i += 1;
121 let val = argv
122 .get(i)
123 .ok_or_else(|| Error::Message("option '--tool' requires an argument".into()))?;
124 opts.tool = Some(parse_tool_value(val)?);
125 }
126 "-x" | "--extcmd" => {
127 i += 1;
128 let val = argv.get(i).ok_or_else(|| {
129 Error::Message("option '--extcmd' requires an argument".into())
130 })?;
131 opts.extcmd = Some(val.clone());
132 }
133 s if s.starts_with("--tool=") => {
134 opts.tool = Some(parse_tool_value(s.strip_prefix("--tool=").unwrap_or(""))?);
135 }
136 s if s.starts_with("--extcmd=") => {
137 opts.extcmd = Some(s.strip_prefix("--extcmd=").unwrap_or("").to_string());
138 }
139 s if s.starts_with("--rotate-to=") => {
140 opts.rotate_to = Some(s.strip_prefix("--rotate-to=").unwrap_or("").to_string());
141 }
142 s if s.starts_with("--skip-to=") => {
143 opts.skip_to = Some(s.strip_prefix("--skip-to=").unwrap_or("").to_string());
144 }
145 "--" => {
146 opts.diff_argv.push("--".to_string());
147 opts.diff_argv.extend_from_slice(&argv[i + 1..]);
148 break;
149 }
150 _ if arg.starts_with('-') => {
151 opts.diff_argv.push(arg.clone());
152 }
153 _ => {
154 opts.diff_argv.push(arg.clone());
155 }
156 }
157 i += 1;
158 }
159 Ok(opts)
160}
161
162fn parse_tool_value(raw: &str) -> Result<String> {
163 if raw.is_empty() {
164 return Err(Error::Message("no <tool> given for --tool=<tool>".into()));
165 }
166 Ok(raw.to_string())
167}
168
169pub fn print_tool_help(config: &ConfigSet, stdout: &mut dyn Write) -> io::Result<()> {
171 writeln!(
172 stdout,
173 "'git difftool --tool=<tool>' may be set to one of the following:"
174 )?;
175 writeln!(stdout)?;
176 let mut names = BTreeSet::new();
177 for entry in config.entries() {
178 if let Some(rest) = entry.key.strip_prefix("difftool.") {
179 if let Some(tool) = rest.strip_suffix(".cmd") {
180 names.insert(tool.to_string());
181 }
182 }
183 if let Some(rest) = entry.key.strip_prefix("mergetool.") {
184 if let Some(tool) = rest.strip_suffix(".cmd") {
185 names.insert(tool.to_string());
186 }
187 }
188 }
189 for tool in &names {
190 writeln!(stdout, "\t{tool:<15}")?;
191 }
192 for tool in ["vimdiff", "meld", "kompare", "tkdiff"] {
193 if !names.contains(tool) {
194 writeln!(stdout, "\t{tool:<15}")?;
195 }
196 }
197 writeln!(stdout)?;
198 Ok(())
199}
200
201pub fn run_difftool(
203 repo: Option<&Repository>,
204 opts: &DifftoolOptions,
205 env: &DifftoolEnv,
206 config: &ConfigSet,
207 stdin: &mut dyn BufRead,
208 stdout: &mut dyn Write,
209) -> Result<DifftoolResult> {
210 if opts.tool_help {
211 print_tool_help(config, stdout)?;
212 return Ok(DifftoolResult { exit_code: 0 });
213 }
214
215 if opts.no_index {
216 return run_no_index_difftool(opts, env, config, stdin, stdout);
217 }
218
219 let repo = repo.ok_or_else(|| Error::NotARepository(".".into()))?;
220 let work_tree = repo
221 .work_tree
222 .as_deref()
223 .ok_or_else(|| Error::Message("this operation must be run in a work tree".into()))?;
224
225 if opts.gui.is_some() && opts.tool.is_some() {
226 return Err(Error::Message(
227 "options '--gui' and '--tool' cannot be used together".into(),
228 ));
229 }
230 if opts.gui.is_some() && opts.extcmd.is_some() {
231 return Err(Error::Message(
232 "options '--gui' and '--extcmd' cannot be used together".into(),
233 ));
234 }
235 if opts.tool.is_some() && opts.extcmd.is_some() {
236 return Err(Error::Message(
237 "options '--tool' and '--extcmd' cannot be used together".into(),
238 ));
239 }
240
241 let trust_exit_code = resolve_trust_exit_code(opts, config);
242 let should_prompt = if opts.dir_diff {
243 false
244 } else {
245 resolve_should_prompt(opts, env, config)
246 };
247 let tool_ctx = resolve_tool_context(opts, env, config)?;
248
249 let index = match repo.load_index() {
250 Ok(idx) => idx,
251 Err(Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => Index::new(),
252 Err(e) => return Err(e),
253 };
254
255 let mut entries = collect_diff_entries(repo, &index, work_tree, &opts.diff_argv)?;
256 entries = apply_rotate_skip(entries, opts.rotate_to.as_deref(), opts.skip_to.as_deref())?;
257
258 if entries.is_empty() {
259 return Ok(DifftoolResult { exit_code: 0 });
260 }
261
262 if opts.dir_diff {
263 return run_dir_diff(
264 repo,
265 &entries,
266 work_tree,
267 &index,
268 &tool_ctx,
269 opts,
270 env,
271 config,
272 trust_exit_code,
273 should_prompt,
274 stdin,
275 stdout,
276 );
277 }
278
279 let tmp_dir = tempfile::tempdir().map_err(Error::Io)?;
280 let total = entries.len();
281 for (idx, entry) in entries.iter().enumerate() {
282 let counter = idx + 1;
283 let exit = launch_file_diff(
284 repo,
285 entry,
286 work_tree,
287 tmp_dir.path(),
288 &tool_ctx,
289 counter,
290 total,
291 should_prompt,
292 trust_exit_code,
293 stdin,
294 stdout,
295 )?;
296 if exit != 0 && trust_exit_code {
297 return Ok(DifftoolResult { exit_code: exit });
298 }
299 if exit >= 126 {
300 return Ok(DifftoolResult { exit_code: exit });
301 }
302 }
303 Ok(DifftoolResult { exit_code: 0 })
304}
305
306#[derive(Debug, Clone)]
308struct ToolContext {
309 tool_name: String,
310 extcmd: Option<String>,
311 tool_cmd: Option<String>,
312 tool_path: Option<String>,
313}
314
315fn resolve_trust_exit_code(opts: &DifftoolOptions, config: &ConfigSet) -> bool {
316 if opts.no_trust_exit_code {
317 return false;
318 }
319 if opts.trust_exit_code {
320 return true;
321 }
322 config
323 .get_bool("difftool.trustExitCode")
324 .and_then(|r| r.ok())
325 .unwrap_or(false)
326}
327
328fn resolve_should_prompt(opts: &DifftoolOptions, env: &DifftoolEnv, config: &ConfigSet) -> bool {
329 if env.git_difftool_no_prompt {
330 return false;
331 }
332 if env.git_difftool_prompt {
333 return true;
334 }
335 if let Some(p) = opts.prompt {
336 return p;
337 }
338 let prompt_merge = config
339 .get_bool("mergetool.prompt")
340 .and_then(|r| r.ok())
341 .unwrap_or(true);
342 config
343 .get_bool("difftool.prompt")
344 .and_then(|r| r.ok())
345 .unwrap_or(prompt_merge)
346}
347
348fn gui_default(config: &ConfigSet, env: &DifftoolEnv) -> Result<bool> {
349 let raw = config
350 .get("difftool.guiDefault")
351 .map(|s| s.to_ascii_lowercase())
352 .unwrap_or_else(|| "false".to_string());
353 if raw == "auto" {
354 return Ok(env.display.as_ref().is_some_and(|d| !d.is_empty()));
355 }
356 Ok(config
357 .get_bool("difftool.guiDefault")
358 .and_then(|r| r.ok())
359 .unwrap_or(false))
360}
361
362fn resolve_tool_context(
363 opts: &DifftoolOptions,
364 env: &DifftoolEnv,
365 config: &ConfigSet,
366) -> Result<ToolContext> {
367 if let Some(ext) = &opts.extcmd {
368 return Ok(ToolContext {
369 tool_name: ext.clone(),
370 extcmd: Some(ext.clone()),
371 tool_cmd: None,
372 tool_path: None,
373 });
374 }
375
376 let use_gui = match opts.gui {
377 Some(v) => v,
378 None => match env.git_mergetool_gui {
379 Some(v) => v,
380 None => gui_default(config, env)?,
381 },
382 };
383
384 let tool_name = if let Some(t) = opts.tool.clone().or_else(|| env.git_diff_tool.clone()) {
385 t
386 } else {
387 select_configured_tool(config, use_gui)?
388 };
389
390 if !valid_tool(config, &tool_name) {
391 return Err(Error::Message(format!("Unknown diff tool {tool_name}")));
392 }
393
394 let tool_cmd = get_tool_cmd(config, &tool_name);
395 let path_key = format!("difftool.{tool_name}.path");
396 let merge_path_key = format!("mergetool.{tool_name}.path");
397 let tool_path = config
398 .get(&path_key)
399 .or_else(|| config.get(&merge_path_key))
400 .or_else(|| Some(tool_name.clone()));
401
402 Ok(ToolContext {
403 tool_name,
404 extcmd: None,
405 tool_cmd,
406 tool_path,
407 })
408}
409
410fn select_configured_tool(config: &ConfigSet, use_gui: bool) -> Result<String> {
411 let keys: &[&str] = if use_gui {
412 &["diff.guitool", "merge.guitool", "diff.tool", "merge.tool"]
413 } else {
414 &["diff.tool", "merge.tool"]
415 };
416 for key in keys {
417 if let Some(val) = config.get(key).filter(|s| !s.is_empty()) {
418 if valid_tool(config, &val) {
419 return Ok(val);
420 }
421 }
422 }
423 Ok("vimdiff".to_string())
424}
425
426fn get_tool_cmd(config: &ConfigSet, tool: &str) -> Option<String> {
427 config
428 .get(&format!("difftool.{tool}.cmd"))
429 .or_else(|| config.get(&format!("mergetool.{tool}.cmd")))
430}
431
432fn valid_tool(config: &ConfigSet, tool: &str) -> bool {
433 if get_tool_cmd(config, tool).is_some() {
434 return true;
435 }
436 let path_key = format!("difftool.{tool}.path");
437 let merge_path_key = format!("mergetool.{tool}.path");
438 if let Some(path) = config
439 .get(&path_key)
440 .or_else(|| config.get(&merge_path_key))
441 {
442 if Command::new("sh")
443 .arg("-c")
444 .arg(format!("type {} >/dev/null 2>&1", shell_quote(&path)))
445 .status()
446 .ok()
447 .is_some_and(|s| s.success())
448 {
449 return true;
450 }
451 }
452 which_tool_executable(tool).is_some()
453}
454
455fn which_tool_executable(tool: &str) -> Option<String> {
456 if Command::new("sh")
457 .arg("-c")
458 .arg(format!("type {tool} >/dev/null 2>&1"))
459 .status()
460 .ok()
461 .is_some_and(|s| s.success())
462 {
463 return Some(tool.to_string());
464 }
465 None
466}
467
468fn collect_diff_entries(
469 repo: &Repository,
470 index: &Index,
471 work_tree: &Path,
472 diff_argv: &[String],
473) -> Result<Vec<DiffEntry>> {
474 let mut cached = false;
475 let mut revs = Vec::new();
476 let mut paths = Vec::new();
477 let mut in_paths = false;
478 for arg in diff_argv {
479 if in_paths {
480 paths.push(arg.clone());
481 continue;
482 }
483 if arg == "--" {
484 in_paths = true;
485 continue;
486 }
487 match arg.as_str() {
488 "--cached" | "--staged" => cached = true,
489 _ if arg.starts_with('-') => {}
490 _ => revs.push(arg.clone()),
491 }
492 }
493
494 let head_tree = head_tree_oid(repo).ok();
495 let entries = match (cached, revs.len()) {
496 (true, 0) => diff_index_to_tree(&repo.odb, index, head_tree.as_ref(), false)?,
497 (true, 1) => {
498 let tree = commit_or_tree_oid(repo, &revs[0])?;
499 diff_index_to_tree(&repo.odb, index, Some(&tree), false)?
500 }
501 (false, 0) => diff_index_to_worktree(&repo.odb, index, work_tree, false, false)?,
502 (false, 1) => {
503 let tree = commit_or_tree_oid(repo, &revs[0])?;
504 diff_tree_to_worktree(&repo.odb, Some(&tree), work_tree, index)?
505 }
506 (false, 2) => {
507 let t1 = commit_or_tree_oid(repo, &revs[0])?;
508 let t2 = commit_or_tree_oid(repo, &revs[1])?;
509 diff_trees(&repo.odb, Some(&t1), Some(&t2), "")?
510 }
511 _ => {
512 return Err(Error::Message("too many revisions for difftool".into()));
513 }
514 };
515
516 let entries = entries
517 .into_iter()
518 .filter(|entry| entry.status != DiffStatus::Unmerged)
519 .collect();
520 let paths = normalize_pathspecs(work_tree, &paths);
521 Ok(filter_paths(entries, &paths))
522}
523
524fn normalize_pathspecs(work_tree: &Path, paths: &[String]) -> Vec<String> {
525 let cwd = std::env::current_dir().unwrap_or_else(|_| work_tree.to_path_buf());
526 let prefix = cwd
527 .strip_prefix(work_tree)
528 .ok()
529 .map(|p| p.to_string_lossy().replace('\\', "/"))
530 .filter(|p| !p.is_empty());
531 paths
532 .iter()
533 .map(|path| {
534 if path == "." {
535 return prefix.clone().unwrap_or_else(|| ".".to_string());
536 }
537 if Path::new(path).is_absolute() {
538 return path.clone();
539 }
540 match &prefix {
541 Some(prefix) => format!("{prefix}/{path}"),
542 None => path.clone(),
543 }
544 })
545 .collect()
546}
547
548fn filter_paths(entries: Vec<DiffEntry>, paths: &[String]) -> Vec<DiffEntry> {
549 if paths.is_empty() {
550 return entries;
551 }
552 entries
553 .into_iter()
554 .filter(|e| {
555 let p = e.path();
556 paths
557 .iter()
558 .any(|f| p == f || p.starts_with(&format!("{f}/")))
559 })
560 .collect()
561}
562
563fn apply_rotate_skip(
564 mut entries: Vec<DiffEntry>,
565 rotate_to: Option<&str>,
566 skip_to: Option<&str>,
567) -> Result<Vec<DiffEntry>> {
568 if let Some(target) = rotate_to {
569 let pos = entries
570 .iter()
571 .position(|e| e.path() == target)
572 .ok_or_else(|| Error::Message(format!("File '{target}' not in diff list")))?;
573 let mut tail = entries.split_off(pos);
574 tail.append(&mut entries);
575 entries = tail;
576 }
577 if let Some(target) = skip_to {
578 let pos = entries
579 .iter()
580 .position(|e| e.path() == target)
581 .ok_or_else(|| Error::Message(format!("File '{target}' not in diff list")))?;
582 entries = entries.split_off(pos);
583 }
584 Ok(entries)
585}
586
587fn head_tree_oid(repo: &Repository) -> Result<ObjectId> {
588 let head = resolve_head(&repo.git_dir)?;
589 let Some(oid) = head.oid() else {
590 return Err(Error::Message("unborn HEAD".into()));
591 };
592 peel_to_tree(repo, *oid)
593}
594
595fn commit_or_tree_oid(repo: &Repository, spec: &str) -> Result<ObjectId> {
596 let oid = resolve_revision(repo, spec).map_err(|e| Error::Message(e.to_string()))?;
597 peel_to_tree(repo, oid)
598}
599
600fn launch_file_diff(
601 repo: &Repository,
602 entry: &DiffEntry,
603 work_tree: &Path,
604 tmp_dir: &Path,
605 tool: &ToolContext,
606 counter: usize,
607 total: usize,
608 should_prompt: bool,
609 trust_exit_code: bool,
610 stdin: &mut dyn BufRead,
611 stdout: &mut dyn Write,
612) -> Result<i32> {
613 let merged = entry.path();
614 let (local_path, remote_path) = materialize_pair(repo, entry, work_tree, tmp_dir)?;
615
616 if should_prompt {
617 writeln!(stdout)?;
618 writeln!(stdout, "Viewing ({counter}/{total}): '{merged}'")?;
619 let prompt_label = tool.extcmd.as_deref().unwrap_or(&tool.tool_name);
620 write!(stdout, "Launch '{prompt_label}' [Y/n]? ")?;
621 stdout.flush().map_err(Error::Io)?;
622 let mut line = String::new();
623 if stdin.read_line(&mut line).ok().filter(|n| *n > 0).is_none() {
624 return Ok(0);
625 }
626 let ans = line.trim();
627 if ans.eq_ignore_ascii_case("n") || ans.eq_ignore_ascii_case("no") {
628 return Ok(0);
629 }
630 }
631
632 let status = run_tool(tool, &local_path, &remote_path, merged, counter, total)?;
633 let mut code = status.code().unwrap_or(1);
634 if code == 127 {
635 code = 128;
636 }
637 if trust_exit_code && code != 0 {
638 return Ok(code);
639 }
640 if code >= 126 {
641 return Ok(code);
642 }
643 Ok(0)
644}
645
646fn materialize_pair(
647 repo: &Repository,
648 entry: &DiffEntry,
649 work_tree: &Path,
650 tmp_dir: &Path,
651) -> Result<(PathBuf, PathBuf)> {
652 let safe_name = entry.path().replace('/', "_");
653 let local_tmp = tmp_dir.join(format!("local_{safe_name}"));
654 let remote_tmp = tmp_dir.join(format!("remote_{safe_name}"));
655
656 match entry.status {
657 DiffStatus::Added => {
658 write_blob_or_empty(&repo.odb, &ObjectId::zero(), &local_tmp)?;
659 write_blob_or_empty(&repo.odb, &entry.new_oid, &remote_tmp)?;
660 Ok((local_tmp, remote_tmp))
661 }
662 DiffStatus::Deleted => {
663 write_blob_or_empty(&repo.odb, &entry.old_oid, &local_tmp)?;
664 Ok((local_tmp, PathBuf::from("/dev/null")))
665 }
666 _ => {
667 write_blob_or_empty(&repo.odb, &entry.old_oid, &local_tmp)?;
668 let wt = work_tree.join(entry.path());
669 if wt.exists() {
670 Ok((local_tmp, wt))
671 } else {
672 write_blob_or_empty(&repo.odb, &entry.new_oid, &remote_tmp)?;
673 Ok((local_tmp, remote_tmp))
674 }
675 }
676 }
677}
678
679fn write_blob_or_empty(odb: &Odb, oid: &ObjectId, dest: &Path) -> Result<()> {
680 if oid.is_zero() {
681 std::fs::write(dest, "").map_err(Error::Io)?;
682 return Ok(());
683 }
684 let data = odb.read(oid)?;
685 std::fs::write(dest, &data.data).map_err(Error::Io)?;
686 Ok(())
687}
688
689fn run_tool(
690 tool: &ToolContext,
691 local: &Path,
692 remote: &Path,
693 merged: &str,
694 counter: usize,
695 total: usize,
696) -> Result<std::process::ExitStatus> {
697 if let Some(extcmd) = &tool.extcmd {
698 let append_pair = !extcmd.contains(char::is_whitespace);
699 let script = format!(
700 "export LOCAL={local} REMOTE={remote} MERGED={merged} BASE={merged}; \
701 export GIT_DIFF_PATH_COUNTER={counter} GIT_DIFF_PATH_TOTAL={total} GIT_PREFIX=.; \
702 set -- \"$MERGED\" \"$LOCAL\" \"$REMOTE\"; \
703 cmd={cmd}; \
704 if test {append_pair} = true; then \
705 eval \"$cmd\" \"$LOCAL\" \"$REMOTE\"; \
706 else \
707 eval \"$cmd\"; \
708 fi",
709 local = shell_quote(&local.display().to_string()),
710 remote = shell_quote(&remote.display().to_string()),
711 merged = shell_quote(merged),
712 cmd = shell_quote(extcmd),
713 append_pair = if append_pair { "true" } else { "false" },
714 );
715 return Command::new("sh")
716 .arg("-c")
717 .arg(&script)
718 .stdout(Stdio::inherit())
719 .status()
720 .map_err(Error::Io);
721 }
722
723 if let Some(tool_cmd) = &tool.tool_cmd {
724 let script = format!(
725 "export LOCAL={local} REMOTE={remote} MERGED={merged} BASE={merged}; \
726 export GIT_DIFF_PATH_COUNTER={counter} GIT_DIFF_PATH_TOTAL={total} GIT_PREFIX=.; \
727 export merge_tool={name} merge_tool_path={path}; \
728 eval {tool_cmd}",
729 local = shell_quote(&local.display().to_string()),
730 remote = shell_quote(&remote.display().to_string()),
731 merged = shell_quote(merged),
732 name = shell_quote(&tool.tool_name),
733 path = shell_quote(tool.tool_path.as_deref().unwrap_or(&tool.tool_name)),
734 tool_cmd = tool_cmd,
735 );
736 return Command::new("sh")
737 .arg("-c")
738 .arg(&script)
739 .stdout(Stdio::inherit())
740 .status()
741 .map_err(Error::Io);
742 }
743
744 let exe = tool.tool_path.as_deref().unwrap_or(&tool.tool_name);
745 Command::new(exe)
746 .arg(local)
747 .arg(remote)
748 .stdout(Stdio::inherit())
749 .status()
750 .map_err(Error::Io)
751}
752
753fn shell_quote(s: &str) -> String {
754 if s.is_empty() {
755 return "''".to_string();
756 }
757 if s.chars()
758 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '@' | '%' | '+' | '-' | '_' | '.' | '/'))
759 {
760 return s.to_string();
761 }
762 format!("'{}'", s.replace('\'', "'\\''"))
763}
764
765fn run_dir_diff(
766 repo: &Repository,
767 entries: &[DiffEntry],
768 work_tree: &Path,
769 index: &Index,
770 tool: &ToolContext,
771 opts: &DifftoolOptions,
772 _env: &DifftoolEnv,
773 config: &ConfigSet,
774 trust_exit_code: bool,
775 should_prompt: bool,
776 stdin: &mut dyn BufRead,
777 stdout: &mut dyn Write,
778) -> Result<DifftoolResult> {
779 let tmp = difftool_tempdir()?;
780 let left = tmp.path().join("left");
781 let right = tmp.path().join("right");
782 std::fs::create_dir_all(&left).map_err(Error::Io)?;
783 std::fs::create_dir_all(&right).map_err(Error::Io)?;
784
785 let use_symlinks = opts
786 .symlinks
787 .or_else(|| config.get_bool("core.symlinks").and_then(|r| r.ok()))
788 .unwrap_or(true);
789
790 for entry in entries {
791 populate_dir_side(repo, &left, entry, true, work_tree, index, use_symlinks)?;
792 populate_dir_side(repo, &right, entry, false, work_tree, index, use_symlinks)?;
793 }
794 let right_baseline = if use_symlinks {
795 BTreeMap::new()
796 } else {
797 capture_dir_diff_baseline(&right, entries)
798 };
799
800 if should_prompt {
801 let prompt_label = tool.extcmd.as_deref().unwrap_or(&tool.tool_name);
802 write!(stdout, "Launch '{prompt_label}' [Y/n]? ")?;
803 stdout.flush().map_err(Error::Io)?;
804 let mut line = String::new();
805 if stdin.read_line(&mut line).ok().filter(|n| *n > 0).is_none() {
806 return Ok(DifftoolResult { exit_code: 0 });
807 }
808 let ans = line.trim();
809 if ans.eq_ignore_ascii_case("n") || ans.eq_ignore_ascii_case("no") {
810 return Ok(DifftoolResult { exit_code: 0 });
811 }
812 }
813
814 let status = if let Some(extcmd) = &tool.extcmd {
815 let script = format!(
816 "export LOCAL={} REMOTE={}; export GIT_DIFFTOOL_DIRDIFF=true; \
817 set -- . \"$LOCAL\" \"$REMOTE\"; eval {} \"$LOCAL\" \"$REMOTE\"",
818 shell_quote(&left.display().to_string()),
819 shell_quote(&right.display().to_string()),
820 extcmd,
821 );
822 Command::new("sh")
823 .arg("-c")
824 .arg(script)
825 .stdout(Stdio::inherit())
826 .status()
827 .map_err(Error::Io)?
828 } else if let Some(tool_cmd) = &tool.tool_cmd {
829 let script = format!(
830 "export LOCAL={} REMOTE={} MERGED=. BASE=.; export GIT_DIFFTOOL_DIRDIFF=true; \
831 export merge_tool={} merge_tool_path={}; eval {}",
832 shell_quote(&left.display().to_string()),
833 shell_quote(&right.display().to_string()),
834 shell_quote(&tool.tool_name),
835 shell_quote(tool.tool_path.as_deref().unwrap_or(&tool.tool_name)),
836 tool_cmd,
837 );
838 Command::new("sh")
839 .arg("-c")
840 .arg(script)
841 .stdout(Stdio::inherit())
842 .status()
843 .map_err(Error::Io)?
844 } else {
845 let exe = tool.tool_path.as_deref().unwrap_or(&tool.tool_name);
846 Command::new(exe)
847 .arg(&left)
848 .arg(&right)
849 .stdout(Stdio::inherit())
850 .status()
851 .map_err(Error::Io)?
852 };
853
854 let code = status.code().unwrap_or(1);
855 if !use_symlinks {
856 if let Err(err) = sync_dir_diff_right_to_worktree(&right, work_tree, &right_baseline) {
857 let _ = tmp.keep();
858 return Err(err);
859 }
860 }
861 if code >= 126 {
862 return Ok(DifftoolResult { exit_code: code });
863 }
864 if trust_exit_code && code != 0 {
865 return Ok(DifftoolResult { exit_code: code });
866 }
867 Ok(DifftoolResult { exit_code: 0 })
868}
869
870fn capture_dir_diff_baseline(
871 right: &Path,
872 entries: &[DiffEntry],
873) -> BTreeMap<String, Option<Vec<u8>>> {
874 let mut baseline = BTreeMap::new();
875 for entry in entries {
876 let Some(path) = entry.new_path.as_deref().or(entry.old_path.as_deref()) else {
877 continue;
878 };
879 baseline.insert(path.to_string(), std::fs::read(right.join(path)).ok());
880 }
881 baseline
882}
883
884fn sync_dir_diff_right_to_worktree(
885 right: &Path,
886 work_tree: &Path,
887 baseline: &BTreeMap<String, Option<Vec<u8>>>,
888) -> Result<()> {
889 let mut conflict = false;
890 for (rel, before) in baseline {
891 let right_path = right.join(rel);
892 let Ok(after) = std::fs::read(&right_path) else {
893 continue;
894 };
895 if before.as_ref() == Some(&after) {
896 continue;
897 }
898 let wt_path = work_tree.join(rel);
899 let wt_now = std::fs::read(&wt_path).ok();
900 if wt_now != *before {
901 conflict = true;
902 continue;
903 }
904 if let Some(parent) = wt_path.parent() {
905 std::fs::create_dir_all(parent).map_err(Error::Io)?;
906 }
907 std::fs::write(&wt_path, after).map_err(Error::Io)?;
908 }
909 if conflict {
910 return Err(Error::Message(
911 "working tree file changed during difftool".into(),
912 ));
913 }
914 Ok(())
915}
916
917fn populate_dir_side(
918 repo: &Repository,
919 dir: &Path,
920 entry: &DiffEntry,
921 is_left: bool,
922 work_tree: &Path,
923 index: &Index,
924 use_symlinks: bool,
925) -> Result<()> {
926 let path = if is_left {
927 entry.old_path.as_deref().or(entry.new_path.as_deref())
928 } else {
929 entry.new_path.as_deref().or(entry.old_path.as_deref())
930 };
931 let Some(rel) = path else {
932 return Ok(());
933 };
934 let dest = dir.join(rel);
935
936 let mode_str = if is_left {
937 &entry.old_mode
938 } else {
939 &entry.new_mode
940 };
941 let oid = if is_left {
942 &entry.old_oid
943 } else {
944 &entry.new_oid
945 };
946
947 if mode_str == "160000" {
948 if let Some(parent) = dest.parent() {
949 std::fs::create_dir_all(parent).map_err(Error::Io)?;
950 }
951 let label = if oid.is_zero() {
952 "Subproject commit 0000000000000000000000000000000000000000"
953 } else {
954 &format!("Subproject commit {}", oid.to_hex())
955 };
956 std::fs::write(&dest, label).map_err(Error::Io)?;
957 return Ok(());
958 }
959
960 if mode_str.starts_with("120000") {
961 if let Some(parent) = dest.parent() {
962 std::fs::create_dir_all(parent).map_err(Error::Io)?;
963 }
964 let wt_symlink = work_tree.join(rel);
965 let target = if oid.is_zero() || (!is_left && use_symlinks && wt_symlink.is_symlink()) {
966 std::fs::read_link(work_tree.join(rel))
967 .map(|p| p.to_string_lossy().into_owned())
968 .unwrap_or_default()
969 } else {
970 match repo.odb.read(oid) {
971 Ok(blob) => String::from_utf8_lossy(&blob.data).into_owned(),
972 Err(_) if !is_left && wt_symlink.is_symlink() => std::fs::read_link(wt_symlink)
973 .map(|p| p.to_string_lossy().into_owned())
974 .map_err(Error::Io)?,
975 Err(err) => return Err(err),
976 }
977 };
978 std::fs::write(&dest, format!("{target}\n")).map_err(Error::Io)?;
979 return Ok(());
980 }
981
982 if oid.is_zero() {
983 return Ok(());
984 }
985
986 if let Some(parent) = dest.parent() {
987 std::fs::create_dir_all(parent).map_err(Error::Io)?;
988 }
989
990 if !is_left && use_symlinks {
991 let wt = work_tree.join(rel);
992 if wt.is_file() {
993 let _ = std::fs::remove_file(&dest);
994 std::os::unix::fs::symlink(&wt, &dest).map_err(Error::Io)?;
995 return Ok(());
996 }
997 }
998
999 if !is_left {
1000 let wt = work_tree.join(rel);
1001 if wt.is_file() {
1002 std::fs::copy(wt, &dest).map_err(Error::Io)?;
1003 return Ok(());
1004 }
1005 }
1006
1007 let data = repo.odb.read(oid)?;
1008 std::fs::write(&dest, &data.data).map_err(Error::Io)?;
1009
1010 if !is_left {
1012 let wt = work_tree.join(rel);
1013 if wt.exists() {
1014 if let Ok(bytes) = std::fs::read(&wt) {
1015 std::fs::write(&dest, bytes).map_err(Error::Io)?;
1016 }
1017 } else if let Some(idx) = index.get(rel.as_bytes(), 0) {
1018 if !idx.oid.is_zero() {
1019 let data = repo.odb.read(&idx.oid)?;
1020 std::fs::write(&dest, &data.data).map_err(Error::Io)?;
1021 }
1022 }
1023 }
1024 Ok(())
1025}
1026
1027fn difftool_tempdir() -> Result<tempfile::TempDir> {
1028 let Some(raw) = std::env::var_os("TMPDIR") else {
1029 return tempfile::tempdir().map_err(Error::Io);
1030 };
1031 let cleaned = PathBuf::from(raw.to_string_lossy().trim_end_matches('/').to_string());
1032 if cleaned.as_os_str().is_empty() {
1033 return tempfile::tempdir().map_err(Error::Io);
1034 }
1035 tempfile::Builder::new()
1036 .tempdir_in(cleaned)
1037 .map_err(Error::Io)
1038}
1039
1040fn run_no_index_difftool(
1041 opts: &DifftoolOptions,
1042 env: &DifftoolEnv,
1043 config: &ConfigSet,
1044 stdin: &mut dyn BufRead,
1045 stdout: &mut dyn Write,
1046) -> Result<DifftoolResult> {
1047 let mut paths = Vec::new();
1048 let mut seen_no_index = false;
1049 for arg in &opts.diff_argv {
1050 if arg == "--no-index" {
1051 seen_no_index = true;
1052 continue;
1053 }
1054 if !arg.starts_with('-') {
1055 paths.push(arg.clone());
1056 }
1057 }
1058 if !seen_no_index || paths.len() != 2 {
1059 return Err(Error::Message(
1060 "difftool --no-index requires exactly two paths".into(),
1061 ));
1062 }
1063 let tool_ctx = resolve_tool_context(opts, env, config)?;
1064 let local = PathBuf::from(&paths[0]);
1065 let remote = PathBuf::from(&paths[1]);
1066 let should_prompt = resolve_should_prompt(opts, env, config);
1067 if should_prompt {
1068 write!(stdout, "Launch '{}' [Y/n]? ", tool_ctx.tool_name)?;
1069 stdout.flush().map_err(Error::Io)?;
1070 let mut line = String::new();
1071 if stdin.read_line(&mut line).ok().filter(|n| *n > 0).is_none() {
1072 return Ok(DifftoolResult { exit_code: 0 });
1073 }
1074 }
1075 let status = run_tool(
1076 &tool_ctx,
1077 &local,
1078 &remote,
1079 local.file_name().and_then(|s| s.to_str()).unwrap_or(""),
1080 1,
1081 1,
1082 )?;
1083 let code = status.code().unwrap_or(1);
1084 if code == 0 && paths_differ(&local, &remote) {
1085 return Ok(DifftoolResult { exit_code: 1 });
1086 }
1087 Ok(DifftoolResult { exit_code: code })
1088}
1089
1090fn paths_differ(left: &Path, right: &Path) -> bool {
1091 match (std::fs::read(left), std::fs::read(right)) {
1092 (Ok(left), Ok(right)) => left != right,
1093 _ => true,
1094 }
1095}