Skip to main content

ag_git/
merge.rs

1use std::path::PathBuf;
2
3use tokio::task::spawn_blocking;
4
5use super::error::GitError;
6use super::repo::{command_output_detail, run_git_command_output_sync, run_git_command_sync};
7use super::worktree::detect_git_info_sync;
8
9/// Outcome of attempting a squash merge operation.
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11pub enum SquashMergeOutcome {
12    /// Squash merge staged changes and created a commit.
13    Committed,
14    /// Squash merge staged nothing because changes already exist in target.
15    AlreadyPresentInTarget,
16}
17
18/// Returns the full patch diff that will be squashed when merging a source
19/// branch into a target branch.
20///
21/// Uses `git diff <target>..<source>`.
22///
23/// # Arguments
24/// * `repo_path` - Path to the git repository root
25/// * `source_branch` - Name of the branch being merged
26/// * `target_branch` - Name of the branch receiving the squash merge
27///
28/// # Returns
29/// The full patch diff for the squash merge range.
30///
31/// # Errors
32/// Returns an error if invoking `git` fails or `git diff` exits with a
33/// non-zero status.
34pub(crate) async fn squash_merge_diff(
35    repo_path: PathBuf,
36    source_branch: String,
37    target_branch: String,
38) -> Result<String, GitError> {
39    spawn_blocking(move || {
40        let revision_range = format!("{target_branch}..{source_branch}");
41
42        run_git_command_sync(
43            &repo_path,
44            &["diff", revision_range.as_str()],
45            "Failed to read squash merge diff",
46        )
47    })
48    .await?
49}
50
51/// Performs a squash merge from a source branch to a target branch.
52///
53/// This function:
54/// 1. Verifies the repository is already on the target branch
55/// 2. Performs `git merge --squash` from the source branch
56/// 3. Commits the squashed changes, running configured commit hooks
57///
58/// The caller is responsible for ensuring `repo_path` is already checked out
59/// on `target_branch`. Switching branches here would disrupt the user's
60/// working directory.
61///
62/// # Arguments
63/// * `repo_path` - Path to the git repository root, already on `target_branch`
64/// * `source_branch` - Name of the branch to merge from (e.g., `wt/abc123`)
65/// * `target_branch` - Name of the branch to merge into (e.g., `main`)
66/// * `commit_message` - Message for the squash commit
67///
68/// # Returns
69/// A [`SquashMergeOutcome`] describing whether a squash commit was created.
70///
71/// # Errors
72/// Returns an error if the repository is on the wrong branch, the merge
73/// fails, or the commit or a configured commit hook fails.
74pub(crate) async fn squash_merge(
75    repo_path: PathBuf,
76    source_branch: String,
77    target_branch: String,
78    commit_message: String,
79) -> Result<SquashMergeOutcome, GitError> {
80    spawn_blocking(move || {
81        // Verify that `repo_path` is already on the target branch.
82        let current_branch = detect_git_info_sync(&repo_path).ok_or_else(|| {
83            GitError::OutputParse(format!(
84                "Failed to detect current branch in {}",
85                repo_path.display()
86            ))
87        })?;
88
89        if current_branch != target_branch {
90            return Err(GitError::CommandFailed {
91                command: "git merge --squash".to_string(),
92                stderr: format!(
93                    "Cannot merge: repository is on '{current_branch}' but expected \
94                     '{target_branch}'. Switch to '{target_branch}' first."
95                ),
96            });
97        }
98
99        run_git_command_sync(
100            &repo_path,
101            &["merge", "--squash", source_branch.as_str()],
102            &format!("Failed to squash merge {source_branch}"),
103        )?;
104
105        // `git diff --cached --quiet` exits 0 when index matches `HEAD`.
106        let cached_diff =
107            run_git_command_output_sync(&repo_path, &["diff", "--cached", "--quiet"])?;
108
109        if cached_diff.status.success() {
110            return Ok(SquashMergeOutcome::AlreadyPresentInTarget);
111        }
112
113        if cached_diff.status.code() != Some(1) {
114            let detail = command_output_detail(&cached_diff.stdout, &cached_diff.stderr);
115
116            return Err(GitError::CommandFailed {
117                command: "git diff --cached".to_string(),
118                stderr: detail,
119            });
120        }
121
122        run_git_command_sync(
123            &repo_path,
124            &["commit", "-m", commit_message.as_str()],
125            "Failed to commit squash merge",
126        )?;
127
128        Ok(SquashMergeOutcome::Committed)
129    })
130    .await?
131}
132
133#[cfg(test)]
134mod tests {
135    use std::fs;
136    #[cfg(unix)]
137    use std::os::unix::fs::PermissionsExt;
138    use std::path::Path;
139    use std::process::Command;
140
141    use tempfile::tempdir;
142
143    use super::*;
144
145    /// Runs `git` in `repo_path` and asserts the command succeeds.
146    fn run_git_command(repo_path: &Path, args: &[&str]) {
147        let output = Command::new("git")
148            .args(args)
149            .current_dir(repo_path)
150            .output()
151            .expect("failed to run git command");
152
153        assert!(
154            output.status.success(),
155            "git command {:?} failed: {}",
156            args,
157            String::from_utf8_lossy(&output.stderr)
158        );
159    }
160
161    /// Runs `git` in `repo_path` and returns trimmed stdout.
162    fn run_git_stdout(repo_path: &Path, args: &[&str]) -> String {
163        let output = Command::new("git")
164            .args(args)
165            .current_dir(repo_path)
166            .output()
167            .expect("failed to run git command");
168
169        assert!(
170            output.status.success(),
171            "git command {:?} failed: {}",
172            args,
173            String::from_utf8_lossy(&output.stderr)
174        );
175
176        String::from_utf8_lossy(&output.stdout).trim().to_string()
177    }
178
179    /// Creates a committed repository rooted at `repo_path`.
180    fn setup_test_git_repo(repo_path: &Path) {
181        run_git_command(repo_path, &["init", "-b", "main"]);
182        run_git_command(repo_path, &["config", "user.name", "Test User"]);
183        run_git_command(repo_path, &["config", "user.email", "test@example.com"]);
184        fs::write(repo_path.join("README.md"), "base\n").expect("failed to write base file");
185        run_git_command(repo_path, &["add", "README.md"]);
186        run_git_command(repo_path, &["commit", "-m", "Initial commit"]);
187    }
188
189    #[tokio::test]
190    async fn squash_merge_returns_branch_mismatch_error_when_target_is_not_checked_out() {
191        // Arrange
192        let temp_dir = tempdir().expect("failed to create temp dir");
193        setup_test_git_repo(temp_dir.path());
194        run_git_command(temp_dir.path(), &["checkout", "-b", "feature-branch"]);
195
196        // Act
197        let result = squash_merge(
198            temp_dir.path().to_path_buf(),
199            "feature-branch".to_string(),
200            "main".to_string(),
201            "Merge feature".to_string(),
202        )
203        .await;
204
205        // Assert
206        let error = result.expect_err("branch mismatch should fail").to_string();
207        assert!(error.contains("repository is on 'feature-branch'"));
208        assert!(error.contains("Switch to 'main' first."));
209    }
210
211    #[tokio::test]
212    async fn squash_merge_commits_the_provided_multiline_message() {
213        // Arrange
214        let temp_dir = tempdir().expect("failed to create temp dir");
215        setup_test_git_repo(temp_dir.path());
216        run_git_command(temp_dir.path(), &["checkout", "-b", "feature-branch"]);
217        fs::write(temp_dir.path().join("feature.txt"), "feature content")
218            .expect("failed to write feature file");
219        run_git_command(temp_dir.path(), &["add", "feature.txt"]);
220        run_git_command(temp_dir.path(), &["commit", "-m", "Add feature"]);
221        run_git_command(temp_dir.path(), &["checkout", "main"]);
222        let commit_message = "Refine merge flow\n\n- Reuse the session commit body".to_string();
223
224        // Act
225        let result = squash_merge(
226            temp_dir.path().to_path_buf(),
227            "feature-branch".to_string(),
228            "main".to_string(),
229            commit_message.clone(),
230        )
231        .await;
232        let head_message = run_git_stdout(temp_dir.path(), &["log", "-1", "--pretty=%B"]);
233
234        // Assert
235        assert_eq!(
236            result.expect("squash merge should succeed"),
237            SquashMergeOutcome::Committed,
238        );
239        assert_eq!(head_message, commit_message);
240    }
241
242    #[cfg(unix)]
243    #[tokio::test]
244    async fn squash_merge_runs_pre_commit_hook() {
245        // Arrange
246        let temp_dir = tempdir().expect("failed to create temp dir");
247        setup_test_git_repo(temp_dir.path());
248        run_git_command(temp_dir.path(), &["checkout", "-b", "feature-branch"]);
249        fs::write(temp_dir.path().join("feature.txt"), "feature content")
250            .expect("failed to write feature file");
251        run_git_command(temp_dir.path(), &["add", "feature.txt"]);
252        run_git_command(temp_dir.path(), &["commit", "-m", "Add feature"]);
253        run_git_command(temp_dir.path(), &["checkout", "main"]);
254        let hooks_dir = temp_dir.path().join("test-hooks");
255        fs::create_dir(&hooks_dir).expect("failed to create hooks directory");
256        let hook_path = hooks_dir.join("pre-commit");
257        fs::write(&hook_path, "#!/bin/sh\necho hook-blocked >&2\nexit 1\n")
258            .expect("failed to write pre-commit hook");
259        let mut permissions = fs::metadata(&hook_path)
260            .expect("failed to read hook metadata")
261            .permissions();
262        permissions.set_mode(0o755);
263        fs::set_permissions(&hook_path, permissions).expect("failed to make hook executable");
264        run_git_command(temp_dir.path(), &["config", "core.hooksPath", "test-hooks"]);
265
266        // Act
267        let error = squash_merge(
268            temp_dir.path().to_path_buf(),
269            "feature-branch".to_string(),
270            "main".to_string(),
271            "Squash merge feature".to_string(),
272        )
273        .await
274        .expect_err("pre-commit hook should block the squash commit");
275
276        // Assert
277        assert!(error.to_string().contains("hook-blocked"));
278    }
279
280    #[tokio::test]
281    async fn squash_merge_skips_commit_creation_when_changes_are_already_present() {
282        // Arrange
283        let temp_dir = tempdir().expect("failed to create temp dir");
284        setup_test_git_repo(temp_dir.path());
285        run_git_command(temp_dir.path(), &["checkout", "-b", "session-branch"]);
286        fs::write(temp_dir.path().join("session.txt"), "session change")
287            .expect("failed to write session file");
288        run_git_command(temp_dir.path(), &["add", "session.txt"]);
289        run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
290        run_git_command(temp_dir.path(), &["checkout", "main"]);
291        fs::write(temp_dir.path().join("session.txt"), "session change")
292            .expect("failed to write main file");
293        run_git_command(temp_dir.path(), &["add", "session.txt"]);
294        run_git_command(
295            temp_dir.path(),
296            &["commit", "-m", "Apply same change on main"],
297        );
298        let commit_count_before = run_git_stdout(temp_dir.path(), &["rev-list", "--count", "HEAD"]);
299        let head_message_before = run_git_stdout(temp_dir.path(), &["log", "-1", "--pretty=%B"]);
300
301        // Act
302        let result = squash_merge(
303            temp_dir.path().to_path_buf(),
304            "session-branch".to_string(),
305            "main".to_string(),
306            "Merge session".to_string(),
307        )
308        .await;
309        let commit_count_after = run_git_stdout(temp_dir.path(), &["rev-list", "--count", "HEAD"]);
310        let head_message_after = run_git_stdout(temp_dir.path(), &["log", "-1", "--pretty=%B"]);
311
312        // Assert
313        assert_eq!(
314            result.expect("squash merge should succeed"),
315            SquashMergeOutcome::AlreadyPresentInTarget,
316        );
317        assert_eq!(commit_count_after, commit_count_before);
318        assert_eq!(head_message_after, head_message_before);
319    }
320
321    #[tokio::test]
322    async fn squash_merge_returns_command_detail_for_missing_source_branch() {
323        // Arrange
324        let temp_dir = tempdir().expect("failed to create temp dir");
325        setup_test_git_repo(temp_dir.path());
326
327        // Act
328        let result = squash_merge(
329            temp_dir.path().to_path_buf(),
330            "missing-branch".to_string(),
331            "main".to_string(),
332            "Merge feature".to_string(),
333        )
334        .await;
335
336        // Assert
337        let error = result.expect_err("missing branch should fail").to_string();
338        assert!(error.contains("Failed to squash merge missing-branch"));
339        assert!(error.contains("missing-branch"));
340    }
341}