Skip to main content

radicle_cli/
git.rs

1//! Git-related functions and types.
2
3pub mod ddiff;
4pub mod pretty_diff;
5pub mod unified_diff;
6
7use std::collections::HashSet;
8use std::fmt::Display;
9use std::fs::{File, OpenOptions};
10use std::io;
11use std::io::Write;
12use std::ops::{Deref, DerefMut};
13use std::path::{Path, PathBuf};
14use std::process::Command;
15use std::str::FromStr;
16
17use anyhow::Context as _;
18use anyhow::anyhow;
19use thiserror::Error;
20
21use radicle::crypto::ssh;
22use radicle::git;
23use radicle::git::raw::{ErrorExt as _, Repository};
24use radicle::git::{VERSION_REQUIRED, Version};
25use radicle::prelude::{NodeId, RepoId};
26use radicle::storage::git::transport;
27
28pub const CONFIG_COMMIT_GPG_SIGN: &str = "commit.gpgsign";
29pub const CONFIG_SIGNING_KEY: &str = "user.signingkey";
30pub const CONFIG_GPG_FORMAT: &str = "gpg.format";
31pub const CONFIG_GPG_SSH_PROGRAM: &str = "gpg.ssh.program";
32pub const CONFIG_GPG_SSH_ALLOWED_SIGNERS: &str = "gpg.ssh.allowedSignersFile";
33
34/// Git revision parameter. Supports extended SHA-1 syntax.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Rev(String);
37
38impl Rev {
39    /// Return the revision as a string.
40    #[must_use]
41    pub fn as_str(&self) -> &str {
42        &self.0
43    }
44
45    /// Resolve the revision to an [`From<git::raw::Oid>`].
46    pub fn resolve<T>(&self, repo: &Repository) -> Result<T, git::raw::Error>
47    where
48        T: From<git::raw::Oid>,
49    {
50        let object = repo.revparse_single(self.as_str())?;
51        Ok(object.id().into())
52    }
53}
54
55impl Display for Rev {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        self.0.fmt(f)
58    }
59}
60
61impl From<String> for Rev {
62    fn from(value: String) -> Self {
63        Rev(value)
64    }
65}
66
67#[derive(Error, Debug)]
68pub enum RemoteError {
69    #[error("url malformed: {0}")]
70    ParseUrl(#[from] transport::local::UrlError),
71    #[error("remote `url` not found")]
72    MissingUrl,
73    #[error("remote `name` not found")]
74    MissingName,
75}
76
77#[derive(Clone)]
78pub struct Remote<'a> {
79    pub name: String,
80    pub url: radicle::git::Url,
81    pub pushurl: Option<radicle::git::Url>,
82
83    inner: git::raw::Remote<'a>,
84}
85
86impl<'a> TryFrom<git::raw::Remote<'a>> for Remote<'a> {
87    type Error = RemoteError;
88
89    fn try_from(value: git::raw::Remote<'a>) -> Result<Self, Self::Error> {
90        let url = value.url().map_or(Err(RemoteError::MissingUrl), |url| {
91            Ok(radicle::git::Url::from_str(url)?)
92        })?;
93        let pushurl = value
94            .pushurl()
95            .map_err(|_| RemoteError::MissingUrl)?
96            .map(radicle::git::Url::from_str)
97            .transpose()?;
98        let name = value
99            .name()
100            .map_err(|_| RemoteError::MissingName)?
101            .ok_or(RemoteError::MissingName)?;
102
103        Ok(Self {
104            name: name.to_owned(),
105            url,
106            pushurl,
107            inner: value,
108        })
109    }
110}
111
112impl<'a> Deref for Remote<'a> {
113    type Target = git::raw::Remote<'a>;
114
115    fn deref(&self) -> &Self::Target {
116        &self.inner
117    }
118}
119
120impl DerefMut for Remote<'_> {
121    fn deref_mut(&mut self) -> &mut Self::Target {
122        &mut self.inner
123    }
124}
125
126/// Get the git repository in the current directory.
127pub fn repository() -> Result<Repository, anyhow::Error> {
128    match Repository::open(".") {
129        Ok(repo) => Ok(repo),
130        Err(err) => Err(err).context("the current working directory is not a git repository"),
131    }
132}
133
134/// Execute a git command by spawning a child process.
135/// Returns [`Result::Ok`] if the command *exited successfully*.
136pub fn git<S: AsRef<std::ffi::OsStr>>(
137    repo: &std::path::Path,
138    args: impl IntoIterator<Item = S>,
139) -> anyhow::Result<std::process::Output> {
140    let output = radicle::git::run(Some(repo), args)?;
141
142    if !output.status.success() {
143        anyhow::bail!(
144            "`git` exited with status {}, stderr and stdout follow:\n{}\n{}\n",
145            output.status,
146            String::from_utf8_lossy(&output.stderr),
147            String::from_utf8_lossy(&output.stdout),
148        )
149    }
150
151    Ok(output)
152}
153
154/// Configure SSH signing in the given git repo, for the given peer.
155pub fn configure_signing(repo: &Path, node_id: &NodeId) -> Result<(), anyhow::Error> {
156    let key = ssh::fmt::key(node_id);
157
158    git(repo, ["config", "--local", CONFIG_SIGNING_KEY, &key])?;
159    git(repo, ["config", "--local", CONFIG_GPG_FORMAT, "ssh"])?;
160    git(repo, ["config", "--local", CONFIG_COMMIT_GPG_SIGN, "true"])?;
161    git(
162        repo,
163        ["config", "--local", CONFIG_GPG_SSH_PROGRAM, "ssh-keygen"],
164    )?;
165    git(
166        repo,
167        [
168            "config",
169            "--local",
170            CONFIG_GPG_SSH_ALLOWED_SIGNERS,
171            ".gitsigners",
172        ],
173    )?;
174
175    Ok(())
176}
177
178/// Write a `.gitsigners` file in the given repository.
179/// Fails if the file already exists.
180pub fn write_gitsigners<'a>(
181    repo: &Path,
182    signers: impl IntoIterator<Item = &'a NodeId>,
183) -> Result<PathBuf, io::Error> {
184    let path = Path::new(".gitsigners");
185    let mut file = OpenOptions::new()
186        .write(true)
187        .create_new(true)
188        .open(repo.join(path))?;
189
190    for node_id in signers.into_iter() {
191        write_gitsigner(&mut file, node_id)?;
192    }
193    Ok(path.to_path_buf())
194}
195
196/// Add signers to the repository's `.gitsigners` file.
197pub fn add_gitsigners<'a>(
198    path: &Path,
199    signers: impl IntoIterator<Item = &'a NodeId>,
200) -> Result<(), io::Error> {
201    let mut file = OpenOptions::new()
202        .append(true)
203        .open(path.join(".gitsigners"))?;
204
205    for node_id in signers.into_iter() {
206        write_gitsigner(&mut file, node_id)?;
207    }
208    Ok(())
209}
210
211/// Read a `.gitsigners` file. Returns SSH keys.
212pub fn read_gitsigners(path: &Path) -> Result<HashSet<String>, io::Error> {
213    use std::io::BufRead;
214
215    let mut keys = HashSet::new();
216    let file = File::open(path.join(".gitsigners"))?;
217
218    for line in io::BufReader::new(file).lines() {
219        let line = line?;
220        if let Some((label, key)) = line.split_once(' ') {
221            if let Ok(peer) = NodeId::from_str(label) {
222                let expected = ssh::fmt::key(&peer);
223                if key != expected {
224                    return Err(io::Error::new(
225                        io::ErrorKind::InvalidData,
226                        "key does not match peer id",
227                    ));
228                }
229            }
230            keys.insert(key.to_owned());
231        }
232    }
233    Ok(keys)
234}
235
236/// Add a path to the repository's git ignore file. Creates the
237/// ignore file if it does not exist.
238pub fn ignore(repo: &Path, item: &Path) -> Result<(), io::Error> {
239    let mut ignore = OpenOptions::new()
240        .append(true)
241        .create(true)
242        .open(repo.join(".gitignore"))?;
243
244    writeln!(ignore, "{}", item.display())
245}
246
247/// Check whether SSH or GPG signing is configured in the given repository.
248pub fn is_signing_configured(repo: &Path) -> Result<bool, anyhow::Error> {
249    Ok(git(repo, ["config", CONFIG_SIGNING_KEY]).is_ok())
250}
251
252/// Return the list of Radicle remotes for the given repository.
253pub fn rad_remotes(repo: &Repository) -> anyhow::Result<Vec<Remote<'_>>> {
254    let remotes: Vec<_> = repo
255        .remotes()?
256        .iter()
257        .filter_map(|name| {
258            let remote = repo.find_remote(name.ok()??).ok()?;
259            Remote::try_from(remote).ok()
260        })
261        .collect();
262    Ok(remotes)
263}
264
265/// Check if the git remote is configured for the `Repository`.
266pub fn is_remote(repo: &Repository, alias: &str) -> anyhow::Result<bool> {
267    match repo.find_remote(alias) {
268        Ok(_) => Ok(true),
269        Err(err) if err.is_not_found() => Ok(false),
270        Err(err) => Err(err.into()),
271    }
272}
273
274/// Get the repository's "rad" remote.
275pub fn rad_remote(repo: &Repository) -> anyhow::Result<(git::raw::Remote<'_>, RepoId)> {
276    match radicle::rad::remote(repo) {
277        Ok((remote, id)) => Ok((remote, id)),
278        Err(radicle::rad::RemoteError::NotFound(_)) => Err(anyhow!(
279            "could not find Radicle remote in git config; did you forget to run `rad init`?"
280        )),
281        Err(err) => Err(err).context("could not read git remote configuration"),
282    }
283}
284
285pub fn remove_remote(repo: &Repository, rid: &RepoId) -> anyhow::Result<()> {
286    // N.b. ensure that we are removing the remote for the correct RID
287    match radicle::rad::remote(repo) {
288        Ok((_, rid_)) => {
289            if rid_ != *rid {
290                return Err(radicle::rad::RemoteError::RidMismatch {
291                    found: rid_,
292                    expected: *rid,
293                }
294                .into());
295            }
296        }
297        Err(radicle::rad::RemoteError::NotFound(_)) => return Ok(()),
298        Err(err) => return Err(err).context("could not read git remote configuration"),
299    };
300
301    match radicle::rad::remove_remote(repo) {
302        Ok(()) => Ok(()),
303        Err(err) => Err(err).context("could not read git remote configuration"),
304    }
305}
306
307/// Set up an upstream tracking branch for the given remote and branch.
308/// Creates the tracking branch if it does not exist.
309///
310/// > scooby/master...rad/scooby/heads/master
311///
312pub fn set_tracking(repo: &Repository, remote: &NodeId, branch: &str) -> anyhow::Result<String> {
313    // The tracking branch name, eg. 'scooby/master'
314    let branch_name = format!("{remote}/{branch}");
315    // The remote branch being tracked, eg. 'rad/scooby/heads/master'
316    let remote_branch_name = format!("rad/{remote}/heads/{branch}");
317    // The target reference this branch should be set to.
318    let target = format!("refs/remotes/{remote_branch_name}");
319    let reference = repo.find_reference(&target)?;
320    let commit = reference.peel_to_commit()?;
321
322    repo.branch(&branch_name, &commit, true)?
323        .set_upstream(Some(&remote_branch_name))?;
324
325    Ok(branch_name)
326}
327
328/// Get the name of the remote of the given branch, if any.
329pub fn branch_remote(repo: &Repository, branch: &str) -> anyhow::Result<String> {
330    let cfg = repo.config()?;
331    let remote = cfg.get_string(&format!("branch.{branch}.remote"))?;
332
333    Ok(remote)
334}
335
336/// Check that the system's git version is supported. Returns an error otherwise.
337pub fn check_version() -> Result<Version, anyhow::Error> {
338    let git_version = git::version()?;
339
340    if git_version < VERSION_REQUIRED {
341        anyhow::bail!("a minimum git version of {} is required", VERSION_REQUIRED);
342    }
343    Ok(git_version)
344}
345
346pub fn add_tag(
347    repo: &Repository,
348    message: &str,
349    patch_tag_name: &str,
350) -> anyhow::Result<git::raw::Oid> {
351    let head = repo.head()?;
352    let commit = head.peel(git::raw::ObjectType::Commit).unwrap();
353    let oid = repo.tag(patch_tag_name, &commit, &repo.signature()?, message, false)?;
354
355    Ok(oid)
356}
357
358fn write_gitsigner(mut w: impl io::Write, signer: &NodeId) -> io::Result<()> {
359    writeln!(w, "{} {}", signer, ssh::fmt::key(signer))
360}
361
362/// From a commit hash, return the signer's fingerprint, if any.
363pub fn commit_ssh_fingerprint(path: &Path, sha1: &str) -> Result<Option<String>, io::Error> {
364    use std::io::BufRead;
365    use std::io::BufReader;
366
367    let output = Command::new("git")
368        .current_dir(path) // We need to place the command execution in the git dir
369        .args(["show", sha1, "--pretty=%GF", "--raw"])
370        .output()?;
371
372    if !output.status.success() {
373        return Err(io::Error::other(String::from_utf8_lossy(&output.stderr)));
374    }
375
376    let string = BufReader::new(output.stdout.as_slice())
377        .lines()
378        .next()
379        .transpose()?;
380
381    // We only return a fingerprint if it's not an empty string
382    if let Some(s) = string
383        && !s.is_empty()
384    {
385        return Ok(Some(s));
386    }
387
388    Ok(None)
389}