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