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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11pub enum SquashMergeOutcome {
12 Committed,
14 AlreadyPresentInTarget,
16}
17
18pub(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
51pub(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 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 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 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 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 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 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 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 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 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 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_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 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 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!(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 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 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_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 let temp_dir = tempdir().expect("failed to create temp dir");
325 setup_test_git_repo(temp_dir.path());
326
327 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 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}