1use crate::repo::GitRepo;
28use anyhow::{Context, Result};
29use crossterm::{execute, terminal};
30use std::io::Write as _;
31use std::path::Path;
32
33pub 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
53fn builtin_cmd(name: &str) -> Option<String> {
55 match name {
56 "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
71pub 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 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 let _ = terminal::enable_raw_mode();
100 let _ = execute!(std::io::stdout(), terminal::EnterAlternateScreen);
101
102 result?;
103 Ok(true)
104}
105
106#[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 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 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 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 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 repo.stage_file(file_path)
209 .with_context(|| format!("failed to stage resolved file '{file_path}'"))?;
210
211 Ok(())
212}