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::{Version, VERSION_REQUIRED};
24use radicle::prelude::{NodeId, RepoId};
25use radicle::storage::git::transport;
26
27pub use radicle::git::Oid;
28
29pub use radicle::git::raw::{
30 build::CheckoutBuilder, AnnotatedCommit, Commit, Direction, ErrorCode, ErrorExt as _,
31 MergeAnalysis, MergeOptions, Reference, Repository, Signature,
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#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Rev(String);
43
44impl Rev {
45 #[must_use]
47 pub fn as_str(&self) -> &str {
48 &self.0
49 }
50
51 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(radicle::git::Url::from_str)
102 .transpose()?;
103 let name = value.name().ok_or(RemoteError::MissingName)?;
104
105 Ok(Self {
106 name: name.to_owned(),
107 url,
108 pushurl,
109 inner: value,
110 })
111 }
112}
113
114impl<'a> Deref for Remote<'a> {
115 type Target = git::raw::Remote<'a>;
116
117 fn deref(&self) -> &Self::Target {
118 &self.inner
119 }
120}
121
122impl DerefMut for Remote<'_> {
123 fn deref_mut(&mut self) -> &mut Self::Target {
124 &mut self.inner
125 }
126}
127
128pub fn repository() -> Result<Repository, anyhow::Error> {
130 match Repository::open(".") {
131 Ok(repo) => Ok(repo),
132 Err(err) => Err(err).context("the current working directory is not a git repository"),
133 }
134}
135
136pub fn git<S: AsRef<std::ffi::OsStr>>(
139 repo: &std::path::Path,
140 args: impl IntoIterator<Item = S>,
141) -> anyhow::Result<std::process::Output> {
142 let output = radicle::git::run(Some(repo), args)?;
143
144 if !output.status.success() {
145 anyhow::bail!(
146 "`git` exited with status {}, stderr and stdout follow:\n{}\n{}\n",
147 output.status,
148 String::from_utf8_lossy(&output.stderr),
149 String::from_utf8_lossy(&output.stdout),
150 )
151 }
152
153 Ok(output)
154}
155
156pub fn configure_signing(repo: &Path, node_id: &NodeId) -> Result<(), anyhow::Error> {
158 let key = ssh::fmt::key(node_id);
159
160 git(repo, ["config", "--local", CONFIG_SIGNING_KEY, &key])?;
161 git(repo, ["config", "--local", CONFIG_GPG_FORMAT, "ssh"])?;
162 git(repo, ["config", "--local", CONFIG_COMMIT_GPG_SIGN, "true"])?;
163 git(
164 repo,
165 ["config", "--local", CONFIG_GPG_SSH_PROGRAM, "ssh-keygen"],
166 )?;
167 git(
168 repo,
169 [
170 "config",
171 "--local",
172 CONFIG_GPG_SSH_ALLOWED_SIGNERS,
173 ".gitsigners",
174 ],
175 )?;
176
177 Ok(())
178}
179
180pub fn write_gitsigners<'a>(
183 repo: &Path,
184 signers: impl IntoIterator<Item = &'a NodeId>,
185) -> Result<PathBuf, io::Error> {
186 let path = Path::new(".gitsigners");
187 let mut file = OpenOptions::new()
188 .write(true)
189 .create_new(true)
190 .open(repo.join(path))?;
191
192 for node_id in signers.into_iter() {
193 write_gitsigner(&mut file, node_id)?;
194 }
195 Ok(path.to_path_buf())
196}
197
198pub fn add_gitsigners<'a>(
200 path: &Path,
201 signers: impl IntoIterator<Item = &'a NodeId>,
202) -> Result<(), io::Error> {
203 let mut file = OpenOptions::new()
204 .append(true)
205 .open(path.join(".gitsigners"))?;
206
207 for node_id in signers.into_iter() {
208 write_gitsigner(&mut file, node_id)?;
209 }
210 Ok(())
211}
212
213pub fn read_gitsigners(path: &Path) -> Result<HashSet<String>, io::Error> {
215 use std::io::BufRead;
216
217 let mut keys = HashSet::new();
218 let file = File::open(path.join(".gitsigners"))?;
219
220 for line in io::BufReader::new(file).lines() {
221 let line = line?;
222 if let Some((label, key)) = line.split_once(' ') {
223 if let Ok(peer) = NodeId::from_str(label) {
224 let expected = ssh::fmt::key(&peer);
225 if key != expected {
226 return Err(io::Error::new(
227 io::ErrorKind::InvalidData,
228 "key does not match peer id",
229 ));
230 }
231 }
232 keys.insert(key.to_owned());
233 }
234 }
235 Ok(keys)
236}
237
238pub fn ignore(repo: &Path, item: &Path) -> Result<(), io::Error> {
241 let mut ignore = OpenOptions::new()
242 .append(true)
243 .create(true)
244 .open(repo.join(".gitignore"))?;
245
246 writeln!(ignore, "{}", item.display())
247}
248
249pub fn is_signing_configured(repo: &Path) -> Result<bool, anyhow::Error> {
251 Ok(git(repo, ["config", CONFIG_SIGNING_KEY]).is_ok())
252}
253
254pub fn rad_remotes(repo: &Repository) -> anyhow::Result<Vec<Remote<'_>>> {
256 let remotes: Vec<_> = repo
257 .remotes()?
258 .iter()
259 .filter_map(|name| {
260 let remote = repo.find_remote(name?).ok()?;
261 Remote::try_from(remote).ok()
262 })
263 .collect();
264 Ok(remotes)
265}
266
267pub fn is_remote(repo: &Repository, alias: &str) -> anyhow::Result<bool> {
269 match repo.find_remote(alias) {
270 Ok(_) => Ok(true),
271 Err(err) if err.is_not_found() => Ok(false),
272 Err(err) => Err(err.into()),
273 }
274}
275
276pub fn rad_remote(repo: &Repository) -> anyhow::Result<(git::raw::Remote<'_>, RepoId)> {
278 match radicle::rad::remote(repo) {
279 Ok((remote, id)) => Ok((remote, id)),
280 Err(radicle::rad::RemoteError::NotFound(_)) => Err(anyhow!(
281 "could not find radicle remote in git config; did you forget to run `rad init`?"
282 )),
283 Err(err) => Err(err).context("could not read git remote configuration"),
284 }
285}
286
287pub fn remove_remote(repo: &Repository, rid: &RepoId) -> anyhow::Result<()> {
288 match radicle::rad::remote(repo) {
290 Ok((_, rid_)) => {
291 if rid_ != *rid {
292 return Err(radicle::rad::RemoteError::RidMismatch {
293 found: rid_,
294 expected: *rid,
295 }
296 .into());
297 }
298 }
299 Err(radicle::rad::RemoteError::NotFound(_)) => return Ok(()),
300 Err(err) => return Err(err).context("could not read git remote configuration"),
301 };
302
303 match radicle::rad::remove_remote(repo) {
304 Ok(()) => Ok(()),
305 Err(err) => Err(err).context("could not read git remote configuration"),
306 }
307}
308
309pub fn set_tracking(repo: &Repository, remote: &NodeId, branch: &str) -> anyhow::Result<String> {
315 let branch_name = format!("{remote}/{branch}");
317 let remote_branch_name = format!("rad/{remote}/heads/{branch}");
319 let target = format!("refs/remotes/{remote_branch_name}");
321 let reference = repo.find_reference(&target)?;
322 let commit = reference.peel_to_commit()?;
323
324 repo.branch(&branch_name, &commit, true)?
325 .set_upstream(Some(&remote_branch_name))?;
326
327 Ok(branch_name)
328}
329
330pub fn branch_remote(repo: &Repository, branch: &str) -> anyhow::Result<String> {
332 let cfg = repo.config()?;
333 let remote = cfg.get_string(&format!("branch.{branch}.remote"))?;
334
335 Ok(remote)
336}
337
338pub fn check_version() -> Result<Version, anyhow::Error> {
340 let git_version = git::version()?;
341
342 if git_version < VERSION_REQUIRED {
343 anyhow::bail!("a minimum git version of {} is required", VERSION_REQUIRED);
344 }
345 Ok(git_version)
346}
347
348pub fn add_tag(
349 repo: &Repository,
350 message: &str,
351 patch_tag_name: &str,
352) -> anyhow::Result<git::raw::Oid> {
353 let head = repo.head()?;
354 let commit = head.peel(git::raw::ObjectType::Commit).unwrap();
355 let oid = repo.tag(patch_tag_name, &commit, &repo.signature()?, message, false)?;
356
357 Ok(oid)
358}
359
360fn write_gitsigner(mut w: impl io::Write, signer: &NodeId) -> io::Result<()> {
361 writeln!(w, "{} {}", signer, ssh::fmt::key(signer))
362}
363
364pub fn commit_ssh_fingerprint(path: &Path, sha1: &str) -> Result<Option<String>, io::Error> {
366 use std::io::BufRead;
367 use std::io::BufReader;
368
369 let output = Command::new("git")
370 .current_dir(path) .args(["show", sha1, "--pretty=%GF", "--raw"])
372 .output()?;
373
374 if !output.status.success() {
375 return Err(io::Error::other(String::from_utf8_lossy(&output.stderr)));
376 }
377
378 let string = BufReader::new(output.stdout.as_slice())
379 .lines()
380 .next()
381 .transpose()?;
382
383 if let Some(s) = string {
385 if !s.is_empty() {
386 return Ok(Some(s));
387 }
388 }
389
390 Ok(None)
391}