1
mod branch;
2
mod hook;
3
mod input;
4
mod reference;
5
mod remote;
6
mod repo;
7

            
8
use std::env;
9
use std::io::BufRead;
10

            
11
use anyhow::{Context, Result};
12

            
13
use hook::{GitHook, GitHookType};
14

            
15
/// Install git-snip hook scripts for post-merge and post-rewrite.
16
pub 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.
29
pub 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

            
36
12
fn snip_repo(repo: &repo::Repository, no_confirm: bool, reader: impl BufRead) -> Result<()> {
37
12
    let result = repo.orphaned_branches();
38

            
39
12
    if let Some(head) = &result.skipped_head {
40
12
        println!("Cannot delete current branch: {head}");
41
12
    }
42

            
43
12
    let mut branches_to_delete = result.branches;
44
12
    if branches_to_delete.is_empty() {
45
3
        println!("No local branches to delete.");
46
3
        return Ok(());
47
9
    }
48

            
49
9
    if !no_confirm {
50
6
        println!("Local branches to delete:");
51
6
        for branch in &branches_to_delete {
52
6
            println!("  - {branch}");
53
6
        }
54

            
55
6
        let user_input = input::prompt("Delete these branches? (y/n): ", reader);
56
6
        if user_input != "y" && user_input != "yes" {
57
3
            println!("Aborting.");
58
3
            return Ok(());
59
3
        }
60
3
    }
61

            
62
9
    for branch in &mut branches_to_delete {
63
9
        println!("Deleting branch: {branch}");
64
9
        branch.delete()?;
65
    }
66

            
67
6
    Ok(())
68
12
}
69

            
70
#[cfg(test)]
71
mod tests {
72
    use std::io::Cursor;
73

            
74
    use crate::test_utilities;
75

            
76
    use super::*;
77

            
78
12
    fn open_repo(testdir: &tempfile::TempDir) -> repo::Repository {
79
12
        repo::Repository::open(testdir.path()).unwrap()
80
12
    }
81

            
82
    #[test]
83
3
    fn test_snip_no_confirm() {
84
        // GIVEN a repo with orphaned branches and no_confirm=true
85
3
        let (testdir, repo) = test_utilities::create_mock_repo();
86
3
        let commit = test_utilities::get_latest_commit(&repo);
87
3
        repo.branch("orphan-1", &commit, false).unwrap();
88
3
        repo.branch("orphan-2", &commit, false).unwrap();
89

            
90
        // WHEN snip is called with no_confirm
91
3
        let result = snip_repo(&open_repo(&testdir), true, Cursor::new(""));
92

            
93
        // THEN it should succeed and the branches should be deleted
94
3
        assert!(result.is_ok());
95
3
        assert!(repo
96
3
            .find_branch("orphan-1", git2::BranchType::Local)
97
3
            .is_err());
98
3
        assert!(repo
99
3
            .find_branch("orphan-2", git2::BranchType::Local)
100
3
            .is_err());
101
3
    }
102

            
103
    #[test]
104
3
    fn test_snip_user_confirms() {
105
        // GIVEN a repo with orphaned branches and a reader that answers "y"
106
3
        let (testdir, repo) = test_utilities::create_mock_repo();
107
3
        let commit = test_utilities::get_latest_commit(&repo);
108
3
        repo.branch("orphan-1", &commit, false).unwrap();
109

            
110
        // WHEN snip is called and user confirms with "y"
111
3
        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
3
        assert!(result.is_ok());
115
3
        assert!(repo
116
3
            .find_branch("orphan-1", git2::BranchType::Local)
117
3
            .is_err());
118
3
    }
119

            
120
    #[test]
121
3
    fn test_snip_user_declines() {
122
        // GIVEN a repo with orphaned branches and a reader that answers "n"
123
3
        let (testdir, repo) = test_utilities::create_mock_repo();
124
3
        let commit = test_utilities::get_latest_commit(&repo);
125
3
        repo.branch("orphan-1", &commit, false).unwrap();
126

            
127
        // WHEN snip is called and user declines with "n"
128
3
        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
3
        assert!(result.is_ok());
132
3
        assert!(repo
133
3
            .find_branch("orphan-1", git2::BranchType::Local)
134
3
            .is_ok());
135
3
    }
136

            
137
    #[test]
138
3
    fn test_snip_no_orphaned_branches() {
139
        // GIVEN a repo with no orphaned branches
140
3
        let (testdir, _repo) = test_utilities::create_mock_repo();
141

            
142
        // WHEN snip is called
143
3
        let result = snip_repo(&open_repo(&testdir), true, Cursor::new(""));
144

            
145
        // THEN it should succeed with no deletions
146
3
        assert!(result.is_ok());
147
3
    }
148
}
149

            
150
#[cfg(test)]
151
pub 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
63
    pub fn create_mock_repo() -> (TempDir, Repository) {
158
63
        let tempdir = TempDir::new().unwrap();
159
63
        let mut opts = RepositoryInitOptions::new();
160
63
        opts.initial_head("main");
161
63
        let repo = Repository::init_opts(tempdir.path(), &opts).unwrap();
162

            
163
        // Create initial commit
164
63
        {
165
63
            let mut config = repo.config().unwrap();
166
63
            config.set_str("user.name", "name").unwrap();
167
63
            config.set_str("user.email", "email").unwrap();
168
63
            let mut index = repo.index().unwrap();
169
63
            let id = index.write_tree().unwrap();
170
63

            
171
63
            let tree = repo.find_tree(id).unwrap();
172
63
            let sig = repo.signature().unwrap();
173
63
            repo.commit(Some("HEAD"), &sig, &sig, "initial\n\nbody", &tree, &[])
174
63
                .unwrap();
175
63
        }
176
63
        (tempdir, repo)
177
63
    }
178

            
179
    /// Find the latest commit in a repository.
180
30
    pub fn get_latest_commit(repo: &git2::Repository) -> git2::Commit<'_> {
181
30
        let head = repo.head().unwrap();
182
30
        let commit = head.peel_to_commit().unwrap();
183
30
        commit
184
30
    }
185
}