1use std::fmt;
4use std::path::PathBuf;
5
6use serde::Serialize;
7
8#[derive(Clone, Eq, Hash, PartialEq, Serialize)]
10pub struct Repo {
11 path: PathBuf,
12}
13
14impl Repo {
15 pub fn new<P: Into<PathBuf>>(path: P) -> Repo {
16 Repo {
17 path: path.into(),
18 }
19 }
20
21 pub fn as_git2_repo(&self) -> ::git2::Repository {
23 ::git2::Repository::open(&self.path).unwrap_or_else(|e| {
24 panic!(
25 "Could not open {} as a git repo: {:?}. Perhaps you should run \
26 `git global scan` again.",
27 self.path.as_path().to_str().unwrap(),
28 e
29 )
30 })
31 }
32
33 pub fn path(&self) -> String {
35 self.path.to_str().unwrap().to_string()
36 }
37
38 pub fn get_status_lines(
40 &self,
41 mut status_opts: ::git2::StatusOptions,
42 ) -> Vec<String> {
43 let git2_repo = self.as_git2_repo();
44 let statuses = git2_repo
45 .statuses(Some(&mut status_opts))
46 .unwrap_or_else(|_| panic!("Could not get statuses for {}.", self));
47 statuses
48 .iter()
49 .map(|entry| {
50 let path = entry.path().unwrap();
51 let status = entry.status();
52 let status_for_path = get_short_format_status(status);
53 format!("{} {}", status_for_path, path)
54 })
55 .collect()
56 }
57
58 fn branch_to_commit(branch: git2::Branch) -> Option<git2::Commit> {
60 branch.into_reference().peel_to_commit().ok()
61 }
62
63 fn get_log(
65 repo: &git2::Repository,
66 commit: git2::Commit,
67 ) -> Vec<git2::Oid> {
68 let mut revwalk = repo.revwalk().unwrap();
69 revwalk.push(commit.id()).unwrap();
70 revwalk.filter_map(|id| id.ok()).collect::<Vec<git2::Oid>>()
71 }
72
73 pub fn is_ahead(&self) -> bool {
75 let repo = self.as_git2_repo();
76 let local_branches = match repo.branches(Some(git2::BranchType::Local))
77 {
78 Ok(branches) => branches,
79 Err(_) => return false,
80 };
81 let remote_branches =
82 match repo.branches(Some(git2::BranchType::Remote)) {
83 Ok(branches) => branches,
84 Err(_) => return false,
85 };
86
87 let remote_commit_ids = remote_branches
88 .filter_map(|branch| branch.ok().map(|b| b.0))
89 .filter_map(Self::branch_to_commit)
90 .flat_map(|commit| Self::get_log(&repo, commit))
91 .collect::<Vec<_>>();
92
93 #[allow(clippy::let_and_return)]
94 let is_ahead = local_branches
95 .filter_map(|branch| branch.ok().map(|b| b.0))
96 .any(|branch| match Self::branch_to_commit(branch) {
97 Some(commit) => !remote_commit_ids.contains(&commit.id()),
98 None => false,
99 });
100 is_ahead
101 }
102
103 pub fn get_stash_list(&self) -> Vec<String> {
105 let mut stash = vec![];
106 self.as_git2_repo()
107 .stash_foreach(|index, name, _oid| {
108 stash.push(format!("stash@{{{}}}: {}", index, name));
109 true
110 })
111 .unwrap();
112 stash
113 }
114}
115
116impl fmt::Display for Repo {
117 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
118 write!(f, "{}", self.path())
119 }
120}
121
122fn get_short_format_status(status: ::git2::Status) -> String {
126 let mut istatus = match status {
127 s if s.is_index_new() => 'A',
128 s if s.is_index_modified() => 'M',
129 s if s.is_index_deleted() => 'D',
130 s if s.is_index_renamed() => 'R',
131 s if s.is_index_typechange() => 'T',
132 _ => ' ',
133 };
134 let mut wstatus = match status {
135 s if s.is_wt_new() => {
136 if istatus == ' ' {
137 istatus = '?';
138 }
139 '?'
140 }
141 s if s.is_wt_modified() => 'M',
142 s if s.is_wt_deleted() => 'D',
143 s if s.is_wt_renamed() => 'R',
144 s if s.is_wt_typechange() => 'T',
145 _ => ' ',
146 };
147 if status.is_ignored() {
148 istatus = '!';
149 wstatus = '!';
150 }
151 if status.is_conflicted() {
152 istatus = 'C';
153 wstatus = 'C';
154 }
155 format!("{}{}", istatus, wstatus)
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161 use crate::test_utils::TestEnv;
162
163 #[test]
164 fn test_is_ahead() {
165 let mut env = TestEnv::new();
166 let repo_path = env.tempdir.path().join("repo_ahead");
167 let repo = Repo::new(repo_path);
168
169 env.create_repo("repo_ahead")
172 .commit("file.txt", "content")
173 .setup_remote()
174 .build();
175
176 assert!(!repo.is_ahead());
177
178 env.create_repo("repo_ahead")
180 .commit("file.txt", "new content")
181 .build();
182
183 assert!(repo.is_ahead());
184 }
185
186 #[test]
187 fn test_get_status_lines() {
188 let mut env = TestEnv::new();
189 let repo_path = env.tempdir.path().join("repo_status");
190 let repo = Repo::new(repo_path);
191
192 env.create_repo("repo_status")
193 .commit("initial.txt", "content")
194 .stage("staged.txt", "new file")
195 .unstaged("unstaged.txt", "untracked file")
196 .build();
197
198 let mut opts = git2::StatusOptions::new();
199 opts.include_untracked(true);
200 let lines = repo.get_status_lines(opts);
201
202 assert!(lines.iter().any(|l| l.starts_with("A staged.txt")));
203 assert!(lines.iter().any(|l| l.starts_with("?? unstaged.txt")));
204 }
205
206 #[test]
207 fn test_get_stash_list() {
208 let mut env = TestEnv::new();
209 let repo_path = env.tempdir.path().join("repo_stash");
210 let repo = Repo::new(repo_path);
211
212 env.create_repo("repo_stash")
213 .commit("file.txt", "content")
214 .unstaged("file.txt", "modified")
215 .stash("stash 1")
216 .unstaged("file.txt", "modified again")
217 .stash("stash 2")
218 .build();
219
220 let stashes = repo.get_stash_list();
221 assert_eq!(stashes.len(), 2);
222 assert!(stashes[0].contains("stash 2"));
224 assert!(stashes[1].contains("stash 1"));
225 }
226}