Skip to main content

git_snip/
lib.rs

1mod branch;
2mod hook;
3mod input;
4mod reference;
5mod remote;
6mod repo;
7
8use std::env;
9use std::io::BufRead;
10
11use anyhow::{Context, Result};
12
13use hook::{GitHook, GitHookType};
14
15/// Install git-snip hook scripts for post-merge and post-rewrite.
16pub fn install_hooks(force: bool) -> Result<()> {
17    let current_dir = env::current_dir().context("Could not get current directory")?;
18    let repo = repo::Repository::open(current_dir)?;
19
20    println!("Installing git-snip hook script.");
21    let h = GitHook::default();
22    hook::install(repo.path(), &h, GitHookType::PostMerge, force)?;
23    hook::install(repo.path(), &h, GitHookType::PostRewrite, force)?;
24
25    Ok(())
26}
27
28/// Delete all local branches that are not in remote branches.
29pub fn snip(no_confirm: bool, reader: impl BufRead) -> Result<()> {
30    let current_dir = env::current_dir().context("Could not get current directory")?;
31    let repo = repo::Repository::open(current_dir)?;
32
33    snip_repo(&repo, no_confirm, reader)
34}
35
36fn snip_repo(repo: &repo::Repository, no_confirm: bool, reader: impl BufRead) -> Result<()> {
37    let result = repo.orphaned_branches();
38
39    if let Some(head) = &result.skipped_head {
40        println!("Cannot delete current branch: {head}");
41    }
42
43    let mut branches_to_delete = result.branches;
44    if branches_to_delete.is_empty() {
45        println!("No local branches to delete.");
46        return Ok(());
47    }
48
49    if !no_confirm {
50        println!("Local branches to delete:");
51        for branch in &branches_to_delete {
52            println!("  - {branch}");
53        }
54
55        let user_input = input::prompt("Delete these branches? (y/n): ", reader);
56        if user_input != "y" && user_input != "yes" {
57            println!("Aborting.");
58            return Ok(());
59        }
60    }
61
62    for branch in &mut branches_to_delete {
63        println!("Deleting branch: {branch}");
64        branch.delete()?;
65    }
66
67    Ok(())
68}
69
70#[cfg(test)]
71mod tests {
72    use std::io::Cursor;
73
74    use crate::test_utilities;
75
76    use super::*;
77
78    fn open_repo(testdir: &tempfile::TempDir) -> repo::Repository {
79        repo::Repository::open(testdir.path()).unwrap()
80    }
81
82    #[test]
83    fn test_snip_no_confirm() {
84        // GIVEN a repo with orphaned branches and no_confirm=true
85        let (testdir, repo) = test_utilities::create_mock_repo();
86        let commit = test_utilities::get_latest_commit(&repo);
87        repo.branch("orphan-1", &commit, false).unwrap();
88        repo.branch("orphan-2", &commit, false).unwrap();
89
90        // WHEN snip is called with no_confirm
91        let result = snip_repo(&open_repo(&testdir), true, Cursor::new(""));
92
93        // THEN it should succeed and the branches should be deleted
94        assert!(result.is_ok());
95        assert!(repo
96            .find_branch("orphan-1", git2::BranchType::Local)
97            .is_err());
98        assert!(repo
99            .find_branch("orphan-2", git2::BranchType::Local)
100            .is_err());
101    }
102
103    #[test]
104    fn test_snip_user_confirms() {
105        // GIVEN a repo with orphaned branches and a reader that answers "y"
106        let (testdir, repo) = test_utilities::create_mock_repo();
107        let commit = test_utilities::get_latest_commit(&repo);
108        repo.branch("orphan-1", &commit, false).unwrap();
109
110        // WHEN snip is called and user confirms with "y"
111        let result = snip_repo(&open_repo(&testdir), false, Cursor::new("y\n"));
112
113        // THEN it should succeed and the branch should be deleted
114        assert!(result.is_ok());
115        assert!(repo
116            .find_branch("orphan-1", git2::BranchType::Local)
117            .is_err());
118    }
119
120    #[test]
121    fn test_snip_user_declines() {
122        // GIVEN a repo with orphaned branches and a reader that answers "n"
123        let (testdir, repo) = test_utilities::create_mock_repo();
124        let commit = test_utilities::get_latest_commit(&repo);
125        repo.branch("orphan-1", &commit, false).unwrap();
126
127        // WHEN snip is called and user declines with "n"
128        let result = snip_repo(&open_repo(&testdir), false, Cursor::new("n\n"));
129
130        // THEN it should succeed and the branch should still exist
131        assert!(result.is_ok());
132        assert!(repo
133            .find_branch("orphan-1", git2::BranchType::Local)
134            .is_ok());
135    }
136
137    #[test]
138    fn test_snip_no_orphaned_branches() {
139        // GIVEN a repo with no orphaned branches
140        let (testdir, _repo) = test_utilities::create_mock_repo();
141
142        // WHEN snip is called
143        let result = snip_repo(&open_repo(&testdir), true, Cursor::new(""));
144
145        // THEN it should succeed with no deletions
146        assert!(result.is_ok());
147    }
148}
149
150#[cfg(test)]
151pub mod test_utilities {
152    use git2::{Repository, RepositoryInitOptions};
153    use tempfile::TempDir;
154
155    /// Create a mock Git repository with initial commit in a temporary
156    /// directory for testing.
157    pub fn create_mock_repo() -> (TempDir, Repository) {
158        let tempdir = TempDir::new().unwrap();
159        let mut opts = RepositoryInitOptions::new();
160        opts.initial_head("main");
161        let repo = Repository::init_opts(tempdir.path(), &opts).unwrap();
162
163        // Create initial commit
164        {
165            let mut config = repo.config().unwrap();
166            config.set_str("user.name", "name").unwrap();
167            config.set_str("user.email", "email").unwrap();
168            let mut index = repo.index().unwrap();
169            let id = index.write_tree().unwrap();
170
171            let tree = repo.find_tree(id).unwrap();
172            let sig = repo.signature().unwrap();
173            repo.commit(Some("HEAD"), &sig, &sig, "initial\n\nbody", &tree, &[])
174                .unwrap();
175        }
176        (tempdir, repo)
177    }
178
179    /// Find the latest commit in a repository.
180    pub fn get_latest_commit(repo: &git2::Repository) -> git2::Commit<'_> {
181        let head = repo.head().unwrap();
182        let commit = head.peel_to_commit().unwrap();
183        commit
184    }
185}