1use git2::{Commit, Config, RebaseOptions, Repository};
2use git2_ext::ops::{Sign, UserSign};
3
4mod error;
5pub use error::SquishError;
6
7#[cfg(test)]
8pub mod test_utils;
9
10pub fn squash_branch(
20 repo_path: &str,
21 branch_refname: String,
22 upstream_spec: String,
23) -> Result<String, SquishError> {
24 let repo = Repository::open(repo_path)?;
25
26 let branch_ref = repo.find_reference(&branch_refname)?;
28 let branch_annot = repo.reference_to_annotated_commit(&branch_ref)?;
29
30 let upstream_obj = repo.revparse_single(&upstream_spec)?;
32 let upstream_id = upstream_obj.id();
33 let upstream_annot = repo.find_annotated_commit(upstream_id)?;
34
35 let mut opts = RebaseOptions::new();
37 opts.inmemory(true);
39
40 let mut rebase = repo.rebase(
41 Some(&branch_annot),
42 Some(&upstream_annot),
43 None,
44 Some(&mut opts),
45 )?;
46
47 let sig = repo.signature()?;
49 while let Some(op_result) = rebase.next() {
50 let _op = op_result?;
51 rebase.commit(Some(&sig), &sig, None)?;
54 }
55 rebase.finish(None)?;
57
58 let rebased_tip_id = repo.refname_to_id(&branch_refname)?;
60 let rebased_tip = repo.find_commit(rebased_tip_id)?;
61 let rebased_tree = rebased_tip.tree()?;
62
63 let upstream_parent = repo.find_commit(upstream_id)?;
66
67 let message = build_squash_message(&repo, &upstream_parent, &rebased_tip)?;
71
72 let git_config = Config::open_default()?;
74 let gpg_sign_enabled = git_config.get_bool("commit.gpgsign").unwrap_or(false);
75
76 let user_sign = if gpg_sign_enabled {
77 UserSign::from_config(&repo, &git_config).ok()
78 } else {
79 None
80 };
81 let signing = user_sign.as_ref().map(|sign| sign as &dyn Sign);
82
83 let new_commit_id = git2_ext::ops::commit(
89 &repo,
90 &sig, &sig, &message,
93 &rebased_tree,
94 &[&upstream_parent],
95 signing,
96 )?;
97
98 let mut branch_ref = repo.find_reference(&branch_refname)?;
100 branch_ref.set_target(new_commit_id, "squash commits into single commit")?;
101
102 if let Ok(mut head) = repo.head() {
104 if head.is_branch() && head.name() == Some(branch_refname.as_str()) {
105 head.set_target(new_commit_id, "move HEAD to squashed commit")?;
106 }
107 }
108
109 Ok(format!(
110 "✅ Successfully rebased and updated {branch_refname}."
111 ))
112}
113
114pub fn get_current_branch_name(repo: &Repository) -> Result<String, SquishError> {
117 let head = repo.head()?;
118
119 if let Some(name) = head.name() {
120 Ok(name.to_string())
121 } else {
122 let head_commit = head.target().ok_or_else(|| SquishError::Other {
124 message: "HEAD does not point to a valid commit".to_string(),
125 })?;
126
127 let mut branches = repo.branches(Some(git2::BranchType::Local))?;
129 for branch_result in &mut branches {
130 let (branch, _) = branch_result?;
131 if let Some(target) = branch.get().target() {
132 if target == head_commit {
133 if let Some(branch_name) = branch.get().name() {
134 return Ok(branch_name.to_string());
135 }
136 }
137 }
138 }
139
140 Err(SquishError::Other {
141 message: "Cannot determine current branch - HEAD is detached and no branch points to current commit".to_string(),
142 })
143 }
144}
145
146fn build_squash_message(
150 repo: &Repository,
151 upstream_parent: &Commit,
152 rebased_tip: &Commit,
153) -> Result<String, SquishError> {
154 let mut revwalk = repo.revwalk()?;
156 revwalk.push(rebased_tip.id())?;
157 revwalk.hide(upstream_parent.id())?;
158 revwalk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?;
159
160 if let Some(first_oid) = revwalk.next() {
162 let first_oid = first_oid?;
163 let first_commit = repo.find_commit(first_oid)?;
164 first_commit
166 .message()
167 .ok_or_else(|| SquishError::Other {
168 message: "First commit has no message".to_string(),
169 })
170 .map(|msg| msg.to_string())
171 } else {
172 Err(SquishError::Other {
173 message: "No commits found in the range to squash".to_string(),
174 })
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use crate::test_utils::{change_to_branch, clone_test_repo, get_current_commit_message};
182 use std::fs;
183
184 fn read_file_contents(
186 repo_path: &std::path::PathBuf,
187 filename: &str,
188 ) -> Result<String, SquishError> {
189 let file_path = repo_path.join(filename);
190 fs::read_to_string(file_path).map_err(|e| SquishError::Other {
191 message: format!("Failed to read file {}: {}", filename, e),
192 })
193 }
194
195 #[test]
196 fn test_squish_topic_branch_workflow() {
197 let (repo_path, _temp_dir) = clone_test_repo().expect("Failed to clone test repository");
199
200 change_to_branch(&repo_path, "topic").expect("Failed to checkout topic branch");
202
203 let repo = Repository::open(&repo_path).expect("Failed to open repository");
205 let branch_refname =
206 get_current_branch_name(&repo).expect("Failed to get current branch name");
207
208 let repo_path_str = repo_path.to_str().expect("Invalid repo path");
210 let result = squash_branch(repo_path_str, branch_refname, "main".to_string());
211
212 assert!(
213 result.is_ok(),
214 "Squash operation failed: {:?}",
215 result.err()
216 );
217
218 let commit_message =
220 get_current_commit_message(&repo_path).expect("Failed to get commit message");
221
222 assert_eq!(
223 commit_message.trim(),
224 "Topic Branch Start",
225 "Expected commit message 'Topic Branch Start', got: '{}'",
226 commit_message
227 );
228
229 let file_contents =
231 read_file_contents(&repo_path, "text.txt").expect("Failed to read text.txt");
232
233 let expected_contents = "\
234Thu Aug 14 15:10:43 EDT 2025
235Thu Aug 14 15:11:01 EDT 2025
236Thu Aug 14 15:11:04 EDT 2025
237Thu Aug 14 15:11:07 EDT 2025
238Thu Aug 14 15:49:25 EDT 2025
239";
240
241 assert_eq!(
242 file_contents, expected_contents,
243 "text.txt contents don't match expected values.\nExpected:\n{}\nActual:\n{}",
244 expected_contents, file_contents
245 );
246 }
247
248 #[test]
249 fn test_squish_conflict_branch_should_fail() {
250 let (repo_path, _temp_dir) = clone_test_repo().expect("Failed to clone test repository");
252
253 change_to_branch(&repo_path, "conflict").expect("Failed to checkout conflict branch");
255
256 let repo = Repository::open(&repo_path).expect("Failed to open repository");
258 let branch_refname =
259 get_current_branch_name(&repo).expect("Failed to get current branch name");
260
261 change_to_branch(&repo_path, "topic").expect("Failed to ensure topic branch exists");
263 change_to_branch(&repo_path, "conflict").expect("Failed to return to conflict branch");
264
265 let repo_path_str = repo_path.to_str().expect("Invalid repo path");
267 let result = squash_branch(repo_path_str, branch_refname, "topic".to_string());
268
269 assert!(
271 result.is_err(),
272 "Expected squash operation to fail due to merge conflict, but it succeeded"
273 );
274
275 let error = result.unwrap_err();
277 match error {
278 SquishError::Git { message } => {
279 assert!(
280 message.contains("conflict"),
281 "Expected conflict-related error message, got: '{}'",
282 message
283 );
284 }
285 _ => panic!(
286 "Expected SquishError::Git with conflict message, got: {:?}",
287 error
288 ),
289 }
290 }
291}