Skip to main content

git_tailor/
mergetool.rs

1// Copyright 2026 Thomas Johannesson
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15// Launch the user-configured merge tool to resolve index conflicts.
16//
17// Git's merge tool contract:
18//   - `merge.tool` names the tool
19//   - `mergetool.<name>.cmd` is an optional shell command for that tool
20//   - The shell command has access to $LOCAL (ours), $REMOTE (theirs),
21//     $BASE (ancestor), and $MERGED (the working-tree file to save the result)
22//   - git runs the cmd through a shell and waits for it to exit
23//
24// We follow the same contract: suspend the TUI, write the three index stages
25// to temp files, run the tool via `sh -c`, wait for exit, then restore.
26
27use crate::repo::GitRepo;
28use anyhow::{Context, Result};
29use crossterm::{execute, terminal};
30use std::io::Write as _;
31use std::path::Path;
32
33/// Resolve the shell command to use for the configured merge tool.
34///
35/// Lookup order:
36/// 1. `merge.tool` config → tool name
37/// 2. `mergetool.<name>.cmd` → custom shell command for that tool
38/// 3. Built-in patterns for well-known tools
39///
40/// Returns `None` when no merge tool is configured or the named tool is not
41/// recognised and has no custom cmd.
42pub fn resolve_merge_tool_cmd(repo: &impl GitRepo) -> Option<String> {
43    let name = repo.get_config_string("merge.tool")?;
44    let name = name.trim().to_string();
45
46    if let Some(cmd) = repo.get_config_string(&format!("mergetool.{name}.cmd")) {
47        return Some(cmd.trim().to_string());
48    }
49
50    builtin_cmd(&name)
51}
52
53/// Shell command for well-known built-in merge tools.
54fn builtin_cmd(name: &str) -> Option<String> {
55    match name {
56        // vimdiff / nvimdiff family — two-way diff with MERGED as output
57        "vimdiff" | "vimdiff2" => Some(format!("{name} -d $LOCAL $MERGED $REMOTE")),
58        "vimdiff3" => Some(format!("{name} -d $LOCAL $BASE $REMOTE $MERGED")),
59        "nvimdiff" | "nvimdiff2" => Some(format!("{name} -d $LOCAL $MERGED $REMOTE")),
60        "nvimdiff3" => Some("nvim -d $LOCAL $BASE $REMOTE $MERGED".to_string()),
61        "meld" => Some("meld $LOCAL $MERGED $REMOTE".to_string()),
62        "kdiff3" => Some(
63            "kdiff3 --L1 $MERGED --L2 $LOCAL --L3 $REMOTE -o $MERGED $BASE $LOCAL $REMOTE"
64                .to_string(),
65        ),
66        "opendiff" => Some("opendiff $LOCAL $REMOTE -ancestor $BASE -merge $MERGED".to_string()),
67        _ => None,
68    }
69}
70
71/// Launch the configured merge tool for every file in `conflicting_files`.
72///
73/// Suspends the TUI before the first tool invocation and restores it after the
74/// last one, so each tool instance has full control of the terminal. The TUI is
75/// always restored, even when a tool exits with a non-zero status.
76///
77/// Returns `true` when the tool was invoked for at least one file, or `false`
78/// when no merge tool is configured (so the caller can show a hint).
79pub fn run_mergetool(repo: &impl GitRepo, conflicting_files: &[String]) -> Result<bool> {
80    let Some(cmd) = resolve_merge_tool_cmd(repo) else {
81        return Ok(false);
82    };
83
84    if conflicting_files.is_empty() {
85        return Ok(true);
86    }
87
88    let workdir = repo
89        .workdir()
90        .ok_or_else(|| anyhow::anyhow!("repository has no working directory"))?;
91
92    // Suspend the TUI before handing the terminal to the merge tool.
93    terminal::disable_raw_mode().context("failed to disable raw mode")?;
94    let _ = execute!(std::io::stdout(), terminal::LeaveAlternateScreen);
95
96    let result = run_for_all_files(&cmd, &workdir, repo, conflicting_files);
97
98    // Restore the TUI unconditionally so the app is never left in a broken state.
99    let _ = terminal::enable_raw_mode();
100    let _ = execute!(std::io::stdout(), terminal::EnterAlternateScreen);
101
102    result?;
103    Ok(true)
104}
105
106/// Lower-level entry point that runs the tool against every file without
107/// touching the TUI. Exposed for integration tests.
108///
109/// Use [`run_mergetool`] from application code — it handles TUI suspend/restore.
110#[doc(hidden)]
111pub fn run_for_all_files(
112    cmd: &str,
113    workdir: &Path,
114    repo: &impl GitRepo,
115    files: &[String],
116) -> Result<()> {
117    for file_path in files {
118        run_tool_for_file(cmd, workdir, repo, file_path)
119            .with_context(|| format!("merge tool failed on '{file_path}'"))?;
120    }
121    Ok(())
122}
123
124fn run_tool_for_file(
125    cmd: &str,
126    workdir: &Path,
127    repo: &impl GitRepo,
128    file_path: &str,
129) -> Result<()> {
130    let base_content = repo
131        .read_index_stage(file_path, 1)
132        .context("failed to read base stage")?
133        .unwrap_or_default();
134    let ours_content = repo
135        .read_index_stage(file_path, 2)
136        .context("failed to read ours stage")?
137        .unwrap_or_default();
138    let theirs_content = repo
139        .read_index_stage(file_path, 3)
140        .context("failed to read theirs stage")?
141        .unwrap_or_default();
142
143    // Include the original file extension so tools can apply syntax highlighting.
144    let ext = Path::new(file_path)
145        .extension()
146        .and_then(|e| e.to_str())
147        .map(|e| format!(".{e}"))
148        .unwrap_or_default();
149
150    let mut base_tmp = tempfile::Builder::new()
151        .suffix(&format!(".BASE{ext}"))
152        .tempfile()
153        .context("failed to create BASE temp file")?;
154    let mut local_tmp = tempfile::Builder::new()
155        .suffix(&format!(".LOCAL{ext}"))
156        .tempfile()
157        .context("failed to create LOCAL temp file")?;
158    let mut remote_tmp = tempfile::Builder::new()
159        .suffix(&format!(".REMOTE{ext}"))
160        .tempfile()
161        .context("failed to create REMOTE temp file")?;
162
163    base_tmp
164        .write_all(&base_content)
165        .context("failed to write BASE temp file")?;
166    local_tmp
167        .write_all(&ours_content)
168        .context("failed to write LOCAL temp file")?;
169    remote_tmp
170        .write_all(&theirs_content)
171        .context("failed to write REMOTE temp file")?;
172
173    // Flush before the child process opens the files.
174    base_tmp.flush().context("failed to flush BASE temp file")?;
175    local_tmp
176        .flush()
177        .context("failed to flush LOCAL temp file")?;
178    remote_tmp
179        .flush()
180        .context("failed to flush REMOTE temp file")?;
181
182    let merged_path = workdir.join(file_path);
183
184    // Run via shell so $LOCAL / $BASE / $REMOTE / $MERGED expand from the env vars.
185    let status = std::process::Command::new("sh")
186        .arg("-c")
187        .arg(cmd)
188        .env("BASE", base_tmp.path())
189        .env("LOCAL", local_tmp.path())
190        .env("REMOTE", remote_tmp.path())
191        .env("MERGED", &merged_path)
192        .status()
193        .context("failed to launch merge tool")?;
194
195    // Temp files are kept alive (not dropped) until here, ensuring the child
196    // can read them for the full duration of its execution.
197    drop(base_tmp);
198    drop(local_tmp);
199    drop(remote_tmp);
200
201    if !status.success() {
202        anyhow::bail!("merge tool exited with {status}");
203    }
204
205    // Stage the resolved file so the index conflict entries (stage 1/2/3) are
206    // replaced with a normal stage-0 entry. This is what `git mergetool` does
207    // automatically and what makes `index.has_conflicts()` return false afterward.
208    repo.stage_file(file_path)
209        .with_context(|| format!("failed to stage resolved file '{file_path}'"))?;
210
211    Ok(())
212}