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 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 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(
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 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 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 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 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 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 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 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 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_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 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 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_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 let temp_dir = tempdir().expect("failed to create temp dir");
286 setup_test_git_repo(temp_dir.path());
287
288 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 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}