1use std::path::Path;
2
3use anyhow::{anyhow, Context};
4use git2::{Remote, Repository, Status, StatusOptions};
5use gtmpl::{self, Func, FuncError, Value};
6use gtmpl_derive::Gtmpl;
7use regex::Regex;
8use slog::{info, trace, warn};
9use std::collections::HashMap;
10use walkdir::DirEntry;
11use walkdir::WalkDir;
12
13pub struct Ctx {
14 pub logger: slog::Logger,
15}
16
17#[derive(Clone, Gtmpl)]
18pub struct GitRepo {
19 path: Location,
20 remotes: Func,
22 working_paths: Func,
23}
24
25#[derive(Debug, Clone, Gtmpl)]
26pub struct Location {
27 full: String,
28 file_name: String,
29}
30
31#[derive(Debug, Clone, Gtmpl)]
32pub struct RemoteData {
33 name: String,
34 url_full: String,
35 url_host: String,
36 url_path: String,
37}
38
39#[derive(Debug, Clone, Gtmpl)]
40pub struct WorkingPaths {
41 untracked: Vec<String>,
42 modified: Vec<String>,
43 deleted: Vec<String>,
44 added: Vec<String>,
45 renamed: Vec<String>,
46 conflicted: Vec<String>,
47}
48
49fn find_repo(args: &[Value]) -> Result<Repository, FuncError> {
50 if let Value::Object(ref o) = &args[0] {
51 let full = o
52 .get("path")
53 .and_then(|v| {
54 if let Value::Object(ref o) = v {
55 o.get("full").map(|s| s.to_string())
56 } else {
57 None
58 }
59 })
60 .ok_or(anyhow!("path.full not empty"))?;
61 let repo = Repository::open(Path::new(&full)).unwrap();
62 Ok(repo)
63 } else {
64 Err(anyhow!("GitRepo required, got: {:?}", args).into())
65 }
66}
67
68fn find_remotes(args: &[Value]) -> Result<Value, FuncError> {
69 let repo = find_repo(args)?;
70 let mut remotes = HashMap::new();
71 repo.remotes()
72 .unwrap()
73 .iter()
74 .filter_map(|x| x.and_then(|name| repo.find_remote(name).map(RemoteData::from).ok()))
75 .for_each(|rd| {
76 remotes.insert(rd.name.clone(), rd);
77 });
78 Ok(remotes.into())
79}
80
81fn find_working_paths(args: &[Value]) -> Result<Value, FuncError> {
82 let repo = find_repo(args)?;
83 let mut opts = StatusOptions::new();
84 opts.include_untracked(true);
85 let statuses = repo
86 .statuses(Some(&mut opts))
87 .context("find status of working path")?;
88 let mut untracked = vec![];
89 let mut modified = vec![];
90 let mut added = vec![];
91 let mut deleted = vec![];
92 let mut renamed = vec![];
93 let mut conflicted = vec![];
94 for entry in statuses.iter() {
95 if let Some(path) = entry.path() {
96 let status = entry.status();
98 if status.intersects(Status::INDEX_MODIFIED) || status.intersects(Status::WT_MODIFIED) {
99 modified.push(path.to_owned());
100 }
101 if status.intersects(Status::INDEX_NEW) {
102 added.push(path.to_owned())
103 }
104 if status.intersects(Status::WT_NEW) {
105 untracked.push(path.to_owned())
106 }
107 if status.intersects(Status::INDEX_DELETED) || status.intersects(Status::WT_DELETED) {
108 deleted.push(path.to_owned())
109 }
110 if status.intersects(Status::INDEX_RENAMED) || status.intersects(Status::WT_RENAMED) {
111 renamed.push(path.to_owned())
112 }
113 if status.intersects(Status::CONFLICTED) {
114 conflicted.push(path.to_owned())
115 }
116 }
117 }
118 Ok(WorkingPaths {
119 untracked,
120 modified,
121 added,
122 deleted,
123 renamed,
124 conflicted,
125 }
126 .into())
127}
128impl<'a> From<&'a Path> for GitRepo {
129 fn from(path: &Path) -> Self {
131 GitRepo {
132 path: Location {
133 full: path.to_str().map(|x| x.to_owned()).unwrap(),
134 file_name: path
135 .file_name()
136 .and_then(|x| x.to_str())
137 .map(|x| x.to_owned())
138 .unwrap(),
139 },
140 remotes: find_remotes,
141 working_paths: find_working_paths,
142 }
143 }
144}
145
146impl<'b> From<Remote<'b>> for RemoteData {
147 fn from(v: Remote) -> Self {
148 let (host, path) = v
151 .url()
152 .map(|url| extract_host_and_path(url))
153 .unwrap_or((None, None));
154 RemoteData {
155 name: v.name().unwrap_or("no_name").to_owned(),
156 url_full: v.url().unwrap_or("").to_owned(),
157 url_host: host.unwrap_or_else(|| "".to_owned()),
158 url_path: path.unwrap_or_else(|| "".to_owned()),
159 }
160 }
161}
162
163fn extract_host_and_path(v: &str) -> (Option<String>, Option<String>) {
165 let http_re = Regex::new(
166 r"^https?://(?P<host>[[:alnum:]\._-]+)(:\d+)?/(?P<path>[[:alnum:]\._\-/]+).git$",
167 )
168 .unwrap();
169 let ssh_re =
170 Regex::new(r"^git@(?P<host>[[:alnum:]\._-]+):(?P<path>[[:alnum:]\._\-/]+).git$").unwrap();
171 ssh_re
172 .captures(v)
173 .or_else(|| http_re.captures(v))
174 .map(|caps| (Some(caps["host"].to_owned()), Some(caps["path"].to_owned())))
175 .unwrap_or((None, None))
176}
177
178pub fn find_repos(ctx: &Ctx, root: &Path) -> Vec<GitRepo> {
179 info!(ctx.logger, "find repositories"; "root" => &root.to_str());
180 let mut found = vec![];
181 let mut it = WalkDir::new(root).into_iter();
182 loop {
183 let entry = match it.next() {
184 Option::None => break,
185 Some(Err(err)) => {
186 warn!(ctx.logger, "fail to access"; "err" => format!("{:?}", err));
187 continue;
188 }
189 Some(Ok(entry)) => entry,
190 };
191 if is_hidden(&entry) {
192 if entry.file_type().is_dir() {
193 it.skip_current_dir();
194 }
195 continue;
196 }
197 if is_gitrepo(&entry) {
198 found.push(GitRepo::from(entry.path()));
199 it.skip_current_dir();
200 continue;
201 }
202 }
203 found
204}
205
206pub fn render(ctx: &Ctx, tmpl: &str, value: &GitRepo) -> String {
207 trace!(ctx.logger, "render");
208 gtmpl::template(tmpl, value.clone()).expect("template")
210}
211
212fn is_hidden(entry: &DirEntry) -> bool {
213 entry
214 .file_name()
215 .to_str()
216 .map(|s| s.starts_with('.'))
217 .unwrap_or(false)
218}
219
220fn is_gitrepo(entry: &DirEntry) -> bool {
221 entry.path().is_dir() && {
222 let p = entry.path().join(".git").join("config");
223 p.exists() && p.is_file()
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use spectral::prelude::*;
231
232 #[test]
233 fn extract_host_and_path_on_ssh() {
234 let v = "git@github.com:davidB/git-find.git";
235 let (host, path) = extract_host_and_path(v);
236 assert_that!(&host).is_equal_to(Some("github.com".to_owned()));
237 assert_that!(&path).is_equal_to(Some("davidB/git-find".to_owned()));
238 }
239
240 #[test]
241 fn extract_host_and_path_on_https() {
242 let v = "https://github.com/davidB/git-find.git";
243 let (host, path) = extract_host_and_path(v);
244 assert_that!(&host).is_equal_to(Some("github.com".to_owned()));
245 assert_that!(&path).is_equal_to(Some("davidB/git-find".to_owned()));
246 }
247
248 #[test]
249 fn extract_host_and_path_on_http() {
250 let v = "http://github.com/davidB/git-find.git";
251 let (host, path) = extract_host_and_path(v);
252 assert_that!(&host).is_equal_to(Some("github.com".to_owned()));
253 assert_that!(&path).is_equal_to(Some("davidB/git-find".to_owned()));
254 }
255}