1use git2::{Commit, RebaseOptions, Repository};
2
3mod error;
4pub use error::SquishError;
5
6#[cfg(test)]
7pub mod test_utils;
8
9pub fn squash_branch(
19 repo_path: &str,
20 branch_refname: String,
21 upstream_spec: String,
22) -> Result<String, SquishError> {
23 let repo = Repository::open(repo_path)?;
24
25 let branch_ref = repo.find_reference(&branch_refname)?;
27 let branch_annot = repo.reference_to_annotated_commit(&branch_ref)?;
28
29 let upstream_obj = repo.revparse_single(&upstream_spec)?;
31 let upstream_id = upstream_obj.id();
32 let upstream_annot = repo.find_annotated_commit(upstream_id)?;
33
34 let mut opts = RebaseOptions::new();
36 opts.inmemory(true);
38
39 let mut rebase = repo.rebase(
40 Some(&branch_annot),
41 Some(&upstream_annot),
42 None,
43 Some(&mut opts),
44 )?;
45
46 let sig = repo.signature()?;
48 while let Some(op_result) = rebase.next() {
49 let _op = op_result?;
50 rebase.commit(Some(&sig), &sig, None)?;
53 }
54 rebase.finish(None)?;
56
57 let rebased_tip_id = repo.refname_to_id(&branch_refname)?;
59 let rebased_tip = repo.find_commit(rebased_tip_id)?;
60 let rebased_tree = rebased_tip.tree()?;
61
62 let upstream_parent = repo.find_commit(upstream_id)?;
65
66 let message = build_squash_message(&repo, &upstream_parent, &rebased_tip)?;
70
71 let new_commit_id = repo.commit(
76 None, &sig, &sig, &message,
80 &rebased_tree,
81 &[&upstream_parent],
82 )?;
83
84 let mut branch_ref = repo.find_reference(&branch_refname)?;
86 branch_ref.set_target(new_commit_id, "squash commits into single commit")?;
87
88 if let Ok(mut head) = repo.head() {
90 if head.is_branch() && head.name() == Some(branch_refname.as_str()) {
91 head.set_target(new_commit_id, "move HEAD to squashed commit")?;
92 }
93 }
94
95 Ok(format!(
96 "✅ Successfully rebased and updated {branch_refname}."
97 ))
98}
99
100pub fn get_current_branch_name(repo: &Repository) -> Result<String, SquishError> {
103 let head = repo.head()?;
104
105 if let Some(name) = head.name() {
106 Ok(name.to_string())
107 } else {
108 let head_commit = head.target().ok_or_else(|| SquishError::Other {
110 message: "HEAD does not point to a valid commit".to_string(),
111 })?;
112
113 let mut branches = repo.branches(Some(git2::BranchType::Local))?;
115 for branch_result in &mut branches {
116 let (branch, _) = branch_result?;
117 if let Some(target) = branch.get().target() {
118 if target == head_commit {
119 if let Some(branch_name) = branch.get().name() {
120 return Ok(branch_name.to_string());
121 }
122 }
123 }
124 }
125
126 Err(SquishError::Other {
127 message: "Cannot determine current branch - HEAD is detached and no branch points to current commit".to_string(),
128 })
129 }
130}
131
132fn build_squash_message(
136 repo: &Repository,
137 upstream_parent: &Commit,
138 rebased_tip: &Commit,
139) -> Result<String, SquishError> {
140 let mut revwalk = repo.revwalk()?;
142 revwalk.push(rebased_tip.id())?;
143 revwalk.hide(upstream_parent.id())?;
144 revwalk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?;
145
146 if let Some(first_oid) = revwalk.next() {
148 let first_oid = first_oid?;
149 let first_commit = repo.find_commit(first_oid)?;
150 first_commit
152 .message()
153 .ok_or_else(|| SquishError::Other {
154 message: "First commit has no message".to_string(),
155 })
156 .map(|msg| msg.to_string())
157 } else {
158 Err(SquishError::Other {
159 message: "No commits found in the range to squash".to_string(),
160 })
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use crate::test_utils::{change_to_branch, clone_test_repo, get_current_commit_message};
168 use std::fs;
169
170 fn read_file_contents(
172 repo_path: &std::path::PathBuf,
173 filename: &str,
174 ) -> Result<String, SquishError> {
175 let file_path = repo_path.join(filename);
176 fs::read_to_string(file_path).map_err(|e| SquishError::Other {
177 message: format!("Failed to read file {}: {}", filename, e),
178 })
179 }
180
181 #[test]
182 fn test_squish_topic_branch_workflow() {
183 let (repo_path, _temp_dir) = clone_test_repo().expect("Failed to clone test repository");
185
186 change_to_branch(&repo_path, "topic").expect("Failed to checkout topic branch");
188
189 let repo = Repository::open(&repo_path).expect("Failed to open repository");
191 let branch_refname =
192 get_current_branch_name(&repo).expect("Failed to get current branch name");
193
194 let repo_path_str = repo_path.to_str().expect("Invalid repo path");
196 let result = squash_branch(repo_path_str, branch_refname, "main".to_string());
197
198 assert!(
199 result.is_ok(),
200 "Squash operation failed: {:?}",
201 result.err()
202 );
203
204 let commit_message =
206 get_current_commit_message(&repo_path).expect("Failed to get commit message");
207
208 assert_eq!(
209 commit_message.trim(),
210 "Topic Branch Start",
211 "Expected commit message 'Topic Branch Start', got: '{}'",
212 commit_message
213 );
214
215 let file_contents =
217 read_file_contents(&repo_path, "text.txt").expect("Failed to read text.txt");
218
219 let expected_contents = "\
220Thu Aug 14 15:10:43 EDT 2025
221Thu Aug 14 15:11:01 EDT 2025
222Thu Aug 14 15:11:04 EDT 2025
223Thu Aug 14 15:11:07 EDT 2025
224Thu Aug 14 15:49:25 EDT 2025
225";
226
227 assert_eq!(
228 file_contents, expected_contents,
229 "text.txt contents don't match expected values.\nExpected:\n{}\nActual:\n{}",
230 expected_contents, file_contents
231 );
232 }
233
234 #[test]
235 fn test_squish_conflict_branch_should_fail() {
236 let (repo_path, _temp_dir) = clone_test_repo().expect("Failed to clone test repository");
238
239 change_to_branch(&repo_path, "conflict").expect("Failed to checkout conflict branch");
241
242 let repo = Repository::open(&repo_path).expect("Failed to open repository");
244 let branch_refname =
245 get_current_branch_name(&repo).expect("Failed to get current branch name");
246
247 change_to_branch(&repo_path, "topic").expect("Failed to ensure topic branch exists");
249 change_to_branch(&repo_path, "conflict").expect("Failed to return to conflict branch");
250
251 let repo_path_str = repo_path.to_str().expect("Invalid repo path");
253 let result = squash_branch(repo_path_str, branch_refname, "topic".to_string());
254
255 assert!(
257 result.is_err(),
258 "Expected squash operation to fail due to merge conflict, but it succeeded"
259 );
260
261 let error = result.unwrap_err();
263 match error {
264 SquishError::Git { message } => {
265 assert!(
266 message.contains("conflict"),
267 "Expected conflict-related error message, got: '{}'",
268 message
269 );
270 }
271 _ => panic!(
272 "Expected SquishError::Git with conflict message, got: {:?}",
273 error
274 ),
275 }
276 }
277}