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() -> 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)?;
23
    hook::install(repo.path(), &h, GitHookType::PostRewrite)?;
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
8
fn snip_repo(repo: &repo::Repository, no_confirm: bool, reader: impl BufRead) -> Result<()> {
37
8
    let result = repo.orphaned_branches();
38

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

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

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

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

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

            
67
4
    Ok(())
68
8
}
69

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

            
74
    use crate::test_utilities;
75

            
76
    use super::*;
77

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

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

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

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

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

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

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

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

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

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

            
145
        // THEN it should succeed with no deletions
146
2
        assert!(result.is_ok());
147
2
    }
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
42
    pub fn create_mock_repo() -> (TempDir, Repository) {
158
42
        let tempdir = TempDir::new().unwrap();
159
42
        let mut opts = RepositoryInitOptions::new();
160
42
        opts.initial_head("main");
161
42
        let repo = Repository::init_opts(tempdir.path(), &opts).unwrap();
162

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

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

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