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 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 (skipping 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 fails.
74pub 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        // Skip hooks here because the session worktree already ran them.
123        run_git_command_sync(
124            &repo_path,
125            &["commit", "--no-verify", "-m", commit_message.as_str()],
126            "Failed to commit squash merge",
127        )?;
128
129        Ok(SquashMergeOutcome::Committed)
130    })
131    .await?
132}
133
134#[cfg(test)]
135mod tests {
136    use std::fs;
137    use std::path::Path;
138    use std::process::Command;
139
140    use tempfile::tempdir;
141
142    use super::*;
143
144    /// Runs `git` in `repo_path` and asserts the command succeeds.
145    fn run_git_command(repo_path: &Path, args: &[&str]) {
146        let output = Command::new("git")
147            .args(args)
148            .current_dir(repo_path)
149            .output()
150            .expect("failed to run git command");
151
152        assert!(
153            output.status.success(),
154            "git command {:?} failed: {}",
155            args,
156            String::from_utf8_lossy(&output.stderr)
157        );
158    }
159
160    /// Runs `git` in `repo_path` and returns trimmed stdout.
161    fn run_git_stdout(repo_path: &Path, args: &[&str]) -> String {
162        let output = Command::new("git")
163            .args(args)
164            .current_dir(repo_path)
165            .output()
166            .expect("failed to run git command");
167
168        assert!(
169            output.status.success(),
170            "git command {:?} failed: {}",
171            args,
172            String::from_utf8_lossy(&output.stderr)
173        );
174
175        String::from_utf8_lossy(&output.stdout).trim().to_string()
176    }
177
178    /// Creates a committed repository rooted at `repo_path`.
179    fn setup_test_git_repo(repo_path: &Path) {
180        run_git_command(repo_path, &["init", "-b", "main"]);
181        run_git_command(repo_path, &["config", "user.name", "Test User"]);
182        run_git_command(repo_path, &["config", "user.email", "test@example.com"]);
183        fs::write(repo_path.join("README.md"), "base\n").expect("failed to write base file");
184        run_git_command(repo_path, &["add", "README.md"]);
185        run_git_command(repo_path, &["commit", "-m", "Initial commit"]);
186    }
187
188    #[tokio::test]
189    async fn squash_merge_returns_branch_mismatch_error_when_target_is_not_checked_out() {
190        // Arrange
191        let temp_dir = tempdir().expect("failed to create temp dir");
192        setup_test_git_repo(temp_dir.path());
193        run_git_command(temp_dir.path(), &["checkout", "-b", "feature-branch"]);
194
195        // Act
196        let result = squash_merge(
197            temp_dir.path().to_path_buf(),
198            "feature-branch".to_string(),
199            "main".to_string(),
200            "Merge feature".to_string(),
201        )
202        .await;
203
204        // Assert
205        let error = result.expect_err("branch mismatch should fail").to_string();
206        assert!(error.contains("repository is on 'feature-branch'"));
207        assert!(error.contains("Switch to 'main' first."));
208    }
209
210    #[tokio::test]
211    async fn squash_merge_commits_the_provided_multiline_message() {
212        // Arrange
213        let temp_dir = tempdir().expect("failed to create temp dir");
214        setup_test_git_repo(temp_dir.path());
215        run_git_command(temp_dir.path(), &["checkout", "-b", "feature-branch"]);
216        fs::write(temp_dir.path().join("feature.txt"), "feature content")
217            .expect("failed to write feature file");
218        run_git_command(temp_dir.path(), &["add", "feature.txt"]);
219        run_git_command(temp_dir.path(), &["commit", "-m", "Add feature"]);
220        run_git_command(temp_dir.path(), &["checkout", "main"]);
221        let commit_message = "Refine merge flow\n\n- Reuse the session commit body".to_string();
222
223        // Act
224        let result = squash_merge(
225            temp_dir.path().to_path_buf(),
226            "feature-branch".to_string(),
227            "main".to_string(),
228            commit_message.clone(),
229        )
230        .await;
231        let head_message = run_git_stdout(temp_dir.path(), &["log", "-1", "--pretty=%B"]);
232
233        // Assert
234        assert_eq!(
235            result.expect("squash merge should succeed"),
236            SquashMergeOutcome::Committed,
237        );
238        assert_eq!(head_message, commit_message);
239    }
240
241    #[tokio::test]
242    async fn squash_merge_skips_commit_creation_when_changes_are_already_present() {
243        // Arrange
244        let temp_dir = tempdir().expect("failed to create temp dir");
245        setup_test_git_repo(temp_dir.path());
246        run_git_command(temp_dir.path(), &["checkout", "-b", "session-branch"]);
247        fs::write(temp_dir.path().join("session.txt"), "session change")
248            .expect("failed to write session file");
249        run_git_command(temp_dir.path(), &["add", "session.txt"]);
250        run_git_command(temp_dir.path(), &["commit", "-m", "Session change"]);
251        run_git_command(temp_dir.path(), &["checkout", "main"]);
252        fs::write(temp_dir.path().join("session.txt"), "session change")
253            .expect("failed to write main file");
254        run_git_command(temp_dir.path(), &["add", "session.txt"]);
255        run_git_command(
256            temp_dir.path(),
257            &["commit", "-m", "Apply same change on main"],
258        );
259        let commit_count_before = run_git_stdout(temp_dir.path(), &["rev-list", "--count", "HEAD"]);
260        let head_message_before = run_git_stdout(temp_dir.path(), &["log", "-1", "--pretty=%B"]);
261
262        // Act
263        let result = squash_merge(
264            temp_dir.path().to_path_buf(),
265            "session-branch".to_string(),
266            "main".to_string(),
267            "Merge session".to_string(),
268        )
269        .await;
270        let commit_count_after = run_git_stdout(temp_dir.path(), &["rev-list", "--count", "HEAD"]);
271        let head_message_after = run_git_stdout(temp_dir.path(), &["log", "-1", "--pretty=%B"]);
272
273        // Assert
274        assert_eq!(
275            result.expect("squash merge should succeed"),
276            SquashMergeOutcome::AlreadyPresentInTarget,
277        );
278        assert_eq!(commit_count_after, commit_count_before);
279        assert_eq!(head_message_after, head_message_before);
280    }
281
282    #[tokio::test]
283    async fn squash_merge_returns_command_detail_for_missing_source_branch() {
284        // Arrange
285        let temp_dir = tempdir().expect("failed to create temp dir");
286        setup_test_git_repo(temp_dir.path());
287
288        // Act
289        let result = squash_merge(
290            temp_dir.path().to_path_buf(),
291            "missing-branch".to_string(),
292            "main".to_string(),
293            "Merge feature".to_string(),
294        )
295        .await;
296
297        // Assert
298        let error = result.expect_err("missing branch should fail").to_string();
299        assert!(error.contains("Failed to squash merge missing-branch"));
300        assert!(error.contains("missing-branch"));
301    }
302}