1use std::path::{Path, PathBuf};
2
3use tempfile::tempdir;
4use tokio::task::spawn_blocking;
5
6use super::error::GitError;
7use super::repo::{command_output_detail, run_git_command_output_sync, run_git_command_sync};
8use super::worktree::detect_git_info_sync;
9
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum SquashMergeOutcome {
13 Committed,
15 AlreadyPresentInTarget,
17}
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21enum MergeTreeAttempt {
22 Clean,
23 Conflict,
24 Unsupported,
25 Failed,
26}
27
28#[derive(Clone, Debug, Eq, PartialEq)]
30struct CompatibilityMergeOutput {
31 stderr: Vec<u8>,
32 stdout: Vec<u8>,
33 success: bool,
34}
35
36#[cfg_attr(test, mockall::automock)]
38trait CompatibilityMergeRunner: Send + Sync {
39 fn run_git_command(
41 &self,
42 repo_path: &Path,
43 args: &[String],
44 error_context: &str,
45 ) -> Result<String, GitError>;
46
47 fn run_git_command_output(
49 &self,
50 repo_path: &Path,
51 args: &[String],
52 ) -> Result<CompatibilityMergeOutput, GitError>;
53}
54
55struct ProcessCompatibilityMergeRunner;
57
58impl CompatibilityMergeRunner for ProcessCompatibilityMergeRunner {
59 fn run_git_command(
60 &self,
61 repo_path: &Path,
62 args: &[String],
63 error_context: &str,
64 ) -> Result<String, GitError> {
65 let args = args.iter().map(String::as_str).collect::<Vec<_>>();
66
67 run_git_command_sync(repo_path, &args, error_context)
68 }
69
70 fn run_git_command_output(
71 &self,
72 repo_path: &Path,
73 args: &[String],
74 ) -> Result<CompatibilityMergeOutput, GitError> {
75 let args = args.iter().map(String::as_str).collect::<Vec<_>>();
76 let output = run_git_command_output_sync(repo_path, &args)?;
77 let success = output.status.success();
78
79 Ok(CompatibilityMergeOutput {
80 stderr: output.stderr,
81 stdout: output.stdout,
82 success,
83 })
84 }
85}
86
87pub(crate) async fn has_merge_conflicts(
95 repo_path: PathBuf,
96 source_branch: String,
97 target_branch: String,
98) -> Result<bool, GitError> {
99 spawn_blocking(move || {
100 let output = run_git_command_output_sync(
101 &repo_path,
102 &[
103 "merge-tree",
104 "--write-tree",
105 target_branch.as_str(),
106 source_branch.as_str(),
107 ],
108 )?;
109
110 let attempt = classify_merge_tree_attempt(
111 output.status.code(),
112 output.stdout.as_slice(),
113 output.stderr.as_slice(),
114 );
115
116 resolve_merge_tree_attempt(
117 &repo_path,
118 source_branch.as_str(),
119 target_branch.as_str(),
120 attempt,
121 output.stdout.as_slice(),
122 output.stderr.as_slice(),
123 )
124 })
125 .await?
126}
127
128fn classify_merge_tree_attempt(
131 exit_code: Option<i32>,
132 stdout: &[u8],
133 stderr: &[u8],
134) -> MergeTreeAttempt {
135 match exit_code {
136 Some(0) => MergeTreeAttempt::Clean,
137 Some(1) if stderr.is_empty() => MergeTreeAttempt::Conflict,
138 Some(129)
139 if !String::from_utf8_lossy(stdout).contains("--write-tree")
140 && !String::from_utf8_lossy(stderr).contains("--write-tree") =>
141 {
142 MergeTreeAttempt::Unsupported
143 }
144 _ => MergeTreeAttempt::Failed,
145 }
146}
147
148fn resolve_merge_tree_attempt(
151 repo_path: &std::path::Path,
152 source_branch: &str,
153 target_branch: &str,
154 attempt: MergeTreeAttempt,
155 stdout: &[u8],
156 stderr: &[u8],
157) -> Result<bool, GitError> {
158 match attempt {
159 MergeTreeAttempt::Clean => Ok(false),
160 MergeTreeAttempt::Conflict => Ok(true),
161 MergeTreeAttempt::Unsupported => {
162 has_merge_conflicts_via_temporary_clone(repo_path, source_branch, target_branch)
163 }
164 MergeTreeAttempt::Failed => {
165 let detail = command_output_detail(stdout, stderr);
166
167 Err(GitError::CommandFailed {
168 command: format!("git merge-tree --write-tree {target_branch} {source_branch}"),
169 stderr: format!("Failed to inspect merge conflicts: {detail}"),
170 })
171 }
172 }
173}
174
175fn has_merge_conflicts_via_temporary_clone(
178 repo_path: &Path,
179 source_branch: &str,
180 target_branch: &str,
181) -> Result<bool, GitError> {
182 let temporary_directory = tempdir()?;
183 let command_runner = ProcessCompatibilityMergeRunner;
184
185 has_merge_conflicts_via_temporary_clone_with_runner(
186 repo_path,
187 source_branch,
188 target_branch,
189 &temporary_directory,
190 &command_runner,
191 )
192}
193
194fn has_merge_conflicts_via_temporary_clone_with_runner(
196 repo_path: &Path,
197 source_branch: &str,
198 target_branch: &str,
199 temporary_directory: &tempfile::TempDir,
200 command_runner: &dyn CompatibilityMergeRunner,
201) -> Result<bool, GitError> {
202 let source_revision = format!("{source_branch}^{{commit}}");
203 let source_commit = command_runner.run_git_command(
204 repo_path,
205 &[
206 "rev-parse".to_string(),
207 "--verify".to_string(),
208 source_revision,
209 ],
210 "Failed to resolve merge source",
211 )?;
212 let target_revision = format!("{target_branch}^{{commit}}");
213 let target_commit = command_runner.run_git_command(
214 repo_path,
215 &[
216 "rev-parse".to_string(),
217 "--verify".to_string(),
218 target_revision,
219 ],
220 "Failed to resolve merge target",
221 )?;
222 let source_commit = source_commit.trim();
223 let target_commit = target_commit.trim();
224
225 let clone_path = temporary_directory.path().join("repository");
226 let clone_path_text = clone_path.to_string_lossy();
227 command_runner.run_git_command(
228 repo_path,
229 &[
230 "clone".to_string(),
231 "--shared".to_string(),
232 "--no-checkout".to_string(),
233 "--quiet".to_string(),
234 ".".to_string(),
235 clone_path_text.into_owned(),
236 ],
237 "Failed to create compatibility merge clone",
238 )?;
239 command_runner.run_git_command(
240 &clone_path,
241 &[
242 "checkout".to_string(),
243 "--detach".to_string(),
244 "--quiet".to_string(),
245 target_commit.to_string(),
246 ],
247 "Failed to check out compatibility merge target",
248 )?;
249
250 let disabled_hooks_path = temporary_directory.path().join("disabled-hooks");
251 let disabled_hooks_path = disabled_hooks_path.to_string_lossy();
252 let hooks_config = format!("core.hooksPath={disabled_hooks_path}");
253 let merge_output = command_runner.run_git_command_output(
254 &clone_path,
255 &[
256 "-c".to_string(),
257 hooks_config,
258 "-c".to_string(),
259 "user.name=Agentty".to_string(),
260 "-c".to_string(),
261 "user.email=agentty@localhost".to_string(),
262 "-c".to_string(),
263 "user.useConfigOnly=true".to_string(),
264 "merge".to_string(),
265 "--no-commit".to_string(),
266 "--no-ff".to_string(),
267 source_commit.to_string(),
268 ],
269 )?;
270 if merge_output.success {
271 return Ok(false);
272 }
273
274 let unmerged_files = command_runner.run_git_command(
275 &clone_path,
276 &["ls-files".to_string(), "--unmerged".to_string()],
277 "Failed to inspect compatibility merge conflicts",
278 )?;
279 if !unmerged_files.trim().is_empty() {
280 return Ok(true);
281 }
282
283 let detail = command_output_detail(&merge_output.stdout, &merge_output.stderr);
284
285 Err(GitError::CommandFailed {
286 command: format!("git merge --no-commit --no-ff {source_commit}"),
287 stderr: format!("Failed to inspect merge conflicts in compatibility clone: {detail}"),
288 })
289}
290
291pub(crate) async fn squash_merge_diff(
308 repo_path: PathBuf,
309 source_branch: String,
310 target_branch: String,
311) -> Result<String, GitError> {
312 spawn_blocking(move || {
313 let revision_range = format!("{target_branch}..{source_branch}");
314
315 run_git_command_sync(
316 &repo_path,
317 &["diff", revision_range.as_str()],
318 "Failed to read squash merge diff",
319 )
320 })
321 .await?
322}
323
324pub(crate) async fn squash_merge(
348 repo_path: PathBuf,
349 source_branch: String,
350 target_branch: String,
351 commit_message: String,
352) -> Result<SquashMergeOutcome, GitError> {
353 spawn_blocking(move || {
354 let current_branch = detect_git_info_sync(&repo_path).ok_or_else(|| {
356 GitError::OutputParse(format!(
357 "Failed to detect current branch in {}",
358 repo_path.display()
359 ))
360 })?;
361
362 if current_branch != target_branch {
363 return Err(GitError::CommandFailed {
364 command: "git merge --squash".to_string(),
365 stderr: format!(
366 "Cannot merge: repository is on '{current_branch}' but expected \
367 '{target_branch}'. Switch to '{target_branch}' first."
368 ),
369 });
370 }
371
372 run_git_command_sync(
373 &repo_path,
374 &["merge", "--squash", source_branch.as_str()],
375 &format!("Failed to squash merge {source_branch}"),
376 )?;
377
378 let cached_diff =
380 run_git_command_output_sync(&repo_path, &["diff", "--cached", "--quiet"])?;
381
382 if cached_diff.status.success() {
383 return Ok(SquashMergeOutcome::AlreadyPresentInTarget);
384 }
385
386 if cached_diff.status.code() != Some(1) {
387 let detail = command_output_detail(&cached_diff.stdout, &cached_diff.stderr);
388
389 return Err(GitError::CommandFailed {
390 command: "git diff --cached".to_string(),
391 stderr: detail,
392 });
393 }
394
395 run_git_command_sync(
396 &repo_path,
397 &["commit", "-m", commit_message.as_str()],
398 "Failed to commit squash merge",
399 )?;
400
401 Ok(SquashMergeOutcome::Committed)
402 })
403 .await?
404}
405
406#[cfg(test)]
407#[path = "merge_test.rs"]
408mod tests;