git_tracker/
git.rs

1use std::{
2    collections::{HashMap, HashSet},
3    io::{self, BufRead},
4    path::Path,
5    str::FromStr,
6};
7
8use anyhow::{anyhow, bail};
9
10use crate::{
11    data::{Branch, Link, Repo, View},
12    os,
13};
14
15impl Repo {
16    #[tracing::instrument]
17    pub async fn read_from_link(link: &Link) -> anyhow::Result<Self> {
18        let result = match link {
19            Link::Fs { dir } => Self::read_from_fs(dir).await,
20            Link::Net { url } => Self::read_from_url(url).await,
21        };
22        if let Err(error) = &result {
23            tracing::error!(?link, ?error, "Failed to read repo.");
24        }
25        result
26    }
27
28    #[tracing::instrument]
29    pub async fn read_from_fs<P>(dir: P) -> anyhow::Result<Self>
30    where
31        P: AsRef<Path> + std::fmt::Debug,
32    {
33        let dir = dir.as_ref();
34        let selph = Self {
35            description: description(dir).await?,
36            branches: branches(dir).await?,
37            remotes: remote_refs(dir).await?,
38        };
39        Ok(selph)
40    }
41
42    #[tracing::instrument]
43    pub async fn read_from_url(url: &str) -> anyhow::Result<Self> {
44        let dir = tempfile::tempdir()?;
45        let dir = dir.path();
46        clone_bare(url, dir).await?;
47        Self::read_from_fs(dir).await
48    }
49}
50
51#[derive(Debug)]
52struct TreeRef {
53    pub name: String,
54    pub hash: String,
55}
56
57impl FromStr for TreeRef {
58    type Err = anyhow::Error;
59
60    fn from_str(s: &str) -> Result<Self, Self::Err> {
61        let mut fields = s.split_whitespace();
62        let hash = fields
63            .next()
64            .map(|str| str.to_string())
65            .ok_or_else(|| anyhow!("Ref line is empty: {s:?}"))?;
66        let name = fields
67            .next()
68            .map(|str| str.to_string())
69            .ok_or_else(|| anyhow!("Ref line missing path: {s:?}"))?;
70        if fields.next().is_some() {
71            bail!("Ref line has too many fields: {s:?}");
72        }
73        Ok(Self { name, hash })
74    }
75}
76
77#[derive(Debug)]
78struct RemoteRef {
79    pub name: String,
80    pub addr: String,
81}
82
83impl FromStr for RemoteRef {
84    type Err = anyhow::Error;
85
86    fn from_str(s: &str) -> Result<Self, Self::Err> {
87        let mut fields = s.split_whitespace();
88        let name = fields
89            .next()
90            .map(|str| str.to_string())
91            .ok_or_else(|| anyhow!("Remote line is empty: {s:?}"))?;
92        let addr = fields
93            .next()
94            .map(|str| str.to_string())
95            .ok_or_else(|| anyhow!("Remote line missing addr: {s:?}"))?;
96        Ok(Self { name, addr })
97    }
98}
99
100pub async fn view(host: &str, link: &Link) -> View {
101    View {
102        host: host.to_string(),
103        link: link.clone(),
104        repo: Repo::read_from_link(link).await.ok(),
105    }
106}
107
108pub async fn is_repo<P: AsRef<Path>>(dir: P) -> bool {
109    let git_dir = format!("--git-dir={}", dir.as_ref().to_string_lossy());
110    os::cmd("git", &[&git_dir, "log", "--format=", "-1"])
111        .await
112        .is_ok()
113}
114
115#[tracing::instrument(skip_all)]
116async fn branches(dir: &Path) -> anyhow::Result<HashMap<String, Branch>> {
117    let mut branches = HashMap::new();
118    // XXX Looking up roots for all refs, rather than just branches, takes a
119    //     long time for repos with many tags and long history.
120    for (name, leaf) in branch_leaves(dir).await? {
121        let roots = branch_roots(dir, &leaf).await?;
122        branches.insert(name, Branch { roots, leaf });
123    }
124    Ok(branches)
125}
126
127#[tracing::instrument(skip_all)]
128async fn branch_leaves(
129    dir: &Path,
130) -> anyhow::Result<HashMap<String, String>> {
131    let git_dir = format!("--git-dir={}", dir.to_string_lossy());
132    let mut refs = HashMap::new();
133    for line_result in os::cmd("git", &[&git_dir, "show-ref", "--branches"])
134        .await?
135        .lines()
136    {
137        let line: String = line_result?;
138        let TreeRef { name, hash } = line.parse()?;
139        if let Some(name) = name.strip_prefix("refs/heads/") {
140            refs.insert(name.to_string(), hash);
141        }
142    }
143    Ok(refs)
144}
145
146#[tracing::instrument(skip_all)]
147pub async fn clone_bare(
148    from_addr: &str,
149    to_dir: &Path,
150) -> anyhow::Result<()> {
151    let to_dir = to_dir.to_string_lossy().to_string();
152    // Q: How to prevent git from prompting for credentials and fail instead?
153    // A: https://serverfault.com/a/1054253/156830
154    let env = HashMap::from([
155        ("GIT_SSH_COMMAND", "ssh -oBatchMode=yes"),
156        ("GIT_TERMINAL_PROMPT", "0"),
157        ("GIT_ASKPASS", "echo"),
158        ("SSH_ASKPASS", "echo"),
159        ("GCM_INTERACTIVE", "never"),
160    ]);
161    let exe = "git";
162    let args = &["clone", "--bare", from_addr, &to_dir];
163    let out = tokio::process::Command::new(exe)
164        .args(args)
165        .envs(&env)
166        .output()
167        .await?;
168    out.status.success().then_some(()).ok_or_else(|| {
169        anyhow!(
170            "Failed to execute command: exe={exe:?} args={args:?} env={env:?} err={:?}",
171            String::from_utf8_lossy(&out.stderr[..])
172        )
173    })
174}
175
176#[tracing::instrument(skip_all)]
177async fn remote_refs(dir: &Path) -> anyhow::Result<HashMap<String, String>> {
178    let git_dir = format!("--git-dir={}", dir.to_string_lossy());
179    let mut remotes = HashMap::new();
180    for line_result in
181        os::cmd("git", &[&git_dir, "remote", "-v"]).await?.lines()
182    {
183        let line = line_result?;
184        let RemoteRef { name, addr } = line.parse()?;
185        remotes.insert(name, addr);
186    }
187    Ok(remotes)
188}
189
190#[tracing::instrument(skip(dir))]
191pub async fn branch_roots(
192    dir: &Path,
193    leaf_hash: &str,
194) -> anyhow::Result<HashSet<String>> {
195    let git_dir = format!("--git-dir={}", dir.to_string_lossy());
196    let output = os::cmd(
197        "git",
198        &[&git_dir, "rev-list", "--max-parents=0", leaf_hash, "--"],
199    )
200    .await?;
201    let roots: HashSet<String> =
202        output.lines().map_while(Result::ok).collect();
203    if roots.is_empty() {
204        bail!("Found 0 roots for leaf hash {leaf_hash} in repo={dir:?}");
205    }
206    Ok(roots)
207}
208
209#[tracing::instrument(skip_all)]
210pub async fn is_bare(dir: &Path) -> anyhow::Result<bool> {
211    let git_dir = format!("--git-dir={}", dir.to_string_lossy());
212    let out =
213        os::cmd("git", &[&git_dir, "rev-parse", "--is-bare-repository"])
214            .await?;
215    let out = String::from_utf8(out)?;
216    let is_bare: bool = out.trim().parse()?;
217    Ok(is_bare)
218}
219
220#[tracing::instrument(skip_all)]
221async fn description(dir: &Path) -> io::Result<Option<String>> {
222    tokio::fs::read_to_string(dir.join("description"))
223        .await
224        .map(|s| (!s.starts_with("Unnamed repository;")).then_some(s))
225}