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::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 = resolve_should_prompt(opts, env, config);
243    let tool_ctx = resolve_tool_context(opts, env, config)?;
244
245    let index = match repo.load_index() {
246        Ok(idx) => idx,
247        Err(Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => Index::new(),
248        Err(e) => return Err(e),
249    };
250
251    let mut entries = collect_diff_entries(repo, &index, work_tree, &opts.diff_argv)?;
252    entries = apply_rotate_skip(entries, opts.rotate_to.as_deref(), opts.skip_to.as_deref())?;
253
254    if entries.is_empty() {
255        return Ok(DifftoolResult { exit_code: 0 });
256    }
257
258    if opts.dir_diff {
259        return run_dir_diff(
260            repo,
261            &entries,
262            work_tree,
263            &index,
264            &tool_ctx,
265            opts,
266            env,
267            config,
268            trust_exit_code,
269            should_prompt,
270            stdin,
271            stdout,
272        );
273    }
274
275    let tmp_dir = tempfile::tempdir().map_err(Error::Io)?;
276    let total = entries.len();
277    for (idx, entry) in entries.iter().enumerate() {
278        let counter = idx + 1;
279        let exit = launch_file_diff(
280            repo,
281            entry,
282            work_tree,
283            tmp_dir.path(),
284            &tool_ctx,
285            counter,
286            total,
287            should_prompt,
288            trust_exit_code,
289            stdin,
290            stdout,
291        )?;
292        if exit != 0 && trust_exit_code {
293            return Ok(DifftoolResult { exit_code: exit });
294        }
295        if exit >= 126 {
296            return Ok(DifftoolResult { exit_code: exit });
297        }
298    }
299    Ok(DifftoolResult { exit_code: 0 })
300}
301
302/// Tool resolution context for launching a diff viewer.
303#[derive(Debug, Clone)]
304struct ToolContext {
305    tool_name: String,
306    extcmd: Option<String>,
307    tool_cmd: Option<String>,
308    tool_path: Option<String>,
309}
310
311fn resolve_trust_exit_code(opts: &DifftoolOptions, config: &ConfigSet) -> bool {
312    if opts.no_trust_exit_code {
313        return false;
314    }
315    if opts.trust_exit_code {
316        return true;
317    }
318    config
319        .get_bool("difftool.trustExitCode")
320        .and_then(|r| r.ok())
321        .unwrap_or(false)
322}
323
324fn resolve_should_prompt(opts: &DifftoolOptions, env: &DifftoolEnv, config: &ConfigSet) -> bool {
325    if env.git_difftool_no_prompt {
326        return false;
327    }
328    if env.git_difftool_prompt {
329        return true;
330    }
331    if let Some(p) = opts.prompt {
332        return p;
333    }
334    let prompt_merge = config
335        .get_bool("mergetool.prompt")
336        .and_then(|r| r.ok())
337        .unwrap_or(true);
338    config
339        .get_bool("difftool.prompt")
340        .and_then(|r| r.ok())
341        .unwrap_or(prompt_merge)
342}
343
344fn gui_default(config: &ConfigSet, env: &DifftoolEnv) -> Result<bool> {
345    let raw = config
346        .get("difftool.guiDefault")
347        .map(|s| s.to_ascii_lowercase())
348        .unwrap_or_else(|| "false".to_string());
349    if raw == "auto" {
350        return Ok(env.display.as_ref().is_some_and(|d| !d.is_empty()));
351    }
352    Ok(config
353        .get_bool("difftool.guiDefault")
354        .and_then(|r| r.ok())
355        .unwrap_or(false))
356}
357
358fn resolve_tool_context(
359    opts: &DifftoolOptions,
360    env: &DifftoolEnv,
361    config: &ConfigSet,
362) -> Result<ToolContext> {
363    if let Some(ext) = &opts.extcmd {
364        return Ok(ToolContext {
365            tool_name: ext.clone(),
366            extcmd: Some(ext.clone()),
367            tool_cmd: None,
368            tool_path: None,
369        });
370    }
371
372    let use_gui = match opts.gui {
373        Some(v) => v,
374        None => match env.git_mergetool_gui {
375            Some(v) => v,
376            None => gui_default(config, env)?,
377        },
378    };
379
380    let tool_name = if let Some(t) = opts.tool.clone().or_else(|| env.git_diff_tool.clone()) {
381        t
382    } else {
383        select_configured_tool(config, use_gui)?
384    };
385
386    if !valid_tool(config, &tool_name) {
387        return Err(Error::Message(format!("Unknown diff tool {tool_name}")));
388    }
389
390    let tool_cmd = get_tool_cmd(config, &tool_name);
391    let path_key = format!("difftool.{tool_name}.path");
392    let merge_path_key = format!("mergetool.{tool_name}.path");
393    let tool_path = config
394        .get(&path_key)
395        .or_else(|| config.get(&merge_path_key))
396        .or_else(|| Some(tool_name.clone()));
397
398    Ok(ToolContext {
399        tool_name,
400        extcmd: None,
401        tool_cmd,
402        tool_path,
403    })
404}
405
406fn select_configured_tool(config: &ConfigSet, use_gui: bool) -> Result<String> {
407    let keys: &[&str] = if use_gui {
408        &["diff.guitool", "merge.guitool", "diff.tool", "merge.tool"]
409    } else {
410        &["diff.tool", "merge.tool"]
411    };
412    for key in keys {
413        if let Some(val) = config.get(key).filter(|s| !s.is_empty()) {
414            if valid_tool(config, &val) {
415                return Ok(val);
416            }
417        }
418    }
419    Ok("vimdiff".to_string())
420}
421
422fn get_tool_cmd(config: &ConfigSet, tool: &str) -> Option<String> {
423    config
424        .get(&format!("difftool.{tool}.cmd"))
425        .or_else(|| config.get(&format!("mergetool.{tool}.cmd")))
426}
427
428fn valid_tool(config: &ConfigSet, tool: &str) -> bool {
429    if get_tool_cmd(config, tool).is_some() {
430        return true;
431    }
432    let path_key = format!("difftool.{tool}.path");
433    let merge_path_key = format!("mergetool.{tool}.path");
434    if let Some(path) = config
435        .get(&path_key)
436        .or_else(|| config.get(&merge_path_key))
437    {
438        if Command::new("sh")
439            .arg("-c")
440            .arg(format!("type {} >/dev/null 2>&1", shell_quote(&path)))
441            .status()
442            .ok()
443            .is_some_and(|s| s.success())
444        {
445            return true;
446        }
447    }
448    which_tool_executable(tool).is_some()
449}
450
451fn which_tool_executable(tool: &str) -> Option<String> {
452    if Command::new("sh")
453        .arg("-c")
454        .arg(format!("type {tool} >/dev/null 2>&1"))
455        .status()
456        .ok()
457        .is_some_and(|s| s.success())
458    {
459        return Some(tool.to_string());
460    }
461    None
462}
463
464fn collect_diff_entries(
465    repo: &Repository,
466    index: &Index,
467    work_tree: &Path,
468    diff_argv: &[String],
469) -> Result<Vec<DiffEntry>> {
470    let mut cached = false;
471    let mut revs = Vec::new();
472    let mut paths = Vec::new();
473    let mut in_paths = false;
474    for arg in diff_argv {
475        if in_paths {
476            paths.push(arg.clone());
477            continue;
478        }
479        if arg == "--" {
480            in_paths = true;
481            continue;
482        }
483        match arg.as_str() {
484            "--cached" | "--staged" => cached = true,
485            _ if arg.starts_with('-') => {}
486            _ => revs.push(arg.clone()),
487        }
488    }
489
490    let head_tree = head_tree_oid(repo).ok();
491    let entries = match (cached, revs.len()) {
492        (true, 0) => diff_index_to_tree(&repo.odb, index, head_tree.as_ref(), false)?,
493        (true, 1) => {
494            let tree = commit_or_tree_oid(repo, &revs[0])?;
495            diff_index_to_tree(&repo.odb, index, Some(&tree), false)?
496        }
497        (false, 0) => diff_index_to_worktree(&repo.odb, index, work_tree, false, false)?,
498        (false, 1) => {
499            let tree = commit_or_tree_oid(repo, &revs[0])?;
500            diff_tree_to_worktree(&repo.odb, Some(&tree), work_tree, index)?
501        }
502        (false, 2) => {
503            let t1 = commit_or_tree_oid(repo, &revs[0])?;
504            let t2 = commit_or_tree_oid(repo, &revs[1])?;
505            diff_trees(&repo.odb, Some(&t1), Some(&t2), "")?
506        }
507        _ => {
508            return Err(Error::Message("too many revisions for difftool".into()));
509        }
510    };
511
512    Ok(filter_paths(entries, &paths))
513}
514
515fn filter_paths(entries: Vec<DiffEntry>, paths: &[String]) -> Vec<DiffEntry> {
516    if paths.is_empty() {
517        return entries;
518    }
519    entries
520        .into_iter()
521        .filter(|e| {
522            let p = e.path();
523            paths
524                .iter()
525                .any(|f| p == f || p.starts_with(&format!("{f}/")))
526        })
527        .collect()
528}
529
530fn apply_rotate_skip(
531    mut entries: Vec<DiffEntry>,
532    rotate_to: Option<&str>,
533    skip_to: Option<&str>,
534) -> Result<Vec<DiffEntry>> {
535    if let Some(target) = rotate_to {
536        let pos = entries
537            .iter()
538            .position(|e| e.path() == target)
539            .ok_or_else(|| Error::Message(format!("File '{target}' not in diff list")))?;
540        let tail = entries.split_off(pos);
541        entries = tail;
542    }
543    if let Some(target) = skip_to {
544        let pos = entries
545            .iter()
546            .position(|e| e.path() == target)
547            .ok_or_else(|| Error::Message(format!("File '{target}' not in diff list")))?;
548        entries = entries.split_off(pos);
549    }
550    Ok(entries)
551}
552
553fn head_tree_oid(repo: &Repository) -> Result<ObjectId> {
554    let head = resolve_head(&repo.git_dir)?;
555    let Some(oid) = head.oid() else {
556        return Err(Error::Message("unborn HEAD".into()));
557    };
558    peel_to_tree(repo, *oid)
559}
560
561fn commit_or_tree_oid(repo: &Repository, spec: &str) -> Result<ObjectId> {
562    let oid = resolve_revision(repo, spec).map_err(|e| Error::Message(e.to_string()))?;
563    peel_to_tree(repo, oid)
564}
565
566fn launch_file_diff(
567    repo: &Repository,
568    entry: &DiffEntry,
569    work_tree: &Path,
570    tmp_dir: &Path,
571    tool: &ToolContext,
572    counter: usize,
573    total: usize,
574    should_prompt: bool,
575    trust_exit_code: bool,
576    stdin: &mut dyn BufRead,
577    stdout: &mut dyn Write,
578) -> Result<i32> {
579    let merged = entry.path();
580    let (local_path, remote_path) = materialize_pair(repo, entry, work_tree, tmp_dir)?;
581
582    if should_prompt {
583        writeln!(stdout)?;
584        writeln!(stdout, "Viewing ({counter}/{total}): '{merged}'")?;
585        let prompt_label = tool.extcmd.as_deref().unwrap_or(&tool.tool_name);
586        write!(stdout, "Launch '{prompt_label}' [Y/n]? ")?;
587        stdout.flush().map_err(Error::Io)?;
588        let mut line = String::new();
589        if stdin.read_line(&mut line).ok().filter(|n| *n > 0).is_none() {
590            return Ok(0);
591        }
592        let ans = line.trim();
593        if ans.eq_ignore_ascii_case("n") || ans.eq_ignore_ascii_case("no") {
594            return Ok(0);
595        }
596    }
597
598    let status = run_tool(tool, &local_path, &remote_path, merged, counter, total)?;
599    let mut code = status.code().unwrap_or(1);
600    if code == 127 {
601        code = 128;
602    }
603    if trust_exit_code && code != 0 {
604        return Ok(code);
605    }
606    if code >= 126 {
607        return Ok(code);
608    }
609    Ok(0)
610}
611
612fn materialize_pair(
613    repo: &Repository,
614    entry: &DiffEntry,
615    work_tree: &Path,
616    tmp_dir: &Path,
617) -> Result<(PathBuf, PathBuf)> {
618    let safe_name = entry.path().replace('/', "_");
619    let local_tmp = tmp_dir.join(format!("local_{safe_name}"));
620    let remote_tmp = tmp_dir.join(format!("remote_{safe_name}"));
621
622    match entry.status {
623        DiffStatus::Added => {
624            write_blob_or_empty(&repo.odb, &entry.new_oid, &local_tmp)?;
625            let wt = work_tree.join(entry.path());
626            Ok((local_tmp, wt))
627        }
628        DiffStatus::Deleted => {
629            write_blob_or_empty(&repo.odb, &entry.old_oid, &local_tmp)?;
630            Ok((local_tmp, PathBuf::from("/dev/null")))
631        }
632        _ => {
633            write_blob_or_empty(&repo.odb, &entry.old_oid, &local_tmp)?;
634            let wt = work_tree.join(entry.path());
635            if wt.exists() {
636                Ok((local_tmp, wt))
637            } else {
638                write_blob_or_empty(&repo.odb, &entry.new_oid, &remote_tmp)?;
639                Ok((local_tmp, remote_tmp))
640            }
641        }
642    }
643}
644
645fn write_blob_or_empty(odb: &Odb, oid: &ObjectId, dest: &Path) -> Result<()> {
646    if oid.is_zero() {
647        std::fs::write(dest, "").map_err(Error::Io)?;
648        return Ok(());
649    }
650    let data = odb.read(oid)?;
651    std::fs::write(dest, &data.data).map_err(Error::Io)?;
652    Ok(())
653}
654
655fn run_tool(
656    tool: &ToolContext,
657    local: &Path,
658    remote: &Path,
659    merged: &str,
660    counter: usize,
661    total: usize,
662) -> Result<std::process::ExitStatus> {
663    if let Some(extcmd) = &tool.extcmd {
664        let script = format!(
665            "export LOCAL={local} REMOTE={remote} MERGED={merged} BASE={merged}; \
666             export GIT_DIFF_PATH_COUNTER={counter} GIT_DIFF_PATH_TOTAL={total} GIT_PREFIX=.; \
667             set -- \"$MERGED\" \"$LOCAL\" \"$REMOTE\"; \
668             eval {extcmd} \"$LOCAL\" \"$REMOTE\"",
669            local = shell_quote(&local.display().to_string()),
670            remote = shell_quote(&remote.display().to_string()),
671            merged = shell_quote(merged),
672            extcmd = extcmd,
673        );
674        return Command::new("sh")
675            .arg("-c")
676            .arg(&script)
677            .stdout(Stdio::inherit())
678            .status()
679            .map_err(Error::Io);
680    }
681
682    if let Some(tool_cmd) = &tool.tool_cmd {
683        let script = format!(
684            "export LOCAL={local} REMOTE={remote} MERGED={merged} BASE={merged}; \
685             export GIT_DIFF_PATH_COUNTER={counter} GIT_DIFF_PATH_TOTAL={total} GIT_PREFIX=.; \
686             export merge_tool={name} merge_tool_path={path}; \
687             eval {tool_cmd}",
688            local = shell_quote(&local.display().to_string()),
689            remote = shell_quote(&remote.display().to_string()),
690            merged = shell_quote(merged),
691            name = shell_quote(&tool.tool_name),
692            path = shell_quote(tool.tool_path.as_deref().unwrap_or(&tool.tool_name)),
693            tool_cmd = tool_cmd,
694        );
695        return Command::new("sh")
696            .arg("-c")
697            .arg(&script)
698            .stdout(Stdio::inherit())
699            .status()
700            .map_err(Error::Io);
701    }
702
703    let exe = tool.tool_path.as_deref().unwrap_or(&tool.tool_name);
704    Command::new(exe)
705        .arg(local)
706        .arg(remote)
707        .stdout(Stdio::inherit())
708        .status()
709        .map_err(Error::Io)
710}
711
712fn shell_quote(s: &str) -> String {
713    if s.is_empty() {
714        return "''".to_string();
715    }
716    if s.chars()
717        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '@' | '%' | '+' | '-' | '_' | '.' | '/'))
718    {
719        return s.to_string();
720    }
721    format!("'{}'", s.replace('\'', "'\\''"))
722}
723
724fn run_dir_diff(
725    repo: &Repository,
726    entries: &[DiffEntry],
727    work_tree: &Path,
728    index: &Index,
729    tool: &ToolContext,
730    opts: &DifftoolOptions,
731    _env: &DifftoolEnv,
732    config: &ConfigSet,
733    trust_exit_code: bool,
734    should_prompt: bool,
735    stdin: &mut dyn BufRead,
736    stdout: &mut dyn Write,
737) -> Result<DifftoolResult> {
738    let tmp = tempfile::tempdir().map_err(Error::Io)?;
739    let left = tmp.path().join("left");
740    let right = tmp.path().join("right");
741    std::fs::create_dir_all(&left).map_err(Error::Io)?;
742    std::fs::create_dir_all(&right).map_err(Error::Io)?;
743
744    let use_symlinks = opts
745        .symlinks
746        .or_else(|| config.get_bool("core.symlinks").and_then(|r| r.ok()))
747        .unwrap_or(true);
748
749    for entry in entries {
750        populate_dir_side(repo, &left, entry, true, work_tree, index, use_symlinks)?;
751        populate_dir_side(repo, &right, entry, false, work_tree, index, use_symlinks)?;
752    }
753
754    if should_prompt {
755        let prompt_label = tool.extcmd.as_deref().unwrap_or(&tool.tool_name);
756        write!(stdout, "Launch '{prompt_label}' [Y/n]? ")?;
757        stdout.flush().map_err(Error::Io)?;
758        let mut line = String::new();
759        if stdin.read_line(&mut line).ok().filter(|n| *n > 0).is_none() {
760            return Ok(DifftoolResult { exit_code: 0 });
761        }
762        let ans = line.trim();
763        if ans.eq_ignore_ascii_case("n") || ans.eq_ignore_ascii_case("no") {
764            return Ok(DifftoolResult { exit_code: 0 });
765        }
766    }
767
768    let status = if let Some(extcmd) = &tool.extcmd {
769        let script = format!(
770            "export LOCAL={} REMOTE={}; export GIT_DIFFTOOL_DIRDIFF=true; \
771             set -- . \"$LOCAL\" \"$REMOTE\"; eval {} \"$LOCAL\" \"$REMOTE\"",
772            shell_quote(&left.display().to_string()),
773            shell_quote(&right.display().to_string()),
774            extcmd,
775        );
776        Command::new("sh")
777            .arg("-c")
778            .arg(script)
779            .stdout(Stdio::inherit())
780            .status()
781            .map_err(Error::Io)?
782    } else if let Some(tool_cmd) = &tool.tool_cmd {
783        let script = format!(
784            "export LOCAL={} REMOTE={} MERGED=. BASE=.; export GIT_DIFFTOOL_DIRDIFF=true; \
785             export merge_tool={} merge_tool_path={}; eval {}",
786            shell_quote(&left.display().to_string()),
787            shell_quote(&right.display().to_string()),
788            shell_quote(&tool.tool_name),
789            shell_quote(tool.tool_path.as_deref().unwrap_or(&tool.tool_name)),
790            tool_cmd,
791        );
792        Command::new("sh")
793            .arg("-c")
794            .arg(script)
795            .stdout(Stdio::inherit())
796            .status()
797            .map_err(Error::Io)?
798    } else {
799        let exe = tool.tool_path.as_deref().unwrap_or(&tool.tool_name);
800        Command::new(exe)
801            .arg(&left)
802            .arg(&right)
803            .stdout(Stdio::inherit())
804            .status()
805            .map_err(Error::Io)?
806    };
807
808    let code = status.code().unwrap_or(1);
809    if code >= 126 {
810        return Ok(DifftoolResult { exit_code: code });
811    }
812    if trust_exit_code && code != 0 {
813        return Ok(DifftoolResult { exit_code: code });
814    }
815    Ok(DifftoolResult { exit_code: 0 })
816}
817
818fn populate_dir_side(
819    repo: &Repository,
820    dir: &Path,
821    entry: &DiffEntry,
822    is_left: bool,
823    work_tree: &Path,
824    index: &Index,
825    use_symlinks: bool,
826) -> Result<()> {
827    let path = if is_left {
828        entry.old_path.as_deref().or(entry.new_path.as_deref())
829    } else {
830        entry.new_path.as_deref().or(entry.old_path.as_deref())
831    };
832    let Some(rel) = path else {
833        return Ok(());
834    };
835    let dest = dir.join(rel);
836    if let Some(parent) = dest.parent() {
837        std::fs::create_dir_all(parent).map_err(Error::Io)?;
838    }
839
840    let mode_str = if is_left {
841        &entry.old_mode
842    } else {
843        &entry.new_mode
844    };
845    let oid = if is_left {
846        &entry.old_oid
847    } else {
848        &entry.new_oid
849    };
850
851    if mode_str == "160000" {
852        let label = if oid.is_zero() {
853            "Subproject commit 0000000000000000000000000000000000000000"
854        } else {
855            &format!("Subproject commit {}", oid.to_hex())
856        };
857        std::fs::write(&dest, label).map_err(Error::Io)?;
858        return Ok(());
859    }
860
861    if mode_str.starts_with("120000") {
862        let target = if oid.is_zero() {
863            std::fs::read_link(work_tree.join(rel))
864                .map(|p| p.to_string_lossy().into_owned())
865                .unwrap_or_default()
866        } else {
867            String::from_utf8_lossy(&repo.odb.read(oid)?.data).into_owned()
868        };
869        if use_symlinks {
870            let _ = std::fs::remove_file(&dest);
871            std::os::unix::fs::symlink(&target, &dest).map_err(Error::Io)?;
872        } else {
873            std::fs::write(&dest, target).map_err(Error::Io)?;
874        }
875        return Ok(());
876    }
877
878    if oid.is_zero() {
879        return Ok(());
880    }
881
882    let data = repo.odb.read(oid)?;
883    std::fs::write(&dest, &data.data).map_err(Error::Io)?;
884
885    // Copy working-tree modifications for right side when applicable.
886    if !is_left {
887        let wt = work_tree.join(rel);
888        if wt.exists() {
889            if let Ok(bytes) = std::fs::read(&wt) {
890                std::fs::write(&dest, bytes).map_err(Error::Io)?;
891            }
892        } else if let Some(idx) = index.get(rel.as_bytes(), 0) {
893            if !idx.oid.is_zero() {
894                let data = repo.odb.read(&idx.oid)?;
895                std::fs::write(&dest, &data.data).map_err(Error::Io)?;
896            }
897        }
898    }
899    Ok(())
900}
901
902fn run_no_index_difftool(
903    opts: &DifftoolOptions,
904    env: &DifftoolEnv,
905    config: &ConfigSet,
906    stdin: &mut dyn BufRead,
907    stdout: &mut dyn Write,
908) -> Result<DifftoolResult> {
909    let mut paths = Vec::new();
910    let mut seen_no_index = false;
911    for arg in &opts.diff_argv {
912        if arg == "--no-index" {
913            seen_no_index = true;
914            continue;
915        }
916        if !arg.starts_with('-') {
917            paths.push(arg.clone());
918        }
919    }
920    if !seen_no_index || paths.len() != 2 {
921        return Err(Error::Message(
922            "difftool --no-index requires exactly two paths".into(),
923        ));
924    }
925    let tool_ctx = resolve_tool_context(opts, env, config)?;
926    let local = PathBuf::from(&paths[0]);
927    let remote = PathBuf::from(&paths[1]);
928    let should_prompt = resolve_should_prompt(opts, env, config);
929    if should_prompt {
930        write!(stdout, "Launch '{}' [Y/n]? ", tool_ctx.tool_name)?;
931        stdout.flush().map_err(Error::Io)?;
932        let mut line = String::new();
933        if stdin.read_line(&mut line).ok().filter(|n| *n > 0).is_none() {
934            return Ok(DifftoolResult { exit_code: 0 });
935        }
936    }
937    let status = run_tool(
938        &tool_ctx,
939        &local,
940        &remote,
941        local.file_name().and_then(|s| s.to_str()).unwrap_or(""),
942        1,
943        1,
944    )?;
945    Ok(DifftoolResult {
946        exit_code: status.code().unwrap_or(1),
947    })
948}