rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 heartwoodaebddc9857bdad8ea26151fd3f09c20fcaf1c70d
{
"request": "trigger",
"version": 1,
"event_type": "patch",
"repository": {
"id": "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
"name": "heartwood",
"description": "Radicle Heartwood Protocol & Stack",
"private": false,
"default_branch": "master",
"delegates": [
"did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT",
"did:key:z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW",
"did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"did:key:z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz",
"did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz"
]
},
"action": "Created",
"patch": {
"id": "c9dfabe3daac1a1eeeba438f64e3d660e490e432",
"author": {
"id": "did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz",
"alias": "lorenz"
},
"title": "Adopt `radicle-surf` from `radicle-git` workspace",
"state": {
"status": "draft",
"conflicts": []
},
"before": "02318f199c6f29a2eede1f282e1f9b99927d27ec",
"after": "aebddc9857bdad8ea26151fd3f09c20fcaf1c70d",
"commits": [
"aebddc9857bdad8ea26151fd3f09c20fcaf1c70d",
"25e881978214a3a0e986837b822c41b4dca63980",
"ed6a823274ce792ae0ab55b3700bcaabdcdce837",
"60827792f673eb2fd14c08a52f1b0e17125ede6e",
"aaac16b015f2216d594bcfbf4d71781bcb010bef",
"57fda8713d247305002d804857441fb64e0287d4",
"33b03aeac5a74ec6804e3a47555031ec9893c034",
"98dfa81bfff84ddbd5d059c1a14b2a137925583b",
"175efac523f8d6411243fd5518045d969e712474"
],
"target": "02318f199c6f29a2eede1f282e1f9b99927d27ec",
"labels": [],
"assignees": [],
"revisions": [
{
"id": "c9dfabe3daac1a1eeeba438f64e3d660e490e432",
"author": {
"id": "did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz",
"alias": "lorenz"
},
"description": "",
"base": "02318f199c6f29a2eede1f282e1f9b99927d27ec",
"oid": "d83fc686a80adabb1f37bdd841b622865b65edc7",
"timestamp": 1768252986
},
{
"id": "40a79eb4724e3ad0e49001efbf3b0f364d5c0408",
"author": {
"id": "did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz",
"alias": "lorenz"
},
"description": "",
"base": "02318f199c6f29a2eede1f282e1f9b99927d27ec",
"oid": "aebddc9857bdad8ea26151fd3f09c20fcaf1c70d",
"timestamp": 1768313737
}
]
}
}
{
"response": "triggered",
"run_id": {
"id": "21dec7d7-6514-4b8b-a389-af82fd89270b"
},
"info_url": "https://cci.rad.levitte.org//21dec7d7-6514-4b8b-a389-af82fd89270b.html"
}
Started at: 2026-01-13 15:16:29.453472+01:00
Commands:
$ rad clone rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 .
✓ Creating checkout in ./...
✓ Remote cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT added
✓ Remote-tracking branch cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT/master created for z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT
✓ Remote cloudhead@z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW added
✓ Remote-tracking branch cloudhead@z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW/master created for z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW
✓ Remote fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM added
✓ Remote-tracking branch fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM/master created for z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM
✓ Remote erikli@z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz added
✓ Remote-tracking branch erikli@z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz/master created for z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz
✓ Remote lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz added
✓ Remote-tracking branch lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz/master created for z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz
✓ Repository successfully cloned under /opt/radcis/ci.rad.levitte.org/cci/state/21dec7d7-6514-4b8b-a389-af82fd89270b/w/
╭────────────────────────────────────╮
│ heartwood │
│ Radicle Heartwood Protocol & Stack │
│ 131 issues · 15 patches │
╰────────────────────────────────────╯
Run `cd ./.` to go to the repository directory.
Exit code: 0
$ rad patch checkout c9dfabe3daac1a1eeeba438f64e3d660e490e432
✓ Switched to branch patch/c9dfabe at revision 40a79eb
✓ Branch patch/c9dfabe setup to track rad/patches/c9dfabe3daac1a1eeeba438f64e3d660e490e432
Exit code: 0
$ git config advice.detachedHead false
Exit code: 0
$ git checkout aebddc9857bdad8ea26151fd3f09c20fcaf1c70d
HEAD is now at aebddc98 surf: Remove mention of `git-platinum` from build
Exit code: 0
$ rad patch show c9dfabe3daac1a1eeeba438f64e3d660e490e432 -p
╭───────────────────────────────────────────────────────────────────────╮
│ Title Adopt `radicle-surf` from `radicle-git` workspace │
│ Patch c9dfabe3daac1a1eeeba438f64e3d660e490e432 │
│ Author lorenz z6MkkPv…WX5sTEz │
│ Head aebddc9857bdad8ea26151fd3f09c20fcaf1c70d │
│ Base 02318f199c6f29a2eede1f282e1f9b99927d27ec │
│ Branches patch/c9dfabe │
│ Commits ahead 9, behind 0 │
│ Status draft │
├───────────────────────────────────────────────────────────────────────┤
│ aebddc9 surf: Remove mention of `git-platinum` from build │
│ 25e8819 surf: Spellchecking │
│ ed6a823 radicle-cli: Adjust OID types │
│ 6082779 surf: Switch to migrated crate in workspace │
│ aaac16b surf: Move `git-platinum` to top level `data` │
│ 57fda87 surf: Testing │
│ 33b03ae surf: Refactor │
│ 98dfa81 surf: Adjust metadata after copy │
│ 175efac surf: Copy over from `radicle-git` │
├───────────────────────────────────────────────────────────────────────┤
│ ● Revision c9dfabe @ d83fc68 by lorenz z6MkkPv…WX5sTEz 16 hours ago │
│ ↑ Revision 40a79eb @ aebddc9 by lorenz z6MkkPv…WX5sTEz 54 seconds ago │
╰───────────────────────────────────────────────────────────────────────╯
commit aebddc9857bdad8ea26151fd3f09c20fcaf1c70d
Author: Lorenz Leutgeb <lorenz.leutgeb@radicle.xyz>
Date: Mon Jan 12 22:20:42 2026 +0100
surf: Remove mention of `git-platinum` from build
Not sure…
diff --git a/crates/radicle-surf/build.rs b/crates/radicle-surf/build.rs
index 5fda4dfc3..91c65cc1a 100644
--- a/crates/radicle-surf/build.rs
+++ b/crates/radicle-surf/build.rs
@@ -35,16 +35,17 @@ impl Command {
}
fn main() {
- let target = Command::new()
+ let _target = Command::new()
.expect("could not determine the cargo command")
.target();
- let git_platinum_tarball = "./data/git-platinum.tgz";
+ // let git_platinum_tarball = "./data/git-platinum.tgz";
- unpack(git_platinum_tarball, target).expect("Failed to unpack git-platinum");
+ // unpack(git_platinum_tarball, target).expect("Failed to unpack git-platinum");
- println!("cargo:rerun-if-changed={git_platinum_tarball}");
+ // println!("cargo:rerun-if-changed={git_platinum_tarball}");
}
+#[allow(dead_code)]
fn unpack(archive_path: impl AsRef<Path>, target: impl AsRef<Path>) -> anyhow::Result<()> {
let content = target.as_ref().join("git-platinum");
if content.exists() {
commit 25e881978214a3a0e986837b822c41b4dca63980
Author: Lorenz Leutgeb <lorenz.leutgeb@radicle.xyz>
Date: Mon Jan 12 22:17:20 2026 +0100
surf: Spellchecking
diff --git a/crates/radicle-surf/CHANGELOG.md b/crates/radicle-surf/CHANGELOG.md
index 835e6d544..afd846ac3 100644
--- a/crates/radicle-surf/CHANGELOG.md
+++ b/crates/radicle-surf/CHANGELOG.md
@@ -13,7 +13,7 @@ changes include:
- `Browser` is removed. Its methods are implemented directly with `Repository`.
- Git will be the only supported VCS. Any extension points for other VCSes were
removed.
-- `Ref` and `RefScope` are removed. Re-use the `git-ref-format` crate and a new
+- `Ref` and `RefScope` are removed. Reuse the `git-ref-format` crate and a new
`Glob` type for the refspec patterns.
- Added support of `Tree` and `Blob` that correspond to their definitions in
Git.
diff --git a/crates/radicle-surf/DEVELOPMENT.md b/crates/radicle-surf/DEVELOPMENT.md
index a3df0fa74..6610737c9 100644
--- a/crates/radicle-surf/DEVELOPMENT.md
+++ b/crates/radicle-surf/DEVELOPMENT.md
@@ -5,7 +5,7 @@ Thanks for wanting to contribute to `radicle-surf`!
## Building & Testing 🏗️
-We try to make development as seemless as possible so we can get down to the real work. We supply
+We try to make development as seamless as possible so we can get down to the real work. We supply
the toolchain via the `rust-toolchain` file, and the formatting rules `.rustmt.toml` file.
For the [Nix](https://nixos.org/) inclined there is a `default.nix` file to get all the necessary
@@ -52,7 +52,7 @@ of development.
If more tests are needed then we should add them under `mod tests` in the relevant module. We strive
to find properties of our programs so that we can use tools like `proptest` to extensively prove our
-programs are correct. As well as this, we add unit tests to esnure the examples in our heads are
+programs are correct. As well as this, we add unit tests to ensure the examples in our heads are
correct, and testing out the ergonomics of our API first-hand.
## CI files 🤖
diff --git a/crates/radicle-surf/README.md b/crates/radicle-surf/README.md
index 405e75585..3b151f052 100644
--- a/crates/radicle-surf/README.md
+++ b/crates/radicle-surf/README.md
@@ -19,4 +19,4 @@ our [LICENSE](../LICENSE) file.
## The Community
-Join our community disccussions at [radicle.community](https://radicle.community)!
+Join our community discussions at [radicle.community](https://radicle.community)!
diff --git a/crates/radicle-surf/docs/denotational-design.md b/crates/radicle-surf/docs/denotational-design.md
index 88a2c41e6..3fa65acdf 100644
--- a/crates/radicle-surf/docs/denotational-design.md
+++ b/crates/radicle-surf/docs/denotational-design.md
@@ -11,7 +11,7 @@ and view their differences.
The stream of consciousness that gave birth to this document started with thinking how the user would interact with
the system, identifying the key components. This is captured in [User Flow](#user-flow). From there we found nouns that
represent objects in our system and verbs that represent functions over those objects. This iteratively informed us as
-to what other actions we would need to supply. We would occassionally look at [GitHub](todo) and [Pijul Nest](todo) for
+to what other actions we would need to supply. We would occasionally look at [GitHub](todo) and [Pijul Nest](todo) for
inspiration, since we would like to imitate the features that they supply, and we ultimately want use one or both of
these for our backends.
@@ -64,7 +64,7 @@ type DirectoryContents
μ DirectoryContents = IsRepo | Directory | File
-- Opaque representation of repository state directories (e.g. `.git`, `.pijul`)
--- Those are not browseable, but have to be present at the repo root 'Directory'.
+-- Those are not browsable, but have to be present at the repo root 'Directory'.
type IsRepo
-- A Directory captures its own Label followed by 1 or more DirectoryContents
@@ -91,7 +91,7 @@ type SystemType
= IsFile
| IsDirectory
--- A Chnage is an enumeration of how a file has changed.
+-- A Change is an enumeration of how a file has changed.
-- This is simply used for getting the difference between two
-- directories.
type Change
diff --git a/crates/radicle-surf/docs/refactor-design.md b/crates/radicle-surf/docs/refactor-design.md
index aac67c084..e20218285 100644
--- a/crates/radicle-surf/docs/refactor-design.md
+++ b/crates/radicle-surf/docs/refactor-design.md
@@ -1,7 +1,7 @@
# An updated design for radicle-surf
This is a design blueprint for the new `radicle-git/radicle-surf` crate. The
-actual design details and implemenation are described and updated in its
+actual design details and implementation are described and updated in its
documentation comments, viewable via `cargo doc`.
## Introduction
@@ -107,7 +107,7 @@ Currently we have multiple types to identify a `Commit` or `Revision`.
The relations between them are: all `Rev` and `Commit` can resolve into `Oid`,
and in most cases `Rev` can resolve into `Commit`.
-On one hand, `Oid` is the ultimate unique identifer but it is more machine-
+On one hand, `Oid` is the ultimate unique identifier but it is more machine-
friendly than human-friendly. On the other hand, `Revision` is most human-
friendly and better suited in the API interface. A conversion from `Revision`
to `Oid` will be useful.
@@ -138,7 +138,7 @@ Our API will use these traits where we expect a `Revision` or a `Commit`.
The current `History` is generic over VCS types and also retrieves the full list
of commits when the history is created. The VCS part can be removed and the
-history can lazy-load the list of commits by implmenting `Iterator` to support
+history can lazy-load the list of commits by implementing `Iterator` to support
potentially very long histories.
We can also store the head commit with the history so that it's easy to get
@@ -288,7 +288,7 @@ pub struct History<'a> {
}
impl<'a> History<'a> {
- /// This method creats a new `RevWalk` internally and return an
+ /// This method creates a new `RevWalk` internally and return an
/// iterator for all commits in a history.
pub fn iter(&self) -> impl Iterator<Item = Commit>;
}
diff --git a/crates/radicle-surf/src/fs.rs b/crates/radicle-surf/src/fs.rs
index 9d146504e..efe4525f9 100644
--- a/crates/radicle-surf/src/fs.rs
+++ b/crates/radicle-surf/src/fs.rs
@@ -227,7 +227,7 @@ impl Ord for Entry {
}
impl Entry {
- /// Get a label for the `Entriess`, either the name of the [`File`],
+ /// Get a label for the `Entries`, either the name of the [`File`],
/// the name of the [`Directory`], or the name of the [`Submodule`].
pub fn name(&self) -> &String {
match self {
@@ -296,7 +296,7 @@ impl Entry {
/// [git-tree]: https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Directory {
- /// The name of the directoy.
+ /// The name of the directory.
name: String,
/// The relative path of the directory, not including the `name`,
/// in respect to the root of the git repository.
diff --git a/crates/radicle-surf/src/test/last_commit.rs b/crates/radicle-surf/src/test/last_commit.rs
index 967ea5efb..e8dc11c45 100644
--- a/crates/radicle-surf/src/test/last_commit.rs
+++ b/crates/radicle-surf/src/test/last_commit.rs
@@ -13,7 +13,7 @@ fn readme_missing_and_memory() {
let oid =
Oid::from_str("d3464e33d75c75c99bfb90fa2e9d16efc0b7d0e3").expect("Failed to parse SHA");
- // memory.rs is commited later so it should not exist here.
+ // memory.rs is committed later so it should not exist here.
let memory_last_commit_oid = repo
.last_commit(&"src/memory.rs", oid)
.expect("Failed to get last commit")
commit ed6a823274ce792ae0ab55b3700bcaabdcdce837
Author: Lorenz Leutgeb <lorenz.leutgeb@radicle.xyz>
Date: Sun Jan 11 15:22:09 2026 +0100
radicle-cli: Adjust OID types
diff --git a/crates/radicle-cli/src/commands/patch/review/builder.rs b/crates/radicle-cli/src/commands/patch/review/builder.rs
index 054273052..f7fd9974d 100644
--- a/crates/radicle-cli/src/commands/patch/review/builder.rs
+++ b/crates/radicle-cli/src/commands/patch/review/builder.rs
@@ -196,28 +196,25 @@ impl ReviewItem {
fn paths(&self) -> (Option<(&Path, Oid)>, Option<(&Path, Oid)>) {
match self {
- Self::FileAdded { path, new, .. } => (None, Some((path, Oid::from(*new.oid)))),
- Self::FileDeleted { path, old, .. } => (Some((path, Oid::from(*old.oid))), None),
+ Self::FileAdded { path, new, .. } => (None, Some((path, new.oid))),
+ Self::FileDeleted { path, old, .. } => (Some((path, old.oid)), None),
Self::FileMoved { moved } => (
- Some((&moved.old_path, Oid::from(*moved.old.oid))),
- Some((&moved.new_path, Oid::from(*moved.new.oid))),
+ Some((&moved.old_path, moved.old.oid)),
+ Some((&moved.new_path, moved.new.oid)),
),
Self::FileCopied { copied } => (
- Some((&copied.old_path, Oid::from(*copied.old.oid))),
- Some((&copied.new_path, Oid::from(*copied.new.oid))),
- ),
- Self::FileModified { path, old, new, .. } => (
- Some((path, Oid::from(*old.oid))),
- Some((path, Oid::from(*new.oid))),
- ),
- Self::FileEofChanged { path, old, new, .. } => (
- Some((path, Oid::from(*old.oid))),
- Some((path, Oid::from(*new.oid))),
- ),
- Self::FileModeChanged { path, old, new, .. } => (
- Some((path, Oid::from(*old.oid))),
- Some((path, Oid::from(*new.oid))),
+ Some((&copied.old_path, copied.old.oid)),
+ Some((&copied.new_path, copied.new.oid)),
),
+ Self::FileModified { path, old, new, .. } => {
+ (Some((path, old.oid)), Some((path, new.oid)))
+ }
+ Self::FileEofChanged { path, old, new, .. } => {
+ (Some((path, old.oid)), Some((path, new.oid)))
+ }
+ Self::FileModeChanged { path, old, new, .. } => {
+ (Some((path, old.oid)), Some((path, new.oid)))
+ }
}
}
diff --git a/crates/radicle-cli/src/git/pretty_diff.rs b/crates/radicle-cli/src/git/pretty_diff.rs
index a5319cfa8..025e607fe 100644
--- a/crates/radicle-cli/src/git/pretty_diff.rs
+++ b/crates/radicle-cli/src/git/pretty_diff.rs
@@ -338,7 +338,7 @@ impl ToPretty for Added {
repo: &R,
) -> Self::Output {
let old = None;
- let new = Some((self.path.as_path(), Oid::from(*self.new.oid)));
+ let new = Some((self.path.as_path(), self.new.oid));
pretty_modification(header, &self.diff, old, new, repo, hi)
}
@@ -354,7 +354,7 @@ impl ToPretty for Deleted {
header: &Self::Context,
repo: &R,
) -> Self::Output {
- let old = Some((self.path.as_path(), Oid::from(*self.old.oid)));
+ let old = Some((self.path.as_path(), self.old.oid));
let new = None;
pretty_modification(header, &self.diff, old, new, repo, hi)
@@ -371,8 +371,8 @@ impl ToPretty for Modified {
header: &Self::Context,
repo: &R,
) -> Self::Output {
- let old = Some((self.path.as_path(), Oid::from(*self.old.oid)));
- let new = Some((self.path.as_path(), Oid::from(*self.new.oid)));
+ let old = Some((self.path.as_path(), self.old.oid));
+ let new = Some((self.path.as_path(), self.new.oid));
pretty_modification(header, &self.diff, old, new, repo, hi)
}
diff --git a/crates/radicle-cli/src/git/unified_diff.rs b/crates/radicle-cli/src/git/unified_diff.rs
index d37cdcbff..4b5dfa3a3 100644
--- a/crates/radicle-cli/src/git/unified_diff.rs
+++ b/crates/radicle-cli/src/git/unified_diff.rs
@@ -306,8 +306,8 @@ impl Encode for FileHeader {
if old.mode == new.mode {
w.meta(format!(
"index {}..{} {:o}",
- term::format::oid(*old.oid),
- term::format::oid(*new.oid),
+ term::format::oid(old.oid),
+ term::format::oid(new.oid),
u32::from(old.mode.clone()),
))?;
} else {
@@ -315,8 +315,8 @@ impl Encode for FileHeader {
w.meta(format!("new mode {:o}", u32::from(new.mode.clone())))?;
w.meta(format!(
"index {}..{}",
- term::format::oid(*old.oid),
- term::format::oid(*new.oid)
+ term::format::oid(old.oid),
+ term::format::oid(new.oid)
))?;
}
@@ -334,7 +334,7 @@ impl Encode for FileHeader {
w.meta(format!(
"index {}..{}",
term::format::oid(git::Oid::sha1_zero()),
- term::format::oid(*new.oid),
+ term::format::oid(new.oid),
))?;
w.meta("--- /dev/null")?;
@@ -354,7 +354,7 @@ impl Encode for FileHeader {
))?;
w.meta(format!(
"index {}..{}",
- term::format::oid(*old.oid),
+ term::format::oid(old.oid),
term::format::oid(git::Oid::sha1_zero())
))?;
commit 60827792f673eb2fd14c08a52f1b0e17125ede6e
Author: Lorenz Leutgeb <lorenz.leutgeb@radicle.xyz>
Date: Sun Jan 11 15:16:09 2026 +0100
surf: Switch to migrated crate in workspace
diff --git a/Cargo.lock b/Cargo.lock
index ae6b601f1..2b03bb6c8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1128,16 +1128,6 @@ version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
-[[package]]
-name = "git-ref-format"
-version = "0.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed6913a77cee9e231cab93577c9a5eea84a1344ab39294d91dc075b3c24499d0"
-dependencies = [
- "git-ref-format-core",
- "git-ref-format-macro",
-]
-
[[package]]
name = "git-ref-format-core"
version = "0.6.0"
@@ -1149,18 +1139,6 @@ dependencies = [
"thiserror 1.0.69",
]
-[[package]]
-name = "git-ref-format-macro"
-version = "0.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4e730f09c82961c28f5465b83da0aa5c2716156ce57da33a1fa51bbd560aa5f7"
-dependencies = [
- "git-ref-format-core",
- "proc-macro-error2",
- "quote",
- "syn 2.0.106",
-]
-
[[package]]
name = "git2"
version = "0.19.0"
@@ -2690,28 +2668,6 @@ dependencies = [
"elliptic-curve",
]
-[[package]]
-name = "proc-macro-error-attr2"
-version = "2.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
-dependencies = [
- "proc-macro2",
- "quote",
-]
-
-[[package]]
-name = "proc-macro-error2"
-version = "2.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
-dependencies = [
- "proc-macro-error-attr2",
- "proc-macro2",
- "quote",
- "syn 2.0.106",
-]
-
[[package]]
name = "proc-macro2"
version = "1.0.101"
@@ -2865,7 +2821,7 @@ dependencies = [
"radicle-git-ref-format",
"radicle-localtime",
"radicle-node",
- "radicle-surf 0.26.0 (registry+https://github.com/rust-lang/crates.io-index)",
+ "radicle-surf",
"radicle-term",
"schemars",
"serde",
@@ -2995,20 +2951,6 @@ dependencies = [
"thiserror 2.0.17",
]
-[[package]]
-name = "radicle-git-ext"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "71a5fbca2ee3fc61a6b467e0b85da7c092421afc2538feb0023ad6792d6e39d0"
-dependencies = [
- "git-ref-format",
- "git2",
- "percent-encoding",
- "radicle-std-ext",
- "serde",
- "thiserror 1.0.69",
-]
-
[[package]]
name = "radicle-git-metadata"
version = "0.1.0"
@@ -3149,12 +3091,6 @@ dependencies = [
"zeroize",
]
-[[package]]
-name = "radicle-std-ext"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fb935931bdd2a2966f3b584f3031d9d54ec0713ddbc563a0193d54e62a88ec73"
-
[[package]]
name = "radicle-surf"
version = "0.26.0"
@@ -3178,25 +3114,6 @@ dependencies = [
"url",
]
-[[package]]
-name = "radicle-surf"
-version = "0.26.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4c814514d0bf56fbec811099eaa14da1349639b04b8317746c9cd9e6b0f02196"
-dependencies = [
- "anyhow",
- "base64 0.21.7",
- "flate2",
- "git2",
- "log",
- "nonempty",
- "radicle-git-ext",
- "radicle-std-ext",
- "tar",
- "thiserror 1.0.69",
- "url",
-]
-
[[package]]
name = "radicle-systemd"
version = "0.11.0"
diff --git a/Cargo.toml b/Cargo.toml
index cf07d16cf..fd3fc4b81 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -57,6 +57,7 @@ radicle-oid = { version = "0.1.0", path = "crates/radicle-oid", default-features
radicle-protocol = { version = "0.4", path = "crates/radicle-protocol" }
radicle-signals = { version = "0.11", path = "crates/radicle-signals" }
radicle-ssh = { version = "0.10", path = "crates/radicle-ssh", default-features = false }
+radicle-surf = { version = "0.26.0", path = "crates/radicle-surf", default-features = false }
radicle-systemd = { version = "0.11", path = "crates/radicle-systemd" }
radicle-term = { version = "0.16", path = "crates/radicle-term" }
schemars = { version = "1.0.4", default-features = false }
@@ -71,12 +72,8 @@ thiserror = { version = "2", default-features = false }
winpipe = "0.1.1"
zeroize = "1.5.7"
-# Crates from the "radicle-git" workspace. These should be synced manually.
-# When updating, start from `radicle-surf`:
-# `radicle-surf` → `radicle-git-ext` → `git-ref-format` → `git-ref-format-core`
-# Also note that `radicle-surf → git2` so try to also sync with `git2`.
+# Crates from the "radicle-git" workspace.
git-ref-format-core = { version = "0.6.0", default-features = false }
-radicle-surf = "0.26.0"
[workspace.lints]
clippy.type_complexity = "allow"
commit aaac16b015f2216d594bcfbf4d71781bcb010bef
Author: Lorenz Leutgeb <lorenz.leutgeb@radicle.xyz>
Date: Mon Jan 12 19:09:15 2026 +0100
surf: Move `git-platinum` to top level `data`
diff --git a/crates/radicle-surf/src/test/mod.rs b/crates/radicle-surf/src/test/mod.rs
index 5819f95f5..be66cecea 100644
--- a/crates/radicle-surf/src/test/mod.rs
+++ b/crates/radicle-surf/src/test/mod.rs
@@ -1,5 +1,5 @@
#[cfg(test)]
-const GIT_PLATINUM: &str = "../data/git-platinum";
+const GIT_PLATINUM: &str = "../../data/git-platinum";
#[cfg(test)]
mod file_system;
commit 57fda8713d247305002d804857441fb64e0287d4
Author: Lorenz Leutgeb <lorenz.leutgeb@radicle.xyz>
Date: Sun Jan 11 15:33:34 2026 +0100
surf: Testing
diff --git a/Cargo.lock b/Cargo.lock
index 3dbdddf34..ae6b601f1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3013,6 +3013,7 @@ dependencies = [
name = "radicle-git-metadata"
version = "0.1.0"
dependencies = [
+ "git2",
"thiserror 2.0.17",
]
@@ -3164,10 +3165,15 @@ dependencies = [
"git2",
"log",
"nonempty",
+ "pretty_assertions",
+ "proptest",
+ "radicle-git-metadata",
"radicle-git-ref-format",
"radicle-oid",
"serde",
+ "serde_json",
"tar",
+ "tempfile",
"thiserror 1.0.69",
"url",
]
diff --git a/crates/radicle-git-metadata/Cargo.toml b/crates/radicle-git-metadata/Cargo.toml
index 86b175ac8..62501587a 100644
--- a/crates/radicle-git-metadata/Cargo.toml
+++ b/crates/radicle-git-metadata/Cargo.toml
@@ -10,4 +10,5 @@ keywords = ["radicle", "git", "metadata"]
rust-version.workspace = true
[dependencies]
-thiserror = { workspace = true, default-features = true }
\ No newline at end of file
+thiserror = { workspace = true, default-features = true }
+git2 = { workspace = true, optional = true }
\ No newline at end of file
diff --git a/crates/radicle-git-metadata/src/author.rs b/crates/radicle-git-metadata/src/author.rs
index e0c133658..f172cb63a 100644
--- a/crates/radicle-git-metadata/src/author.rs
+++ b/crates/radicle-git-metadata/src/author.rs
@@ -123,3 +123,16 @@ impl FromStr for Author {
})
}
}
+
+#[cfg(feature = "git2")]
+impl TryFrom<&Author> for git2::Signature<'_> {
+ type Error = git2::Error;
+
+ fn try_from(author: &Author) -> Result<Self, Self::Error> {
+ git2::Signature::new(
+ &author.name,
+ &author.email,
+ &git2::Time::new(author.time.seconds(), author.time.offset()),
+ )
+ }
+}
diff --git a/crates/radicle-surf/Cargo.toml b/crates/radicle-surf/Cargo.toml
index 61c2aa6dc..cf844bff1 100644
--- a/crates/radicle-surf/Cargo.toml
+++ b/crates/radicle-surf/Cargo.toml
@@ -14,10 +14,6 @@ include = [
"data/git-platinum.tgz",
]
-[lib]
-test = false
-doctest = false
-
[features]
# NOTE: testing `test_submodule_failure` on GH actions
# is painful since it uses this specific repo and expects
@@ -38,7 +34,15 @@ serde = { workspace = true, optional = true, features = ["derive"] }
git2 = { workspace = true, features = ["vendored-libgit2"] }
radicle-oid = { workspace = true, features = ["git2", "sha1"] }
-radicle-git-ref-format = { workspace = true, features = ["macro"]}
+radicle-git-ref-format = { workspace = true, features = ["macro"] }
+radicle-git-metadata = { workspace = true, features = ["git2"] }
+
+[dev-dependencies]
+pretty_assertions = "1.3.0"
+proptest = "1"
+serde_json = "1"
+url = "2.5"
+tempfile = { workspace = true }
[build-dependencies]
anyhow = "1.0"
diff --git a/crates/radicle-surf/src/lib.rs b/crates/radicle-surf/src/lib.rs
index a61f9855f..e8b93ecbb 100644
--- a/crates/radicle-surf/src/lib.rs
+++ b/crates/radicle-surf/src/lib.rs
@@ -60,3 +60,6 @@ mod error;
pub use error::Error;
mod ext;
+
+#[cfg(test)]
+pub mod test;
diff --git a/crates/radicle-surf/t/src/branch.rs b/crates/radicle-surf/src/test/branch.rs
similarity index 82%
rename from crates/radicle-surf/t/src/branch.rs
rename to crates/radicle-surf/src/test/branch.rs
index c632a5bc1..d78671c0f 100644
--- a/crates/radicle-surf/t/src/branch.rs
+++ b/crates/radicle-surf/src/test/branch.rs
@@ -1,13 +1,14 @@
+use super::gen;
use proptest::prelude::*;
-use radicle_git_ext_test::git_ref_format::gen;
+// use radicle_git_ext_test::git_ref_format::gen;
+use super::roundtrip;
+use crate::Branch;
use radicle_git_ref_format::{RefStr, RefString};
-use radicle_surf::Branch;
-use test_helpers::roundtrip;
proptest! {
#[test]
fn prop_test_branch(branch in gen_branch()) {
- roundtrip::json(branch)
+ super::roundtrip::json(branch)
}
}
diff --git a/crates/radicle-surf/t/src/code_browsing.rs b/crates/radicle-surf/src/test/code_browsing.rs
similarity index 99%
rename from crates/radicle-surf/t/src/code_browsing.rs
rename to crates/radicle-surf/src/test/code_browsing.rs
index 52b97dbc8..383c18890 100644
--- a/crates/radicle-surf/t/src/code_browsing.rs
+++ b/crates/radicle-surf/src/test/code_browsing.rs
@@ -1,10 +1,10 @@
use std::path::Path;
-use radicle_git_ref_format::refname;
-use radicle_surf::{
+use crate::{
fs::{self, Directory},
Branch, Repository,
};
+use radicle_git_ref_format::refname;
use super::GIT_PLATINUM;
diff --git a/crates/radicle-surf/t/src/commit.rs b/crates/radicle-surf/src/test/commit.rs
similarity index 88%
rename from crates/radicle-surf/t/src/commit.rs
rename to crates/radicle-surf/src/test/commit.rs
index 42ca5a158..8be559467 100644
--- a/crates/radicle-surf/t/src/commit.rs
+++ b/crates/radicle-surf/src/test/commit.rs
@@ -1,14 +1,14 @@
use std::str::FromStr;
+use crate::{Author, Commit, Time};
use proptest::prelude::*;
use radicle_oid::Oid;
-use radicle_surf::{Author, Commit, Time};
-use test_helpers::roundtrip;
+#[cfg(feature = "serde")]
proptest! {
#[test]
fn prop_test_commits(commit in commits_strategy()) {
- roundtrip::json(commit)
+ super::roundtrip::json(commit)
}
}
diff --git a/crates/radicle-surf/t/src/diff.rs b/crates/radicle-surf/src/test/diff.rs
similarity index 99%
rename from crates/radicle-surf/t/src/diff.rs
rename to crates/radicle-surf/src/test/diff.rs
index e123f7eab..84305df68 100644
--- a/crates/radicle-surf/t/src/diff.rs
+++ b/crates/radicle-surf/src/test/diff.rs
@@ -1,13 +1,13 @@
-use pretty_assertions::assert_eq;
-use radicle_git_ref_format::refname;
-use radicle_oid::Oid;
-use radicle_surf::{
+use crate::{
diff::{
Added, Diff, DiffContent, DiffFile, EofNewLine, FileDiff, FileMode, FileStats, Hunk, Line,
Modification, Modified, Stats,
},
Branch, Error, Repository,
};
+use pretty_assertions::assert_eq;
+use radicle_git_ref_format::refname;
+use radicle_oid::Oid;
use std::{path::Path, str::FromStr};
use super::GIT_PLATINUM;
@@ -212,6 +212,7 @@ fn test_branch_diff() -> Result<(), Error> {
Ok(())
}
+#[cfg(feature = "serde")]
#[test]
fn test_diff_serde() -> Result<(), Error> {
let repo = Repository::open(GIT_PLATINUM)?;
@@ -381,6 +382,7 @@ fn test_diff_serde() -> Result<(), Error> {
Ok(())
}
+#[cfg(feature = "serde")]
#[test]
fn test_rename_with_changes() {
let buf = r"
diff --git a/crates/radicle-surf/t/src/file_system.rs b/crates/radicle-surf/src/test/file_system.rs
similarity index 98%
rename from crates/radicle-surf/t/src/file_system.rs
rename to crates/radicle-surf/src/test/file_system.rs
index ea8726e29..ac11a1073 100644
--- a/crates/radicle-surf/t/src/file_system.rs
+++ b/crates/radicle-surf/src/test/file_system.rs
@@ -1,11 +1,11 @@
-//! Unit tests for radicle_surf::file_system
+//! Unit tests for crate::file_system
-mod directory {
- use radicle_git_ref_format::refname;
- use radicle_surf::{
+pub mod directory {
+ use crate::{
fs::{self, Entry},
Branch, Repository,
};
+ use radicle_git_ref_format::refname;
use std::path::Path;
const GIT_PLATINUM: &str = "../data/git-platinum";
diff --git a/crates/radicle-surf/src/test/gen.rs b/crates/radicle-surf/src/test/gen.rs
new file mode 100644
index 000000000..ad7716461
--- /dev/null
+++ b/crates/radicle-surf/src/test/gen.rs
@@ -0,0 +1,108 @@
+pub(crate) mod commit;
+
+use proptest::prelude::*;
+
+/// Any unicode "word" is trivially a valid refname.
+pub fn trivial() -> impl Strategy<Value = String> {
+ "\\w+"
+}
+
+pub fn valid() -> impl Strategy<Value = String> {
+ prop::collection::vec(trivial(), 1..20).prop_map(|xs| xs.join("/"))
+}
+
+pub fn invalid_char() -> impl Strategy<Value = char> {
+ prop_oneof![
+ Just('\0'),
+ Just('\\'),
+ Just('~'),
+ Just('^'),
+ Just(':'),
+ Just('?'),
+ Just('[')
+ ]
+}
+
+pub fn with_invalid_char() -> impl Strategy<Value = String> {
+ ("\\w*", invalid_char(), "\\w*").prop_map(|(mut pre, invalid, suf)| {
+ pre.push(invalid);
+ pre.push_str(&suf);
+ pre
+ })
+}
+
+pub fn ends_with_dot_lock() -> impl Strategy<Value = String> {
+ "\\w*\\.lock"
+}
+
+pub fn with_double_dot() -> impl Strategy<Value = String> {
+ "\\w*\\.\\.\\w*"
+}
+
+pub fn starts_with_dot() -> impl Strategy<Value = String> {
+ "\\.\\w*"
+}
+
+pub fn ends_with_dot() -> impl Strategy<Value = String> {
+ "\\w+\\."
+}
+
+pub fn with_control_char() -> impl Strategy<Value = String> {
+ "\\w*[\x01-\x1F\x7F]+\\w*"
+}
+
+pub fn with_space() -> impl Strategy<Value = String> {
+ "\\w* +\\w*"
+}
+
+pub fn with_consecutive_slashes() -> impl Strategy<Value = String> {
+ "\\w*//\\w*"
+}
+
+pub fn with_glob() -> impl Strategy<Value = String> {
+ "\\w*\\*\\w*"
+}
+
+pub fn multi_glob() -> impl Strategy<Value = String> {
+ (
+ prop::collection::vec(with_glob(), 2..5),
+ prop::collection::vec(trivial(), 0..5),
+ )
+ .prop_map(|(mut globs, mut valids)| {
+ globs.append(&mut valids);
+ globs
+ })
+ .prop_shuffle()
+ .prop_map(|xs| xs.join("/"))
+}
+
+pub fn invalid() -> impl Strategy<Value = String> {
+ fn path(s: impl Strategy<Value = String>) -> impl Strategy<Value = String> {
+ prop::collection::vec(s, 1..20).prop_map(|xs| xs.join("/"))
+ }
+
+ prop_oneof![
+ Just(String::from("")),
+ Just(String::from("@")),
+ path(with_invalid_char()),
+ path(ends_with_dot_lock()),
+ path(with_double_dot()),
+ path(starts_with_dot()),
+ path(ends_with_dot()),
+ path(with_control_char()),
+ path(with_space()),
+ path(with_consecutive_slashes()),
+ path(trivial()).prop_map(|mut p| {
+ p.push('/');
+ p
+ }),
+ ]
+}
+
+pub fn alphanumeric() -> impl Strategy<Value = String> {
+ "[a-zA-Z0-9_]+"
+}
+
+pub fn alpha() -> impl Strategy<Value = String> {
+ "[a-zA-Z]+"
+}
diff --git a/crates/radicle-surf/src/test/gen/commit.rs b/crates/radicle-surf/src/test/gen/commit.rs
new file mode 100644
index 000000000..da5c7305a
--- /dev/null
+++ b/crates/radicle-surf/src/test/gen/commit.rs
@@ -0,0 +1,102 @@
+use std::convert::Infallible;
+
+use proptest::strategy::Strategy;
+use radicle_git_metadata::commit::CommitData;
+
+mod author;
+mod headers;
+mod trailers;
+
+pub use author::author;
+pub use headers::headers;
+pub use trailers::trailers;
+
+use super::alphanumeric;
+
+pub fn commit() -> impl Strategy<Value = CommitData<TreeData, Infallible>> {
+ (
+ TreeData::gen(),
+ author(),
+ author(),
+ headers(),
+ alphanumeric(),
+ trailers(3),
+ )
+ .prop_map(|(tree, author, committer, headers, message, trailers)| {
+ CommitData::new(tree, vec![], author, committer, headers, message, trailers)
+ })
+}
+
+pub fn write_commits(
+ repo: &git2::Repository,
+ linear: Vec<CommitData<TreeData, Infallible>>,
+) -> Result<Vec<git2::Oid>, git2::Error> {
+ let mut parent = None;
+ let mut commits = Vec::new();
+ for commit in linear {
+ let commit = commit.map_tree(|tree| tree.write(repo))?;
+ let commit = match parent {
+ Some(parent) => commit
+ .map_parents::<git2::Oid, Infallible, _>(|_| Ok(parent))
+ .unwrap(),
+ None => commit
+ .map_parents::<git2::Oid, Infallible, _>(|_| unreachable!("no parents"))
+ .unwrap(),
+ };
+ let tree = repo.find_tree(*commit.tree())?;
+ let oid = repo.commit(
+ None,
+ &git2::Signature::try_from(commit.author()).unwrap(),
+ &git2::Signature::try_from(commit.committer()).unwrap(),
+ commit.message(),
+ &tree,
+ &[],
+ )?;
+ commits.push(oid);
+ parent = Some(oid);
+ }
+ Ok(commits)
+}
+
+#[derive(Clone, Debug)]
+pub enum TreeData {
+ Blob { name: String, data: String },
+ Tree { name: String, inner: Vec<TreeData> },
+}
+
+impl TreeData {
+ fn gen() -> impl Strategy<Value = Self> {
+ let leaf =
+ (alphanumeric(), alphanumeric()).prop_map(|(name, data)| Self::Blob { name, data });
+ leaf.prop_recursive(8, 16, 5, |inner| {
+ (proptest::collection::vec(inner, 1..5), alphanumeric())
+ .prop_map(|(inner, name)| Self::Tree { name, inner })
+ })
+ }
+
+ fn write(&self, repo: &git2::Repository) -> Result<git2::Oid, git2::Error> {
+ let mut builder = repo.treebuilder(None)?;
+ self.write_(repo, &mut builder)?;
+ builder.write()
+ }
+
+ fn write_(
+ &self,
+ repo: &git2::Repository,
+ builder: &mut git2::TreeBuilder,
+ ) -> Result<git2::Oid, git2::Error> {
+ match self {
+ Self::Blob { name, data } => {
+ let oid = repo.blob(data.as_bytes())?;
+ builder.insert(name, oid, git2::FileMode::Blob.into())?;
+ }
+ Self::Tree { name, inner } => {
+ for data in inner {
+ let oid = data.write_(repo, builder)?;
+ builder.insert(name, oid, git2::FileMode::Tree.into())?;
+ }
+ }
+ }
+ builder.write()
+ }
+}
diff --git a/crates/radicle-surf/src/test/gen/commit/author.rs b/crates/radicle-surf/src/test/gen/commit/author.rs
new file mode 100644
index 000000000..190c50cab
--- /dev/null
+++ b/crates/radicle-surf/src/test/gen/commit/author.rs
@@ -0,0 +1,19 @@
+use proptest::strategy::{Just, Strategy};
+use radicle_git_metadata::author::{Author, Time};
+
+use crate::test::gen;
+
+pub fn author() -> impl Strategy<Value = Author> {
+ gen::alphanumeric().prop_flat_map(move |name| {
+ (Just(name), gen::alphanumeric()).prop_flat_map(|(name, domain)| {
+ (Just(name), Just(domain), (0..1000i64)).prop_map(move |(name, domain, time)| {
+ let email = format!("{name}@{domain}");
+ Author {
+ name,
+ email,
+ time: Time::new(time, 0),
+ }
+ })
+ })
+ })
+}
diff --git a/crates/radicle-surf/src/test/gen/commit/headers.rs b/crates/radicle-surf/src/test/gen/commit/headers.rs
new file mode 100644
index 000000000..79abfa773
--- /dev/null
+++ b/crates/radicle-surf/src/test/gen/commit/headers.rs
@@ -0,0 +1,30 @@
+use proptest::{collection, prop_oneof, strategy::Strategy};
+use radicle_git_metadata::commit::headers::Headers;
+
+use crate::test::gen;
+
+pub fn headers() -> impl Strategy<Value = Headers> {
+ collection::vec(prop_oneof![header(), signature()], 0..5).prop_map(|hs| {
+ let mut headers = Headers::new();
+ for (k, v) in hs {
+ headers.push(&k, &v);
+ }
+ headers
+ })
+}
+
+fn header() -> impl Strategy<Value = (String, String)> {
+ (prop_oneof!["test", "foo", "foobar"], gen::alphanumeric())
+}
+
+pub fn signature() -> impl Strategy<Value = (String, String)> {
+ ("gpgsig", prop_oneof![pgp(), ssh()])
+}
+
+pub fn pgp() -> impl Strategy<Value = String> {
+ "-----BEGIN PGP SIGNATURE-----\r?\n([A-Za-z0-9+/=\r\n]+)\r?\n-----END PGP SIGNATURE-----"
+}
+
+pub fn ssh() -> impl Strategy<Value = String> {
+ "-----BEGIN SSH SIGNATURE-----\r?\n([A-Za-z0-9+/=\r\n]+)\r?\n-----END SSH SIGNATURE-----"
+}
diff --git a/crates/radicle-surf/src/test/gen/commit/trailers.rs b/crates/radicle-surf/src/test/gen/commit/trailers.rs
new file mode 100644
index 000000000..68cc18178
--- /dev/null
+++ b/crates/radicle-surf/src/test/gen/commit/trailers.rs
@@ -0,0 +1,18 @@
+use proptest::{collection, strategy::Strategy};
+use radicle_git_metadata::commit::trailers::{OwnedTrailer, Token, Trailer};
+
+use crate::test::gen;
+
+pub fn trailers(n: usize) -> impl Strategy<Value = Vec<OwnedTrailer>> {
+ collection::vec(trailer(), 0..n)
+}
+
+pub fn trailer() -> impl Strategy<Value = OwnedTrailer> {
+ (gen::alpha(), gen::alphanumeric()).prop_map(|(token, value)| {
+ Trailer {
+ token: Token::try_from(format!("X-{}", token).as_str()).unwrap(),
+ value: value.into(),
+ }
+ .to_owned()
+ })
+}
diff --git a/crates/radicle-surf/t/src/last_commit.rs b/crates/radicle-surf/src/test/last_commit.rs
similarity index 99%
rename from crates/radicle-surf/t/src/last_commit.rs
rename to crates/radicle-surf/src/test/last_commit.rs
index de897f6cf..967ea5efb 100644
--- a/crates/radicle-surf/t/src/last_commit.rs
+++ b/crates/radicle-surf/src/test/last_commit.rs
@@ -1,8 +1,8 @@
use std::{path::PathBuf, str::FromStr};
-use radicle_oid::Oid;
+use crate::{Branch, Repository};
use radicle_git_ref_format::refname;
-use radicle_surf::{Branch, Repository};
+use radicle_oid::Oid;
use super::GIT_PLATINUM;
diff --git a/crates/radicle-surf/t/src/lib.rs b/crates/radicle-surf/src/test/mod.rs
similarity index 64%
rename from crates/radicle-surf/t/src/lib.rs
rename to crates/radicle-surf/src/test/mod.rs
index 6e28a47c8..5819f95f5 100644
--- a/crates/radicle-surf/t/src/lib.rs
+++ b/crates/radicle-surf/src/test/mod.rs
@@ -4,11 +4,11 @@ const GIT_PLATINUM: &str = "../data/git-platinum";
#[cfg(test)]
mod file_system;
-#[cfg(test)]
+#[cfg(all(test, feature = "serde"))]
mod source;
-// #[cfg(test)]
-// mod branch;
+#[cfg(all(test, feature = "serde"))]
+mod branch;
#[cfg(test)]
mod code_browsing;
@@ -19,8 +19,8 @@ mod commit;
#[cfg(test)]
mod diff;
-// #[cfg(test)]
-// mod last_commit;
+#[cfg(test)]
+mod last_commit;
#[cfg(test)]
mod namespace;
@@ -31,8 +31,14 @@ mod reference;
#[cfg(test)]
mod rev;
-// #[cfg(test)]
-// mod submodule;
+#[cfg(test)]
+mod submodule;
#[cfg(test)]
mod threading;
+
+mod roundtrip;
+
+mod gen;
+
+mod repository;
diff --git a/crates/radicle-surf/t/src/namespace.rs b/crates/radicle-surf/src/test/namespace.rs
similarity index 97%
rename from crates/radicle-surf/t/src/namespace.rs
rename to crates/radicle-surf/src/test/namespace.rs
index 3ffd11319..1a4249dae 100644
--- a/crates/radicle-surf/t/src/namespace.rs
+++ b/crates/radicle-surf/src/test/namespace.rs
@@ -1,6 +1,6 @@
+use crate::{Branch, Error, Glob, Repository};
use pretty_assertions::{assert_eq, assert_ne};
-use radicle_git_ref_format::{name::component, refname, refspec};
-use radicle_surf::{Branch, Error, Glob, Repository};
+use radicle_git_ref_format::{component, pattern, refname};
use super::GIT_PLATINUM;
diff --git a/crates/radicle-surf/t/src/reference.rs b/crates/radicle-surf/src/test/reference.rs
similarity index 94%
rename from crates/radicle-surf/t/src/reference.rs
rename to crates/radicle-surf/src/test/reference.rs
index 69396d0c2..7c6090056 100644
--- a/crates/radicle-surf/t/src/reference.rs
+++ b/crates/radicle-surf/src/test/reference.rs
@@ -1,5 +1,5 @@
-use radicle_git_ref_format::refspec;
-use radicle_surf::{Glob, Repository};
+use crate::{Glob, Repository};
+use radicle_git_ref_format::pattern;
use super::GIT_PLATINUM;
diff --git a/crates/radicle-surf/src/test/repository.rs b/crates/radicle-surf/src/test/repository.rs
new file mode 100644
index 000000000..ead872587
--- /dev/null
+++ b/crates/radicle-surf/src/test/repository.rs
@@ -0,0 +1,93 @@
+use std::{convert::Infallible, io, path::Path};
+
+use git2::Oid;
+use radicle_git_metadata::commit::CommitData;
+use radicle_git_ref_format::RefString;
+
+use crate::test::gen::commit::{self, TreeData};
+
+pub struct Fixture {
+ pub dir: tempfile::TempDir,
+ pub repo: git2::Repository,
+ pub head: Option<git2::Oid>,
+}
+
+/// Initialise a [`git2::Repository`] in a temporary directory.
+///
+/// The provided `commits` will be added to the repository, and the
+/// head commit will be returned.
+pub fn fixture(
+ refname: &RefString,
+ commits: Vec<CommitData<TreeData, Infallible>>,
+) -> io::Result<Fixture> {
+ let dir = tempfile::tempdir().unwrap();
+ let repo = git2::Repository::init(dir.path()).map_err(io_other)?;
+ let commits = commit::write_commits(&repo, commits).map_err(io_other)?;
+ let head = commits.last().copied();
+
+ if let Some(head) = head {
+ repo.reference(refname.as_str(), head, false, "Initialise repository")
+ .map_err(io_other)?;
+ }
+
+ Ok(Fixture { dir, repo, head })
+}
+
+pub fn bare_fixture(
+ refname: &RefString,
+ commits: Vec<CommitData<TreeData, Infallible>>,
+) -> io::Result<Fixture> {
+ let dir = tempfile::tempdir().unwrap();
+ let repo = git2::Repository::init_bare(dir.path()).map_err(io_other)?;
+ let commits = commit::write_commits(&repo, commits).map_err(io_other)?;
+ let head = commits.last().copied();
+
+ if let Some(head) = head {
+ repo.reference(refname.as_str(), head, false, "Initialise repository")
+ .map_err(io_other)?;
+ }
+
+ Ok(Fixture { dir, repo, head })
+}
+
+pub fn submodule<'a>(
+ parent: &'a git2::Repository,
+ child: &'a git2::Repository,
+ refname: &RefString,
+ head: Oid,
+ author: &git2::Signature,
+) -> io::Result<git2::Submodule<'a>> {
+ let url = format!("file://{}", child.path().canonicalize()?.display());
+ let mut sub = parent
+ .submodule(url.as_str(), Path::new("submodule"), true)
+ .map_err(io_other)?;
+ sub.open().map_err(io_other)?;
+ sub.clone(Some(&mut git2::SubmoduleUpdateOptions::default()))
+ .map_err(io_other)?;
+ sub.add_to_index(true).map_err(io_other)?;
+ sub.add_finalize().map_err(io_other)?;
+ {
+ let mut ix = parent.index().map_err(io_other)?;
+ let tree = ix.write_tree_to(parent).map_err(io_other)?;
+ let tree = parent.find_tree(tree).map_err(io_other)?;
+ let head = parent.find_commit(head).map_err(io_other)?;
+ parent
+ .commit(
+ Some(refname.as_str()),
+ author,
+ author,
+ "Commit submodule",
+ &tree,
+ &[&head],
+ )
+ .map_err(io_other)?;
+ }
+ Ok(sub)
+}
+
+fn io_other<E>(e: E) -> io::Error
+where
+ E: std::error::Error + Send + Sync + 'static,
+{
+ io::Error::other(e)
+}
diff --git a/crates/radicle-surf/t/src/rev.rs b/crates/radicle-surf/src/test/rev.rs
similarity index 96%
rename from crates/radicle-surf/t/src/rev.rs
rename to crates/radicle-surf/src/test/rev.rs
index 70d31ed81..4f22b81cd 100644
--- a/crates/radicle-surf/t/src/rev.rs
+++ b/crates/radicle-surf/src/test/rev.rs
@@ -1,7 +1,7 @@
use std::str::FromStr;
-use radicle_git_ref_format::{name::component, refname};
-use radicle_surf::{Branch, Error, Oid, Repository};
+use crate::{Branch, Error, Oid, Repository};
+use radicle_git_ref_format::{component, refname};
use super::GIT_PLATINUM;
diff --git a/crates/radicle-surf/src/test/roundtrip.rs b/crates/radicle-surf/src/test/roundtrip.rs
new file mode 100644
index 000000000..1f3a2b3b0
--- /dev/null
+++ b/crates/radicle-surf/src/test/roundtrip.rs
@@ -0,0 +1,44 @@
+use std::{
+ fmt::{Debug, Display},
+ str::FromStr,
+};
+
+use pretty_assertions::assert_eq;
+
+#[cfg(feature = "serde")]
+pub fn json<A>(a: A)
+where
+ for<'de> A: Debug + PartialEq + serde::Serialize + serde::Deserialize<'de>,
+{
+ assert_eq!(
+ a,
+ serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap()
+ )
+}
+
+#[cfg(feature = "serde")]
+pub fn json_value<A>(a: A)
+where
+ for<'de> A: Clone + Debug + PartialEq + serde::Serialize + serde::Deserialize<'de>,
+{
+ assert_eq!(
+ a.clone(),
+ serde_json::from_value(serde_json::to_value(a).unwrap()).unwrap()
+ )
+}
+
+#[cfg(feature = "minicbor")]
+pub fn cbor<A>(a: A)
+where
+ for<'de> A: Debug + PartialEq + minicbor::Encode + minicbor::Decode<'de>,
+{
+ assert_eq!(a, minicbor::decode(&minicbor::to_vec(&a).unwrap()).unwrap())
+}
+
+pub fn str<A>(a: A)
+where
+ A: Debug + PartialEq + Display + FromStr,
+ <A as FromStr>::Err: Debug,
+{
+ assert_eq!(a, a.to_string().parse().unwrap())
+}
diff --git a/crates/radicle-surf/t/src/source.rs b/crates/radicle-surf/src/test/source.rs
similarity index 99%
rename from crates/radicle-surf/t/src/source.rs
rename to crates/radicle-surf/src/test/source.rs
index 5a08ab616..19db88d19 100644
--- a/crates/radicle-surf/t/src/source.rs
+++ b/crates/radicle-surf/src/test/source.rs
@@ -1,7 +1,7 @@
use std::path::PathBuf;
+use crate::{Branch, Glob, Repository};
use radicle_git_ref_format::refname;
-use radicle_surf::{Branch, Glob, Repository};
use serde_json::json;
const GIT_PLATINUM: &str = "../data/git-platinum";
diff --git a/crates/radicle-surf/t/src/submodule.rs b/crates/radicle-surf/src/test/submodule.rs
similarity index 77%
rename from crates/radicle-surf/t/src/submodule.rs
rename to crates/radicle-surf/src/test/submodule.rs
index 3d638cbe6..049646a27 100644
--- a/crates/radicle-surf/t/src/submodule.rs
+++ b/crates/radicle-surf/src/test/submodule.rs
@@ -1,11 +1,11 @@
use std::{convert::Infallible, path::Path};
+use super::gen;
+use crate::tree::EntryKind;
+use crate::{fs, Branch, Repository};
use proptest::{collection, proptest};
use radicle_git_metadata::commit::CommitData;
-use radicle_git_ext_test::gen;
use radicle_git_ref_format::refname;
-use radicle_surf::tree::EntryKind;
-use radicle_surf::{fs, Branch, Repository};
proptest! {
#[test]
@@ -28,7 +28,8 @@ proptest! {
}
mod prop {
- use radicle_git_ext_test::{gen::commit, repository};
+ use crate::test::gen::commit;
+ use crate::test::repository;
use super::*;
@@ -40,13 +41,13 @@ mod prop {
let author = git2::Signature::try_from(initial.author()).unwrap();
let submodule = repository::fixture(&refname, commits).unwrap();
- let repo = repository::fixture(&refname, vec![initial]).unwrap();
+ let parent = repository::fixture(&refname, vec![initial]).unwrap();
- let head = repo.head.expect("missing initial commit");
+ let head = parent.head.expect("missing initial commit");
let sub =
- repository::submodule(&repo.inner, &submodule.inner, &refname, head, &author).unwrap();
+ repository::submodule(&parent.repo, &submodule.repo, &refname, head, &author).unwrap();
- let repo = Repository::open(repo.inner.path()).unwrap();
+ let repo = Repository::open(parent.repo.path()).unwrap();
let branch = Branch::local(refname);
let dir = repo.root_dir(&branch).unwrap();
@@ -66,13 +67,13 @@ mod prop {
let author = git2::Signature::try_from(initial.author()).unwrap();
let submodule = repository::fixture(&refname, commits).unwrap();
- let repo = repository::bare_fixture(&refname, vec![initial]).unwrap();
+ let parent = repository::bare_fixture(&refname, vec![initial]).unwrap();
- let head = repo.head.expect("missing initial commit");
+ let head = parent.head.expect("missing initial commit");
let sub =
- repository::submodule(&repo.inner, &submodule.inner, &refname, head, &author).unwrap();
+ repository::submodule(&parent.repo, &submodule.repo, &refname, head, &author).unwrap();
- let repo = Repository::open(repo.inner.path()).unwrap();
+ let repo = Repository::open(parent.repo.path()).unwrap();
let branch = Branch::local(refname);
let dir = repo.root_dir(&branch).unwrap();
diff --git a/crates/radicle-surf/t/src/threading.rs b/crates/radicle-surf/src/test/threading.rs
similarity index 91%
rename from crates/radicle-surf/t/src/threading.rs
rename to crates/radicle-surf/src/test/threading.rs
index ef39ac56f..821ec1d22 100644
--- a/crates/radicle-surf/t/src/threading.rs
+++ b/crates/radicle-surf/src/test/threading.rs
@@ -1,7 +1,7 @@
use std::sync::{Mutex, MutexGuard};
-use radicle_git_ref_format::{name::component, refname};
-use radicle_surf::{Branch, Error, Glob, Repository};
+use crate::{Branch, Error, Glob, Repository};
+use radicle_git_ref_format::{component, refname};
use super::GIT_PLATINUM;
diff --git a/crates/radicle-surf/t/Cargo.toml b/crates/radicle-surf/t/Cargo.toml
index d21193c5d..1e0053476 100644
--- a/crates/radicle-surf/t/Cargo.toml
+++ b/crates/radicle-surf/t/Cargo.toml
@@ -19,21 +19,5 @@ proptest = "1"
serde_json = "1"
url = "2.5"
-[dev-dependencies.git2]
-version = "0.19"
-default-features = false
-features = ["vendored-libgit2"]
-
-[dev-dependencies.radicle-git-ext]
-path = "../../radicle-git-ext"
-
-[dev-dependencies.radicle-git-ext-test]
-path = "../../radicle-git-ext/t"
-features = ["test"]
-
-[dev-dependencies.radicle-surf]
-path = ".."
-features = ["serde"]
-
[dev-dependencies.test-helpers]
path = "../../test/test-helpers"
commit 33b03aeac5a74ec6804e3a47555031ec9893c034
Author: Lorenz Leutgeb <lorenz.leutgeb@radicle.xyz>
Date: Sat Jan 10 15:32:31 2026 +0100
surf: Refactor
Also copies some files from `radicle-git-ext`, to be documented.
diff --git a/Cargo.lock b/Cargo.lock
index c07d7856d..3dbdddf34 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3164,6 +3164,8 @@ dependencies = [
"git2",
"log",
"nonempty",
+ "radicle-git-ref-format",
+ "radicle-oid",
"serde",
"tar",
"thiserror 1.0.69",
diff --git a/crates/radicle-surf/Cargo.toml b/crates/radicle-surf/Cargo.toml
index 2ace30468..61c2aa6dc 100644
--- a/crates/radicle-surf/Cargo.toml
+++ b/crates/radicle-surf/Cargo.toml
@@ -37,6 +37,9 @@ url = "2.5.4"
serde = { workspace = true, optional = true, features = ["derive"] }
git2 = { workspace = true, features = ["vendored-libgit2"] }
+radicle-oid = { workspace = true, features = ["git2", "sha1"] }
+radicle-git-ref-format = { workspace = true, features = ["macro"]}
+
[build-dependencies]
anyhow = "1.0"
flate2 = "1.1"
diff --git a/crates/radicle-surf/examples/diff.rs b/crates/radicle-surf/examples/diff.rs
index 9454589af..0daba0d35 100644
--- a/crates/radicle-surf/examples/diff.rs
+++ b/crates/radicle-surf/examples/diff.rs
@@ -2,7 +2,7 @@ extern crate radicle_surf;
use std::{env::Args, str::FromStr, time::Instant};
-use radicle_git_ext::Oid;
+use radicle_oid::Oid;
use radicle_surf::{diff::Diff, Repository};
fn main() {
diff --git a/crates/radicle-surf/src/blob.rs b/crates/radicle-surf/src/blob.rs
index 427d0ca70..65bb49537 100644
--- a/crates/radicle-surf/src/blob.rs
+++ b/crates/radicle-surf/src/blob.rs
@@ -3,7 +3,7 @@
use std::ops::Deref;
-use radicle_git_ext::Oid;
+use radicle_oid::Oid;
#[cfg(feature = "serde")]
use serde::{
diff --git a/crates/radicle-surf/src/branch.rs b/crates/radicle-surf/src/branch.rs
index 73fe8fa2b..ccc3b206d 100644
--- a/crates/radicle-surf/src/branch.rs
+++ b/crates/radicle-surf/src/branch.rs
@@ -3,7 +3,7 @@ use std::{
str::{self, FromStr},
};
-use git_ext::ref_format::{component, lit, Component, Qualified, RefStr, RefString};
+use radicle_git_ref_format::{lit, name::component, Component, Qualified, RefStr, RefString};
use crate::refs::refstr_join;
@@ -295,7 +295,7 @@ impl FromStr for Remote {
}
pub mod error {
- use radicle_git_ext::ref_format::{self, RefString};
+ use radicle_git_ref_format::{self, RefString};
use thiserror::Error;
#[derive(Debug, Error)]
@@ -305,7 +305,7 @@ pub mod error {
#[error("the refname '{0}' did not begin with 'refs/heads' or 'refs/remotes'")]
NotQualified(String),
#[error(transparent)]
- RefFormat(#[from] ref_format::Error),
+ RefFormat(#[from] radicle_git_ref_format::Error),
#[error(transparent)]
Utf8(#[from] std::str::Utf8Error),
}
@@ -317,7 +317,7 @@ pub mod error {
#[error("the refname '{0}' did not begin with 'refs/heads'")]
NotQualified(String),
#[error(transparent)]
- RefFormat(#[from] ref_format::Error),
+ RefFormat(#[from] radicle_git_ref_format::Error),
#[error(transparent)]
Utf8(#[from] std::str::Utf8Error),
}
@@ -329,7 +329,7 @@ pub mod error {
#[error("the refname '{0}' did not begin with 'refs/remotes'")]
NotRemotes(RefString),
#[error(transparent)]
- RefFormat(#[from] ref_format::Error),
+ RefFormat(#[from] radicle_git_ref_format::Error),
#[error(transparent)]
Utf8(#[from] std::str::Utf8Error),
}
diff --git a/crates/radicle-surf/src/commit.rs b/crates/radicle-surf/src/commit.rs
index c2ee708bd..bc35fe61e 100644
--- a/crates/radicle-surf/src/commit.rs
+++ b/crates/radicle-surf/src/commit.rs
@@ -1,6 +1,6 @@
use std::{convert::TryFrom, str};
-use radicle_git_ext::Oid;
+use radicle_oid::Oid;
use thiserror::Error;
#[cfg(feature = "serde")]
diff --git a/crates/radicle-surf/src/diff.rs b/crates/radicle-surf/src/diff.rs
index f0043aed0..5992e20ca 100644
--- a/crates/radicle-surf/src/diff.rs
+++ b/crates/radicle-surf/src/diff.rs
@@ -10,7 +10,7 @@ use std::{
#[cfg(feature = "serde")]
use serde::{ser, ser::SerializeStruct, Serialize, Serializer};
-use git_ext::Oid;
+use radicle_oid::Oid;
pub mod git;
diff --git a/crates/radicle-surf/src/error.rs b/crates/radicle-surf/src/error.rs
index 1a790dc17..fbe887f47 100644
--- a/crates/radicle-surf/src/error.rs
+++ b/crates/radicle-surf/src/error.rs
@@ -27,7 +27,7 @@ pub enum Error {
#[error(transparent)]
Namespace(#[from] namespace::Error),
#[error(transparent)]
- RefFormat(#[from] git_ext::ref_format::Error),
+ RefFormat(#[from] radicle_git_ref_format::Error),
#[error(transparent)]
Revision(Box<dyn std::error::Error + Send + Sync + 'static>),
#[error(transparent)]
@@ -36,4 +36,6 @@ pub enum Error {
Tags(#[from] refs::error::Tag),
#[error(transparent)]
Repo(#[from] repo::error::Repo),
+ #[error(transparent)]
+ Oid(#[from] radicle_oid::str::error::ParseOidError),
}
diff --git a/crates/radicle-surf/src/ext.rs b/crates/radicle-surf/src/ext.rs
new file mode 100644
index 000000000..37f5b5758
--- /dev/null
+++ b/crates/radicle-surf/src/ext.rs
@@ -0,0 +1,35 @@
+pub(crate) trait ResultExt<T, E> {
+ /// Calls `f` if the result is [`Err`], **and** the predicate `pred` on the
+ /// error value returns true. Otherwise returns the [`Ok`] value of
+ /// `self`. Note that `f` may change the error type, so as long as the
+ /// target type can be converted from the original one.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use std::io;
+ /// use radicle_std_ext::result::ResultExt as _;
+ ///
+ /// let res = Err(io::Error::new(io::ErrorKind::Other, "crashbug"))
+ /// .or_matches::<io::Error, _, _>(|e| matches!(e.kind(), io::ErrorKind::Other), || Ok(()))
+ /// .unwrap();
+ ///
+ /// assert_eq!((), res)
+ /// ```
+ fn or_matches<E2, P, F>(self, pred: P, f: F) -> Result<T, E2>
+ where
+ E2: From<E>,
+ P: FnOnce(&E) -> bool,
+ F: FnOnce() -> Result<T, E2>;
+}
+
+impl<T, E> ResultExt<T, E> for Result<T, E> {
+ fn or_matches<E2, P, F>(self, pred: P, f: F) -> Result<T, E2>
+ where
+ E2: From<E>,
+ P: FnOnce(&E) -> bool,
+ F: FnOnce() -> Result<T, E2>,
+ {
+ self.or_else(|e| if pred(&e) { f() } else { Err(e.into()) })
+ }
+}
diff --git a/crates/radicle-surf/src/fs.rs b/crates/radicle-surf/src/fs.rs
index ce03062f2..9d146504e 100644
--- a/crates/radicle-surf/src/fs.rs
+++ b/crates/radicle-surf/src/fs.rs
@@ -11,11 +11,10 @@ use std::{
};
use git2::Blob;
-use radicle_git_ext::{is_not_found_err, Oid};
-use radicle_std_ext::result::ResultExt as _;
+use radicle_oid::Oid;
use url::Url;
-use crate::{Repository, Revision};
+use crate::{ext::ResultExt as _, Repository, Revision};
pub mod error {
use std::path::PathBuf;
@@ -413,9 +412,10 @@ impl Directory {
let git2_tree = repo.find_tree(self.id)?;
let entry = git2_tree
.get_path(path)
- .or_matches::<error::Directory, _, _>(is_not_found_err, || {
- Err(error::Directory::PathNotFound(path.to_path_buf()))
- })?;
+ .or_matches::<error::Directory, _, _>(
+ |err| err.code() == git2::ErrorCode::NotFound,
+ || Err(error::Directory::PathNotFound(path.to_path_buf())),
+ )?;
let parent = path
.parent()
.ok_or_else(|| error::Directory::InvalidPath(path.to_path_buf()))?;
diff --git a/crates/radicle-surf/src/glob.rs b/crates/radicle-surf/src/glob.rs
index c5b0eb632..e7a9ea385 100644
--- a/crates/radicle-surf/src/glob.rs
+++ b/crates/radicle-surf/src/glob.rs
@@ -1,8 +1,8 @@
use std::marker::PhantomData;
-use git_ext::ref_format::{
- self, refname,
- refspec::{self, PatternString, QualifiedPattern},
+use radicle_git_ref_format::{
+ self, pattern, refname,
+ refspec::{PatternString, QualifiedPattern},
Qualified, RefStr, RefString,
};
use thiserror::Error;
@@ -12,7 +12,7 @@ use crate::{Branch, Local, Namespace, Remote, Tag};
#[derive(Debug, Error)]
pub enum Error {
#[error(transparent)]
- RefFormat(#[from] ref_format::Error),
+ RefFormat(#[from] radicle_git_ref_format::Error),
}
/// A collection of globs for a git reference type.
@@ -51,7 +51,7 @@ impl<T> Glob<T> {
impl Glob<Namespace> {
/// Creates the `Glob` that matches all `refs/namespaces`.
pub fn all_namespaces() -> Self {
- Self::namespaces(refspec::pattern!("*"))
+ Self::namespaces(pattern!("*"))
}
/// Creates a `Glob` for `refs/namespaces`, starting with `glob`.
@@ -101,7 +101,7 @@ impl Extend<PatternString> for Glob<Namespace> {
impl Glob<Tag> {
/// Creates a `Glob` that matches all `refs/tags`.
pub fn all_tags() -> Self {
- Self::tags(refspec::pattern!("*"))
+ Self::tags(pattern!("*"))
}
/// Creates a `Glob` for `refs/tags`, starting with `glob`.
@@ -150,7 +150,7 @@ impl Extend<PatternString> for Glob<Tag> {
impl Glob<Local> {
/// Creates the `Glob` that matches all `refs/heads`.
pub fn all_heads() -> Self {
- Self::heads(refspec::pattern!("*"))
+ Self::heads(pattern!("*"))
}
/// Creates a `Glob` for `refs/heads`, starting with `glob`.
@@ -224,7 +224,7 @@ impl From<Glob<Local>> for Glob<Branch> {
impl Glob<Remote> {
/// Creates the `Glob` that matches all `refs/remotes`.
pub fn all_remotes() -> Self {
- Self::remotes(refspec::pattern!("*"))
+ Self::remotes(pattern!("*"))
}
/// Creates a `Glob` for `refs/remotes`, starting with `glob`.
@@ -298,7 +298,7 @@ impl From<Glob<Remote>> for Glob<Branch> {
impl Glob<Qualified<'_>> {
pub fn all_category<R: AsRef<RefStr>>(category: R) -> Self {
Self {
- globs: vec![Self::qualify_category(category, refspec::pattern!("*"))],
+ globs: vec![Self::qualify_category(category, pattern!("*"))],
glob_type: PhantomData,
}
}
diff --git a/crates/radicle-surf/src/lib.rs b/crates/radicle-surf/src/lib.rs
index c577b0651..a61f9855f 100644
--- a/crates/radicle-surf/src/lib.rs
+++ b/crates/radicle-surf/src/lib.rs
@@ -15,13 +15,11 @@
//!
//! [serde]: https://crates.io/crates/serde
-extern crate radicle_git_ext as git_ext;
-
/// Re-exports.
-pub use radicle_git_ext::ref_format;
+pub use radicle_git_ref_format;
/// Represents an object id in Git. Re-exported from `radicle-git-ext`.
-pub type Oid = radicle_git_ext::Oid;
+pub type Oid = radicle_oid::Oid;
pub mod blob;
pub mod diff;
@@ -60,3 +58,5 @@ mod refs;
mod error;
pub use error::Error;
+
+mod ext;
diff --git a/crates/radicle-surf/src/namespace.rs b/crates/radicle-surf/src/namespace.rs
index 590ff03f1..d6530ebf7 100644
--- a/crates/radicle-surf/src/namespace.rs
+++ b/crates/radicle-surf/src/namespace.rs
@@ -4,12 +4,12 @@ use std::{
str::{self, FromStr},
};
-use git_ext::ref_format::{
+use nonempty::NonEmpty;
+use radicle_git_ref_format::{
self,
refspec::{NamespacedPattern, PatternString, QualifiedPattern},
Component, Namespaced, Qualified, RefStr, RefString,
};
-use nonempty::NonEmpty;
use thiserror::Error;
#[derive(Debug, Error)]
@@ -19,7 +19,7 @@ pub enum Error {
#[error("namespaces must not be empty")]
EmptyNamespace,
#[error(transparent)]
- RefFormat(#[from] ref_format::Error),
+ RefFormat(#[from] radicle_git_ref_format::Error),
#[error(transparent)]
Utf8(#[from] str::Utf8Error),
}
diff --git a/crates/radicle-surf/src/refs.rs b/crates/radicle-surf/src/refs.rs
index e0b9d71c4..11ddbef35 100644
--- a/crates/radicle-surf/src/refs.rs
+++ b/crates/radicle-surf/src/refs.rs
@@ -6,7 +6,7 @@ use std::{
convert::TryFrom as _,
};
-use git_ext::ref_format::{self, lit, name::Components, Component, Qualified, RefString};
+use radicle_git_ref_format::{self, lit, name::Components, Component, Qualified, RefString};
use crate::{tag, Branch, Namespace, Tag};
@@ -187,7 +187,7 @@ impl Iterator for Categories<'_> {
Some(res) => {
return Some(res.map_err(error::Category::from).and_then(|r| {
let name = std::str::from_utf8(r.name_bytes())?;
- let name = ref_format::RefStr::try_from_str(name)?;
+ let name = radicle_git_ref_format::RefStr::try_from_str(name)?;
let name = name.qualified().ok_or_else(|| {
error::Category::NotQualified(name.to_ref_string())
})?;
@@ -207,7 +207,7 @@ impl Iterator for Categories<'_> {
pub mod error {
use std::str;
- use radicle_git_ext::ref_format::{self, RefString};
+ use radicle_git_ref_format::{self, RefString};
use thiserror::Error;
use crate::{branch, tag};
@@ -227,7 +227,7 @@ pub mod error {
#[error("the reference '{0}' was expected to be qualified, i.e. 'refs/<category>/<path>'")]
NotQualified(RefString),
#[error(transparent)]
- RefFormat(#[from] ref_format::Error),
+ RefFormat(#[from] radicle_git_ref_format::Error),
#[error(transparent)]
Utf8(#[from] str::Utf8Error),
}
diff --git a/crates/radicle-surf/src/repo.rs b/crates/radicle-surf/src/repo.rs
index 97c8a57aa..fb7cb1575 100644
--- a/crates/radicle-surf/src/repo.rs
+++ b/crates/radicle-surf/src/repo.rs
@@ -5,10 +5,8 @@ use std::{
str,
};
-use git_ext::{
- ref_format::{refspec::QualifiedPattern, Qualified, RefStr, RefString},
- Oid,
-};
+use radicle_git_ref_format::{refspec::QualifiedPattern, Qualified, RefStr, RefString};
+use radicle_oid::Oid;
use crate::{
blob::{Blob, BlobRef},
@@ -373,7 +371,7 @@ impl Repository {
.to_commit(self)
.map_err(|e| Error::ToCommit(e.into()))?;
- match self.inner.extract_signature(&commit.id, field) {
+ match self.inner.extract_signature(&commit.id.into(), field) {
Err(error) => {
if error.code() == git2::ErrorCode::NotFound {
Ok(None)
diff --git a/crates/radicle-surf/src/revision.rs b/crates/radicle-surf/src/revision.rs
index 099c97436..5cbfaf7af 100644
--- a/crates/radicle-surf/src/revision.rs
+++ b/crates/radicle-surf/src/revision.rs
@@ -1,9 +1,7 @@
use std::{convert::Infallible, str::FromStr};
-use git_ext::{
- ref_format::{Qualified, RefString},
- Oid,
-};
+use radicle_git_ref_format::{Qualified, RefString};
+use radicle_oid::Oid;
use crate::{Branch, Commit, Error, Repository, Tag};
@@ -50,7 +48,7 @@ impl Revision for Oid {
}
impl Revision for &str {
- type Error = git2::Error;
+ type Error = radicle_oid::str::error::ParseOidError;
fn object_id(&self, _repo: &Repository) -> Result<Oid, Self::Error> {
Oid::from_str(self)
@@ -75,7 +73,7 @@ impl Revision for Tag {
}
impl Revision for String {
- type Error = git2::Error;
+ type Error = radicle_oid::str::error::ParseOidError;
fn object_id(&self, _repo: &Repository) -> Result<Oid, Self::Error> {
Oid::from_str(self)
diff --git a/crates/radicle-surf/src/tag.rs b/crates/radicle-surf/src/tag.rs
index daad8b729..d3c189a42 100644
--- a/crates/radicle-surf/src/tag.rs
+++ b/crates/radicle-surf/src/tag.rs
@@ -1,9 +1,7 @@
use std::{convert::TryFrom, str};
-use git_ext::{
- ref_format::{component, lit, Qualified, RefStr, RefString},
- Oid,
-};
+use radicle_git_ref_format::{lit, name::component, Qualified, RefStr, RefString};
+use radicle_oid::Oid;
use crate::{refs::refstr_join, Author};
@@ -62,13 +60,13 @@ impl Tag {
pub mod error {
use std::str;
- use radicle_git_ext::ref_format::{self, RefString};
+ use radicle_git_ref_format::{self, RefString};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum FromTag {
#[error(transparent)]
- RefFormat(#[from] ref_format::Error),
+ RefFormat(#[from] radicle_git_ref_format::Error),
#[error(transparent)]
Utf8(#[from] str::Utf8Error),
}
@@ -84,7 +82,7 @@ pub mod error {
#[error("the refname '{0}' did not begin with 'refs/tags'")]
NotTag(RefString),
#[error(transparent)]
- RefFormat(#[from] ref_format::Error),
+ RefFormat(#[from] radicle_git_ref_format::Error),
#[error(transparent)]
Utf8(#[from] str::Utf8Error),
}
diff --git a/crates/radicle-surf/src/tree.rs b/crates/radicle-surf/src/tree.rs
index 2f691d71c..0d1547d91 100644
--- a/crates/radicle-surf/src/tree.rs
+++ b/crates/radicle-surf/src/tree.rs
@@ -4,7 +4,7 @@
use std::cmp::Ordering;
use std::path::PathBuf;
-use radicle_git_ext::Oid;
+use radicle_oid::Oid;
#[cfg(feature = "serde")]
use serde::{
ser::{SerializeStruct as _, Serializer},
diff --git a/crates/radicle-surf/t/src/branch.rs b/crates/radicle-surf/t/src/branch.rs
index ae4e62d4f..c632a5bc1 100644
--- a/crates/radicle-surf/t/src/branch.rs
+++ b/crates/radicle-surf/t/src/branch.rs
@@ -1,6 +1,6 @@
use proptest::prelude::*;
-use radicle_git_ext::ref_format::{RefStr, RefString};
use radicle_git_ext_test::git_ref_format::gen;
+use radicle_git_ref_format::{RefStr, RefString};
use radicle_surf::Branch;
use test_helpers::roundtrip;
diff --git a/crates/radicle-surf/t/src/code_browsing.rs b/crates/radicle-surf/t/src/code_browsing.rs
index 3cafcde3c..52b97dbc8 100644
--- a/crates/radicle-surf/t/src/code_browsing.rs
+++ b/crates/radicle-surf/t/src/code_browsing.rs
@@ -1,6 +1,6 @@
use std::path::Path;
-use radicle_git_ext::ref_format::refname;
+use radicle_git_ref_format::refname;
use radicle_surf::{
fs::{self, Directory},
Branch, Repository,
diff --git a/crates/radicle-surf/t/src/commit.rs b/crates/radicle-surf/t/src/commit.rs
index b0a722264..42ca5a158 100644
--- a/crates/radicle-surf/t/src/commit.rs
+++ b/crates/radicle-surf/t/src/commit.rs
@@ -1,7 +1,7 @@
use std::str::FromStr;
use proptest::prelude::*;
-use radicle_git_ext::Oid;
+use radicle_oid::Oid;
use radicle_surf::{Author, Commit, Time};
use test_helpers::roundtrip;
diff --git a/crates/radicle-surf/t/src/diff.rs b/crates/radicle-surf/t/src/diff.rs
index 292df46f3..e123f7eab 100644
--- a/crates/radicle-surf/t/src/diff.rs
+++ b/crates/radicle-surf/t/src/diff.rs
@@ -1,5 +1,6 @@
use pretty_assertions::assert_eq;
-use radicle_git_ext::{ref_format::refname, Oid};
+use radicle_git_ref_format::refname;
+use radicle_oid::Oid;
use radicle_surf::{
diff::{
Added, Diff, DiffContent, DiffFile, EofNewLine, FileDiff, FileMode, FileStats, Hunk, Line,
diff --git a/crates/radicle-surf/t/src/file_system.rs b/crates/radicle-surf/t/src/file_system.rs
index 75a5096e5..ea8726e29 100644
--- a/crates/radicle-surf/t/src/file_system.rs
+++ b/crates/radicle-surf/t/src/file_system.rs
@@ -1,7 +1,7 @@
//! Unit tests for radicle_surf::file_system
mod directory {
- use radicle_git_ext::ref_format::refname;
+ use radicle_git_ref_format::refname;
use radicle_surf::{
fs::{self, Entry},
Branch, Repository,
diff --git a/crates/radicle-surf/t/src/last_commit.rs b/crates/radicle-surf/t/src/last_commit.rs
index 3bdf4ac42..de897f6cf 100644
--- a/crates/radicle-surf/t/src/last_commit.rs
+++ b/crates/radicle-surf/t/src/last_commit.rs
@@ -1,6 +1,7 @@
use std::{path::PathBuf, str::FromStr};
-use radicle_git_ext::{ref_format::refname, Oid};
+use radicle_oid::Oid;
+use radicle_git_ref_format::refname;
use radicle_surf::{Branch, Repository};
use super::GIT_PLATINUM;
diff --git a/crates/radicle-surf/t/src/lib.rs b/crates/radicle-surf/t/src/lib.rs
index 812646e6c..6e28a47c8 100644
--- a/crates/radicle-surf/t/src/lib.rs
+++ b/crates/radicle-surf/t/src/lib.rs
@@ -7,8 +7,8 @@ mod file_system;
#[cfg(test)]
mod source;
-#[cfg(test)]
-mod branch;
+// #[cfg(test)]
+// mod branch;
#[cfg(test)]
mod code_browsing;
@@ -19,8 +19,8 @@ mod commit;
#[cfg(test)]
mod diff;
-#[cfg(test)]
-mod last_commit;
+// #[cfg(test)]
+// mod last_commit;
#[cfg(test)]
mod namespace;
@@ -31,8 +31,8 @@ mod reference;
#[cfg(test)]
mod rev;
-#[cfg(test)]
-mod submodule;
+// #[cfg(test)]
+// mod submodule;
#[cfg(test)]
mod threading;
diff --git a/crates/radicle-surf/t/src/namespace.rs b/crates/radicle-surf/t/src/namespace.rs
index 0fe7b74c1..3ffd11319 100644
--- a/crates/radicle-surf/t/src/namespace.rs
+++ b/crates/radicle-surf/t/src/namespace.rs
@@ -1,5 +1,5 @@
use pretty_assertions::{assert_eq, assert_ne};
-use radicle_git_ext::ref_format::{name::component, refname, refspec};
+use radicle_git_ref_format::{name::component, refname, refspec};
use radicle_surf::{Branch, Error, Glob, Repository};
use super::GIT_PLATINUM;
@@ -42,7 +42,7 @@ fn me_namespace() -> Result<(), Error> {
refname!("heads/feature/#1194"),
)];
let mut branches = repo
- .branches(Glob::remotes(refspec::pattern!("fein/*")))?
+ .branches(Glob::remotes(pattern!("fein/*")))?
.collect::<Result<Vec<_>, _>>()?;
branches.sort();
@@ -85,7 +85,7 @@ fn golden_namespace() -> Result<(), Error> {
Branch::remote(remote, refname!("tags/v0.1.0")),
];
let mut branches = repo
- .branches(Glob::remotes(refspec::pattern!("kickflip/*")))?
+ .branches(Glob::remotes(pattern!("kickflip/*")))?
.collect::<Result<Vec<_>, _>>()?;
branches.sort();
diff --git a/crates/radicle-surf/t/src/reference.rs b/crates/radicle-surf/t/src/reference.rs
index 6625feb97..69396d0c2 100644
--- a/crates/radicle-surf/t/src/reference.rs
+++ b/crates/radicle-surf/t/src/reference.rs
@@ -1,4 +1,4 @@
-use radicle_git_ext::ref_format::refspec;
+use radicle_git_ref_format::refspec;
use radicle_surf::{Glob, Repository};
use super::GIT_PLATINUM;
@@ -12,11 +12,7 @@ fn test_branches() {
println!("{}", b.unwrap().refname());
}
let branches = repo
- .branches(
- heads
- .branches()
- .and(Glob::remotes(refspec::pattern!("banana/*"))),
- )
+ .branches(heads.branches().and(Glob::remotes(pattern!("banana/*"))))
.unwrap();
for b in branches {
println!("{}", b.unwrap().refname());
@@ -43,13 +39,11 @@ fn test_namespaces() {
let namespaces = repo.namespaces(&Glob::all_namespaces()).unwrap();
assert_eq!(namespaces.count(), 3);
let namespaces = repo
- .namespaces(&Glob::namespaces(refspec::pattern!("golden/*")))
+ .namespaces(&Glob::namespaces(pattern!("golden/*")))
.unwrap();
assert_eq!(namespaces.count(), 2);
let namespaces = repo
- .namespaces(
- &Glob::namespaces(refspec::pattern!("golden/*")).insert(refspec::pattern!("me/*")),
- )
+ .namespaces(&Glob::namespaces(pattern!("golden/*")).insert(pattern!("me/*")))
.unwrap();
assert_eq!(namespaces.count(), 3);
}
diff --git a/crates/radicle-surf/t/src/rev.rs b/crates/radicle-surf/t/src/rev.rs
index 60294a0f0..70d31ed81 100644
--- a/crates/radicle-surf/t/src/rev.rs
+++ b/crates/radicle-surf/t/src/rev.rs
@@ -1,6 +1,6 @@
use std::str::FromStr;
-use radicle_git_ext::ref_format::{name::component, refname};
+use radicle_git_ref_format::{name::component, refname};
use radicle_surf::{Branch, Error, Oid, Repository};
use super::GIT_PLATINUM;
diff --git a/crates/radicle-surf/t/src/source.rs b/crates/radicle-surf/t/src/source.rs
index 4b97714ab..5a08ab616 100644
--- a/crates/radicle-surf/t/src/source.rs
+++ b/crates/radicle-surf/t/src/source.rs
@@ -1,6 +1,6 @@
use std::path::PathBuf;
-use radicle_git_ext::ref_format::refname;
+use radicle_git_ref_format::refname;
use radicle_surf::{Branch, Glob, Repository};
use serde_json::json;
diff --git a/crates/radicle-surf/t/src/submodule.rs b/crates/radicle-surf/t/src/submodule.rs
index 62129b8f6..3d638cbe6 100644
--- a/crates/radicle-surf/t/src/submodule.rs
+++ b/crates/radicle-surf/t/src/submodule.rs
@@ -1,9 +1,9 @@
use std::{convert::Infallible, path::Path};
use proptest::{collection, proptest};
-use radicle_git_ext::commit::CommitData;
-use radicle_git_ext::ref_format::refname;
+use radicle_git_metadata::commit::CommitData;
use radicle_git_ext_test::gen;
+use radicle_git_ref_format::refname;
use radicle_surf::tree::EntryKind;
use radicle_surf::{fs, Branch, Repository};
diff --git a/crates/radicle-surf/t/src/threading.rs b/crates/radicle-surf/t/src/threading.rs
index 47cca4859..ef39ac56f 100644
--- a/crates/radicle-surf/t/src/threading.rs
+++ b/crates/radicle-surf/t/src/threading.rs
@@ -1,6 +1,6 @@
use std::sync::{Mutex, MutexGuard};
-use radicle_git_ext::ref_format::{name::component, refname};
+use radicle_git_ref_format::{name::component, refname};
use radicle_surf::{Branch, Error, Glob, Repository};
use super::GIT_PLATINUM;
commit 98dfa81bfff84ddbd5d059c1a14b2a137925583b
Author: Lorenz Leutgeb <lorenz.leutgeb@radicle.xyz>
Date: Sat Jan 10 15:31:57 2026 +0100
surf: Adjust metadata after copy
diff --git a/Cargo.lock b/Cargo.lock
index ebe36335f..c07d7856d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2865,7 +2865,7 @@ dependencies = [
"radicle-git-ref-format",
"radicle-localtime",
"radicle-node",
- "radicle-surf",
+ "radicle-surf 0.26.0 (registry+https://github.com/rust-lang/crates.io-index)",
"radicle-term",
"schemars",
"serde",
@@ -3154,6 +3154,22 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb935931bdd2a2966f3b584f3031d9d54ec0713ddbc563a0193d54e62a88ec73"
+[[package]]
+name = "radicle-surf"
+version = "0.26.0"
+dependencies = [
+ "anyhow",
+ "base64 0.21.7",
+ "flate2",
+ "git2",
+ "log",
+ "nonempty",
+ "serde",
+ "tar",
+ "thiserror 1.0.69",
+ "url",
+]
+
[[package]]
name = "radicle-surf"
version = "0.26.0"
@@ -4550,6 +4566,7 @@ dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
+ "serde",
]
[[package]]
diff --git a/crates/radicle-surf/Cargo.toml b/crates/radicle-surf/Cargo.toml
index ee88f0d7b..2ace30468 100644
--- a/crates/radicle-surf/Cargo.toml
+++ b/crates/radicle-surf/Cargo.toml
@@ -1,9 +1,7 @@
[package]
name = "radicle-surf"
description = "A code surfing library for Git repositories"
-readme = "README.md"
version = "0.26.0"
-authors = ["The Radicle Team <dev@radicle.xyz>"]
homepage.workspace = true
repository.workspace = true
edition.workspace = true
@@ -26,7 +24,7 @@ doctest = false
# certain branches to be setup. So we use this feature flag
# to ignore the test on CI.
gh-actions = []
-minicbor = ["radicle-git-ext/minicbor"]
+minicbor = []
serde = ["dep:serde", "url/serde"]
[dependencies]
@@ -36,24 +34,8 @@ nonempty = "0.9"
thiserror = "1.0"
url = "2.5.4"
-[dependencies.git2]
-version = "0.19"
-default-features = false
-features = ["vendored-libgit2"]
-
-[dependencies.radicle-git-ext]
-version = "0.11.0"
-path = "../radicle-git-ext"
-features = ["serde"]
-
-[dependencies.radicle-std-ext]
-version = "0.2.0"
-path = "../radicle-std-ext"
-
-[dependencies.serde]
-version = "1"
-features = ["serde_derive"]
-optional = true
+serde = { workspace = true, optional = true, features = ["derive"] }
+git2 = { workspace = true, features = ["vendored-libgit2"] }
[build-dependencies]
anyhow = "1.0"
commit 175efac523f8d6411243fd5518045d969e712474
Author: Lorenz Leutgeb <lorenz.leutgeb@radicle.xyz>
Date: Sat Jan 10 15:28:52 2026 +0100
surf: Copy over from `radicle-git`
TODO: Document source.
diff --git a/crates/radicle-surf/CHANGELOG.md b/crates/radicle-surf/CHANGELOG.md
new file mode 100644
index 000000000..835e6d544
--- /dev/null
+++ b/crates/radicle-surf/CHANGELOG.md
@@ -0,0 +1,23 @@
+# CHANGELOG
+
+## Version 0.25.0
+
+* Update to `radicle-git-ext-0.10.0` [6422fd5](https://app.radicle.xyz/nodes/seed.radicle.xyz/rad:z6cFWeWpnZNHh9rUW8phgA3b5yGt/commits/6422fd580b1c9c96ba40620197e29d7b9fbe2824)
+
+## Version 0.9.0
+
+This release consists of a major rewrite of this crate. Its API is overall
+simplified and is not compatible with the previous version (v0.8.0). The main
+changes include:
+
+- `Browser` is removed. Its methods are implemented directly with `Repository`.
+- Git will be the only supported VCS. Any extension points for other VCSes were
+removed.
+- `Ref` and `RefScope` are removed. Re-use the `git-ref-format` crate and a new
+`Glob` type for the refspec patterns.
+- Added support of `Tree` and `Blob` that correspond to their definitions in
+Git.
+- Added two new traits `Revision` and `ToCommit` that make methods flexible and
+still simple to use.
+
+For more details, please check out the crate's documentation.
diff --git a/crates/radicle-surf/Cargo.toml b/crates/radicle-surf/Cargo.toml
new file mode 100644
index 000000000..ee88f0d7b
--- /dev/null
+++ b/crates/radicle-surf/Cargo.toml
@@ -0,0 +1,61 @@
+[package]
+name = "radicle-surf"
+description = "A code surfing library for Git repositories"
+readme = "README.md"
+version = "0.26.0"
+authors = ["The Radicle Team <dev@radicle.xyz>"]
+homepage.workspace = true
+repository.workspace = true
+edition.workspace = true
+license.workspace = true
+rust-version.workspace = true
+
+include = [
+ "**/*.rs",
+ "Cargo.toml",
+ "data/git-platinum.tgz",
+]
+
+[lib]
+test = false
+doctest = false
+
+[features]
+# NOTE: testing `test_submodule_failure` on GH actions
+# is painful since it uses this specific repo and expects
+# certain branches to be setup. So we use this feature flag
+# to ignore the test on CI.
+gh-actions = []
+minicbor = ["radicle-git-ext/minicbor"]
+serde = ["dep:serde", "url/serde"]
+
+[dependencies]
+base64 = "0.21"
+log = "0.4"
+nonempty = "0.9"
+thiserror = "1.0"
+url = "2.5.4"
+
+[dependencies.git2]
+version = "0.19"
+default-features = false
+features = ["vendored-libgit2"]
+
+[dependencies.radicle-git-ext]
+version = "0.11.0"
+path = "../radicle-git-ext"
+features = ["serde"]
+
+[dependencies.radicle-std-ext]
+version = "0.2.0"
+path = "../radicle-std-ext"
+
+[dependencies.serde]
+version = "1"
+features = ["serde_derive"]
+optional = true
+
+[build-dependencies]
+anyhow = "1.0"
+flate2 = "1.1"
+tar = "0.4"
diff --git a/crates/radicle-surf/DEVELOPMENT.md b/crates/radicle-surf/DEVELOPMENT.md
new file mode 100644
index 000000000..a3df0fa74
--- /dev/null
+++ b/crates/radicle-surf/DEVELOPMENT.md
@@ -0,0 +1,78 @@
+
+# Radicle Surfing 🏄
+
+Thanks for wanting to contribute to `radicle-surf`!
+
+## Building & Testing 🏗️
+
+We try to make development as seemless as possible so we can get down to the real work. We supply
+the toolchain via the `rust-toolchain` file, and the formatting rules `.rustmt.toml` file.
+
+For the [Nix](https://nixos.org/) inclined there is a `default.nix` file to get all the necessary
+dependencies and it also uses the `rust-toolchain` file to pin to that version of Rust.
+
+You can build the project the usual way:
+```
+cargo build
+```
+
+To run all the tests:
+```
+cargo test
+```
+
+For the full list of checks that get executed in CI you can checkout the [ci/run](./ci/run) script.
+
+If any of this _isn't_ working, then let's work through it together and get it Working on Your
+Machine™.
+
+## Structure 🏛️
+
+The design of `radicle-surf` is to have an in-memory representation of a project's directory which
+can be generated by a VCS's backend. The directory system is modeled under `file_system`, the VCS
+functionality is naturally under `vcs`, and `diff` logic is held under `diff`.
+
+```
+src/
+├── diff
+├── file_system
+└── vcs
+```
+
+## Testing & Documentation 📚
+
+We ensure that the crate is well documented. `cargo clippy` will argue with you anytime a public
+facing piece of the library is undocumented. We should always provide an explanation of what
+something is or does, and also provide examples to allow our users to get up and running as quick
+and easy as possible.
+
+When writing documentation we should try provide one or two examples (if they make sense). This
+provides us with some simple unit tests as well as something our users can copy and paste for ease
+of development.
+
+If more tests are needed then we should add them under `mod tests` in the relevant module. We strive
+to find properties of our programs so that we can use tools like `proptest` to extensively prove our
+programs are correct. As well as this, we add unit tests to esnure the examples in our heads are
+correct, and testing out the ergonomics of our API first-hand.
+
+## CI files 🤖
+
+Our CI infrastructure runs on Buildkite. The build process is run for every commit which is pushed
+to GitHub.
+
+All relevant configuration can be found here:
+
+```
+radicle-surf/.buildkite/
+├── docker
+│ ├── build
+│ │ └── Dockerfile
+│ └── rust-nightly
+│ └── Dockerfile
+└── pipeline.yaml
+```
+
+## Releases 📅
+
+TODO: Once we get the API into a good shape we will keep track of releases via a `CHANGELOG.md` and
+tag the releases via `git tag`.
diff --git a/crates/radicle-surf/README.md b/crates/radicle-surf/README.md
new file mode 100644
index 000000000..405e75585
--- /dev/null
+++ b/crates/radicle-surf/README.md
@@ -0,0 +1,22 @@
+# radicle-surf
+
+A code surfing library for Git repositories 🏄♀️🏄♂️
+
+Welcome to `radicle-surf`!
+
+`radicle-surf` is a library to describe a Git repository as a file system. It
+aims to provide an easy-to-use API to browse a repository via the concept of
+files and directories for any given revision. It also allows the user to diff
+any two different revisions.
+
+One of the use cases would be to create a web GUI for interacting with a Git
+repository (thinking GitHub, GitLab or similar systems).
+
+## Contributing
+
+To get started on contributing you can check out our [developing guide](../DEVELOPMENT.md), and also
+our [LICENSE](../LICENSE) file.
+
+## The Community
+
+Join our community disccussions at [radicle.community](https://radicle.community)!
diff --git a/crates/radicle-surf/build.rs b/crates/radicle-surf/build.rs
new file mode 100644
index 000000000..5fda4dfc3
--- /dev/null
+++ b/crates/radicle-surf/build.rs
@@ -0,0 +1,63 @@
+use std::{
+ env, fs,
+ fs::File,
+ io,
+ path::{Path, PathBuf},
+};
+
+use anyhow::Context as _;
+use flate2::read::GzDecoder;
+use tar::Archive;
+
+enum Command {
+ Build(PathBuf),
+ Publish(PathBuf),
+}
+
+impl Command {
+ fn new() -> io::Result<Self> {
+ let current = env::current_dir()?;
+ Ok(if current.ends_with("radicle-surf") {
+ Self::Build(current)
+ } else {
+ Self::Publish(PathBuf::from(
+ env::var("OUT_DIR").map_err(io::Error::other)?,
+ ))
+ })
+ }
+
+ fn target(&self) -> PathBuf {
+ match self {
+ Self::Build(path) => path.join("data"),
+ Self::Publish(path) => path.join("data"),
+ }
+ }
+}
+
+fn main() {
+ let target = Command::new()
+ .expect("could not determine the cargo command")
+ .target();
+ let git_platinum_tarball = "./data/git-platinum.tgz";
+
+ unpack(git_platinum_tarball, target).expect("Failed to unpack git-platinum");
+
+ println!("cargo:rerun-if-changed={git_platinum_tarball}");
+}
+
+fn unpack(archive_path: impl AsRef<Path>, target: impl AsRef<Path>) -> anyhow::Result<()> {
+ let content = target.as_ref().join("git-platinum");
+ if content.exists() {
+ fs::remove_dir_all(content).context("attempting to remove git-platinum")?;
+ }
+ let archive_path = archive_path.as_ref();
+ let tar_gz = File::open(archive_path).context(format!(
+ "attempting to open file: {}",
+ archive_path.display()
+ ))?;
+ let tar = GzDecoder::new(tar_gz);
+ let mut archive = Archive::new(tar);
+ archive.unpack(target).context("attempting to unpack")?;
+
+ Ok(())
+}
diff --git a/crates/radicle-surf/docs/denotational-design.md b/crates/radicle-surf/docs/denotational-design.md
new file mode 100644
index 000000000..88a2c41e6
--- /dev/null
+++ b/crates/radicle-surf/docs/denotational-design.md
@@ -0,0 +1,265 @@
+# Design Documentation
+
+In this document we will describe the design of `radicle-surf`. The design of the system will rely
+heavily on [denotational design](todo) and use Haskell syntax (because types are easy to reason about, I'm sorry).
+
+`radicle-surf` is a system to describe a file-system in a VCS world. We have the concept of files and directories,
+but these objects can change over time while people iterate on them. Thus, it is a file-system within history and
+we, the user, are viewing the file-system at a particular snapshot. Alongside this, we will wish to take two snapshots
+and view their differences.
+
+The stream of consciousness that gave birth to this document started with thinking how the user would interact with
+the system, identifying the key components. This is captured in [User Flow](#user-flow). From there we found nouns that
+represent objects in our system and verbs that represent functions over those objects. This iteratively informed us as
+to what other actions we would need to supply. We would occassionally look at [GitHub](todo) and [Pijul Nest](todo) for
+inspiration, since we would like to imitate the features that they supply, and we ultimately want use one or both of
+these for our backends.
+
+## User Flow
+
+For the user flow we imagined what it would be like if the user was using a [REPL](todo) to interact with `radicle-surf`.
+The general concept was that the user would enter the repository, build a view of the directory structure, and then
+interact with the directories and files from there (called `browse`).
+```haskell
+repl :: IO ()
+repl = do
+ repo <- getRepo
+ history <- getHistory label repo -- head is SHA1, tail is rest
+ directory <- buildDirectory history
+
+ forever browse directory
+```
+
+But then we thought about what happens when we are in `browse` but we would like to change the history and see that
+file or directory at a different snapshot. This was captured in the pseudo-code below:
+```haskell
+ src_foo_bar <- find...
+ history' <- historyOf src_foo_bar
+```
+
+This information was enough for us to begin the [denotational design](#denotational-design) below.
+
+## Denotational Design
+
+```haskell
+-- A Label is a name for a directory or a file
+type Label
+μ Label = Text
+
+-- A Directory captures its own Label followed by 1 or more
+-- artefacts which can either be sub-directories or files.
+--
+-- An example of "foo/bar.hs" structure:
+-- foo
+-- |-- bar.hs
+--
+-- Would look like:
+-- @("foo", Right ("bar.hs", "module Banana ...") :| [])@
+type Directory
+μ Directory = (Label, NonEmpty (Either Directory File))
+
+-- DirectoryContents can either be the special IsRepo object,
+-- a Directory, or a File.
+type DirectoryContents
+μ DirectoryContents = IsRepo | Directory | File
+
+-- Opaque representation of repository state directories (e.g. `.git`, `.pijul`)
+-- Those are not browseable, but have to be present at the repo root 'Directory'.
+type IsRepo
+
+-- A Directory captures its own Label followed by 1 or more DirectoryContents
+--
+-- An example of "foo/bar.hs" structure:
+-- foo
+-- |-- bar.hs
+--
+-- Would look like:
+-- @("~", IsRepo :| [Directory ("foo", File ("bar.hs", "module Banana ..") :| [])]
+-- where IsRepo is the implicit root of the repository.
+type Directory
+μ Directory = (Label, NonEmpty DirectoryContents)
+
+-- A File is its Label and its contents
+type File
+μ File = (Label, ByteString)
+
+-- An enumeration of what file-system artefact we're looking at.
+-- Useful for listing a directory and denoting what the label is
+-- corresponding to.
+type SystemType
+μ SystemType
+ = IsFile
+ | IsDirectory
+
+-- A Chnage is an enumeration of how a file has changed.
+-- This is simply used for getting the difference between two
+-- directories.
+type Change
+
+-- Constructors of Change - think GADT
+AddLineToFile :: NonEmpty Label -> Location -> ByteString -> Change
+RemoveLineFromFile :: NonEmpty Label -> Location -> Change
+MoveFile :: NonEmpty Label -> NonEmpty Label -> Change
+CreateFile :: NonEmpty Label -> Change
+DeleteFile :: NonEmpty Label -> Change
+
+-- A Diff is a set of Changes that were made
+type Diff
+μ Diff = [Change]
+
+-- History is an ordered set of @a@s. The reason for it being
+-- polymorphic is that it allows us to choose what set artefact we
+-- want to carry around.
+--
+-- For example:
+-- * In `git` this would be a `Commit`.
+-- * In `pijul` it would be a `Patch`.
+type History a
+μ History = NonEmpty a
+
+-- A Repo is a collection of multiple histories.
+-- This would essentially boil down to branches and tags.
+type Repo
+μ Repo a = [History a]
+
+-- A Snapshot is a way of converting a History into a Directory.
+-- In other words it gives us a snapshot of the history in the form of a directory.
+type Snapshot a
+μ Snapshot a = History a -> Directory
+
+-- For example, we have a `git` snapshot or a `pjul` snapshot.
+type Commit
+type GitSnapshot = Snapshot Commit
+
+type Patch
+type PijulSnapshot = Snapshot Patch
+
+-- This is piece de resistance of the design! It turns out,
+-- everything is just a Monad after all.
+--
+-- Our code Browser is a stateful computation of what History
+-- we are currently working with and how to get a Snapshot of it.
+type Browser a b
+μ type Browser a b = ReaderT (Snapshot a) (State (History a) b)
+
+-- A function that will retrieve a repository given an
+-- identifier. In our case the identifier is opaque to the system.
+getRepo :: Repo -> Repo
+
+-- Find a particular History in the Repo. Again, how these things
+-- are equated and found is opaque, but we can think of them as
+-- branch or tag labels.
+getHistory :: Eq a => History a -> Repo a -> Maybe (History a)
+μ getHistory history repo =
+ find history (μ repo)
+
+-- Find if a particular artefact occurs in 0 or more histories.
+findInHistories :: a -> [History a] -> [History a]
+μ findInHistories a histories =
+ filterMaybe (findInHistory a) histories
+
+-- Find a particular artefact is in a history.
+findInHistory :: Eq a => a -> History a -> Maybe a
+μ findInHistory a history = find (== a) (μ history)
+
+-- A special Label that guarantees a starting point, i.e. ~
+rootLabel :: Label
+μ rootLabel = "~"
+
+emptyRepoRoot :: Directory
+μ emptyRepoRoot = (rootLabel, IsRepo :| [])
+
+-- Get the difference between two directory views.
+diff :: Directory -> Directory -> Diff
+
+-- List the current file or directories in a given Directory view.
+listDirectory :: Directory -> [Label, SystemType]
+μ listDirectory directory = foldMap toLabel $ snd (μ directory)
+ where
+ toLabel content = case content of
+ File (label, _) -> [(label, IsFile)]
+ Directory (label, _) -> [(label, IsDirectory)]
+ IsRepo -> []
+
+fileName :: File -> Label
+μ fileName file = fst (μ file)
+
+findFile :: NonEmpty Label -> Directory -> Maybe File
+μ findFile (label :| labels) (Directory (label', contents)) =
+ if label == label' then go labels contents else Nothing
+ where
+ findFileWithLabel :: Foldable f => Label -> f DirectoryContents -> Maybe File
+ findFileWithLabel label = find (\artefact -> case content of
+ File (fileLabel, _) -> fileLabel == label
+ Directory _ -> False
+ IsRepo -> False)
+
+ go :: [Label] -> NonEmpty DirectoryContents -> Just File
+ go [] _ = Nothing
+ go [label] contents = findMaybe (fileWithLabel label) contents
+ go (label:labels) contents = (go labels . snd) <$> find ((label ==) . fst) onlyDirectories contents
+
+onlyDirectories :: Foldable f => f DirectoryContents -> [Directory]
+μ onlyDirectories = fmapMaybe (\content -> case content of
+ d@(Directory _) -> Just d
+ File _ -> Nothing
+ IsRepo -> Nothing) . toList
+
+getSubDirectories :: Directory -> [Directory]
+μ getSubDirectories directory = foldMap f $ snd (μ directory)
+ where
+ f :: DirectoryContents -> [Directory]
+ f = \case
+ d@(Directory _) -> [d]
+ File _ -> []
+ IsRepo -> []
+
+-- Definition elided
+findDirectory :: NonEmpty Label -> Directory -> Maybe Directory
+
+-- Definition elided
+fuzzyFind :: Label -> [Directory]
+
+-- A Git Snapshot is grabbing the HEAD commit of your History
+-- and turning it into a Directory
+gitSnapshot :: Snapshot Commit
+μ gitSnapshot commits = second (\root -> root <> getDirectoryPtr $ Nel.head commits) emptyRepoRoot
+
+-- Opaque and defined by the backend
+getDirectoryPtr :: Commit -> Directory
+
+-- A Pijul history is semantically applying the patches in a
+-- topological order and achieving the Directory view.
+pijulHistory :: Snapshot Patch
+μ pijulHistory = foldl pijulMagic emptyRepoRoot
+
+-- Opaque and defined by the backend
+pijulMagic :: Patch -> Directory -> Directory
+
+-- Get the current History we are working with.
+getHistory :: Browser a (History a)
+μ getHistory = get
+
+setHistory :: History a -> Browser a ()
+μ setHistory = put
+
+-- Get the current Directory in the Browser
+getDirectory :: Browser a Directory
+μ getDirectory = do
+ hist <- get
+ applySnapshot <- ask
+ pure $ applySnapshot hist
+
+-- We modify the history by changing the internal history state.
+switchHistory :: (History a -> History a) -> Browser a b
+μ switchHistory f = modify f
+
+-- | Find the suffix of a History.
+findSuffix :: Eq a => a -> History a -> Maybe (History a)
+μ findSuffix a = nonEmpty . Nel.dropWhile (/= a)
+
+-- View the history up to a given point by supplying a function to modify
+-- the state. If this operation fails, then the default value is used.
+viewAt :: (History a -> Maybe (History a)) -> History a -> Browser a b
+μ viewAt f def = switchHistory (fromMaybe def . f)
+```
diff --git a/crates/radicle-surf/docs/refactor-design.md b/crates/radicle-surf/docs/refactor-design.md
new file mode 100644
index 000000000..aac67c084
--- /dev/null
+++ b/crates/radicle-surf/docs/refactor-design.md
@@ -0,0 +1,341 @@
+# An updated design for radicle-surf
+
+This is a design blueprint for the new `radicle-git/radicle-surf` crate. The
+actual design details and implemenation are described and updated in its
+documentation comments, viewable via `cargo doc`.
+
+## Introduction
+
+In September 2022, we have ported the [`radicle-surf` crate](https://github.com/radicle-dev/radicle-surf)
+from its own github repo to be part of the [`radicle-git` repo](https://github.com/radicle-dev/radicle-git).
+We are taking this opportunity to refactor its design as well. Intuitively,
+`radicle-surf` provides an API so that one can use it to create a GitHub-like
+UI for a git repo:
+
+1. Code browsing: given a specific commit/ref, browse files and directories.
+2. Diff between two revisions that resolve into two commits.
+3. Retrieve the history of commits with a given head, and optionally a file.
+4. List refs and retrieve their metadata: Branches, Tags, Remotes,
+Notes and user-defined "categories", where a category is: `refs/<category>/<...>`.
+
+## Motivation
+
+The `radicle-surf` crate aims to provide a safe and easy-to-use API that
+supports the features listed in [Introduction](#introduction). Based on the
+existing API, the main goals of the refactoring are:
+
+- API review: identify the issues with the current API.
+- Updated API: propose an updated API that reuses parts of the existing API.
+- Address open issues in the original `radicle-surf` repo.
+- Be `git` specific. (i.e. no need to support other VCS systems)
+- Remove `git2` from the public API. The use of `git2` should be an
+implementation detail.
+
+## API review
+
+In this section, we review some core types in the current API and propose
+changes to them. The main theme is to make the API simpler and easier to use.
+
+### Remove the `Browser` type
+
+The type [`Browser`](https://github.com/radicle-dev/radicle-surf/blob/b85d2183d786e5fa447aab9d2f420a32f1061bfa/surf/src/vcs.rs#L145) is awkward:
+
+- it is not a source of truth of any information. For example, `list_branches`
+method is just a wrapper of `Repository::list_branches`.
+- it takes in `History`, but really operates at the `Snapshot` level.
+- it is mutable but its state mutations are not used much.
+
+Can we just remove `Browser` and implement its functionalities using other
+types?
+
+- For iteratoring the history, use `History`.
+- For generating `Directory`, use `Repository` directly given a `Rev`.
+- For accessing `Branch`, `Tag` or `Commit`, use `Repository`.
+
+### Remove the `Snapshot` type
+
+A [`Snapshot`](https://github.com/radicle-dev/radicle-surf/blob/b85d2183d786e5fa447aab9d2f420a32f1061bfa/surf/src/vcs.rs#L140) should be really
+just a tree (or `Directory`) of a `Commit` in git. Currently it is a function
+that returns a `Directory`. As we are moving to be git specific, we don't need
+to have this generic function to create a snapshot across different VCS systems.
+
+The snapshot function can be easily implement as a method of `RepositoryRef`.
+
+### Simplify `Directory` and remove the `Tree` and `Forest` types
+
+The [`Directory`](https://github.com/radicle-dev/radicle-surf/blob/b85d2183d786e5fa447aab9d2f420a32f1061bfa/surf/src/file_system/directory.rs#L144)
+type represents the file system view of a snapshot. Its field
+`sub_directories` is defined a `Forest` based on `Tree`. The types are
+over-engineered from such a simple concept. We could refactor `Directory` to
+use `DirectoryContents` for its items and not to use `Tree` or `Forest` at all.
+
+We also found the `list_directory()` method duplicates with `iter()` method.
+Hence `list_directory()` is removed, together with `SystemType` type.
+
+### Remove `Vcs` trait
+
+The `Vcs` trait was introduced to support different version control backends,
+for example both Git and Pijul, and potentially others. However, since this
+port is part of `radicle-git` repo, we are only supporting Git going forward.
+We no longer need another layer of indirection defined by `Vcs` trait.
+
+## The new API
+
+With the changes proposed in the previous section, we describe what the new API
+would look like and how they meet the requirements.
+
+### Basic types
+
+#### Repository
+
+`Repository` is kept as the entry point of the API, even though its methods
+would change due to changes in other types. Also, we would like to consolidate
+[`Repository`](https://github.com/radicle-dev/radicle-surf/blob/b85d2183d786e5fa447aab9d2f420a32f1061bfa/surf/src/vcs/git/repo.rs#L53) and
+ [`RepositoryRef`](https://github.com/radicle-dev/radicle-surf/blob/b85d2183d786e5fa447aab9d2f420a32f1061bfa/surf/src/vcs/git/repo.rs#L63) to
+ simplify the API.
+
+#### Revision and Commit
+
+In Git, `Revision` commonly resolves into a `Commit` but could refer to other
+objects for example a `Blob`. Hence we need to keep both concepts in the API.
+Currently we have multiple types to identify a `Commit` or `Revision`.
+
+- Commit
+- Oid
+- Rev
+
+The relations between them are: all `Rev` and `Commit` can resolve into `Oid`,
+and in most cases `Rev` can resolve into `Commit`.
+
+On one hand, `Oid` is the ultimate unique identifer but it is more machine-
+friendly than human-friendly. On the other hand, `Revision` is most human-
+friendly and better suited in the API interface. A conversion from `Revision`
+to `Oid` will be useful.
+
+For the places where `Commit` is required, we should explicitly ask for
+`Commit` instead of `Revision`.
+
+In conclusion, we define two new traits to support the use of `Revision` and
+`Commit`:
+
+```Rust
+pub trait Revision {
+ /// Resolves a revision into an object id in `repo`.
+ fn object_id(&self, repo: &RepositoryRef) -> Result<Oid, Error>;
+}
+
+pub trait ToCommit {
+ /// Converts to a commit in `repo`.
+ fn to_commit(self, repo: &RepositoryRef) -> Result<Commit, Error>;
+}
+```
+
+These two traits will be implemented for most common representations of
+`Revision` and `Commit`, for example `&str`, refs like `Branch`, `Tag`, etc.
+Our API will use these traits where we expect a `Revision` or a `Commit`.
+
+#### History
+
+The current `History` is generic over VCS types and also retrieves the full list
+of commits when the history is created. The VCS part can be removed and the
+history can lazy-load the list of commits by implmenting `Iterator` to support
+ potentially very long histories.
+
+We can also store the head commit with the history so that it's easy to get
+the start point and it helps to identify the history.
+
+To support getting the history of a file, we provide methods to modify a
+`History` to filter by a file path.
+
+The new `History` type would look like this:
+
+```Rust
+pub struct History<'a> {
+ repo: RepositoryRef<'a>,
+ head: Commit,
+ revwalk: git2::Revwalk<'a>,
+ filter_by: Option<FilterBy>,
+}
+
+enum FilterBy {
+ File { path: file_system::Path },
+}
+```
+
+For the methods provided by `History`, please see section [Retrieve the history](#retrieve-the-history) below.
+
+#### Commit
+
+`Commit` is a central concept in Git. In `radicle-surf` we define `Commit` type
+to represent its metadata:
+
+```Rust
+pub struct Commit {
+ /// Object Id
+ pub id: Oid,
+ /// The author of the commit.
+ pub author: Author,
+ /// The actor who committed this commit.
+ pub committer: Author,
+ /// The long form message of the commit.
+ pub message: String,
+ /// The summary message of the commit.
+ pub summary: String,
+ /// The parents of this commit.
+ pub parents: Vec<Oid>,
+}
+```
+
+To get the file system snapshot of the commit, the user should use
+`root_dir` method described in [Code browsing](#code-browsing) section.
+
+To get the diff of the commit, the user should use `diff_commit` method
+described in [Diffs](#diffs) section. Note that we might move that method to
+`Commit` type itself.
+
+### Code browsing
+
+The user should be able to browse the files and directories for any given
+commit. The core API is:
+
+- Create a root Directory:
+```Rust
+impl RepositoryRef {
+ pub fn root_dir<C: ToCommit>(&self, commit: C) -> Result<Directory, Error>;
+}
+```
+
+- Browse a Directory's contents:
+```Rust
+impl Directory {
+ pub fn contents(&self) -> impl Iterator<Item = &DirectoryContents>;
+}
+```
+where `DirectoryContents` supports both files and sub-directories:
+```Rust
+pub enum DirectoryContents {
+ /// The `File` variant contains the file's name and the [`File`] itself.
+ File {
+ /// The name of the file.
+ name: Label,
+ /// The file data.
+ file: File,
+ },
+ /// The `Directory` variant contains a sub-directory to the current one.
+ Directory(Directory),
+}
+```
+
+### Diffs
+
+The user would be able to create a diff between any two revisions. In the first
+implementation, these revisions have to resolve into commits. But in future,
+the revisions could refer to other objects, e.g. files (blobs).
+
+The core API is:
+
+```Rust
+impl RepositoryRef {
+ /// Returns the diff between two revisions.
+ pub fn diff<R: Revision>(&self, from: R, to: R) -> Result<Diff, Error>;
+}
+```
+
+To obtain the diff of a particular commit, we will have:
+```Rust
+impl RepositoryRef {
+ pub fn diff_commit(&self, commit: impl ToCommit) -> Result<Diff, Error>;
+}
+```
+
+### Retrieve the history
+
+The user would be able to get the list of previous commits reachable from a
+particular commit.
+
+To create a `History` from a repo with a given head:
+```Rust
+impl RepositoryRef {
+ pub fn history<C: ToCommit>(&self, head: C) -> Result<History, Error>;
+}
+```
+
+To access the `History`, the user simply iterates `History` as it implements the
+`Iterator` trait that produces `Result<Commit, Error>`.
+
+`History` provides a method to filter based on a path and other helper methods:
+
+```Rust
+impl<'a> History<'a> {
+ /// Returns the head commit of a history.
+ pub fn head(&self) -> &Commit;
+
+ // Modifies a history with a filter by `path`.
+ // This is to support getting the history of a file.
+ pub fn by_path(mut self, path: file_system::Path) -> Self;
+```
+
+- Alternative design:
+
+One potential downside of define `History` as an iterator is that:
+`history.next()` takes a mutable history object. A different design is to use
+`History` as immutable object that produces an iterator on-demand:
+
+```Rust
+pub struct History<'a> {
+ repo: RepositoryRef<'a>,
+ head: Commit,
+}
+
+impl<'a> History<'a> {
+ /// This method creats a new `RevWalk` internally and return an
+ /// iterator for all commits in a history.
+ pub fn iter(&self) -> impl Iterator<Item = Commit>;
+}
+```
+
+In this design, `History` does not keep `RevWalk` in its state. It will create
+a new one when `iter()` is called. I like the immutable interface of this design
+but did not implement it in the current code mainly because the libgit2 doc says
+[creating a new `RevWalk` is relatively expensive](https://libgit2.org/libgit2/#HEAD/group/revwalk/git_revwalk_new).
+
+### List refs and retrieve their metadata
+
+Git refs are simple names that point to objects using object IDs. In this new
+design, we no longer group different refs into a single enum. Instead, each kind
+of ref would be their own type, e.g. `Tag`, `Branch`, `Namespace`, etc.
+
+To retrieve the refs, the user would call the `<ref_type>s()` method of a
+repo, e.g. `branches()`, `tags()`. The result is an iterator of available refs,
+e.g. `Branches`.
+
+```Rust
+impl RepositoryRef {
+ /// Lists branch names with `filter`.
+ pub fn branches<G>(&self, pattern: G) -> Result<Branches, Error>
+ where
+ G: Into<Glob<Branch>>
+}
+```
+
+### Git Tree and Blob
+
+In Git, a `Blob` object represents the content of a file, and a `Tree` object
+represents the content of a listing (in a directory). A `Tree` or a `Blob` does
+not have its own name because there could be different names (paths) pointing
+to the same `Tree` or `Blob`. In contrast, a `Directory` or a `File` has names
+included and possible other attributes.
+
+In `radicle-surf` API, we would expose `Tree` and `Blob` as defined in Git, i.e.
+as the content only, with some extra helper methods, for example `commit()` that
+returns the commit that created the object.
+
+The `Repository` provides methods to retrieve `Tree` or `Blob` for a given path.
+
+## Summary
+
+This design document was created as a guideline for refactoring the
+`radicle-surf` crate when we move it into the `radicle-git` repo. As the code
+evolves, this document would not be matching the actual code exactly. However
+we can still come back to this document to learn about the history of the
+design and update it when needed.
diff --git a/crates/radicle-surf/examples/browsing.rs b/crates/radicle-surf/examples/browsing.rs
new file mode 100644
index 000000000..a5206bbdf
--- /dev/null
+++ b/crates/radicle-surf/examples/browsing.rs
@@ -0,0 +1,49 @@
+//! An example of browsing a git repo using `radicle-surf`.
+//!
+//! How to run:
+//!
+//! cargo run --example browsing <git_repo_path>
+//!
+//! This program browses the given repo and prints out the files and
+//! the directories in a tree-like structure.
+
+use radicle_surf::{
+ fs::{self, Directory},
+ Repository,
+};
+use std::{env, time::Instant};
+
+fn main() {
+ let repo_path = match env::args().nth(1) {
+ Some(path) => path,
+ None => {
+ print_usage();
+ return;
+ }
+ };
+ let repo = Repository::discover(repo_path).unwrap();
+ let now = Instant::now();
+ let head = repo.head().unwrap();
+ let root = repo.root_dir(head).unwrap();
+ print_directory(&root, &repo, 0);
+
+ let elapsed_millis = now.elapsed().as_millis();
+ println!("browse with print: {elapsed_millis} ms");
+}
+
+fn print_directory(d: &Directory, repo: &Repository, indent_level: usize) {
+ let indent = " ".repeat(indent_level * 4);
+ println!("{}{}/", &indent, d.name());
+ for entry in d.entries(repo).unwrap() {
+ match entry {
+ fs::Entry::File(f) => println!(" {}{}", &indent, f.name()),
+ fs::Entry::Directory(d) => print_directory(&d, repo, indent_level + 1),
+ fs::Entry::Submodule(s) => println!(" {}{}", &indent, s.name()),
+ }
+ }
+}
+
+fn print_usage() {
+ println!("Usage:");
+ println!("cargo run --example browsing <repo_path>");
+}
diff --git a/crates/radicle-surf/examples/diff.rs b/crates/radicle-surf/examples/diff.rs
new file mode 100644
index 000000000..9454589af
--- /dev/null
+++ b/crates/radicle-surf/examples/diff.rs
@@ -0,0 +1,102 @@
+extern crate radicle_surf;
+
+use std::{env::Args, str::FromStr, time::Instant};
+
+use radicle_git_ext::Oid;
+use radicle_surf::{diff::Diff, Repository};
+
+fn main() {
+ let options = get_options_or_exit();
+ let repo = init_repository_or_exit(&options.path_to_repo);
+ let head_oid = match options.head_revision {
+ HeadRevision::Head => repo.head().unwrap(),
+ HeadRevision::Commit(id) => Oid::from_str(&id).unwrap(),
+ };
+ let base_oid = Oid::from_str(&options.base_revision).unwrap();
+ let now = Instant::now();
+ let elapsed_nanos = now.elapsed().as_nanos();
+ let diff = repo.diff(base_oid, head_oid).unwrap();
+ print_diff_summary(&diff, elapsed_nanos);
+}
+
+fn get_options_or_exit() -> Options {
+ match Options::parse(std::env::args()) {
+ Ok(options) => options,
+ Err(message) => {
+ println!("{message}");
+ std::process::exit(1);
+ }
+ }
+}
+
+fn init_repository_or_exit(path_to_repo: &str) -> Repository {
+ match Repository::open(path_to_repo) {
+ Ok(repo) => repo,
+ Err(e) => {
+ println!("Failed to create repository: {e:?}");
+ std::process::exit(1);
+ }
+ }
+}
+
+fn print_diff_summary(diff: &Diff, elapsed_nanos: u128) {
+ diff.added().for_each(|created| {
+ println!("+++ {:?}", created.path);
+ });
+ diff.deleted().for_each(|deleted| {
+ println!("--- {:?}", deleted.path);
+ });
+ diff.modified().for_each(|modified| {
+ println!("mod {:?}", modified.path);
+ });
+
+ println!(
+ "created {} / deleted {} / modified {} / total {}",
+ diff.added().count(),
+ diff.deleted().count(),
+ diff.modified().count(),
+ diff.added().count() + diff.deleted().count() + diff.modified().count()
+ );
+ println!("diff took {elapsed_nanos} nanos ");
+}
+
+struct Options {
+ path_to_repo: String,
+ base_revision: String,
+ head_revision: HeadRevision,
+}
+
+enum HeadRevision {
+ Head,
+ Commit(String),
+}
+
+impl Options {
+ fn parse(args: Args) -> Result<Self, String> {
+ let args: Vec<String> = args.collect();
+ if args.len() != 4 {
+ return Err(format!(
+ "Usage: {} <path-to-repo> <base-revision> <head-revision>\n\
+ \tpath-to-repo: Path to the directory containing .git subdirectory\n\
+ \tbase-revision: Git commit ID of the base revision (one that will be considered less recent)\n\
+ \thead-revision: Git commit ID of the head revision (one that will be considered more recent) or 'HEAD' to use current git HEAD\n",
+ args[0]));
+ }
+
+ let path_to_repo = args[1].clone();
+ let base_revision = args[2].clone();
+ let head_revision = {
+ if args[3].eq_ignore_ascii_case("HEAD") {
+ HeadRevision::Head
+ } else {
+ HeadRevision::Commit(args[3].clone())
+ }
+ };
+
+ Ok(Options {
+ path_to_repo,
+ base_revision,
+ head_revision,
+ })
+ }
+}
diff --git a/crates/radicle-surf/scripts/update-git-platinum.sh b/crates/radicle-surf/scripts/update-git-platinum.sh
new file mode 100755
index 000000000..f82328cc9
--- /dev/null
+++ b/crates/radicle-surf/scripts/update-git-platinum.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Verify that the script is run from project root.
+BASE=$(basename "$(pwd)")
+
+if [ "${BASE}" != "radicle-surf" ]
+then
+ echo "ERROR: this script should be run from the root of radicle-surf"
+ exit 1
+fi
+
+TARBALL_PATH=data/git-platinum.tgz
+WORKDIR=.workdir
+PLATINUM_REPO="$WORKDIR/git-platinum"
+
+# Create the workdir if needed.
+mkdir -p $WORKDIR
+
+# This is here in case the last script run failed and it never cleaned up.
+rm -rf "$PLATINUM_REPO"
+
+# Clone an up-to-date version of git-platinum.
+git clone https://github.com/radicle-dev/git-platinum.git "$PLATINUM_REPO"
+git -C "$PLATINUM_REPO" checkout empty-branch
+git -C "$PLATINUM_REPO" checkout diff-test
+git -C "$PLATINUM_REPO" checkout dev
+
+# Add the necessary refs.
+input="./data/mock-branches.txt"
+while IFS= read -r line
+do
+ IFS=, read -ra pair <<< "$line"
+ echo "Creating branch ${pair[0]}"
+ git -C "$PLATINUM_REPO" update-ref "${pair[0]}" "${pair[1]}"
+done < "$input"
+
+# Update the archive.
+tar -czf $WORKDIR/git-platinum.tgz -C $WORKDIR git-platinum
+mv $WORKDIR/git-platinum.tgz $TARBALL_PATH
+
+# Clean up.
+rm -rf "$PLATINUM_REPO"
diff --git a/crates/radicle-surf/src/blob.rs b/crates/radicle-surf/src/blob.rs
new file mode 100644
index 000000000..427d0ca70
--- /dev/null
+++ b/crates/radicle-surf/src/blob.rs
@@ -0,0 +1,157 @@
+//! Represents git object type 'blob', i.e. actual file contents.
+//! See git [doc](https://git-scm.com/book/en/v2/Git-Internals-Git-Objects) for more details.
+
+use std::ops::Deref;
+
+use radicle_git_ext::Oid;
+
+#[cfg(feature = "serde")]
+use serde::{
+ ser::{SerializeStruct as _, Serializer},
+ Serialize,
+};
+
+use crate::Commit;
+
+/// Represents a git blob object.
+///
+/// The type parameter `T` can be fulfilled by [`BlobRef`] or a
+/// [`Vec`] of bytes.
+pub struct Blob<T> {
+ id: Oid,
+ is_binary: bool,
+ commit: Commit,
+ content: T,
+}
+
+impl<T> Blob<T> {
+ pub fn object_id(&self) -> Oid {
+ self.id
+ }
+
+ pub fn is_binary(&self) -> bool {
+ self.is_binary
+ }
+
+ /// Returns the commit that created this blob.
+ pub fn commit(&self) -> &Commit {
+ &self.commit
+ }
+
+ pub fn content(&self) -> &[u8]
+ where
+ T: AsRef<[u8]>,
+ {
+ self.content.as_ref()
+ }
+
+ pub fn size(&self) -> usize
+ where
+ T: AsRef<[u8]>,
+ {
+ self.content.as_ref().len()
+ }
+}
+
+impl<'a> Blob<BlobRef<'a>> {
+ /// Returns the [`Blob`] wrapping around an underlying [`git2::Blob`].
+ pub(crate) fn new(id: Oid, git2_blob: git2::Blob<'a>, commit: Commit) -> Self {
+ let is_binary = git2_blob.is_binary();
+ let content = BlobRef { inner: git2_blob };
+ Self {
+ id,
+ is_binary,
+ content,
+ commit,
+ }
+ }
+
+ /// Converts into a `Blob` with owned content bytes.
+ pub fn to_owned(&self) -> Blob<Vec<u8>> {
+ Blob {
+ id: self.id,
+ content: self.content.to_vec(),
+ commit: self.commit.clone(),
+ is_binary: self.is_binary,
+ }
+ }
+}
+
+/// Represents a blob with borrowed content bytes.
+pub struct BlobRef<'a> {
+ pub(crate) inner: git2::Blob<'a>,
+}
+
+impl BlobRef<'_> {
+ pub fn id(&self) -> Oid {
+ self.inner.id().into()
+ }
+}
+
+impl AsRef<[u8]> for BlobRef<'_> {
+ fn as_ref(&self) -> &[u8] {
+ self.inner.content()
+ }
+}
+
+impl Deref for BlobRef<'_> {
+ type Target = [u8];
+
+ fn deref(&self) -> &Self::Target {
+ self.inner.content()
+ }
+}
+
+#[cfg(feature = "serde")]
+impl<T> Serialize for Blob<T>
+where
+ T: AsRef<[u8]>,
+{
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ use base64::Engine as _;
+
+ const FIELDS: usize = 4;
+ let mut state = serializer.serialize_struct("Blob", FIELDS)?;
+ state.serialize_field("id", &self.id)?;
+ state.serialize_field("binary", &self.is_binary())?;
+
+ let bytes = self.content.as_ref();
+ match std::str::from_utf8(bytes) {
+ Ok(s) => state.serialize_field("content", s)?,
+ Err(_) => {
+ let encoded = base64::prelude::BASE64_STANDARD.encode(bytes);
+ state.serialize_field("content", &encoded)?
+ }
+ };
+ state.serialize_field("lastCommit", &self.commit)?;
+ state.end()
+ }
+}
+
+#[cfg(feature = "serde")]
+impl Serialize for BlobRef<'_> {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ use base64::Engine as _;
+
+ const FIELDS: usize = 3;
+ let mut state = serializer.serialize_struct("BlobRef", FIELDS)?;
+ state.serialize_field("id", &self.id())?;
+ state.serialize_field("binary", &self.inner.is_binary())?;
+
+ let bytes = self.as_ref();
+ match std::str::from_utf8(bytes) {
+ Ok(s) => state.serialize_field("content", s)?,
+ Err(_) => {
+ let encoded = base64::prelude::BASE64_STANDARD.encode(bytes);
+ state.serialize_field("content", &encoded)?
+ }
+ };
+ state.end()
+ }
+}
diff --git a/crates/radicle-surf/src/branch.rs b/crates/radicle-surf/src/branch.rs
new file mode 100644
index 000000000..73fe8fa2b
--- /dev/null
+++ b/crates/radicle-surf/src/branch.rs
@@ -0,0 +1,336 @@
+use std::{
+ convert::TryFrom,
+ str::{self, FromStr},
+};
+
+use git_ext::ref_format::{component, lit, Component, Qualified, RefStr, RefString};
+
+use crate::refs::refstr_join;
+
+/// A `Branch` represents any git branch. It can be [`Local`] or [`Remote`].
+///
+/// Note that if a `Branch` is created from a [`git2::Reference`] then
+/// any `refs/namespaces` will be stripped.
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub enum Branch {
+ Local(Local),
+ Remote(Remote),
+}
+
+impl Branch {
+ /// Construct a [`Local`] branch.
+ pub fn local<R>(name: R) -> Self
+ where
+ R: AsRef<RefStr>,
+ {
+ Self::Local(Local::new(name))
+ }
+
+ /// Construct a [`Remote`] branch.
+ /// The `remote` is the remote name of the reference name while
+ /// the `name` is the suffix, i.e. `refs/remotes/<remote>/<name>`.
+ pub fn remote<R>(remote: Component<'_>, name: R) -> Self
+ where
+ R: AsRef<RefStr>,
+ {
+ Self::Remote(Remote::new(remote, name))
+ }
+
+ /// Return the short `Branch` refname,
+ /// e.g. `fix/ref-format`.
+ pub fn short_name(&self) -> &RefString {
+ match self {
+ Branch::Local(local) => local.short_name(),
+ Branch::Remote(remote) => remote.short_name(),
+ }
+ }
+
+ /// Give back the fully qualified `Branch` refname,
+ /// e.g. `refs/remotes/origin/fix/ref-format`,
+ /// `refs/heads/fix/ref-format`.
+ pub fn refname<'a>(&'a self) -> Qualified<'a> {
+ match self {
+ Branch::Local(local) => local.refname(),
+ Branch::Remote(remote) => remote.refname(),
+ }
+ }
+}
+
+impl TryFrom<&git2::Reference<'_>> for Branch {
+ type Error = error::Branch;
+
+ fn try_from(reference: &git2::Reference<'_>) -> Result<Self, Self::Error> {
+ let name = str::from_utf8(reference.name_bytes())?;
+ Self::from_str(name)
+ }
+}
+
+impl TryFrom<&str> for Branch {
+ type Error = error::Branch;
+
+ fn try_from(name: &str) -> Result<Self, Self::Error> {
+ Self::from_str(name)
+ }
+}
+
+impl FromStr for Branch {
+ type Err = error::Branch;
+
+ fn from_str(name: &str) -> Result<Self, Self::Err> {
+ let name = RefStr::try_from_str(name)?;
+ let name = match name.to_namespaced() {
+ None => name
+ .qualified()
+ .ok_or_else(|| error::Branch::NotQualified(name.to_string()))?,
+ Some(name) => name.strip_namespace_recursive(),
+ };
+
+ let (_ref, category, c, cs) = name.non_empty_components();
+
+ if category == component::HEADS {
+ Ok(Self::Local(Local::new(refstr_join(c, cs))))
+ } else if category == component::REMOTES {
+ Ok(Self::Remote(Remote::new(c, cs.collect::<RefString>())))
+ } else {
+ Err(error::Branch::InvalidName(name.into()))
+ }
+ }
+}
+
+/// A `Local` represents a local branch, i.e. it is a reference under
+/// `refs/heads`.
+///
+/// Note that if a `Local` is created from a [`git2::Reference`] then
+/// any `refs/namespaces` will be stripped.
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct Local {
+ name: RefString,
+}
+
+impl Local {
+ /// Construct a new `Local` with the given `name`.
+ ///
+ /// If the name is qualified with `refs/heads`, this will be
+ /// shortened to the suffix. To get the `Qualified` name again,
+ /// use [`Local::refname`].
+ pub(crate) fn new<R>(name: R) -> Self
+ where
+ R: AsRef<RefStr>,
+ {
+ match name.as_ref().qualified() {
+ None => Self {
+ name: name.as_ref().to_ref_string(),
+ },
+ Some(qualified) => {
+ let (_refs, heads, c, cs) = qualified.non_empty_components();
+ if heads == component::HEADS {
+ Self {
+ name: refstr_join(c, cs),
+ }
+ } else {
+ Self {
+ name: name.as_ref().to_ref_string(),
+ }
+ }
+ }
+ }
+ }
+
+ /// Return the short `Local` refname,
+ /// e.g. `fix/ref-format`.
+ pub fn short_name(&self) -> &RefString {
+ &self.name
+ }
+
+ /// Return the fully qualified `Local` refname,
+ /// e.g. `refs/heads/fix/ref-format`.
+ pub fn refname<'a>(&'a self) -> Qualified<'a> {
+ lit::refs_heads(&self.name).into()
+ }
+}
+
+impl TryFrom<&git2::Reference<'_>> for Local {
+ type Error = error::Local;
+
+ fn try_from(reference: &git2::Reference) -> Result<Self, Self::Error> {
+ let name = str::from_utf8(reference.name_bytes())?;
+ Self::from_str(name)
+ }
+}
+
+impl TryFrom<&str> for Local {
+ type Error = error::Local;
+
+ fn try_from(name: &str) -> Result<Self, Self::Error> {
+ Self::from_str(name)
+ }
+}
+
+impl FromStr for Local {
+ type Err = error::Local;
+
+ fn from_str(name: &str) -> Result<Self, Self::Err> {
+ let name = RefStr::try_from_str(name)?;
+ let name = match name.to_namespaced() {
+ None => name
+ .qualified()
+ .ok_or_else(|| error::Local::NotQualified(name.to_string()))?,
+ Some(name) => name.strip_namespace_recursive(),
+ };
+
+ let (_ref, heads, c, cs) = name.non_empty_components();
+ if heads == component::HEADS {
+ Ok(Self::new(refstr_join(c, cs)))
+ } else {
+ Err(error::Local::NotHeads(name.into()))
+ }
+ }
+}
+
+/// A `Remote` represents a remote branch, i.e. it is a reference under
+/// `refs/remotes`.
+///
+/// Note that if a `Remote` is created from a [`git2::Reference`] then
+/// any `refs/namespaces` will be stripped.
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct Remote {
+ remote: RefString,
+ name: RefString,
+}
+
+impl Remote {
+ /// Construct a new `Remote` with the given `name` and `remote`.
+ ///
+ /// ## Note
+ /// `name` is expected to be in short form, i.e. not begin with
+ /// `refs`.
+ ///
+ /// If you are creating a `Remote` with a name that begins with
+ /// `refs/remotes`, use [`Remote::from_refs_remotes`] instead.
+ ///
+ /// To get the `Qualified` name, use [`Remote::refname`].
+ pub(crate) fn new<R>(remote: Component, name: R) -> Self
+ where
+ R: AsRef<RefStr>,
+ {
+ Self {
+ name: name.as_ref().to_ref_string(),
+ remote: remote.to_ref_string(),
+ }
+ }
+
+ /// Parse the `name` from the form `refs/remotes/<remote>/<rest>`.
+ ///
+ /// If the `name` is not of this form, then `None` is returned.
+ pub fn from_refs_remotes<R>(name: R) -> Option<Self>
+ where
+ R: AsRef<RefStr>,
+ {
+ let qualified = name.as_ref().qualified()?;
+ let (_refs, remotes, remote, cs) = qualified.non_empty_components();
+ (remotes == component::REMOTES).then_some(Self {
+ name: cs.collect(),
+ remote: remote.to_ref_string(),
+ })
+ }
+
+ /// Return the short `Remote` refname,
+ /// e.g. `fix/ref-format`.
+ pub fn short_name(&self) -> &RefString {
+ &self.name
+ }
+
+ /// Return the remote of the `Remote`'s refname,
+ /// e.g. `origin`.
+ pub fn remote(&self) -> &RefString {
+ &self.remote
+ }
+
+ /// Give back the fully qualified `Remote` refname,
+ /// e.g. `refs/remotes/origin/fix/ref-format`.
+ pub fn refname<'a>(&'a self) -> Qualified<'a> {
+ lit::refs_remotes(self.remote.join(&self.name)).into()
+ }
+}
+
+impl TryFrom<&git2::Reference<'_>> for Remote {
+ type Error = error::Remote;
+
+ fn try_from(reference: &git2::Reference) -> Result<Self, Self::Error> {
+ let name = str::from_utf8(reference.name_bytes())?;
+ Self::from_str(name)
+ }
+}
+
+impl TryFrom<&str> for Remote {
+ type Error = error::Remote;
+
+ fn try_from(name: &str) -> Result<Self, Self::Error> {
+ Self::from_str(name)
+ }
+}
+
+impl FromStr for Remote {
+ type Err = error::Remote;
+
+ fn from_str(name: &str) -> Result<Self, Self::Err> {
+ let name = RefStr::try_from_str(name)?;
+ let name = match name.to_namespaced() {
+ None => name
+ .qualified()
+ .ok_or_else(|| error::Remote::NotQualified(name.to_string()))?,
+ Some(name) => name.strip_namespace_recursive(),
+ };
+
+ let (_ref, remotes, remote, cs) = name.non_empty_components();
+ if remotes == component::REMOTES {
+ Ok(Self::new(remote, cs.collect::<RefString>()))
+ } else {
+ Err(error::Remote::NotRemotes(name.into()))
+ }
+ }
+}
+
+pub mod error {
+ use radicle_git_ext::ref_format::{self, RefString};
+ use thiserror::Error;
+
+ #[derive(Debug, Error)]
+ pub enum Branch {
+ #[error("the refname '{0}' did not begin with 'refs/heads' or 'refs/remotes'")]
+ InvalidName(RefString),
+ #[error("the refname '{0}' did not begin with 'refs/heads' or 'refs/remotes'")]
+ NotQualified(String),
+ #[error(transparent)]
+ RefFormat(#[from] ref_format::Error),
+ #[error(transparent)]
+ Utf8(#[from] std::str::Utf8Error),
+ }
+
+ #[derive(Debug, Error)]
+ pub enum Local {
+ #[error("the refname '{0}' did not begin with 'refs/heads'")]
+ NotHeads(RefString),
+ #[error("the refname '{0}' did not begin with 'refs/heads'")]
+ NotQualified(String),
+ #[error(transparent)]
+ RefFormat(#[from] ref_format::Error),
+ #[error(transparent)]
+ Utf8(#[from] std::str::Utf8Error),
+ }
+
+ #[derive(Debug, Error)]
+ pub enum Remote {
+ #[error("the refname '{0}' did not begin with 'refs/remotes'")]
+ NotQualified(String),
+ #[error("the refname '{0}' did not begin with 'refs/remotes'")]
+ NotRemotes(RefString),
+ #[error(transparent)]
+ RefFormat(#[from] ref_format::Error),
+ #[error(transparent)]
+ Utf8(#[from] std::str::Utf8Error),
+ }
+}
diff --git a/crates/radicle-surf/src/commit.rs b/crates/radicle-surf/src/commit.rs
new file mode 100644
index 000000000..c2ee708bd
--- /dev/null
+++ b/crates/radicle-surf/src/commit.rs
@@ -0,0 +1,190 @@
+use std::{convert::TryFrom, str};
+
+use radicle_git_ext::Oid;
+use thiserror::Error;
+
+#[cfg(feature = "serde")]
+use serde::{ser::SerializeStruct, Deserialize, Deserializer, Serialize, Serializer};
+
+#[derive(Debug, Error)]
+pub enum Error {
+ /// When trying to get the summary for a [`git2::Commit`] some action
+ /// failed.
+ #[error("an error occurred trying to get a commit's summary")]
+ MissingSummary,
+ #[error(transparent)]
+ Utf8Error(#[from] str::Utf8Error),
+}
+
+/// Represents the authorship of actions in a git repo.
+#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
+#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub struct Author {
+ /// Name of the author.
+ pub name: String,
+ /// Email of the author.
+ pub email: String,
+ /// Time the action was taken, e.g. time of commit.
+ #[cfg_attr(
+ feature = "serde",
+ serde(
+ serialize_with = "serialize_time",
+ deserialize_with = "deserialize_time"
+ )
+ )]
+ pub time: Time,
+}
+
+/// Time used in the authorship of an action in a git repo.
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+pub struct Time {
+ inner: git2::Time,
+}
+
+impl From<git2::Time> for Time {
+ fn from(inner: git2::Time) -> Self {
+ Self { inner }
+ }
+}
+
+impl Time {
+ pub fn new(epoch_seconds: i64, offset_minutes: i32) -> Self {
+ git2::Time::new(epoch_seconds, offset_minutes).into()
+ }
+
+ /// Returns the seconds since UNIX epoch.
+ pub fn seconds(&self) -> i64 {
+ self.inner.seconds()
+ }
+
+ /// Returns the timezone offset in minutes.
+ pub fn offset_minutes(&self) -> i32 {
+ self.inner.offset_minutes()
+ }
+}
+
+#[cfg(feature = "serde")]
+fn deserialize_time<'de, D>(deserializer: D) -> Result<Time, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ let seconds: i64 = Deserialize::deserialize(deserializer)?;
+ Ok(Time::new(seconds, 0))
+}
+
+#[cfg(feature = "serde")]
+fn serialize_time<S>(t: &Time, serializer: S) -> Result<S::Ok, S::Error>
+where
+ S: Serializer,
+{
+ serializer.serialize_i64(t.seconds())
+}
+
+impl std::fmt::Debug for Author {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ use std::cmp::Ordering;
+ let time = match self.time.offset_minutes().cmp(&0) {
+ Ordering::Equal => format!("{}", self.time.seconds()),
+ Ordering::Greater => format!("{}+{}", self.time.seconds(), self.time.offset_minutes()),
+ Ordering::Less => format!("{}{}", self.time.seconds(), self.time.offset_minutes()),
+ };
+ f.debug_struct("Author")
+ .field("name", &self.name)
+ .field("email", &self.email)
+ .field("time", &time)
+ .finish()
+ }
+}
+
+impl TryFrom<git2::Signature<'_>> for Author {
+ type Error = str::Utf8Error;
+
+ fn try_from(signature: git2::Signature) -> Result<Self, Self::Error> {
+ let name = str::from_utf8(signature.name_bytes())?.into();
+ let email = str::from_utf8(signature.email_bytes())?.into();
+ let time = signature.when().into();
+
+ Ok(Author { name, email, time })
+ }
+}
+
+/// `Commit` is the metadata of a [Git commit][git-commit].
+///
+/// [git-commit]: https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
+#[cfg_attr(feature = "serde", derive(Deserialize))]
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub struct Commit {
+ /// Object Id
+ pub id: Oid,
+ /// The author of the commit.
+ pub author: Author,
+ /// The actor who committed this commit.
+ pub committer: Author,
+ /// The long form message of the commit.
+ pub message: String,
+ /// The summary message of the commit.
+ pub summary: String,
+ /// The parents of this commit.
+ pub parents: Vec<Oid>,
+}
+
+impl Commit {
+ /// Returns the commit description text. This is the text after the one-line
+ /// summary.
+ #[must_use]
+ pub fn description(&self) -> &str {
+ self.message
+ .strip_prefix(&self.summary)
+ .unwrap_or(&self.message)
+ .trim()
+ }
+}
+
+#[cfg(feature = "serde")]
+impl Serialize for Commit {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ let mut state = serializer.serialize_struct("Commit", 7)?;
+ state.serialize_field("id", &self.id.to_string())?;
+ state.serialize_field("author", &self.author)?;
+ state.serialize_field("committer", &self.committer)?;
+ state.serialize_field("summary", &self.summary)?;
+ state.serialize_field("message", &self.message)?;
+ state.serialize_field("description", &self.description())?;
+ state.serialize_field(
+ "parents",
+ &self
+ .parents
+ .iter()
+ .map(|oid| oid.to_string())
+ .collect::<Vec<String>>(),
+ )?;
+ state.end()
+ }
+}
+
+impl TryFrom<git2::Commit<'_>> for Commit {
+ type Error = Error;
+
+ fn try_from(commit: git2::Commit) -> Result<Self, Self::Error> {
+ let id = commit.id().into();
+ let author = Author::try_from(commit.author())?;
+ let committer = Author::try_from(commit.committer())?;
+ let message_raw = commit.message_bytes();
+ let message = str::from_utf8(message_raw)?.into();
+ let summary_raw = commit.summary_bytes().ok_or(Error::MissingSummary)?;
+ let summary = str::from_utf8(summary_raw)?.into();
+ let parents = commit.parent_ids().map(|oid| oid.into()).collect();
+
+ Ok(Commit {
+ id,
+ author,
+ committer,
+ message,
+ summary,
+ parents,
+ })
+ }
+}
diff --git a/crates/radicle-surf/src/diff.rs b/crates/radicle-surf/src/diff.rs
new file mode 100644
index 000000000..f0043aed0
--- /dev/null
+++ b/crates/radicle-surf/src/diff.rs
@@ -0,0 +1,612 @@
+//! Types that represent diff(s) in a Git repo.
+
+use std::{
+ borrow::Cow,
+ ops::Range,
+ path::{Path, PathBuf},
+ string::FromUtf8Error,
+};
+
+#[cfg(feature = "serde")]
+use serde::{ser, ser::SerializeStruct, Serialize, Serializer};
+
+use git_ext::Oid;
+
+pub mod git;
+
+/// The serializable representation of a `git diff`.
+///
+/// A [`Diff`] can be retrieved by the following functions:
+/// * [`crate::Repository::diff`]
+/// * [`crate::Repository::diff_commit`]
+#[cfg_attr(feature = "serde", derive(Serialize))]
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct Diff {
+ files: Vec<FileDiff>,
+ stats: Stats,
+}
+
+impl Diff {
+ /// Creates an empty diff.
+ pub(crate) fn new() -> Self {
+ Diff::default()
+ }
+
+ /// Returns an iterator of the file in the diff.
+ pub fn files(&self) -> impl Iterator<Item = &FileDiff> {
+ self.files.iter()
+ }
+
+ /// Returns owned files in the diff.
+ pub fn into_files(self) -> Vec<FileDiff> {
+ self.files
+ }
+
+ pub fn added(&self) -> impl Iterator<Item = &Added> {
+ self.files().filter_map(|x| match x {
+ FileDiff::Added(a) => Some(a),
+ _ => None,
+ })
+ }
+
+ pub fn deleted(&self) -> impl Iterator<Item = &Deleted> {
+ self.files().filter_map(|x| match x {
+ FileDiff::Deleted(a) => Some(a),
+ _ => None,
+ })
+ }
+
+ pub fn moved(&self) -> impl Iterator<Item = &Moved> {
+ self.files().filter_map(|x| match x {
+ FileDiff::Moved(a) => Some(a),
+ _ => None,
+ })
+ }
+
+ pub fn modified(&self) -> impl Iterator<Item = &Modified> {
+ self.files().filter_map(|x| match x {
+ FileDiff::Modified(a) => Some(a),
+ _ => None,
+ })
+ }
+
+ pub fn copied(&self) -> impl Iterator<Item = &Copied> {
+ self.files().filter_map(|x| match x {
+ FileDiff::Copied(a) => Some(a),
+ _ => None,
+ })
+ }
+
+ pub fn stats(&self) -> &Stats {
+ &self.stats
+ }
+
+ fn update_stats(&mut self, diff: &DiffContent) {
+ self.stats.files_changed += 1;
+ if let DiffContent::Plain { hunks, .. } = diff {
+ for h in hunks.iter() {
+ for l in &h.lines {
+ match l {
+ Modification::Addition(_) => self.stats.insertions += 1,
+ Modification::Deletion(_) => self.stats.deletions += 1,
+ _ => (),
+ }
+ }
+ }
+ }
+ }
+
+ pub fn insert_modified(
+ &mut self,
+ path: PathBuf,
+ diff: DiffContent,
+ old: DiffFile,
+ new: DiffFile,
+ ) {
+ self.update_stats(&diff);
+ let diff = FileDiff::Modified(Modified {
+ path,
+ diff,
+ old,
+ new,
+ });
+ self.files.push(diff);
+ }
+
+ pub fn insert_moved(
+ &mut self,
+ old_path: PathBuf,
+ new_path: PathBuf,
+ old: DiffFile,
+ new: DiffFile,
+ content: DiffContent,
+ ) {
+ self.update_stats(&DiffContent::Empty);
+ let diff = FileDiff::Moved(Moved {
+ old_path,
+ new_path,
+ old,
+ new,
+ diff: content,
+ });
+ self.files.push(diff);
+ }
+
+ pub fn insert_copied(
+ &mut self,
+ old_path: PathBuf,
+ new_path: PathBuf,
+ old: DiffFile,
+ new: DiffFile,
+ content: DiffContent,
+ ) {
+ self.update_stats(&DiffContent::Empty);
+ let diff = FileDiff::Copied(Copied {
+ old_path,
+ new_path,
+ old,
+ new,
+ diff: content,
+ });
+ self.files.push(diff);
+ }
+
+ pub fn insert_added(&mut self, path: PathBuf, diff: DiffContent, new: DiffFile) {
+ self.update_stats(&diff);
+ let diff = FileDiff::Added(Added { path, diff, new });
+ self.files.push(diff);
+ }
+
+ pub fn insert_deleted(&mut self, path: PathBuf, diff: DiffContent, old: DiffFile) {
+ self.update_stats(&diff);
+ let diff = FileDiff::Deleted(Deleted { path, diff, old });
+ self.files.push(diff);
+ }
+}
+
+/// A file that was added within a [`Diff`].
+#[cfg_attr(feature = "serde", derive(Serialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Added {
+ /// The path to this file, relative to the repository root.
+ pub path: PathBuf,
+ pub diff: DiffContent,
+ pub new: DiffFile,
+}
+
+/// A file that was deleted within a [`Diff`].
+#[cfg_attr(feature = "serde", derive(Serialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Deleted {
+ /// The path to this file, relative to the repository root.
+ pub path: PathBuf,
+ pub diff: DiffContent,
+ pub old: DiffFile,
+}
+
+/// A file that was moved within a [`Diff`].
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Moved {
+ /// The old path to this file, relative to the repository root.
+ pub old_path: PathBuf,
+ pub old: DiffFile,
+ /// The new path to this file, relative to the repository root.
+ pub new_path: PathBuf,
+ pub new: DiffFile,
+ pub diff: DiffContent,
+}
+
+#[cfg(feature = "serde")]
+impl Serialize for Moved {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ if self.old == self.new {
+ let mut state = serializer.serialize_struct("Moved", 3)?;
+ state.serialize_field("oldPath", &self.old_path)?;
+ state.serialize_field("newPath", &self.new_path)?;
+ state.serialize_field("current", &self.new)?;
+ state.end()
+ } else {
+ let mut state = serializer.serialize_struct("Moved", 5)?;
+ state.serialize_field("oldPath", &self.old_path)?;
+ state.serialize_field("newPath", &self.new_path)?;
+ state.serialize_field("old", &self.old)?;
+ state.serialize_field("new", &self.new)?;
+ state.serialize_field("diff", &self.diff)?;
+ state.end()
+ }
+ }
+}
+
+/// A file that was copied within a [`Diff`].
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Copied {
+ /// The old path to this file, relative to the repository root.
+ pub old_path: PathBuf,
+ /// The new path to this file, relative to the repository root.
+ pub new_path: PathBuf,
+ pub old: DiffFile,
+ pub new: DiffFile,
+ pub diff: DiffContent,
+}
+
+#[cfg(feature = "serde")]
+impl Serialize for Copied {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ if self.old == self.new {
+ let mut state = serializer.serialize_struct("Copied", 3)?;
+ state.serialize_field("oldPath", &self.old_path)?;
+ state.serialize_field("newPath", &self.new_path)?;
+ state.serialize_field("current", &self.new)?;
+ state.end()
+ } else {
+ let mut state = serializer.serialize_struct("Copied", 5)?;
+ state.serialize_field("oldPath", &self.old_path)?;
+ state.serialize_field("newPath", &self.new_path)?;
+ state.serialize_field("old", &self.old)?;
+ state.serialize_field("new", &self.new)?;
+ state.serialize_field("diff", &self.diff)?;
+ state.end()
+ }
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(Serialize), serde(rename_all = "camelCase"))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum EofNewLine {
+ OldMissing,
+ NewMissing,
+ BothMissing,
+ NoneMissing,
+}
+
+impl Default for EofNewLine {
+ fn default() -> Self {
+ Self::NoneMissing
+ }
+}
+
+/// A file that was modified within a [`Diff`].
+#[cfg_attr(feature = "serde", derive(Serialize), serde(rename_all = "camelCase"))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Modified {
+ pub path: PathBuf,
+ pub diff: DiffContent,
+ pub old: DiffFile,
+ pub new: DiffFile,
+}
+
+/// The set of changes for a given file.
+#[cfg_attr(
+ feature = "serde",
+ derive(Serialize),
+ serde(tag = "type", rename_all = "camelCase")
+)]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum DiffContent {
+ /// The file is a binary file and so no set of changes can be provided.
+ Binary,
+ /// The set of changes, as [`Hunks`] for a plaintext file.
+ #[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
+ Plain {
+ hunks: Hunks<Modification>,
+ stats: FileStats,
+ eof: EofNewLine,
+ },
+ Empty,
+}
+
+impl DiffContent {
+ pub fn eof(&self) -> Option<EofNewLine> {
+ match self {
+ Self::Plain { eof, .. } => Some(eof.clone()),
+ _ => None,
+ }
+ }
+
+ pub fn stats(&self) -> Option<&FileStats> {
+ match &self {
+ DiffContent::Plain { stats, .. } => Some(stats),
+ DiffContent::Empty => None,
+ DiffContent::Binary => None,
+ }
+ }
+}
+
+/// File mode in a diff.
+#[derive(Clone, Debug, PartialEq, Eq)]
+#[cfg_attr(feature = "serde", derive(Serialize), serde(rename_all = "camelCase"))]
+pub enum FileMode {
+ /// For regular files.
+ Blob,
+ /// For regular files that are executable.
+ BlobExecutable,
+ /// For directories.
+ Tree,
+ /// For symbolic links.
+ Link,
+ /// Used for Git submodules.
+ Commit,
+}
+
+impl From<FileMode> for u32 {
+ fn from(m: FileMode) -> Self {
+ git2::FileMode::from(m).into()
+ }
+}
+
+impl From<FileMode> for i32 {
+ fn from(m: FileMode) -> Self {
+ git2::FileMode::from(m).into()
+ }
+}
+
+/// A modified file.
+#[derive(Clone, Debug, PartialEq, Eq)]
+#[cfg_attr(feature = "serde", derive(Serialize), serde(rename_all = "camelCase"))]
+pub struct DiffFile {
+ /// File blob id.
+ pub oid: Oid,
+ /// File mode.
+ pub mode: FileMode,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+#[cfg_attr(
+ feature = "serde",
+ derive(Serialize),
+ serde(tag = "status", rename_all = "camelCase")
+)]
+pub enum FileDiff {
+ Added(Added),
+ Deleted(Deleted),
+ Modified(Modified),
+ Moved(Moved),
+ Copied(Copied),
+}
+
+impl FileDiff {
+ pub fn path(&self) -> &Path {
+ match self {
+ FileDiff::Added(x) => x.path.as_path(),
+ FileDiff::Deleted(x) => x.path.as_path(),
+ FileDiff::Modified(x) => x.path.as_path(),
+ FileDiff::Moved(x) => x.new_path.as_path(),
+ FileDiff::Copied(x) => x.new_path.as_path(),
+ }
+ }
+}
+
+/// Statistics describing a particular [`FileDiff`].
+#[cfg_attr(feature = "serde", derive(Serialize), serde(rename_all = "camelCase"))]
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub struct FileStats {
+ /// Get the total number of additions in a [`FileDiff`].
+ pub additions: usize,
+ /// Get the total number of deletions in a [`FileDiff`].
+ pub deletions: usize,
+}
+
+/// Statistics describing a particular [`Diff`].
+#[cfg_attr(feature = "serde", derive(Serialize), serde(rename_all = "camelCase"))]
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub struct Stats {
+ /// Get the total number of files changed in a [`Diff`]
+ pub files_changed: usize,
+ /// Get the total number of insertions in a [`Diff`].
+ pub insertions: usize,
+ /// Get the total number of deletions in a [`Diff`].
+ pub deletions: usize,
+}
+
+/// A set of changes across multiple lines.
+///
+/// The parameter `T` can be an [`Addition`], [`Deletion`], or
+/// [`Modification`].
+#[cfg_attr(feature = "serde", derive(Serialize), serde(rename_all = "camelCase"))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Hunk<T> {
+ pub header: Line,
+ pub lines: Vec<T>,
+ /// Old line range.
+ pub old: Range<u32>,
+ /// New line range.
+ pub new: Range<u32>,
+}
+
+/// A set of [`Hunk`] changes.
+#[cfg_attr(feature = "serde", derive(Serialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Hunks<T>(pub Vec<Hunk<T>>);
+
+impl<T> Default for Hunks<T> {
+ fn default() -> Self {
+ Self(Default::default())
+ }
+}
+
+impl<T> Hunks<T> {
+ pub fn iter(&self) -> impl Iterator<Item = &Hunk<T>> {
+ self.0.iter()
+ }
+}
+
+impl<T> From<Vec<Hunk<T>>> for Hunks<T> {
+ fn from(hunks: Vec<Hunk<T>>) -> Self {
+ Self(hunks)
+ }
+}
+
+/// The content of a single line.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Line(pub(crate) Vec<u8>);
+
+impl Line {
+ pub fn as_bytes(&self) -> &[u8] {
+ self.0.as_slice()
+ }
+
+ pub fn from_utf8(self) -> Result<String, FromUtf8Error> {
+ String::from_utf8(self.0)
+ }
+
+ pub fn from_utf8_lossy<'a>(&'a self) -> Cow<'a, str> {
+ String::from_utf8_lossy(&self.0)
+ }
+}
+
+impl From<Vec<u8>> for Line {
+ fn from(v: Vec<u8>) -> Self {
+ Self(v)
+ }
+}
+
+impl From<String> for Line {
+ fn from(s: String) -> Self {
+ Self(s.into_bytes())
+ }
+}
+
+#[cfg(feature = "serde")]
+impl Serialize for Line {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ let s = std::str::from_utf8(&self.0).map_err(ser::Error::custom)?;
+
+ serializer.serialize_str(s)
+ }
+}
+
+/// Either the modification of a single [`Line`], or just contextual
+/// information.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum Modification {
+ /// A line is an addition in a file.
+ Addition(Addition),
+
+ /// A line is a deletion in a file.
+ Deletion(Deletion),
+
+ /// A contextual line in a file, i.e. there were no changes to the line.
+ Context {
+ line: Line,
+ line_no_old: u32,
+ line_no_new: u32,
+ },
+}
+
+#[cfg(feature = "serde")]
+impl Serialize for Modification {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ use serde::ser::SerializeMap as _;
+
+ match self {
+ Modification::Addition(addition) => {
+ let mut map = serializer.serialize_map(Some(3))?;
+ map.serialize_entry("line", &addition.line)?;
+ map.serialize_entry("lineNo", &addition.line_no)?;
+ map.serialize_entry("type", "addition")?;
+ map.end()
+ }
+ Modification::Deletion(deletion) => {
+ let mut map = serializer.serialize_map(Some(3))?;
+ map.serialize_entry("line", &deletion.line)?;
+ map.serialize_entry("lineNo", &deletion.line_no)?;
+ map.serialize_entry("type", "deletion")?;
+ map.end()
+ }
+ Modification::Context {
+ line,
+ line_no_old,
+ line_no_new,
+ } => {
+ let mut map = serializer.serialize_map(Some(4))?;
+ map.serialize_entry("line", line)?;
+ map.serialize_entry("lineNoOld", line_no_old)?;
+ map.serialize_entry("lineNoNew", line_no_new)?;
+ map.serialize_entry("type", "context")?;
+ map.end()
+ }
+ }
+ }
+}
+
+/// A addition of a [`Line`] at the `line_no`.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Addition {
+ pub line: Line,
+ pub line_no: u32,
+}
+
+#[cfg(feature = "serde")]
+impl Serialize for Addition {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ use serde::ser::SerializeStruct as _;
+
+ let mut s = serializer.serialize_struct("Addition", 3)?;
+ s.serialize_field("line", &self.line)?;
+ s.serialize_field("lineNo", &self.line_no)?;
+ s.serialize_field("type", "addition")?;
+ s.end()
+ }
+}
+
+/// A deletion of a [`Line`] at the `line_no`.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Deletion {
+ pub line: Line,
+ pub line_no: u32,
+}
+
+#[cfg(feature = "serde")]
+impl Serialize for Deletion {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ use serde::ser::SerializeStruct as _;
+
+ let mut s = serializer.serialize_struct("Deletion", 3)?;
+ s.serialize_field("line", &self.line)?;
+ s.serialize_field("lineNo", &self.line_no)?;
+ s.serialize_field("type", "deletion")?;
+ s.end()
+ }
+}
+
+impl Modification {
+ pub fn addition(line: impl Into<Line>, line_no: u32) -> Self {
+ Self::Addition(Addition {
+ line: line.into(),
+ line_no,
+ })
+ }
+
+ pub fn deletion(line: impl Into<Line>, line_no: u32) -> Self {
+ Self::Deletion(Deletion {
+ line: line.into(),
+ line_no,
+ })
+ }
+
+ pub fn context(line: impl Into<Line>, line_no_old: u32, line_no_new: u32) -> Self {
+ Self::Context {
+ line: line.into(),
+ line_no_old,
+ line_no_new,
+ }
+ }
+}
diff --git a/crates/radicle-surf/src/diff/git.rs b/crates/radicle-surf/src/diff/git.rs
new file mode 100644
index 000000000..4adf8d7e5
--- /dev/null
+++ b/crates/radicle-surf/src/diff/git.rs
@@ -0,0 +1,377 @@
+use std::convert::TryFrom;
+
+use super::{
+ Diff, DiffContent, DiffFile, EofNewLine, FileMode, FileStats, Hunk, Hunks, Line, Modification,
+ Stats,
+};
+
+pub mod error {
+ use std::path::PathBuf;
+
+ use thiserror::Error;
+
+ #[derive(Debug, Error)]
+ #[non_exhaustive]
+ pub enum Addition {
+ #[error(transparent)]
+ Git(#[from] git2::Error),
+ #[error("the new line number was missing for an added line")]
+ MissingNewLineNo,
+ }
+
+ #[derive(Debug, Error)]
+ #[non_exhaustive]
+ pub enum Deletion {
+ #[error(transparent)]
+ Git(#[from] git2::Error),
+ #[error("the new line number was missing for an deleted line")]
+ MissingOldLineNo,
+ }
+
+ #[derive(Debug, Error)]
+ #[non_exhaustive]
+ pub enum FileMode {
+ #[error("unknown file mode `{0:?}`")]
+ Unknown(git2::FileMode),
+ }
+
+ #[derive(Debug, Error)]
+ #[non_exhaustive]
+ pub enum Modification {
+ /// A Git `DiffLine` is invalid.
+ #[error(
+ "invalid `git2::DiffLine` which contains no line numbers for either side of the diff"
+ )]
+ Invalid,
+ }
+
+ #[derive(Debug, Error)]
+ #[non_exhaustive]
+ pub enum Hunk {
+ #[error(transparent)]
+ Git(#[from] git2::Error),
+ #[error(transparent)]
+ Line(#[from] Modification),
+ }
+
+ /// A Git diff error.
+ #[derive(Debug, Error)]
+ #[non_exhaustive]
+ pub enum Diff {
+ #[error(transparent)]
+ Addition(#[from] Addition),
+ #[error(transparent)]
+ Deletion(#[from] Deletion),
+ /// A Git delta type isn't currently handled.
+ #[error("git delta type is not handled")]
+ DeltaUnhandled(git2::Delta),
+ #[error(transparent)]
+ Git(#[from] git2::Error),
+ #[error(transparent)]
+ FileMode(#[from] FileMode),
+ #[error(transparent)]
+ Hunk(#[from] Hunk),
+ #[error(transparent)]
+ Line(#[from] Modification),
+ /// A patch is unavailable.
+ #[error("couldn't retrieve patch for {0}")]
+ PatchUnavailable(PathBuf),
+ /// A The path of a file isn't available.
+ #[error("couldn't retrieve file path")]
+ PathUnavailable,
+ }
+}
+
+impl TryFrom<git2::DiffFile<'_>> for DiffFile {
+ type Error = error::FileMode;
+
+ fn try_from(value: git2::DiffFile) -> Result<Self, Self::Error> {
+ Ok(Self {
+ mode: value.mode().try_into()?,
+ oid: value.id().into(),
+ })
+ }
+}
+
+impl TryFrom<git2::FileMode> for FileMode {
+ type Error = error::FileMode;
+
+ fn try_from(value: git2::FileMode) -> Result<Self, Self::Error> {
+ match value {
+ git2::FileMode::Blob => Ok(Self::Blob),
+ git2::FileMode::BlobExecutable => Ok(Self::BlobExecutable),
+ git2::FileMode::Commit => Ok(Self::Commit),
+ git2::FileMode::Tree => Ok(Self::Tree),
+ git2::FileMode::Link => Ok(Self::Link),
+ _ => Err(error::FileMode::Unknown(value)),
+ }
+ }
+}
+
+impl From<FileMode> for git2::FileMode {
+ fn from(m: FileMode) -> Self {
+ match m {
+ FileMode::Blob => git2::FileMode::Blob,
+ FileMode::BlobExecutable => git2::FileMode::BlobExecutable,
+ FileMode::Tree => git2::FileMode::Tree,
+ FileMode::Link => git2::FileMode::Link,
+ FileMode::Commit => git2::FileMode::Commit,
+ }
+ }
+}
+
+impl TryFrom<git2::Patch<'_>> for DiffContent {
+ type Error = error::Hunk;
+
+ fn try_from(patch: git2::Patch) -> Result<Self, Self::Error> {
+ let mut hunks = Vec::new();
+ let mut old_missing_eof = false;
+ let mut new_missing_eof = false;
+ let mut additions = 0;
+ let mut deletions = 0;
+
+ for h in 0..patch.num_hunks() {
+ let (hunk, hunk_lines) = patch.hunk(h)?;
+ let header = Line(hunk.header().to_owned());
+ let mut lines: Vec<Modification> = Vec::new();
+
+ for l in 0..hunk_lines {
+ let line = patch.line_in_hunk(h, l)?;
+ match line.origin_value() {
+ git2::DiffLineType::ContextEOFNL => {
+ new_missing_eof = true;
+ old_missing_eof = true;
+ continue;
+ }
+ git2::DiffLineType::Addition => {
+ additions += 1;
+ }
+ git2::DiffLineType::Deletion => {
+ deletions += 1;
+ }
+ git2::DiffLineType::AddEOFNL => {
+ additions += 1;
+ old_missing_eof = true;
+ continue;
+ }
+ git2::DiffLineType::DeleteEOFNL => {
+ deletions += 1;
+ new_missing_eof = true;
+ continue;
+ }
+ _ => {}
+ }
+ let line = Modification::try_from(line)?;
+ lines.push(line);
+ }
+ hunks.push(Hunk {
+ header,
+ lines,
+ old: hunk.old_start()..hunk.old_start() + hunk.old_lines(),
+ new: hunk.new_start()..hunk.new_start() + hunk.new_lines(),
+ });
+ }
+ let eof = match (old_missing_eof, new_missing_eof) {
+ (true, true) => EofNewLine::BothMissing,
+ (true, false) => EofNewLine::OldMissing,
+ (false, true) => EofNewLine::NewMissing,
+ (false, false) => EofNewLine::NoneMissing,
+ };
+ Ok(DiffContent::Plain {
+ hunks: Hunks(hunks),
+ stats: FileStats {
+ additions,
+ deletions,
+ },
+ eof,
+ })
+ }
+}
+
+impl TryFrom<git2::DiffLine<'_>> for Modification {
+ type Error = error::Modification;
+
+ fn try_from(line: git2::DiffLine) -> Result<Self, Self::Error> {
+ match (line.old_lineno(), line.new_lineno()) {
+ (None, Some(n)) => Ok(Self::addition(line.content().to_owned(), n)),
+ (Some(n), None) => Ok(Self::deletion(line.content().to_owned(), n)),
+ (Some(l), Some(r)) => Ok(Self::context(line.content().to_owned(), l, r)),
+ (None, None) => Err(error::Modification::Invalid),
+ }
+ }
+}
+
+impl From<git2::DiffStats> for Stats {
+ fn from(stats: git2::DiffStats) -> Self {
+ Self {
+ files_changed: stats.files_changed(),
+ insertions: stats.insertions(),
+ deletions: stats.deletions(),
+ }
+ }
+}
+
+impl TryFrom<git2::Diff<'_>> for Diff {
+ type Error = error::Diff;
+
+ fn try_from(git_diff: git2::Diff) -> Result<Diff, Self::Error> {
+ use git2::Delta;
+
+ let mut diff = Diff::new();
+
+ // This allows libgit2 to run the binary detection.
+ // Reference: <https://github.com/libgit2/libgit2/issues/6637>
+ git_diff.foreach(&mut |_, _| true, None, None, None)?;
+
+ for (idx, delta) in git_diff.deltas().enumerate() {
+ match delta.status() {
+ Delta::Added => created(&mut diff, &git_diff, idx, &delta)?,
+ Delta::Deleted => deleted(&mut diff, &git_diff, idx, &delta)?,
+ Delta::Modified => modified(&mut diff, &git_diff, idx, &delta)?,
+ Delta::Renamed => renamed(&mut diff, &git_diff, idx, &delta)?,
+ Delta::Copied => copied(&mut diff, &git_diff, idx, &delta)?,
+ status => {
+ return Err(error::Diff::DeltaUnhandled(status));
+ }
+ }
+ }
+
+ Ok(diff)
+ }
+}
+
+fn created(
+ diff: &mut Diff,
+ git_diff: &git2::Diff<'_>,
+ idx: usize,
+ delta: &git2::DiffDelta<'_>,
+) -> Result<(), error::Diff> {
+ let diff_file = delta.new_file();
+ let is_binary = diff_file.is_binary();
+ let path = diff_file
+ .path()
+ .ok_or(error::Diff::PathUnavailable)?
+ .to_path_buf();
+ let new = DiffFile::try_from(diff_file)?;
+
+ let patch = git2::Patch::from_diff(git_diff, idx)?;
+ if is_binary {
+ diff.insert_added(path, DiffContent::Binary, new);
+ } else if let Some(patch) = patch {
+ diff.insert_added(path, DiffContent::try_from(patch)?, new);
+ } else {
+ return Err(error::Diff::PatchUnavailable(path));
+ }
+ Ok(())
+}
+
+fn deleted(
+ diff: &mut Diff,
+ git_diff: &git2::Diff<'_>,
+ idx: usize,
+ delta: &git2::DiffDelta<'_>,
+) -> Result<(), error::Diff> {
+ let diff_file = delta.old_file();
+ let is_binary = diff_file.is_binary();
+ let path = diff_file
+ .path()
+ .ok_or(error::Diff::PathUnavailable)?
+ .to_path_buf();
+ let patch = git2::Patch::from_diff(git_diff, idx)?;
+ let old = DiffFile::try_from(diff_file)?;
+
+ if is_binary {
+ diff.insert_deleted(path, DiffContent::Binary, old);
+ } else if let Some(patch) = patch {
+ diff.insert_deleted(path, DiffContent::try_from(patch)?, old);
+ } else {
+ return Err(error::Diff::PatchUnavailable(path));
+ }
+ Ok(())
+}
+
+fn modified(
+ diff: &mut Diff,
+ git_diff: &git2::Diff<'_>,
+ idx: usize,
+ delta: &git2::DiffDelta<'_>,
+) -> Result<(), error::Diff> {
+ let diff_file = delta.new_file();
+ let path = diff_file
+ .path()
+ .ok_or(error::Diff::PathUnavailable)?
+ .to_path_buf();
+ let patch = git2::Patch::from_diff(git_diff, idx)?;
+ let old = DiffFile::try_from(delta.old_file())?;
+ let new = DiffFile::try_from(delta.new_file())?;
+
+ if diff_file.is_binary() {
+ diff.insert_modified(path, DiffContent::Binary, old, new);
+ Ok(())
+ } else if let Some(patch) = patch {
+ diff.insert_modified(path, DiffContent::try_from(patch)?, old, new);
+ Ok(())
+ } else {
+ Err(error::Diff::PatchUnavailable(path))
+ }
+}
+
+fn renamed(
+ diff: &mut Diff,
+ git_diff: &git2::Diff<'_>,
+ idx: usize,
+ delta: &git2::DiffDelta<'_>,
+) -> Result<(), error::Diff> {
+ let old_path = delta
+ .old_file()
+ .path()
+ .ok_or(error::Diff::PathUnavailable)?
+ .to_path_buf();
+ let new_path = delta
+ .new_file()
+ .path()
+ .ok_or(error::Diff::PathUnavailable)?
+ .to_path_buf();
+ let patch = git2::Patch::from_diff(git_diff, idx)?;
+ let old = DiffFile::try_from(delta.old_file())?;
+ let new = DiffFile::try_from(delta.new_file())?;
+
+ if delta.new_file().is_binary() {
+ diff.insert_moved(old_path, new_path, old, new, DiffContent::Binary);
+ } else if let Some(patch) = patch {
+ diff.insert_moved(old_path, new_path, old, new, DiffContent::try_from(patch)?);
+ } else {
+ diff.insert_moved(old_path, new_path, old, new, DiffContent::Empty);
+ }
+ Ok(())
+}
+
+fn copied(
+ diff: &mut Diff,
+ git_diff: &git2::Diff<'_>,
+ idx: usize,
+ delta: &git2::DiffDelta<'_>,
+) -> Result<(), error::Diff> {
+ let old_path = delta
+ .old_file()
+ .path()
+ .ok_or(error::Diff::PathUnavailable)?
+ .to_path_buf();
+ let new_path = delta
+ .new_file()
+ .path()
+ .ok_or(error::Diff::PathUnavailable)?
+ .to_path_buf();
+ let patch = git2::Patch::from_diff(git_diff, idx)?;
+ let old = DiffFile::try_from(delta.old_file())?;
+ let new = DiffFile::try_from(delta.new_file())?;
+
+ if delta.new_file().is_binary() {
+ diff.insert_copied(old_path, new_path, old, new, DiffContent::Binary);
+ } else if let Some(patch) = patch {
+ diff.insert_copied(old_path, new_path, old, new, DiffContent::try_from(patch)?);
+ } else {
+ diff.insert_copied(old_path, new_path, old, new, DiffContent::Empty);
+ }
+ Ok(())
+}
diff --git a/crates/radicle-surf/src/error.rs b/crates/radicle-surf/src/error.rs
new file mode 100644
index 000000000..1a790dc17
--- /dev/null
+++ b/crates/radicle-surf/src/error.rs
@@ -0,0 +1,39 @@
+//! Definition for a crate level error type, which wraps up module level
+//! error types transparently.
+
+use crate::{commit, diff, fs, glob, namespace, refs, repo};
+use thiserror::Error;
+
+/// The crate level error type that wraps up module level error types.
+#[derive(Debug, Error)]
+#[non_exhaustive]
+pub enum Error {
+ #[error(transparent)]
+ Branches(#[from] refs::error::Branch),
+ #[error(transparent)]
+ Categories(#[from] refs::error::Category),
+ #[error(transparent)]
+ Commit(#[from] commit::Error),
+ #[error(transparent)]
+ Diff(#[from] diff::git::error::Diff),
+ #[error(transparent)]
+ Directory(#[from] fs::error::Directory),
+ #[error(transparent)]
+ File(#[from] fs::error::File),
+ #[error(transparent)]
+ Git(#[from] git2::Error),
+ #[error(transparent)]
+ Glob(#[from] glob::Error),
+ #[error(transparent)]
+ Namespace(#[from] namespace::Error),
+ #[error(transparent)]
+ RefFormat(#[from] git_ext::ref_format::Error),
+ #[error(transparent)]
+ Revision(Box<dyn std::error::Error + Send + Sync + 'static>),
+ #[error(transparent)]
+ ToCommit(Box<dyn std::error::Error + Send + Sync + 'static>),
+ #[error(transparent)]
+ Tags(#[from] refs::error::Tag),
+ #[error(transparent)]
+ Repo(#[from] repo::error::Repo),
+}
diff --git a/crates/radicle-surf/src/fs.rs b/crates/radicle-surf/src/fs.rs
new file mode 100644
index 000000000..ce03062f2
--- /dev/null
+++ b/crates/radicle-surf/src/fs.rs
@@ -0,0 +1,611 @@
+//! Definition for a file system consisting of `Directory` and `File`.
+//!
+//! A `Directory` is expected to be a non-empty tree of directories and files.
+//! See [`Directory`] for more information.
+
+use std::{
+ cmp::Ordering,
+ collections::BTreeMap,
+ convert::{Infallible, Into as _},
+ path::{Path, PathBuf},
+};
+
+use git2::Blob;
+use radicle_git_ext::{is_not_found_err, Oid};
+use radicle_std_ext::result::ResultExt as _;
+use url::Url;
+
+use crate::{Repository, Revision};
+
+pub mod error {
+ use std::path::PathBuf;
+
+ use thiserror::Error;
+
+ #[derive(Debug, Error, PartialEq)]
+ pub enum Directory {
+ #[error(transparent)]
+ Git(#[from] git2::Error),
+ #[error(transparent)]
+ File(#[from] File),
+ #[error("the path {0} is not valid")]
+ InvalidPath(PathBuf),
+ #[error("the entry at '{0}' must be of type {1}")]
+ InvalidType(PathBuf, &'static str),
+ #[error("the entry name was not valid UTF-8")]
+ Utf8Error,
+ #[error("the path {0} not found")]
+ PathNotFound(PathBuf),
+ #[error(transparent)]
+ Submodule(#[from] Submodule),
+ }
+
+ #[derive(Debug, Error, PartialEq)]
+ pub enum File {
+ #[error(transparent)]
+ Git(#[from] git2::Error),
+ }
+
+ #[derive(Debug, Error, PartialEq)]
+ pub enum Submodule {
+ #[error("URL is invalid utf-8 for submodule '{name}': {err}")]
+ Utf8 {
+ name: String,
+ #[source]
+ err: std::str::Utf8Error,
+ },
+ #[error("failed to parse URL '{url}' for submodule '{name}': {err}")]
+ ParseUrl {
+ name: String,
+ url: String,
+ #[source]
+ err: url::ParseError,
+ },
+ }
+}
+
+/// A `File` in a git repository.
+///
+/// The representation is lightweight and contains the [`Oid`] that
+/// points to the git blob which is this file.
+///
+/// The name of a file can be retrieved via [`File::name`].
+///
+/// The [`FileContent`] of a file can be retrieved via
+/// [`File::content`].
+#[derive(Clone, PartialEq, Eq, Debug)]
+pub struct File {
+ /// The name of the file.
+ name: String,
+ /// The relative path of the file, not including the `name`,
+ /// in respect to the root of the git repository.
+ prefix: PathBuf,
+ /// The object identifier of the git blob of this file.
+ id: Oid,
+}
+
+impl File {
+ /// Construct a new `File`.
+ ///
+ /// The `path` must be the prefix location of the directory, and
+ /// so should not end in `name`.
+ ///
+ /// The `id` must point to a git blob.
+ pub(crate) fn new(name: String, prefix: PathBuf, id: Oid) -> Self {
+ debug_assert!(
+ !prefix.ends_with(&name),
+ "prefix = {prefix:?}, name = {name}",
+ );
+ Self { name, prefix, id }
+ }
+
+ /// The name of this `File`.
+ pub fn name(&self) -> &str {
+ self.name.as_str()
+ }
+
+ /// The object identifier of this `File`.
+ pub fn id(&self) -> Oid {
+ self.id
+ }
+
+ /// Return the exact path for this `File`, including the `name` of
+ /// the directory itself.
+ ///
+ /// The path is relative to the git repository root.
+ pub fn path(&self) -> PathBuf {
+ self.prefix.join(escaped_name(&self.name))
+ }
+
+ /// Return the [`Path`] where this `File` is located, relative to the
+ /// git repository root.
+ pub fn location(&self) -> &Path {
+ &self.prefix
+ }
+
+ /// Get the [`FileContent`] for this `File`.
+ ///
+ /// # Errors
+ ///
+ /// This function will fail if it could not find the `git` blob
+ /// for the `Oid` of this `File`.
+ pub fn content<'a>(&self, repo: &'a Repository) -> Result<FileContent<'a>, error::File> {
+ let blob = repo.find_blob(self.id)?;
+ Ok(FileContent { blob })
+ }
+}
+
+/// The contents of a [`File`].
+///
+/// To construct a `FileContent` use [`File::content`].
+pub struct FileContent<'a> {
+ blob: Blob<'a>,
+}
+
+impl<'a> FileContent<'a> {
+ /// Return the file contents as a byte slice.
+ pub fn as_bytes(&self) -> &[u8] {
+ self.blob.content()
+ }
+
+ /// Return the size of the file contents.
+ pub fn size(&self) -> usize {
+ self.blob.size()
+ }
+
+ /// Creates a `FileContent` using a blob.
+ pub(crate) fn new(blob: Blob<'a>) -> Self {
+ Self { blob }
+ }
+}
+
+/// A representations of a [`Directory`]'s entries.
+pub struct Entries {
+ listing: BTreeMap<String, Entry>,
+}
+
+impl Entries {
+ /// Return the name of each [`Entry`].
+ pub fn names(&self) -> impl Iterator<Item = &String> {
+ self.listing.keys()
+ }
+
+ /// Return each [`Entry`].
+ pub fn entries(&self) -> impl Iterator<Item = &Entry> {
+ self.listing.values()
+ }
+
+ /// Return each [`Entry`] and its name.
+ pub fn iter(&self) -> impl Iterator<Item = (&String, &Entry)> {
+ self.listing.iter()
+ }
+}
+
+impl Iterator for Entries {
+ type Item = Entry;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ // Can be improved when `pop_first()` is stable for BTreeMap.
+ let next_key = match self.listing.keys().next() {
+ Some(k) => k.clone(),
+ None => return None,
+ };
+ self.listing.remove(&next_key)
+ }
+}
+
+/// An `Entry` is either a [`File`] entry or a [`Directory`] entry.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Entry {
+ /// A file entry within a [`Directory`].
+ File(File),
+ /// A sub-directory of a [`Directory`].
+ Directory(Directory),
+ /// An entry points to a submodule.
+ Submodule(Submodule),
+}
+
+impl PartialOrd for Entry {
+ fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+ Some(self.cmp(other))
+ }
+}
+
+impl Ord for Entry {
+ fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+ match (self, other) {
+ (Entry::File(x), Entry::File(y)) => x.name().cmp(y.name()),
+ (Entry::File(_), Entry::Directory(_)) => Ordering::Less,
+ (Entry::File(_), Entry::Submodule(_)) => Ordering::Less,
+ (Entry::Directory(_), Entry::File(_)) => Ordering::Greater,
+ (Entry::Submodule(_), Entry::File(_)) => Ordering::Less,
+ (Entry::Directory(x), Entry::Directory(y)) => x.name().cmp(y.name()),
+ (Entry::Directory(x), Entry::Submodule(y)) => x.name().cmp(y.name()),
+ (Entry::Submodule(x), Entry::Directory(y)) => x.name().cmp(y.name()),
+ (Entry::Submodule(x), Entry::Submodule(y)) => x.name().cmp(y.name()),
+ }
+ }
+}
+
+impl Entry {
+ /// Get a label for the `Entriess`, either the name of the [`File`],
+ /// the name of the [`Directory`], or the name of the [`Submodule`].
+ pub fn name(&self) -> &String {
+ match self {
+ Entry::File(file) => &file.name,
+ Entry::Directory(directory) => directory.name(),
+ Entry::Submodule(submodule) => submodule.name(),
+ }
+ }
+
+ pub fn path(&self) -> PathBuf {
+ match self {
+ Entry::File(file) => file.path(),
+ Entry::Directory(directory) => directory.path(),
+ Entry::Submodule(submodule) => submodule.path(),
+ }
+ }
+
+ pub fn location(&self) -> &Path {
+ match self {
+ Entry::File(file) => file.location(),
+ Entry::Directory(directory) => directory.location(),
+ Entry::Submodule(submodule) => submodule.location(),
+ }
+ }
+
+ /// Returns `true` if the `Entry` is a file.
+ pub fn is_file(&self) -> bool {
+ matches!(self, Entry::File(_))
+ }
+
+ /// Returns `true` if the `Entry` is a directory.
+ pub fn is_directory(&self) -> bool {
+ matches!(self, Entry::Directory(_))
+ }
+
+ pub(crate) fn from_entry(
+ entry: &git2::TreeEntry,
+ path: PathBuf,
+ repo: &Repository,
+ ) -> Result<Self, error::Directory> {
+ let name = entry.name().ok_or(error::Directory::Utf8Error)?.to_string();
+ let id = entry.id().into();
+
+ match entry.kind() {
+ Some(git2::ObjectType::Tree) => Ok(Self::Directory(Directory::new(name, path, id))),
+ Some(git2::ObjectType::Blob) => Ok(Self::File(File::new(name, path, id))),
+ Some(git2::ObjectType::Commit) => {
+ let submodule = (!repo.is_bare())
+ .then(|| repo.find_submodule(&name))
+ .transpose()?;
+ Ok(Self::Submodule(Submodule::new(name, path, submodule, id)?))
+ }
+ _ => Err(error::Directory::InvalidType(path, "tree or blob")),
+ }
+ }
+}
+
+/// A `Directory` is the representation of a file system directory, for a given
+/// [`git` tree][git-tree].
+///
+/// The name of a directory can be retrieved via [`File::name`].
+///
+/// The [`Entries`] of a directory can be retrieved via
+/// [`Directory::entries`].
+///
+/// [git-tree]: https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Directory {
+ /// The name of the directoy.
+ name: String,
+ /// The relative path of the directory, not including the `name`,
+ /// in respect to the root of the git repository.
+ prefix: PathBuf,
+ /// The object identifier of the git tree of this directory.
+ id: Oid,
+}
+
+const ROOT_DIR: &str = "";
+
+impl Directory {
+ /// Creates a directory given its `tree_id`.
+ ///
+ /// The `name` and `prefix` are both set to be empty.
+ pub(crate) fn root(id: Oid) -> Self {
+ Self::new(ROOT_DIR.to_string(), PathBuf::new(), id)
+ }
+
+ /// Creates a directory given its `name` and `id`.
+ ///
+ /// The `path` must be the prefix location of the directory, and
+ /// so should not end in `name`.
+ ///
+ /// The `id` must point to a `git` tree.
+ pub(crate) fn new(name: String, prefix: PathBuf, id: Oid) -> Self {
+ debug_assert!(
+ name.is_empty() || !prefix.ends_with(&name),
+ "prefix = {prefix:?}, name = {name}",
+ );
+ Self { name, prefix, id }
+ }
+
+ /// Get the name of the current `Directory`.
+ pub fn name(&self) -> &String {
+ &self.name
+ }
+
+ /// The object identifier of this `[Directory]`.
+ pub fn id(&self) -> Oid {
+ self.id
+ }
+
+ /// Return the exact path for this `Directory`, including the `name` of the
+ /// directory itself.
+ ///
+ /// The path is relative to the git repository root.
+ pub fn path(&self) -> PathBuf {
+ self.prefix.join(escaped_name(&self.name))
+ }
+
+ /// Return the [`Path`] where this `Directory` is located, relative to the
+ /// git repository root.
+ pub fn location(&self) -> &Path {
+ &self.prefix
+ }
+
+ /// Return the [`Entries`] for this `Directory`'s `Oid`.
+ ///
+ /// The resulting `Entries` will only resolve to this
+ /// `Directory`'s entries. Any sub-directories will need to be
+ /// resolved independently.
+ ///
+ /// # Errors
+ ///
+ /// This function will fail if it could not find the `git` tree
+ /// for the `Oid`.
+ pub fn entries(&self, repo: &Repository) -> Result<Entries, error::Directory> {
+ let tree = repo.find_tree(self.id)?;
+
+ let mut entries = BTreeMap::new();
+ let mut error = None;
+ let path = self.path();
+
+ // Walks only the first level of entries. And `_entry_path` is always
+ // empty for the first level.
+ tree.walk(git2::TreeWalkMode::PreOrder, |_entry_path, entry| {
+ match Entry::from_entry(entry, path.clone(), repo) {
+ Ok(entry) => match entry {
+ Entry::File(_) => {
+ entries.insert(entry.name().clone(), entry);
+ git2::TreeWalkResult::Ok
+ }
+ Entry::Directory(_) => {
+ entries.insert(entry.name().clone(), entry);
+ // Skip nested directories
+ git2::TreeWalkResult::Skip
+ }
+ Entry::Submodule(_) => {
+ entries.insert(entry.name().clone(), entry);
+ git2::TreeWalkResult::Ok
+ }
+ },
+ Err(err) => {
+ error = Some(err);
+ git2::TreeWalkResult::Abort
+ }
+ }
+ })?;
+
+ match error {
+ Some(err) => Err(err),
+ None => Ok(Entries { listing: entries }),
+ }
+ }
+
+ /// Find the [`Entry`] found at a non-empty `path`, if it exists.
+ pub fn find_entry<P>(&self, path: &P, repo: &Repository) -> Result<Entry, error::Directory>
+ where
+ P: AsRef<Path>,
+ {
+ // Search the path in git2 tree.
+ let path = path.as_ref();
+ let git2_tree = repo.find_tree(self.id)?;
+ let entry = git2_tree
+ .get_path(path)
+ .or_matches::<error::Directory, _, _>(is_not_found_err, || {
+ Err(error::Directory::PathNotFound(path.to_path_buf()))
+ })?;
+ let parent = path
+ .parent()
+ .ok_or_else(|| error::Directory::InvalidPath(path.to_path_buf()))?;
+ let root_path = self.path().join(parent);
+
+ Entry::from_entry(&entry, root_path, repo)
+ }
+
+ /// Find the `Oid`, for a [`File`], found at `path`, if it exists.
+ pub fn find_file<P>(&self, path: &P, repo: &Repository) -> Result<File, error::Directory>
+ where
+ P: AsRef<Path>,
+ {
+ match self.find_entry(path, repo)? {
+ Entry::File(file) => Ok(file),
+ _ => Err(error::Directory::InvalidType(
+ path.as_ref().to_path_buf(),
+ "file",
+ )),
+ }
+ }
+
+ /// Find the `Directory` found at `path`, if it exists.
+ ///
+ /// If `path` is `ROOT_DIR` (i.e. an empty path), returns self.
+ pub fn find_directory<P>(&self, path: &P, repo: &Repository) -> Result<Self, error::Directory>
+ where
+ P: AsRef<Path>,
+ {
+ if path.as_ref() == Path::new(ROOT_DIR) {
+ return Ok(self.clone());
+ }
+
+ match self.find_entry(path, repo)? {
+ Entry::Directory(d) => Ok(d),
+ _ => Err(error::Directory::InvalidType(
+ path.as_ref().to_path_buf(),
+ "directory",
+ )),
+ }
+ }
+
+ // TODO(fintan): This is going to be a bit trickier so going to leave it out for
+ // now
+ #[allow(dead_code)]
+ fn fuzzy_find(_label: &Path) -> Vec<Self> {
+ unimplemented!()
+ }
+
+ /// Get the total size, in bytes, of a `Directory`. The size is
+ /// the sum of all files that can be reached from this `Directory`.
+ pub fn size(&self, repo: &Repository) -> Result<usize, error::Directory> {
+ self.traverse(repo, 0, &mut |size, entry| match entry {
+ Entry::File(file) => Ok(size + file.content(repo)?.size()),
+ Entry::Directory(dir) => Ok(size + dir.size(repo)?),
+ Entry::Submodule(_) => Ok(size),
+ })
+ }
+
+ /// Traverse the entire `Directory` using the `initial`
+ /// accumulator and the function `f`.
+ ///
+ /// For each [`Entry::Directory`] this will recursively call
+ /// [`Directory::traverse`] and obtain its [`Entries`].
+ ///
+ /// `Error` is the error type of the fallible function.
+ /// `B` is the type of the accumulator.
+ /// `F` is the fallible function that takes the accumulator and
+ /// the next [`Entry`], possibly providing the next accumulator
+ /// value.
+ pub fn traverse<Error, B, F>(
+ &self,
+ repo: &Repository,
+ initial: B,
+ f: &mut F,
+ ) -> Result<B, Error>
+ where
+ Error: From<error::Directory>,
+ F: FnMut(B, &Entry) -> Result<B, Error>,
+ {
+ self.entries(repo)?
+ .entries()
+ .try_fold(initial, |acc, entry| match entry {
+ Entry::File(_) => f(acc, entry),
+ Entry::Directory(directory) => {
+ let acc = directory.traverse(repo, acc, f)?;
+ f(acc, entry)
+ }
+ Entry::Submodule(_) => f(acc, entry),
+ })
+ }
+}
+
+impl Revision for Directory {
+ type Error = Infallible;
+
+ fn object_id(&self, _repo: &Repository) -> Result<Oid, Self::Error> {
+ Ok(self.id)
+ }
+}
+
+/// A representation of a Git [submodule] when encountered in a Git
+/// repository.
+///
+/// [submodule]: https://git-scm.com/book/en/v2/Git-Tools-Submodules
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Submodule {
+ name: String,
+ prefix: PathBuf,
+ id: Oid,
+ url: Option<Url>,
+}
+
+impl Submodule {
+ /// Construct a new `Submodule`.
+ ///
+ /// The `path` must be the prefix location of the directory, and
+ /// so should not end in `name`.
+ ///
+ /// The `id` is the commit pointer that Git provides when listing
+ /// a submodule.
+ pub fn new(
+ name: String,
+ prefix: PathBuf,
+ submodule: Option<git2::Submodule>,
+ id: Oid,
+ ) -> Result<Self, error::Submodule> {
+ let url = submodule
+ .and_then(|module| {
+ module
+ .opt_url_bytes()
+ .map(|bs| std::str::from_utf8(bs).map(|url| url.to_string()))
+ })
+ .transpose()
+ .map_err(|err| error::Submodule::Utf8 {
+ name: name.clone(),
+ err,
+ })?;
+ let url = url
+ .map(|url| {
+ Url::parse(&url).map_err(|err| error::Submodule::ParseUrl {
+ name: name.clone(),
+ url,
+ err,
+ })
+ })
+ .transpose()?;
+ Ok(Self {
+ name,
+ prefix,
+ id,
+ url,
+ })
+ }
+
+ /// The name of this `Submodule`.
+ pub fn name(&self) -> &String {
+ &self.name
+ }
+
+ /// Return the [`Path`] where this `Submodule` is located, relative to the
+ /// git repository root.
+ pub fn location(&self) -> &Path {
+ &self.prefix
+ }
+
+ /// Return the exact path for this `Submodule`, including the
+ /// `name` of the submodule itself.
+ ///
+ /// The path is relative to the git repository root.
+ pub fn path(&self) -> PathBuf {
+ self.prefix.join(escaped_name(&self.name))
+ }
+
+ /// The object identifier of this `Submodule`.
+ ///
+ /// Note that this does not exist in the parent `Repository`. A
+ /// new `Repository` should be opened for the submodule.
+ pub fn id(&self) -> Oid {
+ self.id
+ }
+
+ /// The URL for the submodule, if it is defined.
+ pub fn url(&self) -> &Option<Url> {
+ &self.url
+ }
+}
+
+/// When we need to escape "\" (represented as `\\`) for `PathBuf`
+/// so that it can be processed correctly.
+fn escaped_name(name: &str) -> String {
+ name.replace('\\', r"\\")
+}
diff --git a/crates/radicle-surf/src/glob.rs b/crates/radicle-surf/src/glob.rs
new file mode 100644
index 000000000..c5b0eb632
--- /dev/null
+++ b/crates/radicle-surf/src/glob.rs
@@ -0,0 +1,338 @@
+use std::marker::PhantomData;
+
+use git_ext::ref_format::{
+ self, refname,
+ refspec::{self, PatternString, QualifiedPattern},
+ Qualified, RefStr, RefString,
+};
+use thiserror::Error;
+
+use crate::{Branch, Local, Namespace, Remote, Tag};
+
+#[derive(Debug, Error)]
+pub enum Error {
+ #[error(transparent)]
+ RefFormat(#[from] ref_format::Error),
+}
+
+/// A collection of globs for a git reference type.
+#[derive(Clone, Debug)]
+pub struct Glob<T> {
+ globs: Vec<QualifiedPattern<'static>>,
+ glob_type: PhantomData<T>, // To support different methods for different T.
+}
+
+impl<T> Default for Glob<T> {
+ fn default() -> Self {
+ Self {
+ globs: Default::default(),
+ glob_type: PhantomData,
+ }
+ }
+}
+
+impl<T> Glob<T> {
+ /// Return the [`QualifiedPattern`] globs of this `Glob`.
+ pub fn globs(&self) -> impl Iterator<Item = &QualifiedPattern<'static>> {
+ self.globs.iter()
+ }
+
+ /// Combine two `Glob`s together by combining their glob lists together.
+ ///
+ /// Note that the `Glob`s must result in the same type,
+ /// e.g. `Glob<Tag>` can only combine with `Glob<Tag>`,
+ /// `Glob<Local>` can combine with `Glob<Remote>`, etc.
+ pub fn and(mut self, other: impl Into<Self>) -> Self {
+ self.globs.extend(other.into().globs);
+ self
+ }
+}
+
+impl Glob<Namespace> {
+ /// Creates the `Glob` that matches all `refs/namespaces`.
+ pub fn all_namespaces() -> Self {
+ Self::namespaces(refspec::pattern!("*"))
+ }
+
+ /// Creates a `Glob` for `refs/namespaces`, starting with `glob`.
+ pub fn namespaces(glob: PatternString) -> Self {
+ let globs = vec![Self::qualify(glob)];
+ Self {
+ globs,
+ glob_type: PhantomData,
+ }
+ }
+
+ /// Adds a `refs/namespaces` pattern to this `Glob`.
+ pub fn insert(mut self, glob: PatternString) -> Self {
+ self.globs.push(Self::qualify(glob));
+ self
+ }
+
+ fn qualify(glob: PatternString) -> QualifiedPattern<'static> {
+ qualify(&refname!("refs/namespaces"), glob).expect("BUG: pattern is qualified")
+ }
+}
+
+impl FromIterator<PatternString> for Glob<Namespace> {
+ fn from_iter<T: IntoIterator<Item = PatternString>>(iter: T) -> Self {
+ let globs = iter
+ .into_iter()
+ .map(|pat| {
+ qualify(&refname!("refs/namespaces"), pat).expect("BUG: pattern is qualified")
+ })
+ .collect();
+
+ Self {
+ globs,
+ glob_type: PhantomData,
+ }
+ }
+}
+
+impl Extend<PatternString> for Glob<Namespace> {
+ fn extend<T: IntoIterator<Item = PatternString>>(&mut self, iter: T) {
+ self.globs.extend(iter.into_iter().map(|pat| {
+ qualify(&refname!("refs/namespaces"), pat).expect("BUG: pattern is qualified")
+ }))
+ }
+}
+
+impl Glob<Tag> {
+ /// Creates a `Glob` that matches all `refs/tags`.
+ pub fn all_tags() -> Self {
+ Self::tags(refspec::pattern!("*"))
+ }
+
+ /// Creates a `Glob` for `refs/tags`, starting with `glob`.
+ pub fn tags(glob: PatternString) -> Self {
+ let globs = vec![Self::qualify(glob)];
+ Self {
+ globs,
+ glob_type: PhantomData,
+ }
+ }
+
+ /// Adds a `refs/tags` pattern to this `Glob`.
+ pub fn insert(mut self, glob: PatternString) -> Self {
+ self.globs.push(Self::qualify(glob));
+ self
+ }
+
+ fn qualify(glob: PatternString) -> QualifiedPattern<'static> {
+ qualify(&refname!("refs/tags"), glob).expect("BUG: pattern is qualified")
+ }
+}
+
+impl FromIterator<PatternString> for Glob<Tag> {
+ fn from_iter<T: IntoIterator<Item = PatternString>>(iter: T) -> Self {
+ let globs = iter
+ .into_iter()
+ .map(|pat| qualify(&refname!("refs/tags"), pat).expect("BUG: pattern is qualified"))
+ .collect();
+
+ Self {
+ globs,
+ glob_type: PhantomData,
+ }
+ }
+}
+
+impl Extend<PatternString> for Glob<Tag> {
+ fn extend<T: IntoIterator<Item = PatternString>>(&mut self, iter: T) {
+ self.globs.extend(
+ iter.into_iter()
+ .map(|pat| qualify(&refname!("refs/tag"), pat).expect("BUG: pattern is qualified")),
+ )
+ }
+}
+
+impl Glob<Local> {
+ /// Creates the `Glob` that matches all `refs/heads`.
+ pub fn all_heads() -> Self {
+ Self::heads(refspec::pattern!("*"))
+ }
+
+ /// Creates a `Glob` for `refs/heads`, starting with `glob`.
+ pub fn heads(glob: PatternString) -> Self {
+ let globs = vec![Self::qualify_heads(glob)];
+ Self {
+ globs,
+ glob_type: PhantomData,
+ }
+ }
+
+ /// Adds a `refs/heads` pattern to this `Glob`.
+ pub fn insert(mut self, glob: PatternString) -> Self {
+ self.globs.push(Self::qualify_heads(glob));
+ self
+ }
+
+ /// When chaining `Glob<Local>` with `Glob<Remote>`, use
+ /// `branches` to convert this `Glob<Local>` into a
+ /// `Glob<Branch>`.
+ ///
+ /// # Example
+ /// ```no_run
+ /// Glob::heads(pattern!("features/*"))
+ /// .insert(pattern!("qa/*"))
+ /// .branches()
+ /// .and(Glob::remotes(pattern!("origin/features/*")))
+ /// ```
+ pub fn branches(self) -> Glob<Branch> {
+ self.into()
+ }
+
+ fn qualify_heads(glob: PatternString) -> QualifiedPattern<'static> {
+ qualify(&refname!("refs/heads"), glob).expect("BUG: pattern is qualified")
+ }
+}
+
+impl FromIterator<PatternString> for Glob<Local> {
+ fn from_iter<T: IntoIterator<Item = PatternString>>(iter: T) -> Self {
+ let globs = iter
+ .into_iter()
+ .map(|pat| qualify(&refname!("refs/heads"), pat).expect("BUG: pattern is qualified"))
+ .collect();
+
+ Self {
+ globs,
+ glob_type: PhantomData,
+ }
+ }
+}
+
+impl Extend<PatternString> for Glob<Local> {
+ fn extend<T: IntoIterator<Item = PatternString>>(&mut self, iter: T) {
+ self.globs.extend(
+ iter.into_iter().map(|pat| {
+ qualify(&refname!("refs/heads"), pat).expect("BUG: pattern is qualified")
+ }),
+ )
+ }
+}
+
+impl From<Glob<Local>> for Glob<Branch> {
+ fn from(Glob { globs, .. }: Glob<Local>) -> Self {
+ Self {
+ globs,
+ glob_type: PhantomData,
+ }
+ }
+}
+
+impl Glob<Remote> {
+ /// Creates the `Glob` that matches all `refs/remotes`.
+ pub fn all_remotes() -> Self {
+ Self::remotes(refspec::pattern!("*"))
+ }
+
+ /// Creates a `Glob` for `refs/remotes`, starting with `glob`.
+ pub fn remotes(glob: PatternString) -> Self {
+ let globs = vec![Self::qualify_remotes(glob)];
+ Self {
+ globs,
+ glob_type: PhantomData,
+ }
+ }
+
+ /// Adds a `refs/remotes` pattern to this `Glob`.
+ pub fn insert(mut self, glob: PatternString) -> Self {
+ self.globs.push(Self::qualify_remotes(glob));
+ self
+ }
+
+ /// When chaining `Glob<Remote>` with `Glob<Local>`, use
+ /// `branches` to convert this `Glob<Remote>` into a
+ /// `Glob<Branch>`.
+ ///
+ /// # Example
+ /// ```no_run
+ /// Glob::remotes(pattern!("origin/features/*"))
+ /// .insert(pattern!("origin/qa/*"))
+ /// .branches()
+ /// .and(Glob::heads(pattern!("features/*")))
+ /// ```
+ pub fn branches(self) -> Glob<Branch> {
+ self.into()
+ }
+
+ fn qualify_remotes(glob: PatternString) -> QualifiedPattern<'static> {
+ qualify(&refname!("refs/remotes"), glob).expect("BUG: pattern is qualified")
+ }
+}
+
+impl FromIterator<PatternString> for Glob<Remote> {
+ fn from_iter<T: IntoIterator<Item = PatternString>>(iter: T) -> Self {
+ let globs = iter
+ .into_iter()
+ .map(|pat| qualify(&refname!("refs/remotes"), pat).expect("BUG: pattern is qualified"))
+ .collect();
+
+ Self {
+ globs,
+ glob_type: PhantomData,
+ }
+ }
+}
+
+impl Extend<PatternString> for Glob<Remote> {
+ fn extend<T: IntoIterator<Item = PatternString>>(&mut self, iter: T) {
+ self.globs.extend(
+ iter.into_iter().map(|pat| {
+ qualify(&refname!("refs/remotes"), pat).expect("BUG: pattern is qualified")
+ }),
+ )
+ }
+}
+
+impl From<Glob<Remote>> for Glob<Branch> {
+ fn from(Glob { globs, .. }: Glob<Remote>) -> Self {
+ Self {
+ globs,
+ glob_type: PhantomData,
+ }
+ }
+}
+
+impl Glob<Qualified<'_>> {
+ pub fn all_category<R: AsRef<RefStr>>(category: R) -> Self {
+ Self {
+ globs: vec![Self::qualify_category(category, refspec::pattern!("*"))],
+ glob_type: PhantomData,
+ }
+ }
+
+ /// Creates a `Glob` for `refs/<category>`, starting with `glob`.
+ pub fn categories<R>(category: R, glob: PatternString) -> Self
+ where
+ R: AsRef<RefStr>,
+ {
+ let globs = vec![Self::qualify_category(category, glob)];
+ Self {
+ globs,
+ glob_type: PhantomData,
+ }
+ }
+
+ /// Adds a `refs/<category>` pattern to this `Glob`.
+ pub fn insert<R>(mut self, category: R, glob: PatternString) -> Self
+ where
+ R: AsRef<RefStr>,
+ {
+ self.globs.push(Self::qualify_category(category, glob));
+ self
+ }
+
+ fn qualify_category<R>(category: R, glob: PatternString) -> QualifiedPattern<'static>
+ where
+ R: AsRef<RefStr>,
+ {
+ let prefix = refname!("refs").and(category);
+ qualify(&prefix, glob).expect("BUG: pattern is qualified")
+ }
+}
+
+fn qualify(prefix: &RefString, glob: PatternString) -> Option<QualifiedPattern<'static>> {
+ prefix.to_pattern(glob).qualified().map(|q| q.into_owned())
+}
diff --git a/crates/radicle-surf/src/history.rs b/crates/radicle-surf/src/history.rs
new file mode 100644
index 000000000..2ad41e13f
--- /dev/null
+++ b/crates/radicle-surf/src/history.rs
@@ -0,0 +1,98 @@
+use std::{
+ convert::TryFrom,
+ path::{Path, PathBuf},
+};
+
+use crate::{Commit, Error, Repository, ToCommit};
+
+/// An iterator that produces the history of commits for a given `head`.
+///
+/// The lifetime of this struct is attached to the underlying [`Repository`].
+pub struct History<'a> {
+ repo: &'a Repository,
+ head: Commit,
+ revwalk: git2::Revwalk<'a>,
+ filter_by: Option<FilterBy>,
+}
+
+/// Internal implementation, subject to refactoring.
+enum FilterBy {
+ File { path: PathBuf },
+}
+
+impl<'a> History<'a> {
+ /// Creates a new history starting from `head`, in `repo`.
+ pub(crate) fn new<C: ToCommit>(repo: &'a Repository, head: C) -> Result<Self, Error> {
+ let head = head
+ .to_commit(repo)
+ .map_err(|err| Error::ToCommit(err.into()))?;
+ let mut revwalk = repo.revwalk()?;
+ revwalk.push(head.id.into())?;
+ let history = Self {
+ repo,
+ head,
+ revwalk,
+ filter_by: None,
+ };
+ Ok(history)
+ }
+
+ /// Returns the first commit (i.e. the head) in the history.
+ pub fn head(&self) -> &Commit {
+ &self.head
+ }
+
+ /// Returns a modified `History` filtered by `path`.
+ ///
+ /// Note that it is possible that a filtered History becomes empty,
+ /// even though calling `.head()` still returns the original head.
+ pub fn by_path<P>(mut self, path: &P) -> Self
+ where
+ P: AsRef<Path>,
+ {
+ self.filter_by = Some(FilterBy::File {
+ path: path.as_ref().to_path_buf(),
+ });
+ self
+ }
+}
+
+impl Iterator for History<'_> {
+ type Item = Result<Commit, Error>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ // Loop through the commits with the optional filtering.
+ while let Some(oid) = self.revwalk.next() {
+ let found = oid
+ .map_err(Error::Git)
+ .and_then(|oid| {
+ let commit = self.repo.find_commit(oid.into())?;
+
+ // Handles the optional filter_by.
+ if let Some(FilterBy::File { path }) = &self.filter_by {
+ // Only check the commit diff if the path is not empty.
+ if !path.as_os_str().is_empty() {
+ let path_opt = self.repo.diff_commit_and_parents(path, &commit)?;
+ if path_opt.is_none() {
+ return Ok(None); // Filter out this commit.
+ }
+ }
+ }
+
+ let commit = Commit::try_from(commit)?;
+ Ok(Some(commit))
+ })
+ .transpose();
+ if found.is_some() {
+ return found;
+ }
+ }
+ None
+ }
+}
+
+impl std::fmt::Debug for History<'_> {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "History of {}", self.head.id)
+ }
+}
diff --git a/crates/radicle-surf/src/lib.rs b/crates/radicle-surf/src/lib.rs
new file mode 100644
index 000000000..c577b0651
--- /dev/null
+++ b/crates/radicle-surf/src/lib.rs
@@ -0,0 +1,62 @@
+//! `radicle-surf` is a library to help users explore a Git repository with
+//! ease. It supports browsing a repository via the concept of files and
+//! directories, or via blobs and trees in a git fashion. With the additional
+//! support of [`diff::Diff`] and [`History`], this library can be used to build
+//! an intuitive UI for any Git repository.
+//!
+//! The main entry point of the library API is [`Repository`].
+//!
+//! Let's start surfing!
+//!
+//! ## Serialization with feature `serde`
+//!
+//! Many types in this crate support serialization using [`Serde`][serde]
+//! through the `serde` feature flag for this crate.
+//!
+//! [serde]: https://crates.io/crates/serde
+
+extern crate radicle_git_ext as git_ext;
+
+/// Re-exports.
+pub use radicle_git_ext::ref_format;
+
+/// Represents an object id in Git. Re-exported from `radicle-git-ext`.
+pub type Oid = radicle_git_ext::Oid;
+
+pub mod blob;
+pub mod diff;
+pub mod fs;
+pub mod tree;
+
+/// Private modules with their public types.
+mod repo;
+pub use repo::Repository;
+
+mod glob;
+pub use glob::Glob;
+
+mod history;
+pub use history::History;
+
+mod branch;
+pub use branch::{Branch, Local, Remote};
+
+mod tag;
+pub use tag::Tag;
+
+mod commit;
+pub use commit::{Author, Commit, Time};
+
+mod namespace;
+pub use namespace::Namespace;
+
+mod stats;
+pub use stats::Stats;
+
+mod revision;
+pub use revision::{Revision, Signature, ToCommit};
+
+mod refs;
+
+mod error;
+pub use error::Error;
diff --git a/crates/radicle-surf/src/namespace.rs b/crates/radicle-surf/src/namespace.rs
new file mode 100644
index 000000000..590ff03f1
--- /dev/null
+++ b/crates/radicle-surf/src/namespace.rs
@@ -0,0 +1,169 @@
+use std::{
+ convert::TryFrom,
+ fmt,
+ str::{self, FromStr},
+};
+
+use git_ext::ref_format::{
+ self,
+ refspec::{NamespacedPattern, PatternString, QualifiedPattern},
+ Component, Namespaced, Qualified, RefStr, RefString,
+};
+use nonempty::NonEmpty;
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+pub enum Error {
+ /// When parsing a namespace we may come across one that was an empty
+ /// string.
+ #[error("namespaces must not be empty")]
+ EmptyNamespace,
+ #[error(transparent)]
+ RefFormat(#[from] ref_format::Error),
+ #[error(transparent)]
+ Utf8(#[from] str::Utf8Error),
+}
+
+/// A `Namespace` value allows us to switch the git namespace of
+/// a repo.
+///
+/// A `Namespace` is one or more name components separated by `/`, e.g. `surf`,
+/// `surf/git`.
+///
+/// For each `Namespace`, the reference name will add a single `refs/namespaces`
+/// prefix, e.g. `refs/namespaces/surf`,
+/// `refs/namespaces/surf/refs/namespaces/git`.
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct Namespace {
+ // XXX: we rely on RefString being non-empty here, which
+ // git-ref-format ensures that there's no way to construct one.
+ pub(super) namespaces: RefString,
+}
+
+impl Namespace {
+ /// Take a `Qualified` reference name and convert it to a `Namespaced` using
+ /// this `Namespace`.
+ ///
+ /// # Example
+ ///
+ /// ```no_run
+ /// let ns = "surf/git".parse::<Namespace>();
+ /// let name = ns.to_namespaced(qualified!("refs/heads/main"));
+ /// assert_eq!(
+ /// name.as_str(),
+ /// "refs/namespaces/surf/refs/namespaces/git/refs/heads/main"
+ /// );
+ /// ```
+ pub(crate) fn to_namespaced<'a>(&self, name: &Qualified<'a>) -> Namespaced<'a> {
+ let mut components = self.namespaces.components().rev();
+ let mut namespaced = name.with_namespace(
+ components
+ .next()
+ .expect("BUG: 'namespaces' cannot be empty"),
+ );
+ for ns in components {
+ let qualified = namespaced.into_qualified();
+ namespaced = qualified.with_namespace(ns);
+ }
+ namespaced
+ }
+
+ /// Take a `QualifiedPattern` reference name and convert it to a
+ /// `NamespacedPattern` using this `Namespace`.
+ ///
+ /// # Example
+ ///
+ /// ```no_run
+ /// let ns = "surf/git".parse::<Namespace>();
+ /// let name = ns.to_namespaced(pattern!("refs/heads/*").to_qualified().unwrap());
+ /// assert_eq!(
+ /// name.as_str(),
+ /// "refs/namespaces/surf/refs/namespaces/git/refs/heads/*"
+ /// );
+ /// ```
+ pub(crate) fn to_namespaced_pattern<'a>(
+ &self,
+ pat: &QualifiedPattern<'a>,
+ ) -> NamespacedPattern<'a> {
+ let pattern = PatternString::from(self.namespaces.clone());
+ let mut components = pattern.components().rev();
+ let mut namespaced = pat
+ .with_namespace(
+ components
+ .next()
+ .expect("BUG: 'namespaces' cannot be empty"),
+ )
+ .expect("BUG: 'namespace' cannot have globs");
+ for ns in components {
+ let qualified = namespaced.into_qualified();
+ namespaced = qualified
+ .with_namespace(ns)
+ .expect("BUG: 'namespaces' cannot have globs");
+ }
+ namespaced
+ }
+}
+
+impl fmt::Display for Namespace {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.namespaces)
+ }
+}
+
+impl<'a> From<NonEmpty<Component<'a>>> for Namespace {
+ fn from(cs: NonEmpty<Component<'a>>) -> Self {
+ Self {
+ namespaces: cs.into_iter().collect::<RefString>(),
+ }
+ }
+}
+
+impl TryFrom<&str> for Namespace {
+ type Error = Error;
+
+ fn try_from(name: &str) -> Result<Self, Self::Error> {
+ Self::from_str(name)
+ }
+}
+
+impl TryFrom<&[u8]> for Namespace {
+ type Error = Error;
+
+ fn try_from(namespace: &[u8]) -> Result<Self, Self::Error> {
+ str::from_utf8(namespace)
+ .map_err(Error::from)
+ .and_then(Self::from_str)
+ }
+}
+
+impl FromStr for Namespace {
+ type Err = Error;
+
+ fn from_str(name: &str) -> Result<Self, Self::Err> {
+ let namespaces = RefStr::try_from_str(name)?.to_ref_string();
+ Ok(Self { namespaces })
+ }
+}
+
+impl From<Namespaced<'_>> for Namespace {
+ fn from(namespaced: Namespaced<'_>) -> Self {
+ let mut namespaces = namespaced.namespace().to_ref_string();
+ let mut qualified = namespaced.strip_namespace();
+ while let Some(namespaced) = qualified.to_namespaced() {
+ namespaces.push(namespaced.namespace());
+ qualified = namespaced.strip_namespace();
+ }
+ Self { namespaces }
+ }
+}
+
+impl TryFrom<&git2::Reference<'_>> for Namespace {
+ type Error = Error;
+
+ fn try_from(reference: &git2::Reference) -> Result<Self, Self::Error> {
+ let name = RefStr::try_from_str(str::from_utf8(reference.name_bytes())?)?;
+ name.to_namespaced()
+ .ok_or(Error::EmptyNamespace)
+ .map(Self::from)
+ }
+}
diff --git a/crates/radicle-surf/src/refs.rs b/crates/radicle-surf/src/refs.rs
new file mode 100644
index 000000000..e0b9d71c4
--- /dev/null
+++ b/crates/radicle-surf/src/refs.rs
@@ -0,0 +1,246 @@
+// I think the following `Tags` and `Branches` would be merged
+// using Generic associated types supported in Rust 1.65.0.
+
+use std::{
+ collections::{btree_set, BTreeSet},
+ convert::TryFrom as _,
+};
+
+use git_ext::ref_format::{self, lit, name::Components, Component, Qualified, RefString};
+
+use crate::{tag, Branch, Namespace, Tag};
+
+/// Iterator over [`Tag`]s.
+#[derive(Default)]
+pub struct Tags<'a> {
+ references: Vec<git2::References<'a>>,
+ current: usize,
+}
+
+/// Iterator over the [`Qualified`] names of [`Tag`]s.
+pub struct TagNames<'a> {
+ inner: Tags<'a>,
+}
+
+impl<'a> Tags<'a> {
+ pub(super) fn push(&mut self, references: git2::References<'a>) {
+ self.references.push(references)
+ }
+
+ pub fn names(self) -> TagNames<'a> {
+ TagNames { inner: self }
+ }
+}
+
+impl Iterator for Tags<'_> {
+ type Item = Result<Tag, error::Tag>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ while self.current < self.references.len() {
+ match self.references.get_mut(self.current) {
+ Some(refs) => match refs.next() {
+ Some(res) => {
+ return Some(
+ res.map_err(error::Tag::from)
+ .and_then(|r| Tag::try_from(&r).map_err(error::Tag::from)),
+ );
+ }
+ None => self.current += 1,
+ },
+ None => break,
+ }
+ }
+ None
+ }
+}
+
+impl Iterator for TagNames<'_> {
+ type Item = Result<Qualified<'static>, error::Tag>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ while self.inner.current < self.inner.references.len() {
+ match self.inner.references.get_mut(self.inner.current) {
+ Some(refs) => match refs.next() {
+ Some(res) => {
+ return Some(res.map_err(error::Tag::from).and_then(|r| {
+ tag::reference_name(&r)
+ .map(|name| lit::refs_tags(name).into())
+ .map_err(error::Tag::from)
+ }))
+ }
+ None => self.inner.current += 1,
+ },
+ None => break,
+ }
+ }
+ None
+ }
+}
+
+/// Iterator over [`Branch`]es.
+#[derive(Default)]
+pub struct Branches<'a> {
+ references: Vec<git2::References<'a>>,
+ current: usize,
+}
+
+/// Iterator over the [`Qualified`] names of [`Branch`]es.
+pub struct BranchNames<'a> {
+ inner: Branches<'a>,
+}
+
+impl<'a> Branches<'a> {
+ pub(super) fn push(&mut self, references: git2::References<'a>) {
+ self.references.push(references)
+ }
+
+ pub fn names(self) -> BranchNames<'a> {
+ BranchNames { inner: self }
+ }
+}
+
+impl Iterator for Branches<'_> {
+ type Item = Result<Branch, error::Branch>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ while self.current < self.references.len() {
+ match self.references.get_mut(self.current) {
+ Some(refs) => match refs.next() {
+ Some(res) => {
+ return Some(
+ res.map_err(error::Branch::from)
+ .and_then(|r| Branch::try_from(&r).map_err(error::Branch::from)),
+ )
+ }
+ None => self.current += 1,
+ },
+ None => break,
+ }
+ }
+ None
+ }
+}
+
+impl Iterator for BranchNames<'_> {
+ type Item = Result<Qualified<'static>, error::Branch>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ while self.inner.current < self.inner.references.len() {
+ match self.inner.references.get_mut(self.inner.current) {
+ Some(refs) => match refs.next() {
+ Some(res) => {
+ return Some(res.map_err(error::Branch::from).and_then(|r| {
+ Branch::try_from(&r)
+ .map(|branch| branch.refname().into_owned())
+ .map_err(error::Branch::from)
+ }))
+ }
+ None => self.inner.current += 1,
+ },
+ None => break,
+ }
+ }
+ None
+ }
+}
+
+// TODO: not sure this buys us much
+/// An iterator for namespaces.
+pub struct Namespaces {
+ namespaces: btree_set::IntoIter<Namespace>,
+}
+
+impl Namespaces {
+ pub(super) fn new(namespaces: BTreeSet<Namespace>) -> Self {
+ Self {
+ namespaces: namespaces.into_iter(),
+ }
+ }
+}
+
+impl Iterator for Namespaces {
+ type Item = Namespace;
+ fn next(&mut self) -> Option<Self::Item> {
+ self.namespaces.next()
+ }
+}
+
+#[derive(Default)]
+pub struct Categories<'a> {
+ references: Vec<git2::References<'a>>,
+ current: usize,
+}
+
+impl<'a> Categories<'a> {
+ pub(super) fn push(&mut self, references: git2::References<'a>) {
+ self.references.push(references)
+ }
+}
+
+impl Iterator for Categories<'_> {
+ type Item = Result<(RefString, RefString), error::Category>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ while self.current < self.references.len() {
+ match self.references.get_mut(self.current) {
+ Some(refs) => match refs.next() {
+ Some(res) => {
+ return Some(res.map_err(error::Category::from).and_then(|r| {
+ let name = std::str::from_utf8(r.name_bytes())?;
+ let name = ref_format::RefStr::try_from_str(name)?;
+ let name = name.qualified().ok_or_else(|| {
+ error::Category::NotQualified(name.to_ref_string())
+ })?;
+ let (_refs, category, c, cs) = name.non_empty_components();
+ Ok((category.to_ref_string(), refstr_join(c, cs)))
+ }));
+ }
+ None => self.current += 1,
+ },
+ None => break,
+ }
+ }
+ None
+ }
+}
+
+pub mod error {
+ use std::str;
+
+ use radicle_git_ext::ref_format::{self, RefString};
+ use thiserror::Error;
+
+ use crate::{branch, tag};
+
+ #[derive(Debug, Error)]
+ pub enum Branch {
+ #[error(transparent)]
+ Git(#[from] git2::Error),
+ #[error(transparent)]
+ Branch(#[from] branch::error::Branch),
+ }
+
+ #[derive(Debug, Error)]
+ pub enum Category {
+ #[error(transparent)]
+ Git(#[from] git2::Error),
+ #[error("the reference '{0}' was expected to be qualified, i.e. 'refs/<category>/<path>'")]
+ NotQualified(RefString),
+ #[error(transparent)]
+ RefFormat(#[from] ref_format::Error),
+ #[error(transparent)]
+ Utf8(#[from] str::Utf8Error),
+ }
+
+ #[derive(Debug, Error)]
+ pub enum Tag {
+ #[error(transparent)]
+ Git(#[from] git2::Error),
+ #[error(transparent)]
+ Tag(#[from] tag::error::FromReference),
+ }
+}
+
+pub(crate) fn refstr_join<'a>(c: Component<'a>, cs: Components<'a>) -> RefString {
+ std::iter::once(c).chain(cs).collect::<RefString>()
+}
diff --git a/crates/radicle-surf/src/repo.rs b/crates/radicle-surf/src/repo.rs
new file mode 100644
index 000000000..97c8a57aa
--- /dev/null
+++ b/crates/radicle-surf/src/repo.rs
@@ -0,0 +1,564 @@
+use std::{
+ collections::BTreeSet,
+ convert::TryFrom,
+ path::{Path, PathBuf},
+ str,
+};
+
+use git_ext::{
+ ref_format::{refspec::QualifiedPattern, Qualified, RefStr, RefString},
+ Oid,
+};
+
+use crate::{
+ blob::{Blob, BlobRef},
+ diff::{Diff, FileDiff},
+ fs::{Directory, File, FileContent},
+ refs::{BranchNames, Branches, Categories, Namespaces, TagNames, Tags},
+ tree::{Entry, Tree},
+ Branch, Commit, Error, Glob, History, Namespace, Revision, Signature, Stats, Tag, ToCommit,
+};
+
+/// Enumeration of errors that can occur in repo operations.
+pub mod error {
+ use std::path::PathBuf;
+ use thiserror::Error;
+
+ #[derive(Debug, Error)]
+ #[non_exhaustive]
+ pub enum Repo {
+ #[error("path not found for: {0}")]
+ PathNotFound(PathBuf),
+ }
+}
+
+/// Represents the state associated with a Git repository.
+///
+/// Many other types in this crate are derived from methods in this struct.
+pub struct Repository {
+ /// Wrapper around the `git2`'s `git2::Repository` type.
+ /// This is to to limit the functionality that we can do
+ /// on the underlying object.
+ inner: git2::Repository,
+}
+
+////////////////////////////////////////////
+// Public API, ONLY add `pub fn` in here. //
+////////////////////////////////////////////
+impl Repository {
+ /// Open a git repository given its exact URI.
+ ///
+ /// # Errors
+ ///
+ /// * [`Error::Git`]
+ pub fn open(repo_uri: impl AsRef<std::path::Path>) -> Result<Self, Error> {
+ let repo = git2::Repository::open(repo_uri)?;
+ Ok(Self { inner: repo })
+ }
+
+ /// Attempt to open a git repository at or above `repo_uri` in the file
+ /// system.
+ pub fn discover(repo_uri: impl AsRef<std::path::Path>) -> Result<Self, Error> {
+ let repo = git2::Repository::discover(repo_uri)?;
+ Ok(Self { inner: repo })
+ }
+
+ /// What is the current namespace we're browsing in.
+ pub fn which_namespace(&self) -> Result<Option<Namespace>, Error> {
+ self.inner
+ .namespace_bytes()
+ .map(|ns| Namespace::try_from(ns).map_err(Error::from))
+ .transpose()
+ }
+
+ /// Switch to a `namespace`
+ pub fn switch_namespace(&self, namespace: &RefString) -> Result<(), Error> {
+ Ok(self.inner.set_namespace(namespace.as_str())?)
+ }
+
+ pub fn with_namespace<T, F>(&self, namespace: &RefString, f: F) -> Result<T, Error>
+ where
+ F: FnOnce() -> Result<T, Error>,
+ {
+ self.switch_namespace(namespace)?;
+ let res = f();
+ self.inner.remove_namespace()?;
+ res
+ }
+
+ /// Returns an iterator of branches that match `pattern`.
+ pub fn branches<'a, G>(&'a self, pattern: G) -> Result<Branches<'a>, Error>
+ where
+ G: Into<Glob<Branch>>,
+ {
+ let pattern = pattern.into();
+ let mut branches = Branches::default();
+ for glob in pattern.globs() {
+ let namespaced = self.namespaced_pattern(glob)?;
+ let references = self.inner.references_glob(&namespaced)?;
+ branches.push(references);
+ }
+ Ok(branches)
+ }
+
+ /// Lists branch names with `filter`.
+ pub fn branch_names<'a, G>(&'a self, filter: G) -> Result<BranchNames<'a>, Error>
+ where
+ G: Into<Glob<Branch>>,
+ {
+ Ok(self.branches(filter)?.names())
+ }
+
+ /// Returns an iterator of tags that match `pattern`.
+ pub fn tags<'a>(&'a self, pattern: &Glob<Tag>) -> Result<Tags<'a>, Error> {
+ let mut tags = Tags::default();
+ for glob in pattern.globs() {
+ let namespaced = self.namespaced_pattern(glob)?;
+ let references = self.inner.references_glob(&namespaced)?;
+ tags.push(references);
+ }
+ Ok(tags)
+ }
+
+ /// Lists tag names in the local RefScope.
+ pub fn tag_names<'a>(&'a self, filter: &Glob<Tag>) -> Result<TagNames<'a>, Error> {
+ Ok(self.tags(filter)?.names())
+ }
+
+ pub fn categories<'a>(
+ &'a self,
+ pattern: &Glob<Qualified<'_>>,
+ ) -> Result<Categories<'a>, Error> {
+ let mut cats = Categories::default();
+ for glob in pattern.globs() {
+ let namespaced = self.namespaced_pattern(glob)?;
+ let references = self.inner.references_glob(&namespaced)?;
+ cats.push(references);
+ }
+ Ok(cats)
+ }
+
+ /// Returns an iterator of namespaces that match `pattern`.
+ pub fn namespaces(&self, pattern: &Glob<Namespace>) -> Result<Namespaces, Error> {
+ let mut set = BTreeSet::new();
+ for glob in pattern.globs() {
+ let new_set = self
+ .inner
+ .references_glob(glob)?
+ .map(|reference| {
+ reference
+ .map_err(Error::Git)
+ .and_then(|r| Namespace::try_from(&r).map_err(Error::from))
+ })
+ .collect::<Result<BTreeSet<Namespace>, Error>>()?;
+ set.extend(new_set);
+ }
+ Ok(Namespaces::new(set))
+ }
+
+ /// Get the [`Diff`] between two commits.
+ pub fn diff(&self, from: impl Revision, to: impl Revision) -> Result<Diff, Error> {
+ let from_commit = self.find_commit(self.object_id(&from)?)?;
+ let to_commit = self.find_commit(self.object_id(&to)?)?;
+ self.diff_commits(None, Some(&from_commit), &to_commit)
+ .and_then(|diff| Diff::try_from(diff).map_err(Error::from))
+ }
+
+ /// Get the [`Diff`] of a `commit`.
+ ///
+ /// If the `commit` has a parent, then it the diff will be a
+ /// comparison between itself and that parent. Otherwise, the left
+ /// hand side of the diff will pass nothing.
+ pub fn diff_commit(&self, commit: impl ToCommit) -> Result<Diff, Error> {
+ let commit = commit
+ .to_commit(self)
+ .map_err(|err| Error::ToCommit(err.into()))?;
+ match commit.parents.first() {
+ Some(parent) => self.diff(*parent, commit.id),
+ None => self.initial_diff(commit.id),
+ }
+ }
+
+ /// Get the [`FileDiff`] between two revisions for a file at `path`.
+ ///
+ /// If `path` is only a directory name, not a file, returns
+ /// a [`FileDiff`] for any file under `path`.
+ pub fn diff_file<P: AsRef<Path>, R: Revision>(
+ &self,
+ path: &P,
+ from: R,
+ to: R,
+ ) -> Result<FileDiff, Error> {
+ let from_commit = self.find_commit(self.object_id(&from)?)?;
+ let to_commit = self.find_commit(self.object_id(&to)?)?;
+ let diff = self
+ .diff_commits(Some(path.as_ref()), Some(&from_commit), &to_commit)
+ .and_then(|diff| Diff::try_from(diff).map_err(Error::from))?;
+ let file_diff = diff
+ .into_files()
+ .pop()
+ .ok_or(error::Repo::PathNotFound(path.as_ref().to_path_buf()))?;
+ Ok(file_diff)
+ }
+
+ /// Parse an [`Oid`] from the given string.
+ pub fn oid(&self, oid: &str) -> Result<Oid, Error> {
+ Ok(self.inner.revparse_single(oid)?.id().into())
+ }
+
+ /// Returns a top level `Directory` without nested sub-directories.
+ ///
+ /// To visit inside any nested sub-directories, call `directory.get(&repo)`
+ /// on the sub-directory.
+ pub fn root_dir<C: ToCommit>(&self, commit: C) -> Result<Directory, Error> {
+ let commit = commit
+ .to_commit(self)
+ .map_err(|err| Error::ToCommit(err.into()))?;
+ let git2_commit = self.inner.find_commit((commit.id).into())?;
+ let tree = git2_commit.as_object().peel_to_tree()?;
+ Ok(Directory::root(tree.id().into()))
+ }
+
+ /// Returns a [`Directory`] for `path` in `commit`.
+ pub fn directory<C: ToCommit, P: AsRef<Path>>(
+ &self,
+ commit: C,
+ path: &P,
+ ) -> Result<Directory, Error> {
+ let root = self.root_dir(commit)?;
+ Ok(root.find_directory(path, self)?)
+ }
+
+ /// Returns a [`File`] for `path` in `commit`.
+ pub fn file<C: ToCommit, P: AsRef<Path>>(&self, commit: C, path: &P) -> Result<File, Error> {
+ let root = self.root_dir(commit)?;
+ Ok(root.find_file(path, self)?)
+ }
+
+ /// Returns a [`Tree`] for `path` in `commit`.
+ pub fn tree<C: ToCommit, P: AsRef<Path>>(&self, commit: C, path: &P) -> Result<Tree, Error> {
+ let commit = commit
+ .to_commit(self)
+ .map_err(|e| Error::ToCommit(e.into()))?;
+ let dir = self.directory(commit.id, path)?;
+ let mut entries = dir
+ .entries(self)?
+ .map(|en| {
+ let name = en.name().to_string();
+ let path = en.path();
+ Ok(Entry::new(name, path, en.into(), commit.clone()))
+ })
+ .collect::<Result<Vec<Entry>, Error>>()?;
+ entries.sort();
+
+ Ok(Tree::new(
+ dir.id(),
+ entries,
+ commit,
+ path.as_ref().to_path_buf(),
+ ))
+ }
+
+ /// Returns a [`Blob`] for `path` in `commit`.
+ pub fn blob<'a, C: ToCommit, P: AsRef<Path>>(
+ &'a self,
+ commit: C,
+ path: &P,
+ ) -> Result<Blob<BlobRef<'a>>, Error> {
+ let commit = commit
+ .to_commit(self)
+ .map_err(|e| Error::ToCommit(e.into()))?;
+ let file = self.file(commit.id, path)?;
+ let last_commit = self
+ .last_commit(path, commit)?
+ .ok_or_else(|| error::Repo::PathNotFound(path.as_ref().to_path_buf()))?;
+ let git2_blob = self.find_blob(file.id())?;
+ Ok(Blob::<BlobRef<'a>>::new(file.id(), git2_blob, last_commit))
+ }
+
+ pub fn blob_ref(&self, oid: Oid) -> Result<BlobRef<'_>, Error> {
+ Ok(BlobRef {
+ inner: self.find_blob(oid)?,
+ })
+ }
+
+ /// Returns the last commit, if exists, for a `path` in the history of
+ /// `rev`.
+ pub fn last_commit<P, C>(&self, path: &P, rev: C) -> Result<Option<Commit>, Error>
+ where
+ P: AsRef<Path>,
+ C: ToCommit,
+ {
+ let history = self.history(rev)?;
+ history.by_path(path).next().transpose()
+ }
+
+ /// Returns a commit for `rev`, if it exists.
+ pub fn commit<R: Revision>(&self, rev: R) -> Result<Commit, Error> {
+ rev.to_commit(self)
+ }
+
+ /// Gets the [`Stats`] of this repository starting from the
+ /// `HEAD` (see [`Repository::head`]) of the repository.
+ pub fn stats(&self) -> Result<Stats, Error> {
+ self.stats_from(&self.head()?)
+ }
+
+ /// Gets the [`Stats`] of this repository starting from the given
+ /// `rev`.
+ pub fn stats_from<R>(&self, rev: &R) -> Result<Stats, Error>
+ where
+ R: Revision,
+ {
+ let branches = self.branches(Glob::all_heads())?.count();
+ let mut history = self.history(rev)?;
+ let (commits, contributors) = history.try_fold(
+ (0, BTreeSet::new()),
+ |(commits, mut contributors), commit| {
+ let commit = commit?;
+ contributors.insert((commit.author.name, commit.author.email));
+ Ok::<_, Error>((commits + 1, contributors))
+ },
+ )?;
+ Ok(Stats {
+ branches,
+ commits,
+ contributors: contributors.len(),
+ })
+ }
+
+ // TODO(finto): I think this can be removed in favour of using
+ // `source::Blob::new`
+ /// Retrieves the file with `path` in this commit.
+ pub fn get_commit_file<'a, P, R>(&'a self, rev: &R, path: &P) -> Result<FileContent<'a>, Error>
+ where
+ P: AsRef<Path>,
+ R: Revision,
+ {
+ let path = path.as_ref();
+ let id = self.object_id(rev)?;
+ let commit = self.find_commit(id)?;
+ let tree = commit.tree()?;
+ let entry = tree.get_path(path)?;
+ let object = entry.to_object(&self.inner)?;
+ let blob = object
+ .into_blob()
+ .map_err(|_| error::Repo::PathNotFound(path.to_path_buf()))?;
+ Ok(FileContent::new(blob))
+ }
+
+ /// Returns the [`Oid`] of the current `HEAD`.
+ pub fn head(&self) -> Result<Oid, Error> {
+ let head = self.inner.head()?;
+ let head_commit = head.peel_to_commit()?;
+ Ok(head_commit.id().into())
+ }
+
+ /// Extract the signature from a commit
+ ///
+ /// # Arguments
+ ///
+ /// `field` - the name of the header field containing the signature block;
+ /// pass `None` to extract the default 'gpgsig'
+ pub fn extract_signature(
+ &self,
+ commit: impl ToCommit,
+ field: Option<&str>,
+ ) -> Result<Option<Signature>, Error> {
+ // Match is necessary here because according to the documentation for
+ // git_commit_extract_signature at
+ // https://libgit2.org/libgit2/#HEAD/group/commit/git_commit_extract_signature
+ // the return value for a commit without a signature will be GIT_ENOTFOUND
+ let commit = commit
+ .to_commit(self)
+ .map_err(|e| Error::ToCommit(e.into()))?;
+
+ match self.inner.extract_signature(&commit.id, field) {
+ Err(error) => {
+ if error.code() == git2::ErrorCode::NotFound {
+ Ok(None)
+ } else {
+ Err(error.into())
+ }
+ }
+ Ok(sig) => Ok(Some(Signature::from(sig.0))),
+ }
+ }
+
+ /// Returns the history with the `head` commit.
+ pub fn history<'a, C: ToCommit>(&'a self, head: C) -> Result<History<'a>, Error> {
+ History::new(self, head)
+ }
+
+ /// Lists branches that are reachable from `rev`.
+ pub fn revision_branches(
+ &self,
+ rev: impl Revision,
+ glob: Glob<Branch>,
+ ) -> Result<Vec<Branch>, Error> {
+ let oid = self.object_id(&rev)?;
+ let mut contained_branches = vec![];
+ for branch in self.branches(glob)? {
+ let branch = branch?;
+ let namespaced = self.namespaced_refname(&branch.refname())?;
+ let reference = self.inner.find_reference(namespaced.as_str())?;
+ if self.reachable_from(&reference, &oid)? {
+ contained_branches.push(branch);
+ }
+ }
+
+ Ok(contained_branches)
+ }
+}
+
+////////////////////////////////////////////////////////////
+// Private API, ONLY add `pub(crate) fn` or `fn` in here. //
+////////////////////////////////////////////////////////////
+impl Repository {
+ pub(crate) fn is_bare(&self) -> bool {
+ self.inner.is_bare()
+ }
+
+ pub(crate) fn find_submodule<'a>(
+ &'a self,
+ name: &str,
+ ) -> Result<git2::Submodule<'a>, git2::Error> {
+ self.inner.find_submodule(name)
+ }
+
+ pub(crate) fn find_blob(&self, oid: Oid) -> Result<git2::Blob<'_>, git2::Error> {
+ self.inner.find_blob(oid.into())
+ }
+
+ pub(crate) fn find_commit(&self, oid: Oid) -> Result<git2::Commit<'_>, git2::Error> {
+ self.inner.find_commit(oid.into())
+ }
+
+ pub(crate) fn find_tree(&self, oid: Oid) -> Result<git2::Tree<'_>, git2::Error> {
+ self.inner.find_tree(oid.into())
+ }
+
+ pub(crate) fn refname_to_id<R>(&self, name: &R) -> Result<Oid, git2::Error>
+ where
+ R: AsRef<RefStr>,
+ {
+ self.inner
+ .refname_to_id(name.as_ref().as_str())
+ .map(Oid::from)
+ }
+
+ pub(crate) fn revwalk(&self) -> Result<git2::Revwalk<'_>, git2::Error> {
+ self.inner.revwalk()
+ }
+
+ pub(super) fn object_id<R: Revision>(&self, r: &R) -> Result<Oid, Error> {
+ r.object_id(self).map_err(|err| Error::Revision(err.into()))
+ }
+
+ /// Get the [`Diff`] of a commit with no parents.
+ fn initial_diff<R: Revision>(&self, rev: R) -> Result<Diff, Error> {
+ let commit = self.find_commit(self.object_id(&rev)?)?;
+ self.diff_commits(None, None, &commit)
+ .and_then(|diff| Diff::try_from(diff).map_err(Error::from))
+ }
+
+ fn reachable_from(&self, reference: &git2::Reference, oid: &Oid) -> Result<bool, Error> {
+ let git2_oid = (*oid).into();
+ let other = reference.peel_to_commit()?.id();
+ let is_descendant = self.inner.graph_descendant_of(other, git2_oid)?;
+
+ Ok(other == git2_oid || is_descendant)
+ }
+
+ pub(crate) fn diff_commit_and_parents<P>(
+ &self,
+ path: &P,
+ commit: &git2::Commit,
+ ) -> Result<Option<PathBuf>, Error>
+ where
+ P: AsRef<Path>,
+ {
+ let mut parents = commit.parents();
+
+ let diff = self.diff_commits(Some(path.as_ref()), parents.next().as_ref(), commit)?;
+ if let Some(_delta) = diff.deltas().next() {
+ Ok(Some(path.as_ref().to_path_buf()))
+ } else {
+ Ok(None)
+ }
+ }
+
+ /// Create a diff with the difference between two tree objects.
+ ///
+ /// Defines some options and flags that are passed to git2.
+ ///
+ /// Note:
+ /// libgit2 optimizes around not loading the content when there's no content
+ /// callbacks configured. Be aware that binaries aren't detected as
+ /// expected.
+ ///
+ /// Reference: <https://github.com/libgit2/libgit2/issues/6637>
+ fn diff_commits<'a>(
+ &'a self,
+ path: Option<&Path>,
+ from: Option<&git2::Commit>,
+ to: &git2::Commit,
+ ) -> Result<git2::Diff<'a>, Error> {
+ let new_tree = to.tree()?;
+ let old_tree = from.map_or(Ok(None), |c| c.tree().map(Some))?;
+
+ let mut opts = git2::DiffOptions::new();
+ if let Some(path) = path {
+ opts.pathspec(path.to_string_lossy().to_string());
+ opts.skip_binary_check(false);
+ }
+
+ let mut diff =
+ self.inner
+ .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut opts))?;
+
+ // Detect renames by default.
+ let mut find_opts = git2::DiffFindOptions::new();
+ find_opts.renames(true);
+ find_opts.copies(true);
+ diff.find_similar(Some(&mut find_opts))?;
+
+ Ok(diff)
+ }
+
+ /// Returns a full reference name with namespace(s) included.
+ pub(crate) fn namespaced_refname<'a>(
+ &'a self,
+ refname: &Qualified<'a>,
+ ) -> Result<Qualified<'a>, Error> {
+ let fullname = match self.which_namespace()? {
+ Some(namespace) => namespace.to_namespaced(refname).into_qualified(),
+ None => refname.clone(),
+ };
+ Ok(fullname)
+ }
+
+ /// Returns a full reference name with namespace(s) included.
+ fn namespaced_pattern<'a>(
+ &'a self,
+ refname: &QualifiedPattern<'a>,
+ ) -> Result<QualifiedPattern<'a>, Error> {
+ let fullname = match self.which_namespace()? {
+ Some(namespace) => namespace.to_namespaced_pattern(refname).into_qualified(),
+ None => refname.clone(),
+ };
+ Ok(fullname)
+ }
+}
+
+impl From<git2::Repository> for Repository {
+ fn from(repo: git2::Repository) -> Self {
+ Repository { inner: repo }
+ }
+}
+
+impl std::fmt::Debug for Repository {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, ".git")
+ }
+}
diff --git a/crates/radicle-surf/src/revision.rs b/crates/radicle-surf/src/revision.rs
new file mode 100644
index 000000000..099c97436
--- /dev/null
+++ b/crates/radicle-surf/src/revision.rs
@@ -0,0 +1,125 @@
+use std::{convert::Infallible, str::FromStr};
+
+use git_ext::{
+ ref_format::{Qualified, RefString},
+ Oid,
+};
+
+use crate::{Branch, Commit, Error, Repository, Tag};
+
+/// The signature of a commit
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub struct Signature(Vec<u8>);
+
+impl From<git2::Buf> for Signature {
+ fn from(other: git2::Buf) -> Self {
+ Signature((*other).into())
+ }
+}
+
+/// Supports various ways to specify a revision used in Git.
+pub trait Revision {
+ type Error: std::error::Error + Send + Sync + 'static;
+
+ /// Returns the object id of this revision in `repo`.
+ fn object_id(&self, repo: &Repository) -> Result<Oid, Self::Error>;
+}
+
+impl Revision for RefString {
+ type Error = git2::Error;
+
+ fn object_id(&self, repo: &Repository) -> Result<Oid, Self::Error> {
+ repo.refname_to_id(self)
+ }
+}
+
+impl Revision for Qualified<'_> {
+ type Error = git2::Error;
+
+ fn object_id(&self, repo: &Repository) -> Result<Oid, Self::Error> {
+ repo.refname_to_id(self)
+ }
+}
+
+impl Revision for Oid {
+ type Error = Infallible;
+
+ fn object_id(&self, _repo: &Repository) -> Result<Oid, Self::Error> {
+ Ok(*self)
+ }
+}
+
+impl Revision for &str {
+ type Error = git2::Error;
+
+ fn object_id(&self, _repo: &Repository) -> Result<Oid, Self::Error> {
+ Oid::from_str(self)
+ }
+}
+
+impl Revision for Branch {
+ type Error = Error;
+
+ fn object_id(&self, repo: &Repository) -> Result<Oid, Self::Error> {
+ let refname = repo.namespaced_refname(&self.refname())?;
+ Ok(repo.refname_to_id(&refname)?)
+ }
+}
+
+impl Revision for Tag {
+ type Error = Infallible;
+
+ fn object_id(&self, _repo: &Repository) -> Result<Oid, Self::Error> {
+ Ok(self.id())
+ }
+}
+
+impl Revision for String {
+ type Error = git2::Error;
+
+ fn object_id(&self, _repo: &Repository) -> Result<Oid, Self::Error> {
+ Oid::from_str(self)
+ }
+}
+
+impl<R: Revision> Revision for &R {
+ type Error = R::Error;
+
+ fn object_id(&self, repo: &Repository) -> Result<Oid, Self::Error> {
+ (*self).object_id(repo)
+ }
+}
+
+impl<R: Revision> Revision for Box<R> {
+ type Error = R::Error;
+
+ fn object_id(&self, repo: &Repository) -> Result<Oid, Self::Error> {
+ self.as_ref().object_id(repo)
+ }
+}
+
+/// A common trait for anything that can convert to a `Commit`.
+pub trait ToCommit {
+ type Error: std::error::Error + Send + Sync + 'static;
+
+ /// Converts to a commit in `repo`.
+ fn to_commit(self, repo: &Repository) -> Result<Commit, Self::Error>;
+}
+
+impl ToCommit for Commit {
+ type Error = Infallible;
+
+ fn to_commit(self, _repo: &Repository) -> Result<Commit, Self::Error> {
+ Ok(self)
+ }
+}
+
+impl<R: Revision> ToCommit for R {
+ type Error = Error;
+
+ fn to_commit(self, repo: &Repository) -> Result<Commit, Self::Error> {
+ let oid = repo.object_id(&self)?;
+ let commit = repo.find_commit(oid)?;
+ Ok(Commit::try_from(commit)?)
+ }
+}
diff --git a/crates/radicle-surf/src/stats.rs b/crates/radicle-surf/src/stats.rs
new file mode 100644
index 000000000..f7f257c17
--- /dev/null
+++ b/crates/radicle-surf/src/stats.rs
@@ -0,0 +1,14 @@
+#[cfg(feature = "serde")]
+use serde::Serialize;
+
+/// Stats for a repository
+#[cfg_attr(feature = "serde", derive(Serialize), serde(rename_all = "camelCase"))]
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub struct Stats {
+ /// Number of commits
+ pub commits: usize,
+ /// Number of local branches
+ pub branches: usize,
+ /// Number of contributors
+ pub contributors: usize,
+}
diff --git a/crates/radicle-surf/src/tag.rs b/crates/radicle-surf/src/tag.rs
new file mode 100644
index 000000000..daad8b729
--- /dev/null
+++ b/crates/radicle-surf/src/tag.rs
@@ -0,0 +1,160 @@
+use std::{convert::TryFrom, str};
+
+use git_ext::{
+ ref_format::{component, lit, Qualified, RefStr, RefString},
+ Oid,
+};
+
+use crate::{refs::refstr_join, Author};
+
+/// The metadata of a [`Git tag`][git-tag].
+///
+/// [git-tag]: https://git-scm.com/book/en/v2/Git-Basics-Tagging
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub enum Tag {
+ /// A light-weight git tag.
+ Light {
+ /// The Object ID for the `Tag`, i.e the SHA1 digest.
+ id: Oid,
+ /// The reference name for this `Tag`.
+ name: RefString,
+ },
+ /// An annotated git tag.
+ Annotated {
+ /// The Object ID for the `Tag`, i.e the SHA1 digest.
+ id: Oid,
+ /// The Object ID for the object that is tagged.
+ target: Oid,
+ /// The reference name for this `Tag`.
+ name: RefString,
+ /// The named author of this `Tag`, if the `Tag` was annotated.
+ tagger: Option<Author>,
+ /// The message with this `Tag`, if the `Tag` was annotated.
+ message: Option<String>,
+ },
+}
+
+impl Tag {
+ /// Get the `Oid` of the tag, regardless of its type.
+ pub fn id(&self) -> Oid {
+ match self {
+ Self::Light { id, .. } => *id,
+ Self::Annotated { id, .. } => *id,
+ }
+ }
+
+ /// Return the short `Tag` refname,
+ /// e.g. `release/v1`.
+ pub fn short_name(&self) -> &RefString {
+ match &self {
+ Tag::Light { name, .. } => name,
+ Tag::Annotated { name, .. } => name,
+ }
+ }
+
+ /// Return the fully qualified `Tag` refname,
+ /// e.g. `refs/tags/release/v1`.
+ pub fn refname<'a>(&'a self) -> Qualified<'a> {
+ lit::refs_tags(self.short_name()).into()
+ }
+}
+
+pub mod error {
+ use std::str;
+
+ use radicle_git_ext::ref_format::{self, RefString};
+ use thiserror::Error;
+
+ #[derive(Debug, Error)]
+ pub enum FromTag {
+ #[error(transparent)]
+ RefFormat(#[from] ref_format::Error),
+ #[error(transparent)]
+ Utf8(#[from] str::Utf8Error),
+ }
+
+ #[derive(Debug, Error)]
+ pub enum FromReference {
+ #[error(transparent)]
+ FromTag(#[from] FromTag),
+ #[error(transparent)]
+ Git(#[from] git2::Error),
+ #[error("the refname '{0}' did not begin with 'refs/tags'")]
+ NotQualified(String),
+ #[error("the refname '{0}' did not begin with 'refs/tags'")]
+ NotTag(RefString),
+ #[error(transparent)]
+ RefFormat(#[from] ref_format::Error),
+ #[error(transparent)]
+ Utf8(#[from] str::Utf8Error),
+ }
+}
+
+impl TryFrom<&git2::Tag<'_>> for Tag {
+ type Error = error::FromTag;
+
+ fn try_from(tag: &git2::Tag) -> Result<Self, Self::Error> {
+ let id = tag.id().into();
+ let target = tag.target_id().into();
+ let name = {
+ let name = str::from_utf8(tag.name_bytes())?;
+ RefStr::try_from_str(name)?.to_ref_string()
+ };
+ let tagger = tag.tagger().map(Author::try_from).transpose()?;
+ let message = tag
+ .message_bytes()
+ .map(str::from_utf8)
+ .transpose()?
+ .map(|message| message.into());
+
+ Ok(Tag::Annotated {
+ id,
+ target,
+ name,
+ tagger,
+ message,
+ })
+ }
+}
+
+impl TryFrom<&git2::Reference<'_>> for Tag {
+ type Error = error::FromReference;
+
+ fn try_from(reference: &git2::Reference) -> Result<Self, Self::Error> {
+ let name = reference_name(reference)?;
+ match reference.peel_to_tag() {
+ Ok(tag) => Tag::try_from(&tag).map_err(error::FromReference::from),
+ // If we get an error peeling to a tag _BUT_ we also have confirmed the
+ // reference is a tag, that means we have a lightweight tag,
+ // i.e. a commit SHA and name.
+ Err(err)
+ if err.class() == git2::ErrorClass::Object
+ && err.code() == git2::ErrorCode::InvalidSpec =>
+ {
+ let commit = reference.peel_to_commit()?;
+ Ok(Tag::Light {
+ id: commit.id().into(),
+ name,
+ })
+ }
+ Err(err) => Err(err.into()),
+ }
+ }
+}
+
+pub(crate) fn reference_name(
+ reference: &git2::Reference,
+) -> Result<RefString, error::FromReference> {
+ let name = str::from_utf8(reference.name_bytes())?;
+ let name = RefStr::try_from_str(name)?
+ .qualified()
+ .ok_or_else(|| error::FromReference::NotQualified(name.to_string()))?;
+
+ let (_refs, tags, c, cs) = name.non_empty_components();
+
+ if tags == component::TAGS {
+ Ok(refstr_join(c, cs))
+ } else {
+ Err(error::FromReference::NotTag(name.into()))
+ }
+}
diff --git a/crates/radicle-surf/src/tree.rs b/crates/radicle-surf/src/tree.rs
new file mode 100644
index 000000000..2f691d71c
--- /dev/null
+++ b/crates/radicle-surf/src/tree.rs
@@ -0,0 +1,290 @@
+//! Represents git object type 'tree', i.e. like directory entries in Unix.
+//! See git [doc](https://git-scm.com/book/en/v2/Git-Internals-Git-Objects) for more details.
+
+use std::cmp::Ordering;
+use std::path::PathBuf;
+
+use radicle_git_ext::Oid;
+#[cfg(feature = "serde")]
+use serde::{
+ ser::{SerializeStruct as _, Serializer},
+ Serialize,
+};
+use url::Url;
+
+use crate::{fs, Commit, Error, Repository};
+
+/// Represents a tree object as in git. It is essentially the content of
+/// one directory. Note that multiple directories can have the same content,
+/// i.e. have the same tree object. Hence this struct does not embed its path.
+#[derive(Clone, Debug)]
+pub struct Tree {
+ /// The object id of this tree.
+ id: Oid,
+ /// The first descendant entries for this tree.
+ entries: Vec<Entry>,
+ /// The commit object that created this tree object.
+ commit: Commit,
+ /// The root path this tree was constructed from.
+ root: PathBuf,
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum LastCommitError {
+ #[error(transparent)]
+ Repo(#[from] Error),
+ #[error("could not get the last commit for this entry")]
+ Missing,
+}
+
+impl Tree {
+ /// Creates a new tree, ensuring the `entries` are sorted.
+ pub(crate) fn new(id: Oid, mut entries: Vec<Entry>, commit: Commit, root: PathBuf) -> Self {
+ entries.sort();
+ Self {
+ id,
+ entries,
+ commit,
+ root,
+ }
+ }
+
+ pub fn object_id(&self) -> Oid {
+ self.id
+ }
+
+ /// Returns the commit for which this [`Tree`] was constructed from.
+ pub fn commit(&self) -> &Commit {
+ &self.commit
+ }
+
+ /// Returns the commit that last touched this [`Tree`].
+ pub fn last_commit(&self, repo: &Repository) -> Result<Commit, LastCommitError> {
+ repo.last_commit(&self.root, self.commit().clone())?
+ .ok_or(LastCommitError::Missing)
+ }
+
+ /// Returns the entries of the tree.
+ pub fn entries(&self) -> &Vec<Entry> {
+ &self.entries
+ }
+}
+
+#[cfg(feature = "serde")]
+impl Serialize for Tree {
+ /// Sample output:
+ /// (for `<entry_1>` and `<entry_2>` sample output, see [`Entry`])
+ /// ```
+ /// {
+ /// "entries": [
+ /// { <entry_1> },
+ /// { <entry_2> },
+ /// ],
+ /// "root": "src/foo",
+ /// "commit": {
+ /// "author": {
+ /// "email": "foobar@gmail.com",
+ /// "name": "Foo Bar"
+ /// },
+ /// "committer": {
+ /// "email": "noreply@github.com",
+ /// "name": "GitHub"
+ /// },
+ /// "committerTime": 1582198877,
+ /// "description": "A sample commit.",
+ /// "sha1": "b57846bbc8ced6587bf8329fc4bce970eb7b757e",
+ /// "summary": "Add a new sample"
+ /// },
+ /// "oid": "dd52e9f8dfe1d8b374b2a118c25235349a743dd2"
+ /// }
+ /// ```
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ const FIELDS: usize = 4;
+ let mut state = serializer.serialize_struct("Tree", FIELDS)?;
+ state.serialize_field("oid", &self.id)?;
+ state.serialize_field("entries", &self.entries)?;
+ state.serialize_field("commit", &self.commit)?;
+ state.serialize_field("root", &self.root)?;
+ state.end()
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum EntryKind {
+ Tree(Oid),
+ Blob(Oid),
+ Submodule { id: Oid, url: Option<Url> },
+}
+
+impl PartialOrd for EntryKind {
+ fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+ Some(self.cmp(other))
+ }
+}
+
+impl Ord for EntryKind {
+ fn cmp(&self, other: &Self) -> Ordering {
+ match (self, other) {
+ (EntryKind::Submodule { .. }, EntryKind::Submodule { .. }) => Ordering::Equal,
+ (EntryKind::Submodule { .. }, EntryKind::Tree(_)) => Ordering::Equal,
+ (EntryKind::Tree(_), EntryKind::Submodule { .. }) => Ordering::Equal,
+ (EntryKind::Tree(_), EntryKind::Tree(_)) => Ordering::Equal,
+ (EntryKind::Tree(_), EntryKind::Blob(_)) => Ordering::Less,
+ (EntryKind::Blob(_), EntryKind::Tree(_)) => Ordering::Greater,
+ (EntryKind::Submodule { .. }, EntryKind::Blob(_)) => Ordering::Less,
+ (EntryKind::Blob(_), EntryKind::Submodule { .. }) => Ordering::Greater,
+ (EntryKind::Blob(_), EntryKind::Blob(_)) => Ordering::Equal,
+ }
+ }
+}
+
+/// An entry that can be found in a tree.
+///
+/// # Ordering
+///
+/// The ordering of a [`Entry`] is first by its `entry` where
+/// [`EntryKind::Tree`]s come before [`EntryKind::Blob`]. If both kinds
+/// are equal then they are next compared by the lexicographical ordering
+/// of their `name`s.
+#[derive(Clone, Debug)]
+pub struct Entry {
+ name: String,
+ entry: EntryKind,
+ path: PathBuf,
+ /// The commit from which this entry was constructed from.
+ commit: Commit,
+}
+
+impl Entry {
+ pub(crate) fn new(name: String, path: PathBuf, entry: EntryKind, commit: Commit) -> Self {
+ Self {
+ name,
+ entry,
+ path,
+ commit,
+ }
+ }
+
+ pub fn name(&self) -> &str {
+ &self.name
+ }
+
+ /// The full path to this entry from the root of the Git repository
+ pub fn path(&self) -> &PathBuf {
+ &self.path
+ }
+
+ pub fn entry(&self) -> &EntryKind {
+ &self.entry
+ }
+
+ pub fn is_tree(&self) -> bool {
+ matches!(self.entry, EntryKind::Tree(_))
+ }
+
+ pub fn commit(&self) -> &Commit {
+ &self.commit
+ }
+
+ pub fn object_id(&self) -> Oid {
+ match self.entry {
+ EntryKind::Blob(id) => id,
+ EntryKind::Tree(id) => id,
+ EntryKind::Submodule { id, .. } => id,
+ }
+ }
+
+ /// Returns the commit that last touched this [`Entry`].
+ pub fn last_commit(&self, repo: &Repository) -> Result<Commit, LastCommitError> {
+ repo.last_commit(&self.path, self.commit.clone())?
+ .ok_or(LastCommitError::Missing)
+ }
+}
+
+// To support `sort`.
+impl Ord for Entry {
+ fn cmp(&self, other: &Self) -> Ordering {
+ self.entry
+ .cmp(&other.entry)
+ .then(self.name.cmp(&other.name))
+ }
+}
+
+impl PartialOrd for Entry {
+ fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+ Some(self.cmp(other))
+ }
+}
+
+impl PartialEq for Entry {
+ fn eq(&self, other: &Self) -> bool {
+ self.entry == other.entry && self.name == other.name
+ }
+}
+
+impl Eq for Entry {}
+
+impl From<fs::Entry> for EntryKind {
+ fn from(entry: fs::Entry) -> Self {
+ match entry {
+ fs::Entry::File(f) => EntryKind::Blob(f.id()),
+ fs::Entry::Directory(d) => EntryKind::Tree(d.id()),
+ fs::Entry::Submodule(u) => EntryKind::Submodule {
+ id: u.id(),
+ url: u.url().clone(),
+ },
+ }
+ }
+}
+
+#[cfg(feature = "serde")]
+impl Serialize for Entry {
+ /// Sample output:
+ /// ```json
+ /// {
+ /// "kind": "blob",
+ /// "commit": {
+ /// "author": {
+ /// "email": "foobar@gmail.com",
+ /// "name": "Foo Bar"
+ /// },
+ /// "committer": {
+ /// "email": "noreply@github.com",
+ /// "name": "GitHub"
+ /// },
+ /// "committerTime": 1578309972,
+ /// "description": "This is a sample file",
+ /// "sha1": "2873745c8f6ffb45c990eb23b491d4b4b6182f95",
+ /// "summary": "Add a new sample"
+ /// },
+ /// "path": "src/foo/Sample.rs",
+ /// "name": "Sample.rs",
+ /// "oid": "6d6240123a8d8ea8a8376610168a0a4bcb96afd0"
+ /// },
+ /// ```
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ const FIELDS: usize = 5;
+ let mut state = serializer.serialize_struct("TreeEntry", FIELDS)?;
+ state.serialize_field("name", &self.name)?;
+ state.serialize_field(
+ "kind",
+ match self.entry {
+ EntryKind::Blob(_) => "blob",
+ EntryKind::Tree(_) => "tree",
+ EntryKind::Submodule { .. } => "submodule",
+ },
+ )?;
+ if let EntryKind::Submodule { url: Some(url), .. } = &self.entry {
+ state.serialize_field("url", url)?;
+ };
+ state.serialize_field("oid", &self.object_id())?;
+ state.serialize_field("commit", &self.path)?;
+ state.end()
+ }
+}
diff --git a/crates/radicle-surf/t/Cargo.toml b/crates/radicle-surf/t/Cargo.toml
new file mode 100644
index 000000000..d21193c5d
--- /dev/null
+++ b/crates/radicle-surf/t/Cargo.toml
@@ -0,0 +1,39 @@
+[package]
+name = "radicle-surf-test"
+version = "0.1.0"
+edition.workspace = true
+license.workspace = true
+
+publish = false
+
+[lib]
+test = true
+
+[features]
+test = []
+
+[dev-dependencies]
+nonempty = "0.5"
+pretty_assertions = "1.3.0"
+proptest = "1"
+serde_json = "1"
+url = "2.5"
+
+[dev-dependencies.git2]
+version = "0.19"
+default-features = false
+features = ["vendored-libgit2"]
+
+[dev-dependencies.radicle-git-ext]
+path = "../../radicle-git-ext"
+
+[dev-dependencies.radicle-git-ext-test]
+path = "../../radicle-git-ext/t"
+features = ["test"]
+
+[dev-dependencies.radicle-surf]
+path = ".."
+features = ["serde"]
+
+[dev-dependencies.test-helpers]
+path = "../../test/test-helpers"
diff --git a/crates/radicle-surf/t/src/branch.rs b/crates/radicle-surf/t/src/branch.rs
new file mode 100644
index 000000000..ae4e62d4f
--- /dev/null
+++ b/crates/radicle-surf/t/src/branch.rs
@@ -0,0 +1,24 @@
+use proptest::prelude::*;
+use radicle_git_ext::ref_format::{RefStr, RefString};
+use radicle_git_ext_test::git_ref_format::gen;
+use radicle_surf::Branch;
+use test_helpers::roundtrip;
+
+proptest! {
+ #[test]
+ fn prop_test_branch(branch in gen_branch()) {
+ roundtrip::json(branch)
+ }
+}
+
+fn gen_branch() -> impl Strategy<Value = Branch> {
+ prop_oneof![
+ gen::valid().prop_map(|name| Branch::local(RefString::try_from(name).unwrap())),
+ (gen::valid(), gen::valid()).prop_map(|(remote, name): (String, String)| {
+ let remote =
+ RefStr::try_from_str(&remote).expect("BUG: reference strings should be valid");
+ let name = RefStr::try_from_str(&name).expect("BUG: reference strings should be valid");
+ Branch::remote(remote.head(), name)
+ })
+ ]
+}
diff --git a/crates/radicle-surf/t/src/code_browsing.rs b/crates/radicle-surf/t/src/code_browsing.rs
new file mode 100644
index 000000000..3cafcde3c
--- /dev/null
+++ b/crates/radicle-surf/t/src/code_browsing.rs
@@ -0,0 +1,100 @@
+use std::path::Path;
+
+use radicle_git_ext::ref_format::refname;
+use radicle_surf::{
+ fs::{self, Directory},
+ Branch, Repository,
+};
+
+use super::GIT_PLATINUM;
+
+#[test]
+fn iterate_root_dir_recursive() {
+ let repo = Repository::open(GIT_PLATINUM).unwrap();
+
+ let root_dir = repo.root_dir(Branch::local(refname!("master"))).unwrap();
+ let count = println_dir(&root_dir, &repo);
+
+ assert_eq!(count, 36); // Check total file count.
+
+ /// Prints items in `dir` with `indent_level`.
+ /// For sub-directories, will do Depth-First-Search and print
+ /// recursively.
+ /// Returns the number of items visited (i.e. printed)
+ fn println_dir(dir: &Directory, repo: &Repository) -> i32 {
+ dir.traverse::<fs::error::Directory, _, _>(
+ repo,
+ (0, 0),
+ &mut |(count, indent_level), entry| {
+ println!("> {}{}", " ".repeat(indent_level * 4), entry.name());
+ match entry {
+ fs::Entry::File(_) => Ok((count + 1, indent_level)),
+ fs::Entry::Directory(_) => Ok((count + 1, indent_level + 1)),
+ fs::Entry::Submodule(_) => Ok((count + 1, indent_level)),
+ }
+ },
+ )
+ .unwrap()
+ .0
+ }
+}
+
+#[test]
+fn browse_repo_lazily() {
+ let repo = Repository::open(GIT_PLATINUM).unwrap();
+
+ let root_dir = repo.root_dir(Branch::local(refname!("master"))).unwrap();
+ let count = root_dir.entries(&repo).unwrap().entries().count();
+ assert_eq!(count, 8);
+ let count = traverse(&root_dir, &repo);
+ assert_eq!(count, 36);
+
+ fn traverse(dir: &Directory, repo: &Repository) -> i32 {
+ dir.traverse::<fs::error::Directory, _, _>(repo, 0, &mut |count, _| Ok(count + 1))
+ .unwrap()
+ }
+}
+
+#[test]
+fn test_file_history() {
+ let repo = Repository::open(GIT_PLATINUM).unwrap();
+ let history = repo.history(Branch::local(refname!("dev"))).unwrap();
+ let path = Path::new("README.md");
+ let mut file_history = history.by_path(&path);
+ let commit = file_history.next().unwrap().unwrap();
+ let file = repo.get_commit_file(&commit.id, &path).unwrap();
+ assert_eq!(file.size(), 67);
+}
+
+#[test]
+fn test_commit_history() {
+ let repo = Repository::open(GIT_PLATINUM).unwrap();
+ let head = "a0dd9122d33dff2a35f564d564db127152c88e02";
+
+ // verify `&str` works.
+ let h1 = repo.history(head).unwrap();
+
+ // verify `&String` works.
+ let head_string = head.to_string();
+ let h2 = repo.history(&head_string).unwrap();
+
+ assert_eq!(h1.head().id, h2.head().id);
+}
+
+#[test]
+fn test_commit_signature() {
+ let repo = Repository::open(GIT_PLATINUM).unwrap();
+ let commit_with_signature = "e24124b7538658220b5aaf3b6ef53758f0a106dc";
+ let signature = repo.extract_signature(commit_with_signature, None).unwrap();
+ assert!(signature.is_some());
+
+ let commit_without_signature = "80bacafba303bf0cdf6142921f430ff265f25095";
+ let signature = repo
+ .extract_signature(commit_without_signature, None)
+ .unwrap();
+ assert!(signature.is_none());
+
+ let commit_nonexist = "8080808080";
+ let signature = repo.extract_signature(commit_nonexist, None);
+ assert!(signature.is_err());
+}
diff --git a/crates/radicle-surf/t/src/commit.rs b/crates/radicle-surf/t/src/commit.rs
new file mode 100644
index 000000000..b0a722264
--- /dev/null
+++ b/crates/radicle-surf/t/src/commit.rs
@@ -0,0 +1,32 @@
+use std::str::FromStr;
+
+use proptest::prelude::*;
+use radicle_git_ext::Oid;
+use radicle_surf::{Author, Commit, Time};
+use test_helpers::roundtrip;
+
+proptest! {
+ #[test]
+ fn prop_test_commits(commit in commits_strategy()) {
+ roundtrip::json(commit)
+ }
+}
+
+fn commits_strategy() -> impl Strategy<Value = Commit> {
+ ("[a-fA-F0-9]{40}", any::<String>(), any::<i64>()).prop_map(|(id, text, time)| Commit {
+ id: Oid::from_str(&id).unwrap(),
+ author: Author {
+ name: text.clone(),
+ email: text.clone(),
+ time: Time::new(time, 0),
+ },
+ committer: Author {
+ name: text.clone(),
+ email: text.clone(),
+ time: Time::new(time, 0),
+ },
+ message: text.clone(),
+ summary: text,
+ parents: vec![Oid::from_str(&id).unwrap(), Oid::from_str(&id).unwrap()],
+ })
+}
diff --git a/crates/radicle-surf/t/src/diff.rs b/crates/radicle-surf/t/src/diff.rs
new file mode 100644
index 000000000..292df46f3
--- /dev/null
+++ b/crates/radicle-surf/t/src/diff.rs
@@ -0,0 +1,671 @@
+use pretty_assertions::assert_eq;
+use radicle_git_ext::{ref_format::refname, Oid};
+use radicle_surf::{
+ diff::{
+ Added, Diff, DiffContent, DiffFile, EofNewLine, FileDiff, FileMode, FileStats, Hunk, Line,
+ Modification, Modified, Stats,
+ },
+ Branch, Error, Repository,
+};
+use std::{path::Path, str::FromStr};
+
+use super::GIT_PLATINUM;
+
+#[test]
+fn test_initial_diff() -> Result<(), Error> {
+ let repo = Repository::open(GIT_PLATINUM)?;
+ let oid = Oid::from_str("d3464e33d75c75c99bfb90fa2e9d16efc0b7d0e3")?;
+ let commit = repo.commit(oid).unwrap();
+ assert!(commit.parents.is_empty());
+
+ let diff = repo.diff_commit(oid)?;
+ let diff_stats = *diff.stats();
+ let diff_files = diff.into_files();
+
+ let expected_files = vec![FileDiff::Added(Added {
+ path: Path::new("README.md").to_path_buf(),
+ diff: DiffContent::Plain {
+ hunks: vec![Hunk {
+ header: Line::from(b"@@ -0,0 +1 @@\n".to_vec()),
+ lines: vec![Modification::addition(
+ b"This repository is a data source for the Upstream front-end tests.\n"
+ .to_vec(),
+ 1,
+ )],
+ old: 0..0,
+ new: 1..2,
+ }]
+ .into(),
+ stats: FileStats {
+ additions: 1,
+ deletions: 0,
+ },
+ eof: EofNewLine::default(),
+ },
+ new: DiffFile {
+ oid: Oid::from_str("7f48df0118b1674f4ab0ed1717c1368091a5dddc").unwrap(),
+ mode: FileMode::Blob,
+ },
+ })];
+
+ let expected_stats = Stats {
+ files_changed: 1,
+ insertions: 1,
+ deletions: 0,
+ };
+
+ assert_eq!(expected_files, diff_files);
+ assert_eq!(expected_stats, diff_stats);
+
+ Ok(())
+}
+
+#[test]
+fn test_diff_of_rev() -> Result<(), Error> {
+ let repo = Repository::open(GIT_PLATINUM)?;
+ let diff = repo.diff_commit("80bacafba303bf0cdf6142921f430ff265f25095")?;
+ assert_eq!(diff.files().count(), 1);
+ Ok(())
+}
+
+#[test]
+fn test_diff_file() -> Result<(), Error> {
+ let repo = Repository::open(GIT_PLATINUM)?;
+ let path_buf = Path::new("README.md").to_path_buf();
+ let diff = repo.diff_file(
+ &path_buf,
+ "d6880352fc7fda8f521ae9b7357668b17bb5bad5",
+ "223aaf87d6ea62eef0014857640fd7c8dd0f80b5",
+ )?;
+ let expected_diff = FileDiff::Modified(Modified {
+ path: path_buf,
+ diff: DiffContent::Plain {
+ hunks: vec unit tests.\n".to_vec(), 2),
+ ],
+ old: 1..2,
+ new: 1..3,
+ }]
+ .into(),
+ stats: FileStats {
+ additions: 2,
+ deletions: 1
+ },
+ eof: EofNewLine::default(),
+ },
+ old: DiffFile {
+ oid: Oid::from_str("7f48df0118b1674f4ab0ed1717c1368091a5dddc").unwrap(),
+ mode: FileMode::Blob,
+ },
+ new: DiffFile {
+ oid: Oid::from_str("5e07534cd74a6a9b2ccd2729b181c4ef26173a5e").unwrap(),
+ mode: FileMode::Blob,
+ },
+ });
+ assert_eq!(expected_diff, diff);
+
+ Ok(())
+}
+
+#[test]
+fn test_diff() -> Result<(), Error> {
+ let repo = Repository::open(GIT_PLATINUM)?;
+ let oid = "80bacafba303bf0cdf6142921f430ff265f25095";
+ let commit = repo.commit(oid).unwrap();
+ let parent_oid = commit.parents.first().unwrap();
+ let diff = repo.diff(*parent_oid, oid)?;
+
+ let expected_files = vec unit tests.\n".to_vec(), 2),
+ ],
+ old: 1..2,
+ new: 1..3,
+ }]
+ .into(),
+ stats: FileStats {
+ additions: 2,
+ deletions: 1
+ },
+ eof: EofNewLine::default(),
+ },
+ old: DiffFile {
+ oid: Oid::from_str("7f48df0118b1674f4ab0ed1717c1368091a5dddc").unwrap(),
+ mode: FileMode::Blob,
+ },
+ new: DiffFile {
+ oid: Oid::from_str("5e07534cd74a6a9b2ccd2729b181c4ef26173a5e").unwrap(),
+ mode: FileMode::Blob,
+ },
+ })];
+ let expected_stats = Stats {
+ files_changed: 1,
+ insertions: 2,
+ deletions: 1,
+ };
+ let diff_stats = *diff.stats();
+ let diff_files = diff.into_files();
+ assert_eq!(expected_files, diff_files);
+ assert_eq!(expected_stats, diff_stats);
+
+ Ok(())
+}
+
+#[test]
+fn test_branch_diff() -> Result<(), Error> {
+ let repo = Repository::open(GIT_PLATINUM)?;
+ let rev_from = Branch::local(refname!("master"));
+ let rev_to = Branch::local(refname!("dev"));
+ let diff = repo.diff(&rev_from, &rev_to)?;
+
+ println!("Diff two branches: master -> dev");
+ println!(
+ "result: added {} deleted {} moved {} modified {}",
+ diff.added().count(),
+ diff.deleted().count(),
+ diff.moved().count(),
+ diff.modified().count()
+ );
+ assert_eq!(diff.added().count(), 1);
+ assert_eq!(diff.deleted().count(), 11);
+ assert_eq!(diff.moved().count(), 1);
+ assert_eq!(diff.modified().count(), 2);
+ for c in diff.added() {
+ println!("added: {:?}", &c.path);
+ }
+ for d in diff.deleted() {
+ println!("deleted: {:?}", &d.path);
+ }
+ for m in diff.moved() {
+ println!("moved: {:?} -> {:?}", &m.old_path, &m.new_path);
+ }
+ for m in diff.modified() {
+ println!("modified: {:?}", &m.path);
+ }
+
+ // Verify moved.
+ let diff_moved = diff.moved().next().unwrap();
+
+ // We can find a `FileDiff` for the old_path in a move.
+ let file_diff = repo.diff_file(&diff_moved.old_path, &rev_from, &rev_to)?;
+ println!("old path file diff: {:?}", &file_diff);
+
+ // We can find a `FileDiff` for the new_path in a move.
+ let file_diff = repo.diff_file(&diff_moved.new_path, &rev_from, &rev_to)?;
+ println!("new path file diff: {:?}", &file_diff);
+
+ // We can find a `FileDiff` if given a directory name.
+ let dir_diff = repo.diff_file(&"special/", &rev_from, &rev_to)?;
+ println!("dir diff: {dir_diff:?}");
+
+ Ok(())
+}
+
+#[test]
+fn test_diff_serde() -> Result<(), Error> {
+ let repo = Repository::open(GIT_PLATINUM)?;
+ let rev_from = Branch::local(refname!("master"));
+ let rev_to = Branch::local(refname!("diff-test"));
+ let diff = repo.diff(rev_from, rev_to)?;
+
+ let json = serde_json::json!({
+ "files": [{
+ "path": "LICENSE",
+ "diff": {
+ "type": "plain",
+ "hunks": [{
+ "header": "@@ -0,0 +1,2 @@\n",
+ "lines": [{
+ "line": "This is a license file.\n",
+ "lineNo": 1,
+ "type": "addition",
+ },
+ {
+ "line": "\n",
+ "lineNo": 2,
+ "type": "addition",
+ }],
+ "old": { "start": 0, "end": 0 },
+ "new": { "start": 1, "end": 3 },
+ }],
+ "stats": {
+ "additions": 2,
+ "deletions": 0,
+ },
+ "eof": "noneMissing",
+ },
+ "new": {
+ "mode": "blob",
+ "oid": "02f70f56ec62396ceaf38804c37e169e875ab291",
+ },
+ "status": "added"
+ },
+ {
+ "path": "README.md",
+ "diff": {
+ "type": "plain",
+ "hunks": [{
+ "header": "@@ -1,2 +1,2 @@\n",
+ "lines": [
+ { "lineNo": 1,
+ "line": "This repository is a data source for the Upstream front-end tests and the\n",
+ "type": "deletion"
+ },
+ { "lineNo": 2,
+ "line": "[`radicle-surf`](https://github.com/radicle-dev/git-platinum) unit tests.\n",
+ "type": "deletion"
+ },
+ { "lineNo": 1,
+ "line": "This repository is a data source for the upstream front-end tests and the\n",
+ "type": "addition"
+ },
+ { "lineNo": 2,
+ "line": "[`radicle-surf`](https://github.com/radicle-dev/radicle-surf) unit tests.\n",
+ "type": "addition"
+ },
+ ],
+ "old": { "start": 1, "end": 3 },
+ "new": { "start": 1, "end": 3 },
+ }],
+ "stats": {
+ "additions": 2,
+ "deletions": 2
+ },
+ "eof": "noneMissing",
+ },
+ "new": {
+ "mode": "blob",
+ "oid": "b033ecf407a44922b28c942c696922a7d7daf06e",
+ },
+ "old": {
+ "mode": "blob",
+ "oid": "5e07534cd74a6a9b2ccd2729b181c4ef26173a5e",
+ },
+ "status": "modified",
+ },
+ {
+ "current": {
+ "mode": "blob",
+ "oid": "1570277532948712fea9029d100a4208f9e34241",
+ },
+ "oldPath": "text/emoji.txt",
+ "newPath": "emoji.txt",
+ "status": "moved"
+ },
+ {
+ "current": {
+ "mode": "blob",
+ "oid": "5e07534cd74a6a9b2ccd2729b181c4ef26173a5e",
+ },
+ "newPath": "file_operations/copied.md",
+ "oldPath": "README.md",
+ "status": "copied"
+ },
+ {
+ "path": "text/arrows.txt",
+ "status": "deleted",
+ "old": {
+ "mode": "blob",
+ "oid": "95418c04010a3cc758fb3a37f9918465f147566f",
+ },
+ "diff": {
+ "type": "plain",
+ "hunks": [{
+ "header": "@@ -1,7 +0,0 @@\n",
+ "lines": [
+ {
+ "line": " ;;;;; ;;;;; ;;;;;\n",
+ "lineNo": 1,
+ "type": "deletion",
+ },
+ {
+ "line": " ;;;;; ;;;;; ;;;;;\n",
+ "lineNo": 2,
+ "type": "deletion",
+ },
+ {
+ "line": " ;;;;; ;;;;; ;;;;;\n",
+ "lineNo": 3,
+ "type": "deletion",
+ },
+ {
+ "line": " ;;;;; ;;;;; ;;;;;\n",
+ "lineNo": 4,
+ "type": "deletion",
+ },
+ {
+ "line": "..;;;;;.. ..;;;;;.. ..;;;;;..\n",
+ "lineNo": 5,
+ "type": "deletion",
+ },
+ {
+ "line": " ':::::' ':::::' ':::::'\n",
+ "lineNo": 6,
+ "type": "deletion",
+ },
+ {
+ "line": " ':` ':` ':`\n",
+ "lineNo": 7,
+ "type": "deletion",
+ },
+ ],
+ "old": { "start": 1, "end": 8 },
+ "new": { "start": 0, "end": 0 },
+ }],
+ "stats": {
+ "additions": 0,
+ "deletions": 7,
+ },
+ "eof": "noneMissing",
+ },
+ }],
+ "stats": {
+ "deletions": 9,
+ "filesChanged": 5,
+ "insertions": 4,
+ }
+ });
+ assert_eq!(serde_json::to_value(diff).unwrap(), json);
+
+ Ok(())
+}
+
+#[test]
+fn test_rename_with_changes() {
+ let buf = r"
+diff --git a/radicle/src/node/tracking/config.rs b/radicle-node/src/service/tracking.rs
+similarity index 96%
+rename from radicle/src/node/tracking/config.rs
+rename to radicle-node/src/service/tracking.rs
+index 3f69208f3..cbc843c82 100644
+--- a/radicle/src/node/tracking/config.rs
++++ b/radicle-node/src/service/tracking.rs
+@@ -5,15 +5,17 @@ use std::ops;
+ use log::error;
+ use thiserror::Error;
+
+-use crate::crypto::PublicKey;
+-use crate::identity::IdentityError;
++use radicle::crypto::PublicKey;
++use radicle::identity::IdentityError;
++use radicle::storage::{Namespaces, ReadRepository as _, ReadStorage};
++
++use crate::prelude::Id;
++use crate::service::NodeId;
++
+ pub use crate::node::tracking::store;
+ pub use crate::node::tracking::store::Config as Store;
+ pub use crate::node::tracking::store::Error;
+ pub use crate::node::tracking::{Alias, Node, Policy, Repo, Scope};
+-use crate::node::NodeId;
+-use crate::prelude::Id;
+-use crate::storage::{Namespaces, ReadRepository as _, ReadStorage};
+
+ #[derive(Debug, Error)]
+ pub enum NamespacesError {
+";
+ let diff = git2::Diff::from_buffer(buf.as_bytes()).unwrap();
+ let diff = Diff::try_from(diff).unwrap();
+ let json = serde_json::json!(
+ {
+ "files": [
+ {
+ "diff": {
+ "eof": "noneMissing",
+ "hunks": [
+ {
+ "header": "@@ -5,15 +5,17 @@ use std::ops;\n",
+ "lines": [
+ {
+ "line": "use log::error;\n",
+ "lineNoNew": 5,
+ "lineNoOld": 5,
+ "type": "context",
+ },
+ {
+ "line": "use thiserror::Error;\n",
+ "lineNoNew": 6,
+ "lineNoOld": 6,
+ "type": "context",
+ },
+ {
+ "line": "\n",
+ "lineNoNew": 7,
+ "lineNoOld": 7,
+ "type": "context",
+ },
+ {
+ "line": "use crate::crypto::PublicKey;\n",
+ "lineNo": 8,
+ "type": "deletion",
+ },
+ {
+ "line": "use crate::identity::IdentityError;\n",
+ "lineNo": 9,
+ "type": "deletion",
+ },
+ {
+ "line": "use radicle::crypto::PublicKey;\n",
+ "lineNo": 8,
+ "type": "addition",
+ },
+ {
+ "line": "use radicle::identity::IdentityError;\n",
+ "lineNo": 9,
+ "type": "addition",
+ },
+ {
+ "line": "use radicle::storage::{Namespaces, ReadRepository as _, ReadStorage};\n",
+ "lineNo": 10,
+ "type": "addition",
+ },
+ {
+ "line": "\n",
+ "lineNo": 11,
+ "type": "addition",
+ },
+ {
+ "line": "use crate::prelude::Id;\n",
+ "lineNo": 12,
+ "type": "addition",
+ },
+ {
+ "line": "use crate::service::NodeId;\n",
+ "lineNo": 13,
+ "type": "addition",
+ },
+ {
+ "line": "\n",
+ "lineNo": 14,
+ "type": "addition",
+ },
+ {
+ "line": "pub use crate::node::tracking::store;\n",
+ "lineNoNew": 15,
+ "lineNoOld": 10,
+ "type": "context",
+ },
+ {
+ "line": "pub use crate::node::tracking::store::Config as Store;\n",
+ "lineNoNew": 16,
+ "lineNoOld": 11,
+ "type": "context",
+ },
+ {
+ "line": "pub use crate::node::tracking::store::Error;\n",
+ "lineNoNew": 17,
+ "lineNoOld": 12,
+ "type": "context",
+ },
+ {
+ "line": "pub use crate::node::tracking::{Alias, Node, Policy, Repo, Scope};\n",
+ "lineNoNew": 18,
+ "lineNoOld": 13,
+ "type": "context",
+ },
+ {
+ "line": "use crate::node::NodeId;\n",
+ "lineNo": 14,
+ "type": "deletion",
+ },
+ {
+ "line": "use crate::prelude::Id;\n",
+ "lineNo": 15,
+ "type": "deletion",
+ },
+ {
+ "line": "use crate::storage::{Namespaces, ReadRepository as _, ReadStorage};\n",
+ "lineNo": 16,
+ "type": "deletion",
+ },
+ {
+ "line": "\n",
+ "lineNoNew": 19,
+ "lineNoOld": 17,
+ "type": "context",
+ },
+ {
+ "line": "#[derive(Debug, Error)]\n",
+ "lineNoNew": 20,
+ "lineNoOld": 18,
+ "type": "context",
+ },
+ {
+ "line": "pub enum NamespacesError {\n",
+ "lineNoNew": 21,
+ "lineNoOld": 19,
+ "type": "context",
+ },
+ ],
+ "new": {
+ "end": 22,
+ "start": 5,
+ },
+ "old": {
+ "end": 20,
+ "start": 5,
+ },
+ },
+ ],
+ "stats": {
+ "additions": 7,
+ "deletions": 5
+ },
+ "type": "plain",
+ },
+ "new": {
+ "mode": "blob",
+ "oid": "cbc843c820000000000000000000000000000000",
+ },
+ "newPath": "radicle-node/src/service/tracking.rs",
+ "old": {
+ "mode": "blob",
+ "oid": "3f69208f30000000000000000000000000000000",
+ },
+ "oldPath": "radicle/src/node/tracking/config.rs",
+ "status": "moved"
+ },
+ ],
+ "stats": {
+ "deletions": 0,
+ "filesChanged": 1,
+ "insertions": 0,
+ },
+ });
+
+ assert_eq!(serde_json::to_value(diff).unwrap(), json);
+}
+
+// A possible false positive is being hit here for this clippy
+// warning. Tracking issue:
+// https://github.com/rust-lang/rust-clippy/issues/11402
+#[allow(clippy::needless_raw_string_hashes)]
+#[test]
+fn test_both_missing_eof_newline() {
+ let buf = r#"
+diff --git a/.env b/.env
+index f89e4c0..7c56eb7 100644
+--- a/.env
++++ b/.env
+@@ -1 +1 @@
+-hello=123
+\ No newline at end of file
++hello=1234
+\ No newline at end of file
+"#;
+ let diff = git2::Diff::from_buffer(buf.as_bytes()).unwrap();
+ let diff = Diff::try_from(diff).unwrap();
+ assert_eq!(
+ diff.modified().next().unwrap().diff.eof(),
+ Some(EofNewLine::BothMissing)
+ );
+}
+
+#[test]
+fn test_none_missing_eof_newline() {
+ let buf = r#"
+diff --git a/.env b/.env
+index f89e4c0..7c56eb7 100644
+--- a/.env
++++ b/.env
+@@ -1 +1 @@
+-hello=123
++hello=1234
+"#;
+ let diff = git2::Diff::from_buffer(buf.as_bytes()).unwrap();
+ let diff = Diff::try_from(diff).unwrap();
+ assert_eq!(
+ diff.modified().next().unwrap().diff.eof(),
+ Some(EofNewLine::NoneMissing)
+ );
+}
+
+#[test]
+fn test_old_missing_eof_newline() {
+ let buf = r#"
+diff --git a/.env b/.env
+index f89e4c0..7c56eb7 100644
+--- a/.env
++++ b/.env
+@@ -1 +1 @@
+-hello=123
+\ No newline at end of file
++hello=1234
+"#;
+ let diff = git2::Diff::from_buffer(buf.as_bytes()).unwrap();
+ let diff = Diff::try_from(diff).unwrap();
+ assert_eq!(
+ diff.modified().next().unwrap().diff.eof(),
+ Some(EofNewLine::OldMissing)
+ );
+}
+
+#[test]
+fn test_new_missing_eof_newline() {
+ let buf = r#"
+diff --git a/.env b/.env
+index f89e4c0..7c56eb7 100644
+--- a/.env
++++ b/.env
+@@ -1 +1 @@
+-hello=123
++hello=1234
+\ No newline at end of file
+"#;
+ let diff = git2::Diff::from_buffer(buf.as_bytes()).unwrap();
+ let diff = Diff::try_from(diff).unwrap();
+ assert_eq!(
+ diff.modified().next().unwrap().diff.eof(),
+ Some(EofNewLine::NewMissing)
+ );
+}
diff --git a/crates/radicle-surf/t/src/file_system.rs b/crates/radicle-surf/t/src/file_system.rs
new file mode 100644
index 000000000..75a5096e5
--- /dev/null
+++ b/crates/radicle-surf/t/src/file_system.rs
@@ -0,0 +1,140 @@
+//! Unit tests for radicle_surf::file_system
+
+mod directory {
+ use radicle_git_ext::ref_format::refname;
+ use radicle_surf::{
+ fs::{self, Entry},
+ Branch, Repository,
+ };
+ use std::path::Path;
+
+ const GIT_PLATINUM: &str = "../data/git-platinum";
+
+ #[test]
+ fn directory_find_entry() {
+ let repo = Repository::open(GIT_PLATINUM).unwrap();
+ let root = repo.root_dir(Branch::local(refname!("master"))).unwrap();
+
+ // find_entry for a file.
+ let path = Path::new("src/memory.rs");
+ let entry = root.find_entry(&path, &repo).unwrap();
+ assert!(matches!(entry, fs::Entry::File(_)));
+
+ // find_entry for a directory.
+ let path = Path::new("this/is/a/really/deeply/nested/directory/tree");
+ let entry = root.find_entry(&path, &repo).unwrap();
+ assert!(matches!(entry, fs::Entry::Directory(_)));
+
+ // find_entry for a non-leaf directory and its relative path.
+ let path = Path::new("text");
+ let entry = root.find_entry(&path, &repo).unwrap();
+ assert!(matches!(entry, fs::Entry::Directory(_)));
+ if let fs::Entry::Directory(sub_dir) = entry {
+ let inner_path = Path::new("garden.txt");
+ let inner_entry = sub_dir.find_entry(&inner_path, &repo).unwrap();
+ assert!(matches!(inner_entry, fs::Entry::File(_)));
+ }
+
+ // find_entry for non-existing file
+ let path = Path::new("this/is/a/really/missing_file");
+ let result = root.find_entry(&path, &repo);
+ assert!(matches!(result, Err(fs::error::Directory::PathNotFound(_))));
+
+ // find_entry for absolute path: fail.
+ let path = Path::new("/src/memory.rs");
+ let result = root.find_entry(&path, &repo);
+ assert!(result.is_err());
+
+ // find entry for an empty path
+ let path = Path::new("");
+ let result = root.find_entry(&path, &repo);
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn directory_find_file_and_directory() {
+ let repo = Repository::open(GIT_PLATINUM).unwrap();
+ // Get the snapshot of the directory for a given commit.
+ let root = repo
+ .root_dir("80ded66281a4de2889cc07293a8f10947c6d57fe")
+ .unwrap();
+
+ // Assert that we can find the memory.rs file!
+ assert!(root.find_file(&Path::new("src/memory.rs"), &repo).is_ok());
+
+ let root_contents: Vec<Entry> = root.entries(&repo).unwrap().collect();
+ assert_eq!(root_contents.len(), 7);
+ assert!(root_contents[0].is_file());
+ assert!(root_contents[1].is_file());
+ assert!(root_contents[2].is_file());
+ assert_eq!(root_contents[0].name(), ".i-am-well-hidden");
+ assert_eq!(root_contents[1].name(), ".i-too-am-hidden");
+ assert_eq!(root_contents[2].name(), "README.md");
+
+ assert!(root_contents[3].is_directory());
+ assert!(root_contents[4].is_directory());
+ assert!(root_contents[5].is_directory());
+ assert!(root_contents[6].is_directory());
+ assert_eq!(root_contents[3].name(), "bin");
+ assert_eq!(root_contents[4].name(), "src");
+ assert_eq!(root_contents[5].name(), "text");
+ assert_eq!(root_contents[6].name(), "this");
+
+ let src = root.find_directory(&Path::new("src"), &repo).unwrap();
+ assert_eq!(src.path(), Path::new("src").to_path_buf());
+ let src_contents: Vec<Entry> = src.entries(&repo).unwrap().collect();
+ assert_eq!(src_contents.len(), 3);
+ assert_eq!(src_contents[0].name(), "Eval.hs");
+ assert_eq!(src_contents[1].name(), "Folder.svelte");
+ assert_eq!(src_contents[2].name(), "memory.rs");
+ }
+
+ #[test]
+ fn directory_size() {
+ let repo = Repository::open(GIT_PLATINUM).unwrap();
+ let root = repo.root_dir(Branch::local(refname!("master"))).unwrap();
+
+ /*
+ git-platinum (master) $ ls -l src
+ -rw-r--r-- 1 pi pi 10044 Oct 31 11:32 Eval.hs
+ -rw-r--r-- 1 pi pi 6253 Oct 31 11:27 memory.rs
+
+ 10044 + 6253 = 16297
+ */
+
+ let path = Path::new("src");
+ let entry = root.find_entry(&path, &repo).unwrap();
+ assert!(matches!(entry, fs::Entry::Directory(_)));
+ if let fs::Entry::Directory(d) = entry {
+ assert_eq!(16297, d.size(&repo).unwrap());
+ }
+ }
+
+ #[test]
+ fn directory_last_commit() {
+ let repo = Repository::open(GIT_PLATINUM).unwrap();
+ let branch = Branch::local(refname!("dev"));
+ let root = repo.root_dir(&branch).unwrap();
+ let dir = root.find_directory(&"this/is", &repo).unwrap();
+ let last_commit = repo.last_commit(&dir.path(), &branch).unwrap().unwrap();
+ assert_eq!(
+ last_commit.id.to_string(),
+ "2429f097664f9af0c5b7b389ab998b2199ffa977"
+ );
+ }
+
+ #[test]
+ fn file_last_commit() {
+ let repo = Repository::open(GIT_PLATINUM).unwrap();
+ let branch = Branch::local(refname!("master"));
+ let root = repo.root_dir(&branch).unwrap();
+
+ // Find a file with "\" in its name.
+ let f = root.find_file(&"special/faux\\path", &repo).unwrap();
+ let last_commit = repo.last_commit(&f.path(), &branch).unwrap().unwrap();
+ assert_eq!(
+ last_commit.id.to_string(),
+ "a0dd9122d33dff2a35f564d564db127152c88e02"
+ );
+ }
+}
diff --git a/crates/radicle-surf/t/src/last_commit.rs b/crates/radicle-surf/t/src/last_commit.rs
new file mode 100644
index 000000000..3bdf4ac42
--- /dev/null
+++ b/crates/radicle-surf/t/src/last_commit.rs
@@ -0,0 +1,119 @@
+use std::{path::PathBuf, str::FromStr};
+
+use radicle_git_ext::{ref_format::refname, Oid};
+use radicle_surf::{Branch, Repository};
+
+use super::GIT_PLATINUM;
+
+#[test]
+fn readme_missing_and_memory() {
+ let repo = Repository::open(GIT_PLATINUM)
+ .expect("Could not retrieve ./data/git-platinum as git repository");
+ let oid =
+ Oid::from_str("d3464e33d75c75c99bfb90fa2e9d16efc0b7d0e3").expect("Failed to parse SHA");
+
+ // memory.rs is commited later so it should not exist here.
+ let memory_last_commit_oid = repo
+ .last_commit(&"src/memory.rs", oid)
+ .expect("Failed to get last commit")
+ .map(|commit| commit.id);
+
+ assert_eq!(memory_last_commit_oid, None);
+
+ // README.md exists in this commit.
+ let readme_last_commit = repo
+ .last_commit(&"README.md", oid)
+ .expect("Failed to get last commit")
+ .map(|commit| commit.id);
+
+ assert_eq!(readme_last_commit, Some(oid));
+}
+
+#[test]
+fn folder_svelte() {
+ let repo = Repository::open(GIT_PLATINUM)
+ .expect("Could not retrieve ./data/git-platinum as git repository");
+ // Check that last commit is the actual last commit even if head commit differs.
+ let oid =
+ Oid::from_str("19bec071db6474af89c866a1bd0e4b1ff76e2b97").expect("Could not parse SHA");
+
+ let expected_commit_id = Oid::from_str("f3a089488f4cfd1a240a9c01b3fcc4c34a4e97b2").unwrap();
+
+ let folder_svelte = repo
+ .last_commit(&"examples/Folder.svelte", oid)
+ .expect("Failed to get last commit")
+ .map(|commit| commit.id);
+
+ assert_eq!(folder_svelte, Some(expected_commit_id));
+}
+
+#[test]
+fn nest_directory() {
+ let repo = Repository::open(GIT_PLATINUM)
+ .expect("Could not retrieve ./data/git-platinum as git repository");
+ // Check that last commit is the actual last commit even if head commit differs.
+ let oid =
+ Oid::from_str("19bec071db6474af89c866a1bd0e4b1ff76e2b97").expect("Failed to parse SHA");
+
+ let expected_commit_id = Oid::from_str("2429f097664f9af0c5b7b389ab998b2199ffa977").unwrap();
+
+ let nested_directory_tree_commit_id = repo
+ .last_commit(&"this/is/a/really/deeply/nested/directory/tree", oid)
+ .expect("Failed to get last commit")
+ .map(|commit| commit.id);
+
+ assert_eq!(nested_directory_tree_commit_id, Some(expected_commit_id));
+}
+
+#[test]
+#[cfg(not(windows))]
+fn can_get_last_commit_for_special_filenames() {
+ let repo = Repository::open(GIT_PLATINUM)
+ .expect("Could not retrieve ./data/git-platinum as git repository");
+
+ // Check that last commit is the actual last commit even if head commit differs.
+ let oid =
+ Oid::from_str("a0dd9122d33dff2a35f564d564db127152c88e02").expect("Failed to parse SHA");
+
+ let expected_commit_id = Oid::from_str("a0dd9122d33dff2a35f564d564db127152c88e02").unwrap();
+
+ let backslash_commit_id = repo
+ .last_commit(&r"special/faux\\path", oid)
+ .expect("Failed to get last commit")
+ .map(|commit| commit.id);
+ assert_eq!(backslash_commit_id, Some(expected_commit_id));
+
+ let ogre_commit_id = repo
+ .last_commit(&"special/👹👹👹", oid)
+ .expect("Failed to get last commit")
+ .map(|commit| commit.id);
+ assert_eq!(ogre_commit_id, Some(expected_commit_id));
+}
+
+#[test]
+fn root() {
+ let repo = Repository::open(GIT_PLATINUM)
+ .expect("Could not retrieve ./data/git-platinum as git repository");
+ let rev = Branch::local(refname!("master"));
+ let root_last_commit_id = repo
+ .last_commit(&PathBuf::new(), rev)
+ .expect("Failed to get last commit")
+ .map(|commit| commit.id);
+
+ let expected_oid = repo
+ .history(Branch::local(refname!("master")))
+ .unwrap()
+ .head()
+ .id;
+ assert_eq!(root_last_commit_id, Some(expected_oid));
+}
+
+#[test]
+fn binary_file() {
+ let repo = Repository::open(GIT_PLATINUM)
+ .expect("Could not retrieve ./data/git-platinum as git repository");
+ let history = repo.history(Branch::local(refname!("dev"))).unwrap();
+ let file_commit = history.by_path(&"bin/cat").next();
+ assert!(file_commit.is_some());
+ println!("file commit: {:?}", &file_commit);
+}
diff --git a/crates/radicle-surf/t/src/lib.rs b/crates/radicle-surf/t/src/lib.rs
new file mode 100644
index 000000000..812646e6c
--- /dev/null
+++ b/crates/radicle-surf/t/src/lib.rs
@@ -0,0 +1,38 @@
+#[cfg(test)]
+const GIT_PLATINUM: &str = "../data/git-platinum";
+
+#[cfg(test)]
+mod file_system;
+
+#[cfg(test)]
+mod source;
+
+#[cfg(test)]
+mod branch;
+
+#[cfg(test)]
+mod code_browsing;
+
+#[cfg(test)]
+mod commit;
+
+#[cfg(test)]
+mod diff;
+
+#[cfg(test)]
+mod last_commit;
+
+#[cfg(test)]
+mod namespace;
+
+#[cfg(test)]
+mod reference;
+
+#[cfg(test)]
+mod rev;
+
+#[cfg(test)]
+mod submodule;
+
+#[cfg(test)]
+mod threading;
diff --git a/crates/radicle-surf/t/src/namespace.rs b/crates/radicle-surf/t/src/namespace.rs
new file mode 100644
index 000000000..0fe7b74c1
--- /dev/null
+++ b/crates/radicle-surf/t/src/namespace.rs
@@ -0,0 +1,121 @@
+use pretty_assertions::{assert_eq, assert_ne};
+use radicle_git_ext::ref_format::{name::component, refname, refspec};
+use radicle_surf::{Branch, Error, Glob, Repository};
+
+use super::GIT_PLATINUM;
+
+#[test]
+fn switch_to_banana() -> Result<(), Error> {
+ let repo = Repository::open(GIT_PLATINUM)?;
+ let history_master = repo.history(Branch::local(refname!("master")))?;
+ repo.switch_namespace(&refname!("golden"))?;
+ let history_banana = repo.history(Branch::local(refname!("banana")))?;
+
+ assert_ne!(history_master.head(), history_banana.head());
+
+ Ok(())
+}
+
+#[test]
+fn me_namespace() -> Result<(), Error> {
+ let repo = Repository::open(GIT_PLATINUM)?;
+ let history = repo.history(Branch::local(refname!("master")))?;
+
+ assert_eq!(repo.which_namespace().unwrap(), None);
+
+ repo.switch_namespace(&refname!("me"))?;
+ assert_eq!(repo.which_namespace().unwrap(), Some("me".parse()?));
+
+ let history_feature = repo.history(Branch::local(refname!("feature/#1194")))?;
+ assert_eq!(history.head(), history_feature.head());
+
+ let expected_branches: Vec<Branch> = vec![Branch::local(refname!("feature/#1194"))];
+ let mut branches = repo
+ .branches(Glob::all_heads())?
+ .collect::<Result<Vec<_>, _>>()?;
+ branches.sort();
+
+ assert_eq!(expected_branches, branches);
+
+ let expected_branches: Vec<Branch> = vec![Branch::remote(
+ component!("fein"),
+ refname!("heads/feature/#1194"),
+ )];
+ let mut branches = repo
+ .branches(Glob::remotes(refspec::pattern!("fein/*")))?
+ .collect::<Result<Vec<_>, _>>()?;
+ branches.sort();
+
+ assert_eq!(expected_branches, branches);
+
+ Ok(())
+}
+
+#[test]
+fn golden_namespace() -> Result<(), Error> {
+ let repo = Repository::open(GIT_PLATINUM)?;
+ let history = repo.history(Branch::local(refname!("master")))?;
+
+ assert_eq!(repo.which_namespace().unwrap(), None);
+
+ repo.switch_namespace(&refname!("golden"))?;
+
+ assert_eq!(repo.which_namespace().unwrap(), Some("golden".parse()?));
+
+ let golden_history = repo.history(Branch::local(refname!("master")))?;
+ assert_eq!(history.head(), golden_history.head());
+
+ let expected_branches: Vec<Branch> = vec![
+ Branch::local(refname!("banana")),
+ Branch::local(refname!("master")),
+ ];
+ let mut branches = repo
+ .branches(Glob::all_heads())?
+ .collect::<Result<Vec<_>, _>>()?;
+ branches.sort();
+
+ assert_eq!(expected_branches, branches);
+
+ // NOTE: these tests used to remove the categories, i.e. heads & tags, but that
+ // was specialised logic based on the radicle-link storage layout.
+ let remote = component!("kickflip");
+ let expected_branches: Vec<Branch> = vec![
+ Branch::remote(remote.clone(), refname!("heads/fakie/bigspin")),
+ Branch::remote(remote.clone(), refname!("heads/heelflip")),
+ Branch::remote(remote, refname!("tags/v0.1.0")),
+ ];
+ let mut branches = repo
+ .branches(Glob::remotes(refspec::pattern!("kickflip/*")))?
+ .collect::<Result<Vec<_>, _>>()?;
+ branches.sort();
+
+ assert_eq!(expected_branches, branches);
+
+ Ok(())
+}
+
+#[test]
+fn silver_namespace() -> Result<(), Error> {
+ let repo = Repository::open(GIT_PLATINUM)?;
+ let history = repo.history(Branch::local(refname!("master")))?;
+
+ assert_eq!(repo.which_namespace().unwrap(), None);
+
+ repo.switch_namespace(&refname!("golden/silver"))?;
+ assert_eq!(
+ repo.which_namespace().unwrap(),
+ Some("golden/silver".parse()?)
+ );
+ let silver_history = repo.history(Branch::local(refname!("master")))?;
+ assert_ne!(history.head(), silver_history.head());
+
+ let expected_branches: Vec<Branch> = vec![Branch::local(refname!("master"))];
+ let mut branches = repo
+ .branches(Glob::all_heads().branches().and(Glob::all_remotes()))?
+ .collect::<Result<Vec<_>, _>>()?;
+ branches.sort();
+
+ assert_eq!(expected_branches, branches);
+
+ Ok(())
+}
diff --git a/crates/radicle-surf/t/src/reference.rs b/crates/radicle-surf/t/src/reference.rs
new file mode 100644
index 000000000..6625feb97
--- /dev/null
+++ b/crates/radicle-surf/t/src/reference.rs
@@ -0,0 +1,55 @@
+use radicle_git_ext::ref_format::refspec;
+use radicle_surf::{Glob, Repository};
+
+use super::GIT_PLATINUM;
+
+#[test]
+fn test_branches() {
+ let repo = Repository::open(GIT_PLATINUM).unwrap();
+ let heads = Glob::all_heads();
+ let branches = repo.branches(heads.clone()).unwrap();
+ for b in branches {
+ println!("{}", b.unwrap().refname());
+ }
+ let branches = repo
+ .branches(
+ heads
+ .branches()
+ .and(Glob::remotes(refspec::pattern!("banana/*"))),
+ )
+ .unwrap();
+ for b in branches {
+ println!("{}", b.unwrap().refname());
+ }
+}
+
+#[test]
+fn test_tag_snapshot() {
+ let repo = Repository::open(GIT_PLATINUM).unwrap();
+ let tags = repo
+ .tags(&Glob::all_tags())
+ .unwrap()
+ .collect::<Result<Vec<_>, _>>()
+ .unwrap();
+ assert_eq!(tags.len(), 6);
+ let root_dir = repo.root_dir(&tags[0]).unwrap();
+ assert_eq!(root_dir.entries(&repo).unwrap().entries().count(), 1);
+}
+
+#[test]
+fn test_namespaces() {
+ let repo = Repository::open(GIT_PLATINUM).unwrap();
+
+ let namespaces = repo.namespaces(&Glob::all_namespaces()).unwrap();
+ assert_eq!(namespaces.count(), 3);
+ let namespaces = repo
+ .namespaces(&Glob::namespaces(refspec::pattern!("golden/*")))
+ .unwrap();
+ assert_eq!(namespaces.count(), 2);
+ let namespaces = repo
+ .namespaces(
+ &Glob::namespaces(refspec::pattern!("golden/*")).insert(refspec::pattern!("me/*")),
+ )
+ .unwrap();
+ assert_eq!(namespaces.count(), 3);
+}
diff --git a/crates/radicle-surf/t/src/rev.rs b/crates/radicle-surf/t/src/rev.rs
new file mode 100644
index 000000000..60294a0f0
--- /dev/null
+++ b/crates/radicle-surf/t/src/rev.rs
@@ -0,0 +1,92 @@
+use std::str::FromStr;
+
+use radicle_git_ext::ref_format::{name::component, refname};
+use radicle_surf::{Branch, Error, Oid, Repository};
+
+use super::GIT_PLATINUM;
+
+// **FIXME**: This seems to break occasionally on
+// buildkite. For some reason the commit
+// 3873745c8f6ffb45c990eb23b491d4b4b6182f95, which is on master
+// (currently HEAD), is not found. It seems to load the history
+// with d6880352fc7fda8f521ae9b7357668b17bb5bad5 as the HEAD.
+//
+// To temporarily fix this, we need to select "New Build" from the build kite
+// build page that's failing.
+// * Under "Message" put whatever you want.
+// * Under "Branch" put in the branch you're working on.
+// * Expand "Options" and select "clean checkout".
+#[test]
+fn _master() -> Result<(), Error> {
+ let repo = Repository::open(GIT_PLATINUM)?;
+ let mut history = repo.history(Branch::remote(component!("origin"), refname!("master")))?;
+
+ let commit1 = Oid::from_str("3873745c8f6ffb45c990eb23b491d4b4b6182f95")?;
+ assert!(
+ history.any(|commit| commit.unwrap().id == commit1),
+ "commit_id={}, history =\n{:#?}",
+ commit1,
+ &history
+ );
+
+ let commit2 = Oid::from_str("d6880352fc7fda8f521ae9b7357668b17bb5bad5")?;
+ assert!(
+ history.any(|commit| commit.unwrap().id == commit2),
+ "commit_id={}, history =\n{:#?}",
+ commit2,
+ &history
+ );
+
+ Ok(())
+}
+
+#[test]
+fn commit() -> Result<(), Error> {
+ let repo = Repository::open(GIT_PLATINUM)?;
+ let rev = Oid::from_str("3873745c8f6ffb45c990eb23b491d4b4b6182f95")?;
+ let mut history = repo.history(rev)?;
+
+ let commit1 = Oid::from_str("3873745c8f6ffb45c990eb23b491d4b4b6182f95")?;
+ assert!(history.any(|commit| commit.unwrap().id == commit1));
+
+ Ok(())
+}
+
+#[test]
+fn commit_parents() -> Result<(), Error> {
+ let repo = Repository::open(GIT_PLATINUM)?;
+ let rev = Oid::from_str("3873745c8f6ffb45c990eb23b491d4b4b6182f95")?;
+ let history = repo.history(rev)?;
+ let commit = history.head();
+
+ assert_eq!(
+ commit.parents,
+ vec![Oid::from_str("d6880352fc7fda8f521ae9b7357668b17bb5bad5")?]
+ );
+
+ Ok(())
+}
+
+#[test]
+fn commit_short() -> Result<(), Error> {
+ let repo = Repository::open(GIT_PLATINUM)?;
+ let rev = repo.oid("3873745c8")?;
+ let mut history = repo.history(rev)?;
+
+ let commit1 = Oid::from_str("3873745c8f6ffb45c990eb23b491d4b4b6182f95")?;
+ assert!(history.any(|commit| commit.unwrap().id == commit1));
+
+ Ok(())
+}
+
+#[test]
+fn tag() -> Result<(), Error> {
+ let repo = Repository::open(GIT_PLATINUM)?;
+ let rev = refname!("refs/tags/v0.2.0");
+ let history = repo.history(&rev)?;
+
+ let commit1 = Oid::from_str("2429f097664f9af0c5b7b389ab998b2199ffa977")?;
+ assert_eq!(history.head().id, commit1);
+
+ Ok(())
+}
diff --git a/crates/radicle-surf/t/src/source.rs b/crates/radicle-surf/t/src/source.rs
new file mode 100644
index 000000000..4b97714ab
--- /dev/null
+++ b/crates/radicle-surf/t/src/source.rs
@@ -0,0 +1,222 @@
+use std::path::PathBuf;
+
+use radicle_git_ext::ref_format::refname;
+use radicle_surf::{Branch, Glob, Repository};
+use serde_json::json;
+
+const GIT_PLATINUM: &str = "../data/git-platinum";
+
+#[test]
+fn tree_serialization() {
+ let repo = Repository::open(GIT_PLATINUM).unwrap();
+ let tree = repo.tree(refname!("refs/heads/master"), &"src").unwrap();
+
+ let expected = json!({
+ "oid": "ed52e9f8dfe1d8b374b2a118c25235349a743dd2",
+ "entries": [
+ {
+ "name": "Eval.hs",
+ "kind": "blob",
+ "oid": "7d6240123a8d8ea8a8376610168a0a4bcb96afd0",
+ "commit": "src/Eval.hs"
+ },
+ {
+ "name": "memory.rs",
+ "kind": "blob",
+ "oid": "b84992d24be67536837f5ab45a943f1b3f501878",
+ "commit": "src/memory.rs"
+ }
+ ],
+ "commit": {
+ "id": "a0dd9122d33dff2a35f564d564db127152c88e02",
+ "author": {
+ "name": "Rūdolfs Ošiņš",
+ "email": "rudolfs@osins.org",
+ "time": 1602778504
+ },
+ "committer": {
+ "name": "GitHub",
+ "email": "noreply@github.com",
+ "time": 1602778504
+ },
+ "summary": "Add files with special characters in their filenames (#5)",
+ "message": "Add files with special characters in their filenames (#5)\n\n",
+ "description": "",
+ "parents": [
+ "223aaf87d6ea62eef0014857640fd7c8dd0f80b5"
+ ]
+ },
+ "root": "src"
+ });
+
+ assert_eq!(
+ serde_json::to_value(&tree).unwrap(),
+ expected,
+ "Got:\n{}",
+ serde_json::to_string_pretty(&tree).unwrap()
+ )
+}
+
+#[test]
+fn test_tree_last_commit() {
+ let repo = Repository::open(GIT_PLATINUM).unwrap();
+ let tree = repo.tree(refname!("refs/heads/master"), &"src").unwrap();
+ let last_commit = tree.last_commit(&repo).unwrap();
+ assert_ne!(*tree.commit(), last_commit);
+ assert_eq!(
+ last_commit.id.to_string(),
+ "a57846bbc8ced6587bf8329fc4bce970eb7b757e"
+ )
+}
+
+#[test]
+fn repo_tree_empty_branch() {
+ let repo = Repository::open(GIT_PLATINUM).unwrap();
+ let rev = Branch::local(refname!("empty-branch"));
+ let tree = repo.tree(rev, &"").unwrap();
+ assert_eq!(tree.entries().len(), 0);
+
+ // Verify the last commit is the empty commit.
+ assert_eq!(
+ tree.commit().id.to_string(),
+ "e972683fe8136bf8a5cb2378cf50303554008049"
+ );
+}
+
+#[test]
+fn repo_tree() {
+ let repo = Repository::open(GIT_PLATINUM).unwrap();
+ let tree = repo
+ .tree("27acd68c7504755aa11023300890bb85bbd69d45", &"src")
+ .unwrap();
+ assert_eq!(tree.entries().len(), 3);
+
+ let commit_header = tree.commit();
+ assert_eq!(
+ commit_header.id.to_string(),
+ "27acd68c7504755aa11023300890bb85bbd69d45"
+ );
+
+ let tree_oid = tree.object_id();
+ assert_eq!(
+ tree_oid.to_string(),
+ "dbd5d80c64a00969f521b96401a315e9481e9561"
+ );
+
+ let entries = tree.entries();
+ assert_eq!(entries.len(), 3);
+ let entry = &entries[0];
+ assert!(!entry.is_tree());
+ assert_eq!(entry.name(), "Eval.hs");
+ assert_eq!(
+ entry.object_id().to_string(),
+ "8c7447d13b907aa994ac3a38317c1e9633bf0732"
+ );
+ let commit = entry.commit();
+ assert_eq!(
+ commit.id.to_string(),
+ "27acd68c7504755aa11023300890bb85bbd69d45"
+ );
+ let last_commit = entry.last_commit(&repo).unwrap();
+ assert_eq!(
+ last_commit.id.to_string(),
+ "e24124b7538658220b5aaf3b6ef53758f0a106dc"
+ );
+
+ // Verify that an empty path works for getting the root tree.
+ let root_tree = repo
+ .tree("27acd68c7504755aa11023300890bb85bbd69d45", &"")
+ .unwrap();
+ assert_eq!(root_tree.entries().len(), 8);
+}
+
+#[test]
+fn repo_blob() {
+ let repo = Repository::open(GIT_PLATINUM).unwrap();
+ let blob = repo
+ .blob("27acd68c7504755aa11023300890bb85bbd69d45", &"src/memory.rs")
+ .unwrap();
+
+ let blob_oid = blob.object_id();
+ assert_eq!(
+ blob_oid.to_string(),
+ "b84992d24be67536837f5ab45a943f1b3f501878"
+ );
+
+ let commit_header = blob.commit();
+ assert_eq!(
+ commit_header.id.to_string(),
+ "e24124b7538658220b5aaf3b6ef53758f0a106dc"
+ );
+
+ assert!(!blob.is_binary());
+
+ // Verify the blob content size matches with the file size of "memory.rs"
+ let content = blob.content();
+ assert_eq!(blob.size(), 6253);
+
+ // Verify to_owned().
+ let blob_owned = blob.to_owned();
+ assert_eq!(blob_owned.size(), 6253);
+ assert_eq!(blob.content(), blob_owned.content());
+
+ // Verify JSON output is the same.
+ let json_ref = json!({ "content": content }).to_string();
+ let json_owned = json!( {
+ "content": blob_owned.content()
+ })
+ .to_string();
+ assert_eq!(json_ref, json_owned);
+}
+
+#[test]
+fn tree_ordering() {
+ let repo = Repository::open(GIT_PLATINUM).unwrap();
+ let tree = repo
+ .tree(refname!("refs/heads/master"), &PathBuf::new())
+ .unwrap();
+ assert_eq!(
+ tree.entries()
+ .iter()
+ .map(|entry| entry.name().to_string())
+ .collect::<Vec<_>>(),
+ vec![
+ "bin".to_string(),
+ "special".to_string(),
+ "src".to_string(),
+ "text".to_string(),
+ "this".to_string(),
+ ".i-am-well-hidden".to_string(),
+ ".i-too-am-hidden".to_string(),
+ "README.md".to_string(),
+ ]
+ );
+}
+
+#[test]
+fn commit_branches() {
+ let repo = Repository::open(GIT_PLATINUM).unwrap();
+ let init_commit = "d3464e33d75c75c99bfb90fa2e9d16efc0b7d0e3";
+ let glob = Glob::all_heads().branches().and(Glob::all_remotes());
+ let branches = repo.revision_branches(init_commit, glob).unwrap();
+
+ assert_eq!(branches.len(), 11);
+
+ let refnames: Vec<_> = branches.iter().map(|b| b.refname().to_string()).collect();
+ assert_eq!(
+ refnames,
+ vec![
+ "refs/heads/dev",
+ "refs/heads/diff-test",
+ "refs/heads/empty-branch",
+ "refs/heads/master",
+ "refs/remotes/banana/orange/pineapple",
+ "refs/remotes/banana/pineapple",
+ "refs/remotes/origin/HEAD",
+ "refs/remotes/origin/dev",
+ "refs/remotes/origin/diff-test",
+ "refs/remotes/origin/empty-branch",
+ "refs/remotes/origin/master"
+ ]
+ );
+}
diff --git a/crates/radicle-surf/t/src/submodule.rs b/crates/radicle-surf/t/src/submodule.rs
new file mode 100644
index 000000000..62129b8f6
--- /dev/null
+++ b/crates/radicle-surf/t/src/submodule.rs
@@ -0,0 +1,86 @@
+use std::{convert::Infallible, path::Path};
+
+use proptest::{collection, proptest};
+use radicle_git_ext::commit::CommitData;
+use radicle_git_ext::ref_format::refname;
+use radicle_git_ext_test::gen;
+use radicle_surf::tree::EntryKind;
+use radicle_surf::{fs, Branch, Repository};
+
+proptest! {
+ #[test]
+ fn test_submodule(
+ initial in gen::commit::commit(),
+ commits in collection::vec(gen::commit::commit(), 1..5)
+ ) {
+ prop::test_submodule(initial, commits)
+ }
+
+ #[ignore = "segfault"]
+ #[test]
+ fn test_submodule_bare(
+ initial in gen::commit::commit(),
+ commits in collection::vec(gen::commit::commit(), 1..5)
+ ) {
+ prop::test_submodule_bare(initial, commits)
+ }
+
+}
+
+mod prop {
+ use radicle_git_ext_test::{gen::commit, repository};
+
+ use super::*;
+
+ pub fn test_submodule(
+ initial: CommitData<commit::TreeData, Infallible>,
+ commits: Vec<CommitData<commit::TreeData, Infallible>>,
+ ) {
+ let refname = refname!("refs/heads/master");
+ let author = git2::Signature::try_from(initial.author()).unwrap();
+
+ let submodule = repository::fixture(&refname, commits).unwrap();
+ let repo = repository::fixture(&refname, vec![initial]).unwrap();
+
+ let head = repo.head.expect("missing initial commit");
+ let sub =
+ repository::submodule(&repo.inner, &submodule.inner, &refname, head, &author).unwrap();
+
+ let repo = Repository::open(repo.inner.path()).unwrap();
+ let branch = Branch::local(refname);
+ let dir = repo.root_dir(&branch).unwrap();
+
+ let platinum = dir.find_entry(&sub.path(), &repo).unwrap();
+ assert!(matches!(&platinum, fs::Entry::Submodule(module) if module.url().is_some()));
+
+ let root = repo.tree(&branch, &Path::new("")).unwrap();
+ let kind = EntryKind::from(platinum);
+ assert!(root.entries().iter().any(|e| e.entry() == &kind));
+ }
+
+ pub fn test_submodule_bare(
+ initial: CommitData<commit::TreeData, Infallible>,
+ commits: Vec<CommitData<commit::TreeData, Infallible>>,
+ ) {
+ let refname = refname!("refs/heads/master");
+ let author = git2::Signature::try_from(initial.author()).unwrap();
+
+ let submodule = repository::fixture(&refname, commits).unwrap();
+ let repo = repository::bare_fixture(&refname, vec![initial]).unwrap();
+
+ let head = repo.head.expect("missing initial commit");
+ let sub =
+ repository::submodule(&repo.inner, &submodule.inner, &refname, head, &author).unwrap();
+
+ let repo = Repository::open(repo.inner.path()).unwrap();
+ let branch = Branch::local(refname);
+ let dir = repo.root_dir(&branch).unwrap();
+
+ let platinum = dir.find_entry(&sub.path(), &repo).unwrap();
+ assert!(matches!(&platinum, fs::Entry::Submodule(module) if module.url().is_some()));
+
+ let root = repo.tree(&branch, &Path::new("")).unwrap();
+ let kind = EntryKind::from(platinum);
+ assert!(root.entries().iter().any(|e| e.entry() == &kind));
+ }
+}
diff --git a/crates/radicle-surf/t/src/threading.rs b/crates/radicle-surf/t/src/threading.rs
new file mode 100644
index 000000000..47cca4859
--- /dev/null
+++ b/crates/radicle-surf/t/src/threading.rs
@@ -0,0 +1,37 @@
+use std::sync::{Mutex, MutexGuard};
+
+use radicle_git_ext::ref_format::{name::component, refname};
+use radicle_surf::{Branch, Error, Glob, Repository};
+
+use super::GIT_PLATINUM;
+
+#[test]
+fn basic_test() -> Result<(), Error> {
+ let shared_repo = Mutex::new(Repository::open(GIT_PLATINUM)?);
+ let locked_repo: MutexGuard<Repository> = shared_repo.lock().unwrap();
+ let mut branches = locked_repo
+ .branches(Glob::all_heads().branches().and(Glob::all_remotes()))?
+ .collect::<Result<Vec<_>, _>>()?;
+ branches.sort();
+
+ let origin = component!("origin");
+ let banana = component!("banana");
+ assert_eq!(
+ branches,
+ vec![
+ Branch::local(refname!("dev")),
+ Branch::local(refname!("diff-test")),
+ Branch::local(refname!("empty-branch")),
+ Branch::local(refname!("master")),
+ Branch::remote(banana.clone(), refname!("orange/pineapple")),
+ Branch::remote(banana, refname!("pineapple")),
+ Branch::remote(origin.clone(), refname!("HEAD")),
+ Branch::remote(origin.clone(), refname!("dev")),
+ Branch::remote(origin.clone(), refname!("diff-test")),
+ Branch::remote(origin.clone(), refname!("empty-branch")),
+ Branch::remote(origin, refname!("master")),
+ ]
+ );
+
+ Ok(())
+}
Exit code: 0
shell: 'export RUSTDOCFLAGS=''-D warnings'' cargo --version rustc --version cargo fmt --check cargo clippy --all-targets --workspace -- --deny warnings cargo build --all-targets --workspace cargo doc --workspace --no-deps --all-features cargo test --workspace --no-fail-fast '
Commands:
$ podman run --name 21dec7d7-6514-4b8b-a389-af82fd89270b -v /opt/radcis/ci.rad.levitte.org/cci/state/21dec7d7-6514-4b8b-a389-af82fd89270b/s:/21dec7d7-6514-4b8b-a389-af82fd89270b/s:ro -v /opt/radcis/ci.rad.levitte.org/cci/state/21dec7d7-6514-4b8b-a389-af82fd89270b/w:/21dec7d7-6514-4b8b-a389-af82fd89270b/w -w /21dec7d7-6514-4b8b-a389-af82fd89270b/w -v /opt/radcis/ci.rad.levitte.org/.radicle:/${id}/.radicle:ro -e RAD_HOME=/${id}/.radicle rust:trixie bash /21dec7d7-6514-4b8b-a389-af82fd89270b/s/script.sh
+ export 'RUSTDOCFLAGS=-D warnings'
+ RUSTDOCFLAGS='-D warnings'
+ cargo --version
info: syncing channel updates for '1.90-x86_64-unknown-linux-gnu'
info: latest update on 2025-09-18, rust version 1.90.0 (1159e78c4 2025-09-14)
info: downloading component 'cargo'
info: downloading component 'clippy'
info: downloading component 'rust-docs'
info: downloading component 'rust-src'
info: downloading component 'rust-std'
info: downloading component 'rustc'
info: downloading component 'rustfmt'
info: installing component 'cargo'
info: installing component 'clippy'
info: installing component 'rust-docs'
info: installing component 'rust-src'
info: installing component 'rust-std'
info: installing component 'rustc'
info: installing component 'rustfmt'
cargo 1.90.0 (840b83a10 2025-07-30)
+ rustc --version
rustc 1.90.0 (1159e78c4 2025-09-14)
+ cargo fmt --check
+ cargo clippy --all-targets --workspace -- --deny warnings
Updating crates.io index
Downloading crates ...
Downloaded base-x v0.2.11
Downloaded either v1.11.0
Downloaded idna_adapter v1.2.0
Downloaded hash32 v0.3.1
Downloaded libz-rs-sys v0.5.2
Downloaded matchers v0.1.0
Downloaded lazy_static v1.5.0
Downloaded num-bigint v0.4.6
Downloaded once_cell v1.21.3
Downloaded p256 v0.13.2
Downloaded flate2 v1.1.1
Downloaded filetime v0.2.23
Downloaded pem-rfc7468 v0.7.0
Downloaded parking_lot v0.12.5
Downloaded num-integer v0.1.46
Downloaded crossbeam-channel v0.5.15
Downloaded bcrypt-pbkdf v0.10.0
Downloaded faster-hex v0.10.0
Downloaded fraction v0.15.3
Downloaded icu_provider v1.5.0
Downloaded group v0.13.0
Downloaded gix-lock v18.0.0
Downloaded gix-config-value v0.15.1
Downloaded icu_collections v1.5.0
Downloaded polyval v0.6.2
Downloaded qcheck-macros v1.0.0
Downloaded ref-cast-impl v1.0.24
Downloaded num-iter v0.1.45
Downloaded inout v0.1.3
Downloaded displaydoc v0.2.5
Downloaded indexmap v2.2.6
Downloaded icu_provider_macros v1.5.0
Downloaded hmac v0.12.1
Downloaded colored v2.1.0
Downloaded serde_spanned v1.0.0
Downloaded gix-sec v0.12.0
Downloaded rsa v0.9.6
Downloaded escargot v0.5.10
Downloaded indicatif v0.18.0
Downloaded num v0.4.3
Downloaded convert_case v0.7.1
Downloaded nonempty v0.9.0
Downloaded newline-converter v0.3.0
Downloaded normalize-line-endings v0.3.0
Downloaded noise-framework v0.4.0
Downloaded ssh-encoding v0.2.0
Downloaded jobserver v0.1.31
Downloaded fnv v1.0.7
Downloaded cc v1.2.2
Downloaded miniz_oxide v0.8.8
Downloaded idna v1.0.3
Downloaded synstructure v0.13.1
Downloaded pin-project-lite v0.2.16
Downloaded outref v0.5.2
Downloaded poly1305 v0.8.0
Downloaded pkcs8 v0.10.2
Downloaded tree-sitter-toml-ng v0.6.0
Downloaded serde_json v1.0.140
Downloaded vsimd v0.8.0
Downloaded zlib-rs v0.5.2
Downloaded yoke v0.7.5
Downloaded rand_core v0.9.3
Downloaded percent-encoding v2.3.1
Downloaded regex-automata v0.1.10
Downloaded sha1 v0.10.6
Downloaded schemars_derive v1.0.4
Downloaded sem_safe v0.2.0
Downloaded p521 v0.13.3
Downloaded portable-atomic v1.11.0
Downloaded signal-hook-registry v1.4.5
Downloaded scopeguard v1.2.0
Downloaded regex-automata v0.4.9
Downloaded signal-hook-mio v0.2.4
Downloaded ssh-cipher v0.2.0
Downloaded siphasher v0.3.11
Downloaded shell-words v1.1.0
Downloaded ryu v1.0.17
Downloaded sval_serde v2.14.1
Downloaded test-log v0.2.18
Downloaded snapbox-macros v0.3.8
Downloaded test-log-macros v0.2.18
Downloaded thiserror v2.0.17
Downloaded tinyvec_macros v0.1.1
Downloaded wait-timeout v0.2.1
Downloaded version_check v0.9.4
Downloaded utf16_iter v1.0.5
Downloaded toml_writer v1.0.2
Downloaded tracing-log v0.2.0
Downloaded value-bag-serde1 v1.11.1
Downloaded xattr v1.3.1
Downloaded zerofrom-derive v0.1.6
Downloaded yoke-derive v0.7.5
Downloaded signals_receipts v0.2.0
Downloaded zeroize v1.7.0
Downloaded yansi v0.5.1
Downloaded zerovec-derive v0.10.3
Downloaded similar v2.5.0
Downloaded value-bag v1.11.1
Downloaded unicode-ident v1.0.12
Downloaded tree-sitter-css v0.23.1
Downloaded walkdir v2.5.0
Downloaded tracing-core v0.1.34
Downloaded uuid v1.16.0
Downloaded url v2.5.4
Downloaded tracing v0.1.41
Downloaded ssh-key v0.6.6
Downloaded unicode-segmentation v1.11.0
Downloaded tree-sitter-go v0.23.4
Downloaded unicode-normalization v0.1.23
Downloaded zerovec v0.10.4
Downloaded toml v0.9.5
Downloaded proptest v1.9.0
Downloaded tree-sitter v0.24.4
Downloaded zerocopy v0.7.35
Downloaded regex v1.11.1
Downloaded vcpkg v0.2.15
Downloaded tree-sitter-c v0.23.2
Downloaded syn v1.0.109
Downloaded rustix v0.38.34
Downloaded unicode-width v0.2.1
Downloaded tree-sitter-md v0.3.2
Downloaded tree-sitter-rust v0.23.2
Downloaded sha1-checked v0.10.0
Downloaded libc v0.2.174
Downloaded syn v2.0.106
Downloaded regex-syntax v0.8.5
Downloaded tree-sitter-bash v0.23.3
Downloaded winnow v0.7.13
Downloaded tree-sitter-python v0.23.4
Downloaded tracing-subscriber v0.3.19
Downloaded tree-sitter-ruby v0.23.1
Downloaded rand v0.8.5
Downloaded typenum v1.17.0
Downloaded thiserror-impl v1.0.69
Downloaded rand v0.9.2
Downloaded writeable v0.5.5
Downloaded unicode-width v0.1.11
Downloaded tree-sitter-html v0.23.2
Downloaded tree-sitter-highlight v0.24.4
Downloaded streaming-iterator v0.1.9
Downloaded snapbox v0.4.17
Downloaded smallvec v1.15.1
Downloaded signal-hook v0.3.18
Downloaded serde_derive v1.0.219
Downloaded prodash v30.0.1
Downloaded value-bag-sval2 v1.11.1
Downloaded tree-sitter-typescript v0.23.2
Downloaded tokio v1.47.1
Downloaded unit-prefix v0.5.1
Downloaded sha3 v0.10.8
Downloaded thread_local v1.1.9
Downloaded sharded-slab v0.1.7
Downloaded zerofrom v0.1.6
Downloaded utf8parse v0.2.1
Downloaded universal-hash v0.5.1
Downloaded uuid-simd v0.8.0
Downloaded linux-raw-sys v0.4.13
Downloaded toml_datetime v0.7.0
Downloaded systemd-journal-logger v2.2.2
Downloaded sval_nested v2.14.1
Downloaded tree-sitter-language v0.1.2
Downloaded tree-sitter-json v0.24.8
Downloaded unicode-display-width v0.3.0
Downloaded thiserror-impl v2.0.17
Downloaded sha2 v0.10.8
Downloaded utf8_iter v1.0.4
Downloaded unarray v0.1.4
Downloaded typeid v1.0.3
Downloaded timeago v0.4.2
Downloaded thiserror v1.0.69
Downloaded tar v0.4.40
Downloaded serde v1.0.219
Downloaded tinystr v0.7.6
Downloaded tempfile v3.23.0
Downloaded sval v2.14.1
Downloaded stable_deref_trait v1.2.0
Downloaded shlex v1.3.0
Downloaded serde_derive_internals v0.29.1
Downloaded serde-untagged v0.1.7
Downloaded sval_buffer v2.14.1
Downloaded libgit2-sys v0.17.0+1.8.1
Downloaded strsim v0.11.1
Downloaded socks5-client v0.4.1
Downloaded sval_fmt v2.14.1
Downloaded subtle v2.5.0
Downloaded sval_ref v2.14.1
Downloaded sval_json v2.14.1
Downloaded sval_dynamic v2.14.1
Downloaded structured-logger v1.0.4
Downloaded siphasher v1.0.1
Downloaded signature v2.2.0
Downloaded serde_fmt v1.0.3
Downloaded rustix v1.0.7
Downloaded signature v1.6.4
Downloaded qcheck v1.0.0
Downloaded linux-raw-sys v0.9.4
Downloaded sqlite3-sys v0.15.2
Downloaded sqlite v0.32.0
Downloaded spki v0.7.3
Downloaded spin v0.9.8
Downloaded socket2 v0.5.7
Downloaded schemars v1.0.4
Downloaded regex-syntax v0.6.29
Downloaded quote v1.0.41
Downloaded quick-error v1.2.3
Downloaded sec1 v0.7.3
Downloaded referencing v0.30.0
Downloaded object v0.36.7
Downloaded salsa20 v0.10.2
Downloaded rfc6979 v0.4.0
Downloaded phf v0.11.3
Downloaded scrypt v0.11.0
Downloaded same-file v1.0.6
Downloaded icu_properties_data v1.5.1
Downloaded write16 v1.0.0
Downloaded rusty-fork v0.3.1
Downloaded rustc-demangle v0.1.26
Downloaded rand_xorshift v0.4.0
Downloaded rand_chacha v0.9.0
Downloaded ref-cast v1.0.24
Downloaded proc-macro2 v1.0.101
Downloaded pretty_assertions v1.4.0
Downloaded jsonschema v0.30.0
Downloaded itertools v0.14.0
Downloaded rand_core v0.6.4
Downloaded rand_chacha v0.3.1
Downloaded primeorder v0.13.6
Downloaded pbkdf2 v0.12.2
Downloaded parking_lot_core v0.9.12
Downloaded hashbrown v0.14.3
Downloaded ppv-lite86 v0.2.17
Downloaded paste v1.0.15
Downloaded hashbrown v0.15.5
Downloaded tinyvec v1.6.0
Downloaded num-traits v0.2.19
Downloaded memchr v2.7.2
Downloaded pkg-config v0.3.30
Downloaded pkcs1 v0.7.5
Downloaded phf_shared v0.11.3
Downloaded p384 v0.13.0
Downloaded overload v0.1.1
Downloaded os_info v3.12.0
Downloaded libm v0.2.8
Downloaded getrandom v0.2.15
Downloaded opaque-debug v0.3.1
Downloaded num-complex v0.4.6
Downloaded num-cmp v0.1.0
Downloaded num-bigint-dig v0.8.4
Downloaded mio v1.0.4
Downloaded sqlite3-src v0.5.1
Downloaded mio v0.8.11
Downloaded litemap v0.7.5
Downloaded aes-gcm v0.10.3
Downloaded jiff-static v0.2.15
Downloaded crossterm v0.29.0
Downloaded crossterm v0.25.0
Downloaded bloomy v1.2.0
Downloaded heapless v0.8.0
Downloaded gix-revwalk v0.21.0
Downloaded chrono v0.4.38
Downloaded memmap2 v0.9.8
Downloaded lexopt v0.3.0
Downloaded nu-ansi-term v0.46.0
Downloaded icu_locid v1.5.0
Downloaded gix-validate v0.10.0
Downloaded lock_api v0.4.14
Downloaded console v0.16.0
Downloaded litrs v0.4.1
Downloaded crc32fast v1.5.0
Downloaded ahash v0.8.11
Downloaded fxhash v0.2.1
Downloaded derive_more v2.0.1
Downloaded anstream v0.6.13
Downloaded amplify_syn v2.0.1
Downloaded multibase v0.9.1
Downloaded maybe-async v0.2.10
Downloaded log v0.4.27
Downloaded keccak v0.1.5
Downloaded itoa v1.0.11
Downloaded icu_normalizer_data v1.5.1
Downloaded icu_locid_transform v1.5.0
Downloaded gix-url v0.32.0
Downloaded ed25519 v1.5.3
Downloaded document-features v0.2.11
Downloaded data-encoding-macro v0.1.14
Downloaded cyphergraphy v0.3.0
Downloaded chacha20poly1305 v0.10.1
Downloaded bitflags v2.9.1
Downloaded anstyle-query v1.0.2
Downloaded amplify_derive v4.0.0
Downloaded dyn-clone v1.0.17
Downloaded data-encoding v2.5.0
Downloaded ct-codecs v1.1.1
Downloaded clap_builder v4.5.44
Downloaded cbc v0.1.2
Downloaded borrow-or-share v0.2.2
Downloaded fluent-uri v0.3.2
Downloaded blowfish v0.9.1
Downloaded chacha20 v0.9.1
Downloaded byteorder v1.5.0
Downloaded icu_properties v1.5.1
Downloaded human-panic v2.0.3
Downloaded libz-sys v1.1.16
Downloaded ghash v0.5.1
Downloaded const-oid v0.9.6
Downloaded anstyle-parse v0.2.3
Downloaded clap v4.5.44
Downloaded cipher v0.4.4
Downloaded gix-pack v0.60.0
Downloaded bytecount v0.6.8
Downloaded home v0.5.9
Downloaded gix-ref v0.53.1
Downloaded gix-actor v0.35.4
Downloaded fancy-regex v0.14.0
Downloaded cyphernet v0.5.2
Downloaded crypto-bigint v0.5.5
Downloaded crossbeam-utils v0.8.19
Downloaded clap_lex v0.7.5
Downloaded clap_derive v4.5.41
Downloaded block-padding v0.3.3
Downloaded gix-utils v0.3.0
Downloaded gix-refspec v0.31.0
Downloaded gix-quote v0.6.0
Downloaded gix-fs v0.16.1
Downloaded bit-vec v0.8.0
Downloaded base64ct v1.6.0
Downloaded aes v0.8.4
Downloaded colorchoice v1.0.0
Downloaded base64 v0.22.1
Downloaded der v0.7.9
Downloaded iana-time-zone v0.1.60
Downloaded heck v0.5.0
Downloaded generic-array v0.14.7
Downloaded form_urlencoded v1.2.1
Downloaded num-rational v0.4.2
Downloaded gix-transport v0.48.0
Downloaded gix-revision v0.35.0
Downloaded data-encoding-macro-internal v0.1.12
Downloaded bitflags v1.3.2
Downloaded gix-trace v0.1.13
Downloaded gix-prompt v0.11.1
Downloaded gix-object v0.50.2
Downloaded gix-hash v0.19.0
Downloaded gimli v0.31.1
Downloaded ctr v0.9.2
Downloaded cpufeatures v0.2.12
Downloaded bit-set v0.8.0
Downloaded icu_normalizer v1.5.0
Downloaded gix-protocol v0.51.0
Downloaded gix-command v0.6.2
Downloaded gix-chunk v0.4.11
Downloaded fastrand v2.3.0
Downloaded env_logger v0.11.8
Downloaded email_address v0.2.9
Downloaded digest v0.10.7
Downloaded diff v0.1.13
Downloaded cypheraddr v0.4.0
Downloaded clap_complete v4.5.60
Downloaded bytes v1.10.1
Downloaded gix-hashtable v0.9.0
Downloaded anstyle v1.0.11
Downloaded aho-corasick v1.1.3
Downloaded elliptic-curve v0.13.8
Downloaded gix-date v0.10.5
Downloaded emojis v0.6.4
Downloaded ecdsa v0.16.9
Downloaded ec25519 v0.1.0
Downloaded dunce v1.0.5
Downloaded gix-negotiate v0.21.0
Downloaded getrandom v0.3.3
Downloaded fast-glob v0.3.3
Downloaded equivalent v1.0.1
Downloaded derive_more-impl v2.0.1
Downloaded block-buffer v0.10.4
Downloaded base64 v0.21.7
Downloaded amplify_num v0.5.2
Downloaded icu_locid_transform_data v1.5.1
Downloaded gix-tempfile v18.0.0
Downloaded gix-path v0.10.20
Downloaded gix-odb v0.70.0
Downloaded gix-diff v0.53.0
Downloaded gix-credentials v0.30.0
Downloaded git-ref-format-core v0.6.0
Downloaded erased-serde v0.4.6
Downloaded env_filter v0.1.3
Downloaded crypto-common v0.1.6
Downloaded bytesize v2.0.1
Downloaded anyhow v1.0.82
Downloaded errno v0.3.13
Downloaded cfg-if v1.0.0
Downloaded bstr v1.12.0
Downloaded jiff v0.2.15
Downloaded inquire v0.7.5
Downloaded gix-shallow v0.5.0
Downloaded gix-packetline v0.19.1
Downloaded gix-features v0.43.1
Downloaded git2 v0.19.0
Downloaded gix-traverse v0.47.0
Downloaded gix-commitgraph v0.29.0
Downloaded ff v0.13.0
Downloaded autocfg v1.2.0
Downloaded amplify v4.6.0
Downloaded addr2line v0.24.2
Downloaded base32 v0.4.0
Downloaded base16ct v0.2.0
Downloaded backtrace v0.3.75
Downloaded ascii v1.1.0
Downloaded arc-swap v1.7.1
Downloaded aead v0.5.2
Downloaded adler2 v2.0.0
Compiling libc v0.2.174
Compiling proc-macro2 v1.0.101
Compiling unicode-ident v1.0.12
Compiling quote v1.0.41
Checking cfg-if v1.0.0
Compiling shlex v1.3.0
Compiling version_check v0.9.4
Checking memchr v2.7.2
Compiling typenum v1.17.0
Checking getrandom v0.2.15
Compiling generic-array v0.14.7
Compiling syn v2.0.106
Compiling jobserver v0.1.31
Checking rand_core v0.6.4
Compiling serde v1.0.219
Compiling cc v1.2.2
Checking regex-syntax v0.8.5
Checking crypto-common v0.1.6
Checking aho-corasick v1.1.3
Checking smallvec v1.15.1
Checking regex-automata v0.4.9
Compiling thiserror v2.0.17
Checking subtle v2.5.0
Checking once_cell v1.21.3
Checking stable_deref_trait v1.2.0
Checking cpufeatures v0.2.12
Checking fastrand v2.3.0
Compiling parking_lot_core v0.9.12
Checking scopeguard v1.2.0
Checking lock_api v0.4.14
Checking block-buffer v0.10.4
Checking parking_lot v0.12.5
Checking digest v0.10.7
Checking bitflags v2.9.1
Checking byteorder v1.5.0
Checking tinyvec_macros v0.1.1
Compiling crc32fast v1.5.0
Checking tinyvec v1.6.0
Compiling typeid v1.0.3
Checking gix-trace v0.1.13
Checking home v0.5.9
Checking zlib-rs v0.5.2
Checking unicode-normalization v0.1.23
Checking bstr v1.12.0
Checking gix-utils v0.3.0
Compiling synstructure v0.13.1
Checking libz-rs-sys v0.5.2
Checking same-file v1.0.6
Checking flate2 v1.1.1
Checking walkdir v2.5.0
Checking prodash v30.0.1
Checking itoa v1.0.11
Compiling heapless v0.8.0
Checking hash32 v0.3.1
Compiling getrandom v0.3.3
Compiling icu_locid_transform_data v1.5.1
Checking writeable v0.5.5
Checking litemap v0.7.5
Compiling pkg-config v0.3.30
Compiling serde_derive v1.0.219
Compiling thiserror-impl v2.0.17
Compiling zerofrom-derive v0.1.6
Compiling yoke-derive v0.7.5
Checking zerofrom v0.1.6
Checking gix-validate v0.10.0
Compiling zerovec-derive v0.10.3
Checking gix-path v0.10.20
Checking gix-features v0.43.1
Checking yoke v0.7.5
Compiling displaydoc v0.2.5
Checking faster-hex v0.10.0
Compiling icu_provider_macros v1.5.0
Compiling icu_properties_data v1.5.1
Checking zerovec v0.10.4
Compiling rustix v1.0.7
Checking sha1 v0.10.6
Compiling icu_normalizer_data v1.5.1
Checking linux-raw-sys v0.9.4
Checking sha1-checked v0.10.0
Checking tinystr v0.7.6
Checking icu_locid v1.5.0
Checking icu_collections v1.5.0
Checking zeroize v1.7.0
Checking icu_provider v1.5.0
Checking gix-hash v0.19.0
Checking icu_locid_transform v1.5.0
Checking utf8_iter v1.0.4
Checking utf16_iter v1.0.5
Checking write16 v1.0.0
Checking percent-encoding v2.3.1
Checking block-padding v0.3.3
Checking inout v0.1.3
Checking icu_properties v1.5.1
Compiling syn v1.0.109
Checking cipher v0.4.4
Checking erased-serde v0.4.6
Checking serde_fmt v1.0.3
Checking form_urlencoded v1.2.1
Compiling vcpkg v0.2.15
Checking value-bag-serde1 v1.11.1
Checking value-bag v1.11.1
Checking log v0.4.27
Compiling libz-sys v1.1.16
Checking hashbrown v0.14.3
Checking icu_normalizer v1.5.0
Checking equivalent v1.0.1
Compiling serde_json v1.0.140
Checking tempfile v3.23.0
Checking idna_adapter v1.2.0
Checking indexmap v2.2.6
Checking idna v1.0.3
Checking ryu v1.0.17
Compiling ref-cast v1.0.24
Checking url v2.5.4
Compiling libgit2-sys v0.17.0+1.8.1
Compiling ref-cast-impl v1.0.24
Compiling autocfg v1.2.0
Compiling num-traits v0.2.19
Checking sha2 v0.10.8
Checking dyn-clone v1.0.17
Compiling thiserror v1.0.69
Compiling thiserror-impl v1.0.69
Checking universal-hash v0.5.1
Checking opaque-debug v0.3.1
Compiling serde_derive_internals v0.29.1
Compiling amplify_syn v2.0.1
Checking rand v0.8.5
Compiling schemars_derive v1.0.4
Checking signature v1.6.4
Checking ed25519 v1.5.3
Checking qcheck v1.0.0
Compiling amplify_derive v4.0.0
Checking git-ref-format-core v0.6.0
Checking aead v0.5.2
Checking ascii v1.1.0
Checking amplify_num v0.5.2
Checking schemars v1.0.4
Checking ct-codecs v1.1.1
Checking poly1305 v0.8.0
Checking ec25519 v0.1.0
Checking chacha20 v0.9.1
Checking amplify v4.6.0
Checking polyval v0.6.2
Compiling sqlite3-src v0.5.1
Checking hmac v0.12.1
Checking cyphergraphy v0.3.0
Checking keccak v0.1.5
Checking base64ct v1.6.0
Checking sha3 v0.10.8
Checking pbkdf2 v0.12.2
Checking pem-rfc7468 v0.7.0
Checking ghash v0.5.1
Checking aes v0.8.4
Checking ctr v0.9.2
Compiling data-encoding v2.5.0
Checking base32 v0.4.0
Checking cypheraddr v0.4.0
Checking aes-gcm v0.10.3
Checking ssh-encoding v0.2.0
Compiling data-encoding-macro-internal v0.1.12
Checking chacha20poly1305 v0.10.1
Checking blowfish v0.9.1
Checking cbc v0.1.2
Checking ssh-cipher v0.2.0
Checking bcrypt-pbkdf v0.10.0
Checking data-encoding-macro v0.1.14
Checking noise-framework v0.4.0
Checking socks5-client v0.4.1
Compiling crossbeam-utils v0.8.19
Checking signature v2.2.0
Checking base-x v0.2.11
Checking multibase v0.9.1
Checking ssh-key v0.6.6
Checking cyphernet v0.5.2
Checking nonempty v0.9.0
Checking radicle-ssh v0.10.0 (/21dec7d7-6514-4b8b-a389-af82fd89270b/w/crates/radicle-ssh)
Checking radicle-git-ref-format v0.1.0 (/21dec7d7-6514-4b8b-a389-af82fd89270b/w/crates/radicle-git-ref-format)
Checking lazy_static v1.5.0
Checking jiff v0.2.15
Checking crossbeam-channel v0.5.15
Checking base64 v0.21.7
Checking anstyle-query v1.0.2
Checking siphasher v1.0.1
Checking radicle-dag v0.10.0 (/21dec7d7-6514-4b8b-a389-af82fd89270b/w/crates/radicle-dag)
Checking winnow v0.7.13
Checking hashbrown v0.15.5
Checking utf8parse v0.2.1
Checking anstyle-parse v0.2.3
Checking gix-date v0.10.5
Checking gix-hashtable v0.9.0
Checking gix-actor v0.35.4
Checking colorchoice v1.0.0
Checking iana-time-zone v0.1.60
Checking anstyle v1.0.11
Checking chrono v0.4.38
Checking anstream v0.6.13
Checking gix-object v0.50.2
Checking colored v2.1.0
Checking radicle-localtime v0.1.0 (/21dec7d7-6514-4b8b-a389-af82fd89270b/w/crates/radicle-localtime)
Checking serde-untagged v0.1.7
Checking bytesize v2.0.1
Checking memmap2 v0.9.8
Checking dunce v1.0.5
Checking tree-sitter-language v0.1.2
Checking fast-glob v0.3.3
Checking gix-chunk v0.4.11
Checking gix-fs v0.16.1
Checking gix-commitgraph v0.29.0
Checking gix-tempfile v18.0.0
Checking gix-revwalk v0.21.0
Checking mio v1.0.4
Checking gix-quote v0.6.0
Compiling rustix v0.38.34
Checking sem_safe v0.2.0
Checking errno v0.3.13
Compiling linux-raw-sys v0.4.13
Checking shell-words v1.1.0
Checking either v1.11.0
Compiling anyhow v1.0.82
Checking gix-command v0.6.2
Checking signals_receipts v0.2.0
Compiling object v0.36.7
Compiling signal-hook v0.3.18
Compiling adler2 v2.0.0
Compiling miniz_oxide v0.8.8
Compiling xattr v1.3.1
Compiling filetime v0.2.23
Checking gix-lock v18.0.0
Checking gix-url v0.32.0
Checking gix-config-value v0.15.1
Checking gix-sec v0.12.0
Checking signal-hook-registry v1.4.5
Checking gimli v0.31.1
Checking gix-prompt v0.11.1
Compiling tar v0.4.40
Checking addr2line v0.24.2
Checking radicle-signals v0.11.0 (/21dec7d7-6514-4b8b-a389-af82fd89270b/w/crates/radicle-signals)
Checking gix-traverse v0.47.0
Checking gix-revision v0.35.0
Checking gix-diff v0.53.0
Checking mio v0.8.11
Checking gix-packetline v0.19.1
Compiling tree-sitter v0.24.4
Compiling unicode-segmentation v1.11.0
Checking rustc-demangle v0.1.26
Compiling convert_case v0.7.1
Checking backtrace v0.3.75
Checking gix-transport v0.48.0
Checking signal-hook-mio v0.2.4
Checking gix-pack v0.60.0
Checking gix-refspec v0.31.0
Compiling radicle-surf v0.26.0 (/21dec7d7-6514-4b8b-a389-af82fd89270b/w/crates/radicle-surf)
Checking gix-credentials v0.30.0
Checking gix-ref v0.53.1
Checking gix-shallow v0.5.0
Checking gix-negotiate v0.21.0
Compiling maybe-async v0.2.10
Checking regex v1.11.1
Compiling portable-atomic v1.11.0
Checking arc-swap v1.7.1
Checking gix-odb v0.70.0
Checking gix-protocol v0.51.0
Compiling derive_more-impl v2.0.1
Checking uuid v1.16.0
Checking bitflags v1.3.2
Checking unicode-width v0.2.1
Compiling litrs v0.4.1
Checking diff v0.1.13
Checking yansi v0.5.1
Checking bytes v1.10.1
Checking pretty_assertions v1.4.0
Compiling document-features v0.2.11
Checking derive_more v2.0.1
Checking console v0.16.0
Checking crossterm v0.25.0
Checking newline-converter v0.3.0
Checking snapbox-macros v0.3.8
Checking salsa20 v0.10.2
Checking fxhash v0.2.1
Checking clap_lex v0.7.5
Checking strsim v0.11.1
Checking unicode-width v0.1.11
Checking similar v2.5.0
Compiling heck v0.5.0
Checking streaming-iterator v0.1.9
Checking unit-prefix v0.5.1
Checking siphasher v0.3.11
Checking normalize-line-endings v0.3.0
Checking snapbox v0.4.17
Checking bloomy v1.2.0
Checking indicatif v0.18.0
Compiling clap_derive v4.5.41
Checking inquire v0.7.5
Checking clap_builder v4.5.44
Checking scrypt v0.11.0
Checking crossterm v0.29.0
Checking sqlite3-sys v0.15.2
Checking sqlite v0.32.0
Checking radicle-crypto v0.14.0 (/21dec7d7-6514-4b8b-a389-af82fd89270b/w/crates/radicle-crypto)
Checking unicode-display-width v0.3.0
Checking systemd-journal-logger v2.2.2
Checking toml_datetime v0.7.0
Checking serde_spanned v1.0.0
Compiling tree-sitter-go v0.23.4
Compiling tree-sitter-c v0.23.2
Compiling tree-sitter-typescript v0.23.2
Compiling tree-sitter-python v0.23.4
Compiling tree-sitter-json v0.24.8
Compiling tree-sitter-toml-ng v0.6.0
Compiling tree-sitter-md v0.3.2
Compiling tree-sitter-css v0.23.1
Compiling tree-sitter-html v0.23.2
Compiling tree-sitter-ruby v0.23.1
Compiling tree-sitter-bash v0.23.3
Compiling tree-sitter-rust v0.23.2
Checking bit-vec v0.8.0
Checking pin-project-lite v0.2.16
Checking toml_writer v1.0.2
Checking tokio v1.47.1
Checking bit-set v0.8.0
Checking toml v0.9.5
Checking clap v4.5.44
Checking os_info v3.12.0
Checking rand_core v0.9.3
Compiling radicle-node v0.16.0 (/21dec7d7-6514-4b8b-a389-af82fd89270b/w/crates/radicle-node)
Compiling radicle-cli v0.17.0 (/21dec7d7-6514-4b8b-a389-af82fd89270b/w/crates/radicle-cli)
Checking human-panic v2.0.3
Checking clap_complete v4.5.60
Checking structured-logger v1.0.4
Checking radicle-systemd v0.11.0 (/21dec7d7-6514-4b8b-a389-af82fd89270b/w/crates/radicle-systemd)
Checking tree-sitter-highlight v0.24.4
Checking itertools v0.14.0
Compiling qcheck-macros v1.0.0
Checking num-integer v0.1.46
Checking socket2 v0.5.7
Checking lexopt v0.3.0
Checking timeago v0.4.2
Compiling escargot v0.5.10
Checking wait-timeout v0.2.1
Checking fnv v1.0.7
Checking ppv-lite86 v0.2.17
Checking quick-error v1.2.3
Checking rusty-fork v0.3.1
Checking rand_chacha v0.9.0
Checking num-bigint v0.4.6
Checking rand v0.9.2
Checking rand_xorshift v0.4.0
Compiling ahash v0.8.11
Checking unarray v0.1.4
Checking proptest v1.9.0
Checking num-iter v0.1.45
Checking num-complex v0.4.6
Checking num-rational v0.4.2
Checking env_filter v0.1.3
Checking zerocopy v0.7.35
Checking borrow-or-share v0.2.2
Checking fluent-uri v0.3.2
Checking env_logger v0.11.8
Checking num v0.4.3
Checking phf_shared v0.11.3
Compiling test-log-macros v0.2.18
Compiling paste v1.0.15
Checking vsimd v0.8.0
Compiling radicle-remote-helper v0.14.0 (/21dec7d7-6514-4b8b-a389-af82fd89270b/w/crates/radicle-remote-helper)
Checking outref v0.5.2
Checking uuid-simd v0.8.0
Checking test-log v0.2.18
Checking phf v0.11.3
Checking fraction v0.15.3
Checking referencing v0.30.0
Checking fancy-regex v0.14.0
Checking email_address v0.2.9
Checking num-cmp v0.1.0
Checking bytecount v0.6.8
Checking base64 v0.22.1
Checking emojis v0.6.4
Checking jsonschema v0.30.0
Checking git2 v0.19.0
Checking radicle-oid v0.1.0 (/21dec7d7-6514-4b8b-a389-af82fd89270b/w/crates/radicle-oid)
Checking radicle-git-metadata v0.1.0 (/21dec7d7-6514-4b8b-a389-af82fd89270b/w/crates/radicle-git-metadata)
Checking radicle-term v0.16.0 (/21dec7d7-6514-4b8b-a389-af82fd89270b/w/crates/radicle-term)
Checking radicle-core v0.1.0 (/21dec7d7-6514-4b8b-a389-af82fd89270b/w/crates/radicle-core)
Checking radicle-cob v0.17.0 (/21dec7d7-6514-4b8b-a389-af82fd89270b/w/crates/radicle-cob)
Checking radicle v0.20.0 (/21dec7d7-6514-4b8b-a389-af82fd89270b/w/crates/radicle)
error: function `commits_strategy` is never used
--> crates/radicle-surf/src/test/commit.rs:15:4
|
15 | fn commits_strategy() -> impl Strategy<Value = Commit> {
| ^^^^^^^^^^^^^^^^
|
= note: `-D dead-code` implied by `-D warnings`
= help: to override `-D warnings` add `#[allow(dead_code)]`
error: function `str` is never used
--> crates/radicle-surf/src/test/roundtrip.rs:38:8
|
38 | pub fn str<A>(a: A)
| ^^^
error: function `trivial` is never used
--> crates/radicle-surf/src/test/gen.rs:6:8
|
6 | pub fn trivial() -> impl Strategy<Value = String> {
| ^^^^^^^
error: function `valid` is never used
--> crates/radicle-surf/src/test/gen.rs:10:8
|
10 | pub fn valid() -> impl Strategy<Value = String> {
| ^^^^^
error: function `invalid_char` is never used
--> crates/radicle-surf/src/test/gen.rs:14:8
|
14 | pub fn invalid_char() -> impl Strategy<Value = char> {
| ^^^^^^^^^^^^
error: function `with_invalid_char` is never used
--> crates/radicle-surf/src/test/gen.rs:26:8
|
26 | pub fn with_invalid_char() -> impl Strategy<Value = String> {
| ^^^^^^^^^^^^^^^^^
error: function `ends_with_dot_lock` is never used
--> crates/radicle-surf/src/test/gen.rs:34:8
|
34 | pub fn ends_with_dot_lock() -> impl Strategy<Value = String> {
| ^^^^^^^^^^^^^^^^^^
error: function `with_double_dot` is never used
--> crates/radicle-surf/src/test/gen.rs:38:8
|
38 | pub fn with_double_dot() -> impl Strategy<Value = String> {
| ^^^^^^^^^^^^^^^
error: function `starts_with_dot` is never used
--> crates/radicle-surf/src/test/gen.rs:42:8
|
42 | pub fn starts_with_dot() -> impl Strategy<Value = String> {
| ^^^^^^^^^^^^^^^
error: function `ends_with_dot` is never used
--> crates/radicle-surf/src/test/gen.rs:46:8
|
46 | pub fn ends_with_dot() -> impl Strategy<Value = String> {
| ^^^^^^^^^^^^^
error: function `with_control_char` is never used
--> crates/radicle-surf/src/test/gen.rs:50:8
|
50 | pub fn with_control_char() -> impl Strategy<Value = String> {
| ^^^^^^^^^^^^^^^^^
error: function `with_space` is never used
--> crates/radicle-surf/src/test/gen.rs:54:8
|
54 | pub fn with_space() -> impl Strategy<Value = String> {
| ^^^^^^^^^^
error: function `with_consecutive_slashes` is never used
--> crates/radicle-surf/src/test/gen.rs:58:8
|
58 | pub fn with_consecutive_slashes() -> impl Strategy<Value = String> {
| ^^^^^^^^^^^^^^^^^^^^^^^^
error: function `with_glob` is never used
--> crates/radicle-surf/src/test/gen.rs:62:8
|
62 | pub fn with_glob() -> impl Strategy<Value = String> {
| ^^^^^^^^^
error: function `multi_glob` is never used
--> crates/radicle-surf/src/test/gen.rs:66:8
|
66 | pub fn multi_glob() -> impl Strategy<Value = String> {
| ^^^^^^^^^^
error: function `invalid` is never used
--> crates/radicle-surf/src/test/gen.rs:79:8
|
79 | pub fn invalid() -> impl Strategy<Value = String> {
| ^^^^^^^
error: field `dir` is never read
--> crates/radicle-surf/src/test/repository.rs:10:9
|
9 | pub struct Fixture {
| ------- field in this struct
10 | pub dir: tempfile::TempDir,
| ^^^
error: could not compile `radicle-surf` (lib test) due to 17 previous errors
warning: build failed, waiting for other jobs to finish...
Exit code: 101
{
"response": "finished",
"result": "failure"
}