Skip to main content

ag_git/
merge.rs

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/// Outcome of attempting a squash merge operation.
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum SquashMergeOutcome {
13    /// Squash merge staged changes and created a commit.
14    Committed,
15    /// Squash merge staged nothing because changes already exist in target.
16    AlreadyPresentInTarget,
17}
18
19/// Outcome classification for one attempted `merge-tree --write-tree` probe.
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21enum MergeTreeAttempt {
22    Clean,
23    Conflict,
24    Unsupported,
25    Failed,
26}
27
28/// Captured output from the compatibility merge command.
29#[derive(Clone, Debug, Eq, PartialEq)]
30struct CompatibilityMergeOutput {
31    stderr: Vec<u8>,
32    stdout: Vec<u8>,
33    success: bool,
34}
35
36/// Executes the Git commands used by the compatibility merge probe.
37#[cfg_attr(test, mockall::automock)]
38trait CompatibilityMergeRunner: Send + Sync {
39    /// Runs a Git command that must succeed and returns its standard output.
40    fn run_git_command(
41        &self,
42        repo_path: &Path,
43        args: &[String],
44        error_context: &str,
45    ) -> Result<String, GitError>;
46
47    /// Runs the merge command and returns its status and captured output.
48    fn run_git_command_output(
49        &self,
50        repo_path: &Path,
51        args: &[String],
52    ) -> Result<CompatibilityMergeOutput, GitError>;
53}
54
55/// Compatibility merge runner backed by local Git subprocesses.
56struct 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
87/// Returns whether merging `source_branch` into `target_branch` would produce
88/// conflicts without reading or changing the repository index or worktree.
89///
90/// # Errors
91/// Returns an error when either branch cannot be resolved or neither the
92/// native `git merge-tree` probe nor its compatibility fallback can compute
93/// the merge.
94pub(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
128/// Classifies native merge-tree output, including the pre-2.38 unsupported
129/// synopsis that does not advertise `--write-tree`.
130fn 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
148/// Resolves a classified native probe, delegating unsupported Git versions to
149/// an isolated compatibility merge.
150fn 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
175/// Computes the merge in a disposable local clone for Git versions whose
176/// `merge-tree` lacks `--write-tree`.
177fn 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
194/// Computes a compatibility merge through an injectable command boundary.
195fn 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
291/// Returns the full patch diff that will be squashed when merging a source
292/// branch into a target branch.
293///
294/// Uses `git diff <target>..<source>`.
295///
296/// # Arguments
297/// * `repo_path` - Path to the git repository root
298/// * `source_branch` - Name of the branch being merged
299/// * `target_branch` - Name of the branch receiving the squash merge
300///
301/// # Returns
302/// The full patch diff for the squash merge range.
303///
304/// # Errors
305/// Returns an error if invoking `git` fails or `git diff` exits with a
306/// non-zero status.
307pub(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
324/// Performs a squash merge from a source branch to a target branch.
325///
326/// This function:
327/// 1. Verifies the repository is already on the target branch
328/// 2. Performs `git merge --squash` from the source branch
329/// 3. Commits the squashed changes, running configured commit hooks
330///
331/// The caller is responsible for ensuring `repo_path` is already checked out
332/// on `target_branch`. Switching branches here would disrupt the user's
333/// working directory.
334///
335/// # Arguments
336/// * `repo_path` - Path to the git repository root, already on `target_branch`
337/// * `source_branch` - Name of the branch to merge from (e.g., `wt/abc123`)
338/// * `target_branch` - Name of the branch to merge into (e.g., `main`)
339/// * `commit_message` - Message for the squash commit
340///
341/// # Returns
342/// A [`SquashMergeOutcome`] describing whether a squash commit was created.
343///
344/// # Errors
345/// Returns an error if the repository is on the wrong branch, the merge
346/// fails, or the commit or a configured commit hook fails.
347pub(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        // Verify that `repo_path` is already on the target branch.
355        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        // `git diff --cached --quiet` exits 0 when index matches `HEAD`.
379        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;