1
use std::collections::HashSet;
2
use std::path::Path;
3

            
4
use anyhow::{Context, Result};
5
use git2::BranchType;
6

            
7
use crate::branch::Branch;
8
use crate::reference::Reference;
9
use crate::remote::Remote;
10

            
11
/// Result of querying for orphaned branches.
12
pub struct OrphanedBranches<'repo> {
13
    /// Branches that can be deleted.
14
    pub branches: Vec<Branch<'repo>>,
15
    /// Name of the current branch, if it was skipped from deletion.
16
    pub skipped_head: Option<String>,
17
}
18

            
19
/// Internal representation of a Git repository.
20
pub struct Repository {
21
    repository: git2::Repository,
22
}
23

            
24
impl Repository {
25
    /// Open a repository at the given path. Traverse the directory tree
26
    /// to find the repository root. Return Ok(Repository) if found, or
27
    /// Error if not found.
28
    ///
29
    /// # Errors
30
    /// Returns an error if the repository cannot be opened.
31
28
    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
32
26
        let repository =
33
28
            git2::Repository::discover(path).context("Failed to discover repository")?;
34
26
        Ok(Repository { repository })
35
28
    }
36

            
37
    /// Get path of the repository
38
6
    pub fn path(&self) -> &Path {
39
6
        self.repository.path()
40
6
    }
41

            
42
    /// Get current HEAD reference.
43
12
    pub fn head(&self) -> Result<Reference<'_>> {
44
12
        let head = self
45
12
            .repository
46
12
            .head()
47
12
            .context("Failed to get HEAD reference")?;
48
12
        Ok(Reference::from(head))
49
12
    }
50

            
51
    /// Return a list of all branches of a given BranchType in a repository as a Vec.
52
22
    pub fn branches(&self, branch_type: BranchType) -> Vec<Branch<'_>> {
53
22
        self.repository
54
22
            .branches(Some(branch_type))
55
22
            .ok()
56
22
            .into_iter()
57
33
            .flat_map(|branches_list| branches_list.filter_map(Result::ok))
58
39
            .map(|(branch, _)| branch.into())
59
22
            .collect()
60
22
    }
61

            
62
    /// Return all orphaned branches in a repository - branches without a
63
    /// remote counterpart. Also reports if the current HEAD branch was
64
    /// excluded from the result.
65
10
    pub fn orphaned_branches(&self) -> OrphanedBranches<'_> {
66
10
        let local_branches = self.branches(BranchType::Local);
67
10
        let remote_branches = self.branches(BranchType::Remote);
68
10
        let remote_prefixes = self
69
10
            .remotes()
70
10
            .iter()
71
10
            .map(|r| r.as_prefix())
72
10
            .collect::<HashSet<String>>();
73

            
74
10
        let remote_names: HashSet<String> = remote_branches
75
10
            .iter()
76
10
            .filter_map(|r| r.name_without_prefix(&remote_prefixes).ok())
77
10
            .collect();
78

            
79
10
        let mut branches = local_branches
80
10
            .into_iter()
81
27
            .filter(|b| b.name().ok().is_none_or(|n| !remote_names.contains(&n)))
82
10
            .collect::<Vec<Branch>>();
83

            
84
        // Check if the current branch is in the list of orphaned branches. If it is,
85
        // remove it from the list.
86
10
        let mut skipped_head = None;
87
10
        if let Ok(head_ref) = self.head() {
88
10
            if let Some(head_short) = head_ref.shorthand() {
89
10
                let initial_len = branches.len();
90
27
                branches.retain(|b| b.to_string() != head_short);
91
10
                if branches.len() < initial_len {
92
10
                    skipped_head = Some(head_short.to_string());
93
10
                }
94
            }
95
        }
96

            
97
10
        OrphanedBranches {
98
10
            branches,
99
10
            skipped_head,
100
10
        }
101
10
    }
102

            
103
    /// Get a list of remotes for a repository. If there are no remotes, return an
104
    /// empty Vec.
105
16
    pub fn remotes(&self) -> Vec<Remote> {
106
16
        let mut remotes = Vec::new();
107
16
        if let Ok(remote_list) = self.repository.remotes() {
108
16
            for remote in remote_list.iter().flatten() {
109
6
                remotes.push(Remote::new(remote));
110
6
            }
111
        }
112
16
        remotes
113
16
    }
