Skip to main content

grit_lib/
difftool.rs

1//! Git-compatible `difftool` engine.
2//!
3//! Launches external diff viewers for changed paths, mirroring Git's
4//! `git-difftool` / `git-difftool--helper` behavior.
5
6use 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/// Environment overrides mirroring Git's `GIT_*` difftool variables.
24#[derive(Debug, Clone, Default)]
25pub struct DifftoolEnv {
26    /// `GIT_DIFF_TOOL` — force a particular tool name.
27    pub git_diff_tool: Option<String>,
28    /// `GIT_DIFFTOOL_NO_PROMPT` is set (any value).
29    pub git_difftool_no_prompt: bool,
30    /// `GIT_DIFFTOOL_PROMPT` is set (any value).
31    pub git_difftool_prompt: bool,
32    /// `GIT_MERGETOOL_GUI` — `"true"` / `"false"` when explicitly set.
33    pub git_mergetool_gui: Option<bool>,
34    /// `DISPLAY` for `difftool.guiDefault=auto`.
35    pub display: Option<String>,
36}
37
38/// Parsed difftool-specific CLI flags (not forwarded to `diff`).
39#[derive(Debug, Clone, Default)]
40pub struct DifftoolOptions {
41    /// `-g` / `--gui` when explicitly true.
42    pub gui: Option<bool>,
43    /// `-d` / `--dir-diff`.
44    pub dir_diff: bool,
45    /// `-y` / `--no-prompt` → false; `--prompt` → true; unset → use config/env.
46    pub prompt: Option<bool>,
47    /// `--trust-exit-code`.
48    pub trust_exit_code: bool,
49    /// `--no-trust-exit-code`.
50    pub no_trust_exit_code: bool,
51    /// `-t` / `--tool`.
52    pub tool: Option<String>,
53    /// `-x` / `--extcmd`.
54    pub extcmd: Option<String>,
55    /// `--tool-help`.
56    pub tool_help: bool,
57    /// `--no-index` (forwarded to diff, but also recorded here).
58    pub no_index: bool,
59    /// `--symlinks` / `--no-symlinks` for dir-diff.
60    pub symlinks: Option<bool>,
61    /// `--rotate-to=<path>`.
62    pub rotate_to: Option<String>,
63    /// `--skip-to=<path>`.
64    pub skip_to: Option<String>,
65    /// Remaining arguments forwarded to diff (revs, `--cached`, paths, …).
66    pub diff_argv: Vec<String>,
67}
68
69/// Result of a difftool run.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct DifftoolResult {
72    /// Process exit code (0 = success).
73    pub exit_code: i32,
74}
75
76/// Parse `argv` into [`DifftoolOptions`], consuming only difftool-specific flags.
77///
78/// Unknown options and positional arguments are collected into `diff_argv`.
79pub 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
169/// Print built-in / configured diff tools (like `git difftool --tool-help`).
170pub 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
201/// Run difftool against `repo` (or without repo for `--no-index`).
202pub 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/// Tool resolution context for launching a diff viewer.
307#[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    // Copy working-tree modifications for right side when applicable.
1011    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}