git_find/
lib.rs

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    //repo: git2::Repository,
21    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            //eprintln!("path : {} {:?}", path, entry.status());
97            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    //TODO manage result & error
130    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 = url_parsed.host_str().unwrap_or("").to_owned();
149        // let path = url_parsed.path().to_owned();
150        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
163/// url data extractor for git remote url (ssh, http, https)
164fn 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    //TODO remove the clone() and provide Value for &GitRepo
209    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}