git_squish/
lib.rs

1use git2::{Commit, RebaseOptions, Repository};
2
3mod error;
4pub use error::SquishError;
5
6#[cfg(test)]
7pub mod test_utils;
8
9/// Squash a branch onto an upstream branch, replacing the branch history with a single commit.
10///
11/// # Arguments
12/// * `repo_path` - Path to the git repository
13/// * `branch_refname` - The branch to squash (e.g., "refs/heads/feature")
14/// * `upstream_spec` - The upstream to rebase onto (e.g., "main" or "origin/main")
15///
16/// # Returns
17/// A success message on completion, or a SquishError if the operation fails.
18pub 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    // Resolve the branch head to an AnnotatedCommit.
26    let branch_ref = repo.find_reference(&branch_refname)?;
27    let branch_annot = repo.reference_to_annotated_commit(&branch_ref)?;
28
29    // Resolve upstream (you may pass "main" or "origin/main" etc.).
30    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    // --- 1) Standard rebase to linearize the topic branch onto upstream ---
35    let mut opts = RebaseOptions::new();
36    // In-memory avoids touching the worktree while applying; safer for automation.
37    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    // Apply each operation and commit it (in-memory).
47    let sig = repo.signature()?;
48    while let Some(op_result) = rebase.next() {
49        let _op = op_result?;
50        // If there are conflicts, you'd inspect `rebase.inmemory_index()?` and resolve.
51        // For brevity we assume clean application.
52        rebase.commit(Some(&sig), &sig, None)?;
53    }
54    // Finalize the rebase (updates the branch ref to the rebased tip).
55    rebase.finish(None)?;
56
57    // Fetch the rebased branch tip and its tree.
58    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    // --- 2) "Squash" by replacing the rebased linear series with ONE commit ---
63    // Parent of the squash commit is the upstream commit we rebased onto.
64    let upstream_parent = repo.find_commit(upstream_id)?;
65
66    // Compose a sensible commit message:
67    //   - take the first (oldest) commit's subject + append shortened list
68    //     of included commits (optional, tweak as you like).
69    let message = build_squash_message(&repo, &upstream_parent, &rebased_tip)?;
70
71    // Create a *new* commit that has:
72    //   - the exact tree of the rebased tip (i.e., all changes combined)
73    //   - a single parent: the upstream base
74    //   - but don't update the branch ref yet (do it manually afterward)
75    let new_commit_id = repo.commit(
76        None, // Don't update any reference yet
77        &sig, // author
78        &sig, // committer
79        &message,
80        &rebased_tree,
81        &[&upstream_parent],
82    )?;
83
84    // Now manually update the branch reference to point to our new squashed commit
85    let mut branch_ref = repo.find_reference(&branch_refname)?;
86    branch_ref.set_target(new_commit_id, "squash commits into single commit")?;
87
88    // Optional: force-move HEAD if it was on this branch (useful in detached states etc.).
89    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
100/// Get the current branch name from the repository's HEAD.
101/// Returns the full reference name (e.g., "refs/heads/feature").
102pub 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        // HEAD is detached, get the current commit and find which branch points to it
109        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        // Look for a branch that points to the same commit
114        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
132/// Build a squash message using the message from the first commit.
133/// This scans commits reachable from `rebased_tip` back to (but excluding) `upstream_parent`
134/// and returns the full message from the first (oldest) commit.
135fn build_squash_message(
136    repo: &Repository,
137    upstream_parent: &Commit,
138    rebased_tip: &Commit,
139) -> Result<String, SquishError> {
140    // Walk from rebased_tip back until we hit upstream_parent.
141    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    // Get the first commit in the range
147    if let Some(first_oid) = revwalk.next() {
148        let first_oid = first_oid?;
149        let first_commit = repo.find_commit(first_oid)?;
150        // Return the full message from the first commit
151        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    /// Read the contents of a file in the repository.
171    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        // Clone the test repository
184        let (repo_path, _temp_dir) = clone_test_repo().expect("Failed to clone test repository");
185
186        // Checkout the topic branch
187        change_to_branch(&repo_path, "topic").expect("Failed to checkout topic branch");
188
189        // Get the current branch name (should be refs/heads/topic)
190        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        // Squish the topic branch against main
195        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        // Verify the log message is "Topic Branch Start"
205        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        // Verify the contents of text.txt
216        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        // Clone the test repository
237        let (repo_path, _temp_dir) = clone_test_repo().expect("Failed to clone test repository");
238
239        // Checkout the conflict branch
240        change_to_branch(&repo_path, "conflict").expect("Failed to checkout conflict branch");
241
242        // Get the current branch name (should be refs/heads/conflict)
243        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        // First, make sure we have the topic branch locally
248        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        // Try to squish the conflict branch against topic - this should fail with a merge conflict
252        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 that the operation failed
256        assert!(
257            result.is_err(),
258            "Expected squash operation to fail due to merge conflict, but it succeeded"
259        );
260
261        // Verify that it's a conflict-related error
262        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}