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