1pub 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#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Rev(String);
42
43impl Rev {
44 pub fn as_str(&self) -> &str {
46 &self.0
47 }
48
49 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 self.0.fmt(f)
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
126pub 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
134pub 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
154pub 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
178pub 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
196pub 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
211pub 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
236pub 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
247pub fn is_signing_configured(repo: &Path) -> Result<bool, anyhow::Error> {
249 Ok(git(repo, ["config", CONFIG_SIGNING_KEY]).is_ok())
250}
251
252pub fn rad_remotes(repo: &git2::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()?;
259 Remote::try_from(remote).ok()
260 })
261 .collect();
262 Ok(remotes)
263}
264
265pub fn is_remote(repo: &git2::Repository, alias: &str) -> anyhow::Result<bool> {
267 match repo.find_remote(alias) {
268 Ok(_) => Ok(true),
269 Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(false),
270 Err(err) => Err(err.into()),
271 }
272}
273
274pub fn rad_remote(repo: &Repository) -> anyhow::Result<(git2::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 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
307pub fn set_tracking(repo: &Repository, remote: &NodeId, branch: &str) -> anyhow::Result<String> {
313 let branch_name = format!("{remote}/{branch}");
315 let remote_branch_name = format!("rad/{remote}/heads/{branch}");
317 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
328pub 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
336pub 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 parse_remote(refspec: &str) -> Option<(NodeId, &str)> {
348 refspec
349 .strip_prefix("refs/remotes/")
350 .and_then(|s| s.split_once('/'))
351 .and_then(|(peer, r)| NodeId::from_str(peer).ok().map(|p| (p, r)))
352}
353
354pub fn add_tag(
355 repo: &git2::Repository,
356 message: &str,
357 patch_tag_name: &str,
358) -> anyhow::Result<git2::Oid> {
359 let head = repo.head()?;
360 let commit = head.peel(git2::ObjectType::Commit).unwrap();
361 let oid = repo.tag(patch_tag_name, &commit, &repo.signature()?, message, false)?;
362
363 Ok(oid)
364}
365
366fn write_gitsigner(mut w: impl io::Write, signer: &NodeId) -> io::Result<()> {
367 writeln!(w, "{} {}", signer, ssh::fmt::key(signer))
368}
369
370pub fn commit_ssh_fingerprint(path: &Path, sha1: &str) -> Result<Option<String>, io::Error> {
372 use std::io::BufRead;
373 use std::io::BufReader;
374
375 let output = Command::new("git")
376 .current_dir(path) .args(["show", sha1, "--pretty=%GF", "--raw"])
378 .output()?;
379
380 if !output.status.success() {
381 return Err(io::Error::other(String::from_utf8_lossy(&output.stderr)));
382 }
383
384 let string = BufReader::new(output.stdout.as_slice())
385 .lines()
386 .next()
387 .transpose()?;
388
389 if let Some(s) = string {
391 if !s.is_empty() {
392 return Ok(Some(s));
393 }
394 }
395
396 Ok(None)
397}