114
}
115

            
116
#[cfg(test)]
117
mod tests {
118
    use super::*;
119

            
120
    use std::fs;
121

            
122
    use crate::test_utilities;
123

            
124
    #[test]
125
2
    fn test_open_current() {
126
        // GIVEN a repository
127
2
        let (testdir, repo) = test_utilities::create_mock_repo();
128

            
129
        // WHEN the repository is opened
130
2
        let actual = Repository::open(testdir).unwrap();
131

            
132
        // THEN it should match the expected repository
133
2
        assert_eq!(actual.path(), repo.path());
134
2
    }
135

            
136
    #[test]
137
2
    fn test_open_parent() {
138
        // GIVEN a repository with a subdirectory
139
2
        let (testdir, repo) = test_utilities::create_mock_repo();
140
2
        let subdir = repo.path().join("subdir");
141
2
        fs::create_dir(&subdir).unwrap();
142

            
143
        // WHEN the repository is opened
144
2
        let actual = Repository::open(testdir).unwrap();
145

            
146
        // THEN it should match the expected repository
147
2
        assert_eq!(actual.path(), repo.path());
148
2
    }
149

            
150
    #[test]
151
2
    fn test_open_not_found() {
152
        // GIVEN a directory that is not a repository
153
2
        let testdir = tempfile::tempdir().unwrap();
154

            
155
        // WHEN the repository is opened
156
2
        let actual = Repository::open(testdir.path());
157

            
158
        // THEN it should not be found
159
2
        assert!(actual.is_err());
160
2
    }
161

            
162
    #[test]
163
2
    fn test_head() {
164
        // GIVEN a repository
165
2
        let (testdir, repo) = test_utilities::create_mock_repo();
166

            
167
        // WHEN getting the HEAD reference
168
2
        let binding = Repository::open(testdir.path()).unwrap();
169
2
        let actual = binding.head().unwrap();
170
2
        let expected = repo.head().unwrap();
171

            
172
        // THEN it should match the expected reference
173
2
        assert_eq!(actual.name(), expected.name());
174
2
    }
175

            
176
    #[test]
177
2
    fn test_list_local() {
178
        // GIVEN a repository with local branches
179
2
        let (testdir, repo) = test_utilities::create_mock_repo();
180
2
        let target_commit = test_utilities::get_latest_commit(&repo);
181
2
        let local_branches = vec!["local-branch-1", "local-branch-2"];
182
4
        for branch_name in local_branches.iter() {
183
4
            let _ = repo.branch(branch_name, &target_commit, false);
184
4
        }
185

            
186
5
        let mut expected = Vec::from_iter(local_branches.iter().map(|b| b.to_string()));
187
2
        expected.push("main".to_string());
188

            
189
        // WHEN the list of branches is retrieved
190
2
        let actual = Repository::open(testdir.path())
191
2
            .unwrap()
192
2
            .branches(BranchType::Local)
193
2
            .iter()
194
7
            .map(|b| b.to_string())
195
2
            .collect::<Vec<String>>();
196

            
197
        // THEN the set of branches should match the expected one
198
2
        assert_eq!(actual, expected);
199
2
    }
200

            
201
    #[test]
202
2
    fn test_orphaned_branches() {
203
        // GIVEN a repository with local and remote branches
204
2
        let (testdir, repo) = test_utilities::create_mock_repo();
205
2
        let target_commit = test_utilities::get_latest_commit(&repo);
206
2
        let orphaned_branches = vec!["to-delete-1", "to-delete-2"];
207
4
        for branch_name in orphaned_branches.iter() {
208
4
            let _ = repo.branch(branch_name, &target_commit, false);
209
4
        }
210

            
211
        // WHEN the list of orphaned branches is retrieved
212
2
        let repo = Repository::open(testdir.path()).unwrap();
213
2
        let result = repo.orphaned_branches();
214
5
        let actual: Vec<String> = result.branches.iter().map(|b| b.to_string()).collect();
215

            
216
        // THEN the set of branches should match the expected one
217
2
        assert_eq!(actual, orphaned_branches);
218
        // main is HEAD so it gets skipped
219
2
        assert_eq!(result.skipped_head.as_deref(), Some("main"));
220
2
    }
221

            
222
    #[test]
223
2
    fn test_path() {
224
        // GIVEN a repository
225
2
        let (testdir, repo) = test_utilities::create_mock_repo();
226

            
227
        // WHEN getting the path
228
2
        let binding = Repository::open(testdir).unwrap();
229
2
        let actual = binding.path();
230

            
231
        // THEN it should match the expected path
232
2
        assert_eq!(actual, repo.path());
233
2
    }
234

            
235
    #[test]
236
2
    fn test_remotes_one() {
237
        // GIVEN a repository with remote
238
2
        let (testdir, repo) = test_utilities::create_mock_repo();
239
2
        let remote_name = "origin";
240
2
        repo.remote(remote_name, "https://example.com").unwrap();
241
2
        let remote = Remote::new(remote_name);
242

            
243
        // WHEN the remotes are listed
244
2
        let actual = Repository::open(testdir).unwrap().remotes();
245

            
246
        // THEN it should match the expected remotes
247
2
        assert_eq!(actual.len(), 1);
248
2
        assert!(actual.contains(&remote));
249
2
    }
250

            
251
    #[test]
252
2
    fn test_remotes_multiple() {
253
        // GIVEN a repository with multiple remotes
254
2
        let (testdir, repo) = test_utilities::create_mock_repo();
255

            
256
2
        let remote1_name = "origin";
257
2
        repo.remote(remote1_name, "https://example.com").unwrap();
258
2
        let remote1 = Remote::new(remote1_name);
259

            
260
2
        let remote2_name = "upstream";
261
2
        repo.remote(remote2_name, "https://example.org").unwrap();
262
2
        let remote2 = Remote::new(remote2_name);
263

            
264
        // WHEN the remotes are listed
265
2
        let actual = Repository::open(testdir).unwrap().remotes();
266

            
267
        // THEN it should match the expected remotes
268
2
        assert_eq!(actual.len(), 2);
269
2
        assert!(actual.contains(&remote1));
270
2
        assert!(actual.contains(&remote2));
271
2
    }
272

            
273
    #[test]
274
2
    fn test_remotes_empty() {
275
        // GIVEN a repository without remotes
276
2
        let (testdir, _repo) = crate::test_utilities::create_mock_repo();
277

            
278
        // WHEN the remote prefixes are listed
279
2
        let actual = Repository::open(testdir).unwrap().remotes();
280

            
281
        // THEN it should be empty
282
2
        assert!(actual.is_empty());
283
2
    }
284
}