git_squish/
lib.rs

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
10/// Squash a branch onto an upstream branch, replacing the branch history with a single commit.
11///
12/// # Arguments
13/// * `repo_path` - Path to the git repository
14/// * `branch_refname` - The branch to squash (e.g., "refs/heads/feature")
15/// * `upstream_spec` - The upstream to rebase onto (e.g., "main" or "origin/main")
16///
17/// # Returns
18/// A success message on completion, or a SquishError if the operation fails.
19pub 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    // Resolve the branch head to an AnnotatedCommit.
27    let branch_ref = repo.find_reference(&branch_refname)?;
28    let branch_annot = repo.reference_to_annotated_commit(&branch_ref)?;
29
30    // Resolve upstream (you may pass "main" or "origin/main" etc.).
31    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    // --- 1) Standard rebase to linearize the topic branch onto upstream ---
36    let mut opts = RebaseOptions::new();
37    // In-memory avoids touching the worktree while applying; safer for automation.
38    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    // Apply each operation and commit it (in-memory).
48    let sig = repo.signature()?;
49    while let Some(op_result) = rebase.next() {
50        let _op = op_result?;
51        // If there are conflicts, you'd inspect `rebase.inmemory_index()?` and resolve.
52        // For brevity we assume clean application.
53        rebase.commit(Some(&sig), &sig, None)?;
54    }
55    // Finalize the rebase (updates the branch ref to the rebased tip).
56    rebase.finish(None)?;
57
58    // Fetch the rebased branch tip and its tree.
59    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    // --- 2) "Squash" by replacing the rebased linear series with ONE commit ---
64    // Parent of the squash commit is the upstream commit we rebased onto.
65    let upstream_parent = repo.find_commit(upstream_id)?;
66
67    // Compose a sensible commit message:
68    //   - take the first (oldest) commit's subject + append shortened list
69    //     of included commits (optional, tweak as you like).
70    let message = build_squash_message(&repo, &upstream_parent, &rebased_tip)?;
71
72    // Get git config and configuration for optional GPG signing
73    let git_config = Config::open_default()?;
74    let user_sign = UserSign::from_config(&repo, &git_config).ok();
75    let signing = user_sign.as_ref().map(|sign| sign as &dyn Sign);
76
77    // Create a *new* commit that has:
78    //   - the exact tree of the rebased tip (i.e., all changes combined)
79    //   - a single parent: the upstream base
80    //   - but don't update the branch ref yet (do it manually afterward)
81    //   - optionally signed with GPG if configured
82    let new_commit_id = git2_ext::ops::commit(
83        &repo,
84        &sig, // author
85        &sig, // committer
86        &message,
87        &rebased_tree,
88        &[&upstream_parent],
89        signing,
90    )?;
91
92    // Now manually update the branch reference to point to our new squashed commit
93    let mut branch_ref = repo.find_reference(&branch_refname)?;
94    branch_ref.set_target(new_commit_id, "squash commits into single commit")?;
95
96    // Optional: force-move HEAD if it was on this branch (useful in detached states etc.).
97    if let Ok(mut head) = repo.head() {
98        if head.is_branch() && head.name() == Some(branch_refname.as_str()) {
99            head.set_target(new_commit_id, "move HEAD to squashed commit")?;
100        }
101    }
102
103    Ok(format!(
104        "✅ Successfully rebased and updated {branch_refname}."
105    ))
106}
107
108/// Get the current branch name from the repository's HEAD.
109/// Returns the full reference name (e.g., "refs/heads/feature").
110pub fn get_current_branch_name(repo: &Repository) -> Result<String, SquishError> {
111    let head = repo.head()?;
112
113    if let Some(name) = head.name() {
114        Ok(name.to_string())
115    } else {
116        // HEAD is detached, get the current commit and find which branch points to it
117        let head_commit = head.target().ok_or_else(|| SquishError::Other {
118            message: "HEAD does not point to a valid commit".to_string(),
119        })?;
120
121        // Look for a branch that points to the same commit
122        let mut branches = repo.branches(Some(git2::BranchType::Local))?;
123        for branch_result in &mut branches {
124            let (branch, _) = branch_result?;
125            if let Some(target) = branch.get().target() {
126                if target == head_commit {
127                    if let Some(branch_name) = branch.get().name() {
128                        return Ok(branch_name.to_string());
129                    }
130                }
131            }
132        }
133
134        Err(SquishError::Other {
135            message: "Cannot determine current branch - HEAD is detached and no branch points to current commit".to_string(),
136        })
137    }
138}
139
140/// Build a squash message using the message from the first commit.
141/// This scans commits reachable from `rebased_tip` back to (but excluding) `upstream_parent`
142/// and returns the full message from the first (oldest) commit.
143fn build_squash_message(
144    repo: &Repository,
145    upstream_parent: &Commit,
146    rebased_tip: &Commit,
147) -> Result<String, SquishError> {
148    // Walk from rebased_tip back until we hit upstream_parent.
149    let mut revwalk = repo.revwalk()?;
150    revwalk.push(rebased_tip.id())?;
151    revwalk.hide(upstream_parent.id())?;
152    revwalk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?;
153
154    // Get the first commit in the range
155    if let Some(first_oid) = revwalk.next() {
156        let first_oid = first_oid?;
157        let first_commit = repo.find_commit(first_oid)?;
158        // Return the full message from the first commit
159        first_commit
160            .message()
161            .ok_or_else(|| SquishError::Other {
162                message: "First commit has no message".to_string(),
163            })
164            .map(|msg| msg.to_string())
165    } else {
166        Err(SquishError::Other {
167            message: "No commits found in the range to squash".to_string(),
168        })
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::test_utils::{change_to_branch, clone_test_repo, get_current_commit_message};
176    use std::fs;
177
178    /// Read the contents of a file in the repository.
179    fn read_file_contents(
180        repo_path: &std::path::PathBuf,
181        filename: &str,
182    ) -> Result<String, SquishError> {
183        let file_path = repo_path.join(filename);
184        fs::read_to_string(file_path).map_err(|e| SquishError::Other {
185            message: format!("Failed to read file {}: {}", filename, e),
186        })
187    }
188
189    #[test]
190    fn test_squish_topic_branch_workflow() {
191        // Clone the test repository
192        let (repo_path, _temp_dir) = clone_test_repo().expect("Failed to clone test repository");
193
194        // Checkout the topic branch
195        change_to_branch(&repo_path, "topic").expect("Failed to checkout topic branch");
196
197        // Get the current branch name (should be refs/heads/topic)
198        let repo = Repository::open(&repo_path).expect("Failed to open repository");
199        let branch_refname =
200            get_current_branch_name(&repo).expect("Failed to get current branch name");
201
202        // Squish the topic branch against main
203        let repo_path_str = repo_path.to_str().expect("Invalid repo path");
204        let result = squash_branch(repo_path_str, branch_refname, "main".to_string());
205
206        assert!(
207            result.is_ok(),
208            "Squash operation failed: {:?}",
209            result.err()
210        );
211
212        // Verify the log message is "Topic Branch Start"
213        let commit_message =
214            get_current_commit_message(&repo_path).expect("Failed to get commit message");
215
216        assert_eq!(
217            commit_message.trim(),
218            "Topic Branch Start",
219            "Expected commit message 'Topic Branch Start', got: '{}'",
220            commit_message
221        );
222
223        // Verify the contents of text.txt
224        let file_contents =
225            read_file_contents(&repo_path, "text.txt").expect("Failed to read text.txt");
226
227        let expected_contents = "\
228Thu Aug 14 15:10:43 EDT 2025
229Thu Aug 14 15:11:01 EDT 2025
230Thu Aug 14 15:11:04 EDT 2025
231Thu Aug 14 15:11:07 EDT 2025
232Thu Aug 14 15:49:25 EDT 2025
233";
234
235        assert_eq!(
236            file_contents, expected_contents,
237            "text.txt contents don't match expected values.\nExpected:\n{}\nActual:\n{}",
238            expected_contents, file_contents
239        );
240    }
241
242    #[test]
243    fn test_squish_conflict_branch_should_fail() {
244        // Clone the test repository
245        let (repo_path, _temp_dir) = clone_test_repo().expect("Failed to clone test repository");
246
247        // Checkout the conflict branch
248        change_to_branch(&repo_path, "conflict").expect("Failed to checkout conflict branch");
249
250        // Get the current branch name (should be refs/heads/conflict)
251        let repo = Repository::open(&repo_path).expect("Failed to open repository");
252        let branch_refname =
253            get_current_branch_name(&repo).expect("Failed to get current branch name");
254
255        // First, make sure we have the topic branch locally
256        change_to_branch(&repo_path, "topic").expect("Failed to ensure topic branch exists");
257        change_to_branch(&repo_path, "conflict").expect("Failed to return to conflict branch");
258
259        // Try to squish the conflict branch against topic - this should fail with a merge conflict
260        let repo_path_str = repo_path.to_str().expect("Invalid repo path");
261        let result = squash_branch(repo_path_str, branch_refname, "topic".to_string());
262
263        // Assert that the operation failed
264        assert!(
265            result.is_err(),
266            "Expected squash operation to fail due to merge conflict, but it succeeded"
267        );
268
269        // Verify that it's a conflict-related error
270        let error = result.unwrap_err();
271        match error {
272            SquishError::Git { message } => {
273                assert!(
274                    message.contains("conflict"),
275                    "Expected conflict-related error message, got: '{}'",
276                    message
277                );
278            }
279            _ => panic!(
280                "Expected SquishError::Git with conflict message, got: {:?}",
281                error
282            ),
283        }
284    }
285}