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 check if GPG signing is explicitly enabled
73    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    // Create a *new* commit that has:
84    //   - the exact tree of the rebased tip (i.e., all changes combined)
85    //   - a single parent: the upstream base
86    //   - but don't update the branch ref yet (do it manually afterward)
87    //   - optionally signed with GPG if configured
88    let new_commit_id = git2_ext::ops::commit(
89        &repo,
90        &sig, // author
91        &sig, // committer
92        &message,
93        &rebased_tree,
94        &[&upstream_parent],
95        signing,
96    )?;
97
98    // Now manually update the branch reference to point to our new squashed commit
99    let mut branch_ref = repo.find_reference(&branch_refname)?;
100    branch_ref.set_target(new_commit_id, "squash commits into single commit")?;
101
102    // Optional: force-move HEAD if it was on this branch (useful in detached states etc.).
103    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
114/// Get the current branch name from the repository's HEAD.
115/// Returns the full reference name (e.g., "refs/heads/feature").
116pub 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        // HEAD is detached, get the current commit and find which branch points to it
123        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        // Look for a branch that points to the same commit
128        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
146/// Build a squash message using the message from the first commit.
147/// This scans commits reachable from `rebased_tip` back to (but excluding) `upstream_parent`
148/// and returns the full message from the first (oldest) commit.
149fn build_squash_message(
150    repo: &Repository,
151    upstream_parent: &Commit,
152    rebased_tip: &Commit,
153) -> Result<String, SquishError> {
154    // Walk from rebased_tip back until we hit upstream_parent.
155    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    // Get the first commit in the range
161    if let Some(first_oid) = revwalk.next() {
162        let first_oid = first_oid?;
163        let first_commit = repo.find_commit(first_oid)?;
164        // Return the full message from the first commit
165        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    /// Read the contents of a file in the repository.
185    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        // Clone the test repository
198        let (repo_path, _temp_dir) = clone_test_repo().expect("Failed to clone test repository");
199
200        // Checkout the topic branch
201        change_to_branch(&repo_path, "topic").expect("Failed to checkout topic branch");
202
203        // Get the current branch name (should be refs/heads/topic)
204        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        // Squish the topic branch against main
209        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        // Verify the log message is "Topic Branch Start"
219        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        // Verify the contents of text.txt
230        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        // Clone the test repository
251        let (repo_path, _temp_dir) = clone_test_repo().expect("Failed to clone test repository");
252
253        // Checkout the conflict branch
254        change_to_branch(&repo_path, "conflict").expect("Failed to checkout conflict branch");
255
256        // Get the current branch name (should be refs/heads/conflict)
257        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        // First, make sure we have the topic branch locally
262        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        // Try to squish the conflict branch against topic - this should fail with a merge conflict
266        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 that the operation failed
270        assert!(
271            result.is_err(),
272            "Expected squash operation to fail due to merge conflict, but it succeeded"
273        );
274
275        // Verify that it's a conflict-related error
276        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}