cli: Use `git push` to open or update patches

This adds the ability to the git-remote-helper to open and update
patches via a `git push` to the magic ref `refs/patches` and
`refs/heads/patches/<patch-id>`.

The main change is that we had to move away from offering the `connect`
capability in the remote helper, to offering `push` and `fetch`
individually.

To get a feel for how this works, see the `rad-patch-via-push.md`
example.
This commit is contained in:
Alexis Sellier 2023-05-10 15:03:44 +02:00
parent f8ee109aaa
commit fd1237b2db
No known key found for this signature in database
25 changed files with 1200 additions and 133 deletions

1
Cargo.lock generated
View File

@ -2093,6 +2093,7 @@ version = "0.2.0"
dependencies = [ dependencies = [
"radicle", "radicle",
"radicle-crypto", "radicle-crypto",
"radicle-git-ext",
"thiserror", "thiserror",
] ]

View File

@ -0,0 +1,30 @@
```
$ cd heartwood
$ git branch -r
rad/master
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/master
```
```
$ git ls-remote z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi 'refs/heads/*'
145e1e69bef3ad93d14946ea212249c2fa9b9828 refs/heads/alice/1
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/heads/master
```
``` (stderr)
$ git fetch z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
* [new branch] alice/1 -> z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/alice/1
```
```
$ git branch -r
rad/master
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/alice/1
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/master
```
```
$ git rev-parse z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/alice/1
145e1e69bef3ad93d14946ea212249c2fa9b9828
```

View File

@ -0,0 +1,18 @@
Finally, we can also delete branches with `git push`:
```
$ git ls-remote rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi refs/heads/*
145e1e69bef3ad93d14946ea212249c2fa9b9828 refs/heads/alice/1
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/heads/master
```
``` (stderr) RAD_SOCKET=/dev/null
$ git push rad :alice/1
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
- [deleted] alice/1
```
```
$ git ls-remote rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi refs/heads/*
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/heads/master
```

View File

@ -0,0 +1,61 @@
```
$ git checkout -b alice/1
$ git commit -m "Alice's commit" --allow-empty -s
[alice/1 87fa120] Alice's commit
```
``` (stderr) RAD_SOCKET=/dev/null
$ git push rad HEAD:alice/1
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
* [new branch] HEAD -> alice/1
```
Make sure we can't force-push without `+`:
``` (stderr)
$ git commit --amend -m "Alice's amended commit" --allow-empty -s
```
``` (stderr) (fail)
$ git push rad HEAD:alice/1
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
! [rejected] HEAD -> alice/1 (non-fast-forward)
error: failed to push some refs to 'rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. Integrate the remote changes (e.g.
hint: 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
```
And that we can with `+`:
``` (stderr) RAD_SOCKET=/dev/null
$ git push rad +HEAD:alice/1
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+ 87fa120...145e1e6 HEAD -> alice/1 (forced update)
```
```
$ git branch -r -vv
rad/alice/1 145e1e6 Alice's amended commit
rad/master f2de534 Second commit
```
List our namespaced refs:
```
$ git ls-remote rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi 'refs/heads/*'
145e1e69bef3ad93d14946ea212249c2fa9b9828 refs/heads/alice/1
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/heads/master
```
List the canonical refs:
```
$ git ls-remote rad
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/heads/master
```
```
$ rad sync
✓ Synced with 1 node(s)
```

View File

@ -0,0 +1,220 @@
# Using `git push` to open patches
Let's checkout a branch, make a commit and push to the magic ref `refs/patches`.
When we push to this ref, a patch is created from our commits.
``` (stderr)
$ git checkout -b feature/1
Switched to a new branch 'feature/1'
$ git commit -a -m "Add things" -q --allow-empty
$ git push rad HEAD:refs/patches
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
* [new reference] HEAD -> refs/patches
```
We can see a patch was created:
```
$ rad patch show 8f0e8ec
╭─────────────────────────────────────────────────────────────────────────────────────────╮
│ Title Add things │
│ Patch 8f0e8ecb47a17e8f3219f33150a4092d645e4875 │
│ Author did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi │
│ Head 42d894a83c9c356552a57af09ccdbd5587a99045 │
│ Branches feature/1 │
│ Commits ahead 1, behind 0 │
│ Status open │
├─────────────────────────────────────────────────────────────────────────────────────────┤
│ ● opened by did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (you) [ .. ] │
╰─────────────────────────────────────────────────────────────────────────────────────────╯
```
If we check our local branch, we can see its upstream is set to track a remote
branch associated with this patch:
```
$ git branch -vv
* feature/1 42d894a [rad/patches/8f0e8ecb47a17e8f3219f33150a4092d645e4875] Add things
master f2de534 [rad/master] Second commit
```
Let's check that it's up to date with our local head:
```
$ git status --short --branch
## feature/1...rad/patches/8f0e8ecb47a17e8f3219f33150a4092d645e4875
$ git fetch
$ git push
```
And let's look at our local and remote refs:
```
$ git show-ref
42d894a83c9c356552a57af09ccdbd5587a99045 refs/heads/feature/1
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/heads/master
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/remotes/rad/master
42d894a83c9c356552a57af09ccdbd5587a99045 refs/remotes/rad/patches/8f0e8ecb47a17e8f3219f33150a4092d645e4875
```
```
$ git ls-remote rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi 'refs/heads/patches/*'
42d894a83c9c356552a57af09ccdbd5587a99045 refs/heads/patches/8f0e8ecb47a17e8f3219f33150a4092d645e4875
$ git ls-remote rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi 'refs/cobs/*'
8f0e8ecb47a17e8f3219f33150a4092d645e4875 refs/cobs/xyz.radicle.patch/8f0e8ecb47a17e8f3219f33150a4092d645e4875
```
We can also create patches by pushing to the `rad/patches` remote. It's a bit
simpler:
``` (stderr)
$ git checkout -b feature/2 -q
$ git commit -a -m "Add more things" -q --allow-empty
$ git push rad/patches
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
* [new reference] HEAD -> refs/patches
```
We see both branches with upstreams now:
```
$ git branch -vv
feature/1 42d894a [rad/patches/8f0e8ecb47a17e8f3219f33150a4092d645e4875] Add things
* feature/2 b94a835 [rad/patches/8678aafff1d1e28430952abf431e60b87e28023c] Add more things
master f2de534 [rad/master] Second commit
```
And both patches:
```
$ rad patch
╭────────────────────────────────────────────────────────────────────────────────────╮
│ ● ID Title Author Head + - Updated │
├────────────────────────────────────────────────────────────────────────────────────┤
│ ● 8678aaf Add more things z6MknSL…StBU8Vi (you) b94a835 +0 -0 [ ... ] │
│ ● 8f0e8ec Add things z6MknSL…StBU8Vi (you) 42d894a +0 -0 [ ... ] │
╰────────────────────────────────────────────────────────────────────────────────────╯
```
Note that we can't fetch from `rad/patches`:
``` (stderr) (fail)
$ git fetch rad/patches
fatal: couldn't find remote ref refs/patches
```
To update our patch, we simply push commits to the upstream branch:
```
$ git commit -a -m "Improve code" -q --allow-empty
```
``` (stderr)
$ git push
4b6618a6ccb0b406659364a70a00bb60e4cd7cf0
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
b94a835..662843e feature/2 -> patches/8678aafff1d1e28430952abf431e60b87e28023c
```
This last `git push` worked without specifying an upstream branch despite the
local branch having a different name than the remote. This is because Radicle
configures repositories upon `rad init` with `push.default = upstream`:
```
$ git config --local --get push.default
upstream
```
This allows for pushing to the remote patch branch without using the full
`<src>:<dst>` syntax.
We can then see that the patch head has moved:
```
$ rad patch show 8678aaf
╭─────────────────────────────────────────────────────────────────────────────────────────╮
│ Title Add more things │
│ Patch 8678aafff1d1e28430952abf431e60b87e28023c │
│ Author did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi │
│ Head 662843ed81e76efa69d7901fb7bdd775043015d0 │
│ Branches feature/2 │
│ Commits ahead 3, behind 0 │
│ Status open │
├─────────────────────────────────────────────────────────────────────────────────────────┤
│ ● opened by did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (you) [ ... ] │
│ ↑ updated to 4b6618a6ccb0b406659364a70a00bb60e4cd7cf0 (662843e) [ ... ] │
╰─────────────────────────────────────────────────────────────────────────────────────────╯
```
And we can check that all the refs are properly updated in our repository:
```
$ git rev-parse HEAD
662843ed81e76efa69d7901fb7bdd775043015d0
```
```
$ git status --short --branch
## feature/2...rad/patches/8678aafff1d1e28430952abf431e60b87e28023c
```
```
$ git rev-parse refs/remotes/rad/patches/8678aafff1d1e28430952abf431e60b87e28023c
662843ed81e76efa69d7901fb7bdd775043015d0
$ git ls-remote rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi refs/heads/patches/8678aafff1d1e28430952abf431e60b87e28023c
662843ed81e76efa69d7901fb7bdd775043015d0 refs/heads/patches/8678aafff1d1e28430952abf431e60b87e28023c
```
## Force push
Sometimes, it's necessary to force-push a patch update. For example, if we amended
the commit and want the updated patch to reflect that.
Let's try.
```
$ git commit --amend -m "Amended commit" --allow-empty
[feature/2 3507cd5] Amended commit
Date: [..]
```
Now let's push to the patch head.
``` (stderr) (fail)
$ git push
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
! [rejected] feature/2 -> patches/8678aafff1d1e28430952abf431e60b87e28023c (non-fast-forward)
error: failed to push some refs to 'rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi'
hint: Updates were rejected because a pushed branch tip is behind its remote
hint: counterpart. Check out this branch and integrate the remote changes
hint: (e.g. 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
```
The push fails because it's not a fast-forward update. To remedy this, we can
use `--force` to force the update.
``` (stderr)
$ git push --force
983f2d21c9fbe91c21ddfbcd3e3d6cd13db0a944
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+ 662843e...3507cd5 feature/2 -> patches/8678aafff1d1e28430952abf431e60b87e28023c (forced update)
```
That worked. We can see the new revision if we call `rad patch show`:
```
$ rad patch show 8678aaf
╭─────────────────────────────────────────────────────────────────────────────────────────╮
│ Title Add more things │
│ Patch 8678aafff1d1e28430952abf431e60b87e28023c │
│ Author did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi │
│ Head 3507cd57811fe5f21f6a0a610a1ac8068b3a5d9f │
│ Branches feature/2 │
│ Commits ahead 3, behind 0 │
│ Status open │
├─────────────────────────────────────────────────────────────────────────────────────────┤
│ ● opened by did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (you) 4 months ago │
│ ↑ updated to 4b6618a6ccb0b406659364a70a00bb60e4cd7cf0 (662843e) 4 months ago │
│ ↑ updated to 983f2d21c9fbe91c21ddfbcd3e3d6cd13db0a944 (3507cd5) 4 months ago │
╰─────────────────────────────────────────────────────────────────────────────────────────╯
```

View File

@ -9,9 +9,10 @@ Now, we can see that there is a new remote in the list of remotes:
``` ```
$ rad remote list $ rad remote list
bob z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk (fetch) bob z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk (fetch)
rad the canonical upstream (fetch) rad (canonical upstream) (fetch)
rad z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (push) rad z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (push)
rad/patches (patches upstream) (push)
``` ```
You can see both `bob` and `rad` as remotes. The `rad` remote is our personal remote of the project. You can see both `bob` and `rad` as remotes. The `rad` remote is our personal remote of the project.

View File

@ -62,6 +62,13 @@ $ rad patch show 5f0a547f7a91bf002bb0542035a647fd5af134a5
╰─────────────────────────────────────────────────────────────────────────────────────────╯ ╰─────────────────────────────────────────────────────────────────────────────────────────╯
``` ```
We can also confirm that the patch branch is in storage:
```
$ git ls-remote rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk refs/heads/flux-capacitor-power
3e674d1a1df90807e934f9ae5da2591dd6848a33 refs/heads/flux-capacitor-power
```
Wait, let's add a README too! Just for fun. Wait, let's add a README too! Just for fun.
``` ```

View File

@ -16,7 +16,13 @@ peer. Upcoming versions of radicle will not require this step.
``` ```
$ rad remote add z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --name bob $ rad remote add z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --name bob
✓ Remote bob added with rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk ✓ Remote bob added with rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
```
``` (stderr)
$ git fetch bob $ git fetch bob
From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
* [new branch] flux-capacitor-power -> bob/flux-capacitor-power
* [new branch] master -> bob/master
``` ```
The contributor's changes are now visible to us. The contributor's changes are now visible to us.

View File

@ -239,9 +239,9 @@ pub fn init(options: Options, profile: &profile::Profile) -> anyhow::Result<()>
// Setup eg. `master` -> `rad/master` // Setup eg. `master` -> `rad/master`
radicle::git::set_upstream( radicle::git::set_upstream(
&repo, &repo,
&radicle::rad::REMOTE_NAME, &*radicle::rad::REMOTE_NAME,
proj.default_branch(), proj.default_branch(),
&radicle::git::refs::workdir::branch(proj.default_branch()), radicle::git::refs::workdir::branch(proj.default_branch()),
)?; )?;
} else { } else {
push_cmd = format!("git push {}", *radicle::rad::REMOTE_NAME); push_cmd = format!("git push {}", *radicle::rad::REMOTE_NAME);

View File

@ -60,11 +60,12 @@ pub fn run(
let (target_ref, target_oid) = get_merge_target(storage, &head_branch)?; let (target_ref, target_oid) = get_merge_target(storage, &head_branch)?;
if head_branch.upstream().is_err() { if head_branch.upstream().is_err() {
// TODO(cloudhead): Disable this when invoked from remote helper.
radicle::git::set_upstream( radicle::git::set_upstream(
workdir, workdir,
&radicle::rad::REMOTE_NAME, &*radicle::rad::REMOTE_NAME,
branch_name(&head_branch)?, branch_name(&head_branch)?,
&head_branch_name, head_branch_name,
)?; )?;
} }

View File

@ -1,3 +1,4 @@
use radicle::rad;
use radicle_term::{Element, Table}; use radicle_term::{Element, Table};
use crate::git; use crate::git;
@ -11,13 +12,22 @@ pub fn run(repo: &git::Repository) -> anyhow::Result<()> {
let Some(url) = url else { let Some(url) = url else {
continue; continue;
}; };
let nid = url.namespace.map_or(
term::format::dim("the canonical upstream".to_string()).italic(), let description = if r.name == rad::PATCHES_REMOTE_NAME.as_str() {
|namespace| term::format::tertiary(namespace.to_string()), if dir == "fetch" {
); // Fetching from the patches remote is not allowed.
continue;
}
term::format::dim("(patches upstream)".to_string()).italic()
} else {
url.namespace.map_or(
term::format::dim("(canonical upstream)".to_string()).italic(),
|namespace| term::format::tertiary(namespace.to_string()),
)
};
table.push([ table.push([
term::format::bold(r.name.clone()), term::format::bold(r.name.clone()),
nid, description,
term::format::parens(term::format::secondary(dir.to_owned())), term::format::parens(term::format::secondary(dir.to_owned())),
]); ]);
} }

View File

@ -136,7 +136,7 @@ fn announce(rid: Id, mut node: Node, timeout: time::Duration) -> anyhow::Result<
let mut seeds = seeds.connected().collect::<BTreeSet<_>>(); let mut seeds = seeds.connected().collect::<BTreeSet<_>>();
if seeds.is_empty() { if seeds.is_empty() {
term::info!("Not connected to any seeds"); term::info!("Not connected to any seeds.");
return Ok(()); return Ok(());
} }
node.announce_refs(rid)?; node.announce_refs(rid)?;

View File

@ -37,7 +37,7 @@ impl<'a> SetupRemote<'a> {
let local_branch = radicle::git::refs::workdir::branch( let local_branch = radicle::git::refs::workdir::branch(
node_ref.join(&self.default_branch).as_refstr(), node_ref.join(&self.default_branch).as_refstr(),
); );
radicle::git::set_upstream(self.repo, &node.to_string(), &branch_name, &local_branch)?; radicle::git::set_upstream(self.repo, node.to_string(), &branch_name, local_branch)?;
return Ok(Some((remote, branch_name))); return Ok(Some((remote, branch_name)));
} }

View File

@ -292,6 +292,28 @@ fn rad_patch_draft() {
.unwrap(); .unwrap();
} }
#[test]
fn rad_patch_via_push() {
logger::init(log::Level::Debug);
let mut environment = Environment::new();
let profile = environment.profile("alice");
let working = tempfile::tempdir().unwrap();
let home = &profile.home;
// Setup a test repository.
fixtures::repository(working.path());
test("examples/rad-init.md", working.path(), Some(home), []).unwrap();
test(
"examples/rad-patch-via-push.md",
working.path(),
Some(home),
[],
)
.unwrap();
}
#[test] #[test]
fn rad_rm() { fn rad_rm() {
let mut environment = Environment::new(); let mut environment = Environment::new();
@ -791,6 +813,60 @@ fn rad_remote() {
.unwrap(); .unwrap();
} }
#[test]
fn git_push_and_pull() {
logger::init(log::Level::Debug);
let mut environment = Environment::new();
let alice = environment.node("alice");
let bob = environment.node("bob");
let working = environment.tmp().join("working");
fixtures::repository(working.join("alice"));
test(
"examples/rad-init.md",
working.join("alice"),
Some(&alice.home),
[],
)
.unwrap();
let alice = alice.spawn(Config::default());
let mut bob = bob.spawn(Config::default());
bob.connect(&alice).converge([&alice]);
test(
"examples/rad-clone.md",
&working.join("bob"),
Some(&bob.home),
[],
)
.unwrap();
test(
"examples/git/git-push.md",
&working.join("alice"),
Some(&alice.home),
[],
)
.unwrap();
test(
"examples/git/git-pull.md",
&working.join("bob"),
Some(&bob.home),
[],
)
.unwrap();
test(
"examples/git/git-push-delete.md",
&working.join("alice"),
Some(&alice.home),
[],
)
.unwrap();
}
#[test] #[test]
fn rad_workflow() { fn rad_workflow() {
let mut environment = Environment::new(); let mut environment = Environment::new();

View File

@ -7,6 +7,7 @@ edition = "2021"
[dependencies] [dependencies]
thiserror = "1" thiserror = "1"
radicle-git-ext = { version = "0" }
[dependencies.radicle] [dependencies.radicle]
path = "../radicle" path = "../radicle"

View File

@ -0,0 +1,70 @@
use std::io;
use std::path::Path;
use thiserror::Error;
use radicle::git;
use radicle::storage::git::transport::local::Url;
use radicle::storage::ReadRepository;
use crate::read_line;
#[derive(Debug, Error)]
pub enum Error {
/// Invalid command received.
#[error("invalid command `{0}`")]
InvalidCommand(String),
/// I/O error.
#[error("i/o error: {0}")]
Io(#[from] io::Error),
/// Invalid reference name.
#[error("invalid ref: {0}")]
InvalidRef(#[from] radicle::git::fmt::Error),
/// Git error.
#[error("git: {0}")]
Git(#[from] git::raw::Error),
}
/// Run a git fetch command.
pub fn run<R: ReadRepository>(
mut refs: Vec<String>,
working: &Path,
url: Url,
stored: R,
stdin: &io::Stdin,
) -> Result<(), Error> {
// Read all the `fetch` lines.
let mut line = String::new();
loop {
let tokens = read_line(stdin, &mut line)?;
match tokens.as_slice() {
["fetch", _oid, refstr] => {
refs.push(refstr.to_string());
}
// An empty line means end of input.
[] => break,
// Once the first `fetch` command is received, we don't expect anything else.
_ => return Err(Error::InvalidCommand(line.trim().to_owned())),
}
}
// Verify them and prepare the final refspecs.
let mut refspecs = Vec::new();
for refstr in refs {
let refstr = git::RefString::try_from(refstr)?;
if let Some(nid) = url.namespace {
refspecs.push(nid.to_namespace().join(refstr).to_string());
} else {
refspecs.push(refstr.to_string());
};
}
git::raw::Repository::open(working)?
.remote_anonymous(&git::url::File::new(stored.path()).to_string())?
.fetch(&refspecs, None, None)?;
// Nb. An empty line means we're done.
println!();
Ok(())
}

View File

@ -1,34 +1,25 @@
#![allow(clippy::collapsible_if)] //! The Radicle Git remote helper.
use std::os::fd::{AsRawFd, FromRawFd}; //!
//! Communication with the user is done via `stderr` (`eprintln`).
//! Communication with Git tooling is done via `stdout` (`println`).
mod fetch;
mod list;
mod push;
use std::path::PathBuf; use std::path::PathBuf;
use std::{env, io, process}; use std::{env, io};
use thiserror::Error; use thiserror::Error;
use radicle::crypto::PublicKey; use radicle::git;
use radicle::node::Handle;
use radicle::storage::git::transport::local::{Url, UrlError}; use radicle::storage::git::transport::local::{Url, UrlError};
use radicle::storage::{ReadRepository, WriteRepository, WriteStorage}; use radicle::storage::{ReadRepository, WriteStorage};
/// The service invoked by git on the remote repository, during a push.
const GIT_RECEIVE_PACK: &str = "git-receive-pack";
/// The service invoked by git on the remote repository, during a fetch.
const GIT_UPLOAD_PACK: &str = "git-upload-pack";
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum Error { pub enum Error {
/// Remote repository not found (or empty). /// Remote repository not found (or empty).
#[error("remote repository `{0}` not found")] #[error("remote repository `{0}` not found")]
RepositoryNotFound(PathBuf), RepositoryNotFound(PathBuf),
/// Secret key is not registered, eg. with ssh-agent.
#[error("public key `{0}` is not registered with ssh-agent")]
KeyNotRegistered(PublicKey),
/// Public key doesn't match the remote namespace we're pushing to.
#[error("public key `{0}` does not match remote namespace")]
KeyMismatch(PublicKey),
/// No public key is given
#[error("no public key given as a remote namespace, perhaps you are attempting to push to restricted refs")]
NoKey,
/// Invalid command received. /// Invalid command received.
#[error("invalid command `{0}`")] #[error("invalid command `{0}`")]
InvalidCommand(String), InvalidCommand(String),
@ -38,12 +29,31 @@ pub enum Error {
/// Error with the remote url. /// Error with the remote url.
#[error("invalid remote url: {0}")] #[error("invalid remote url: {0}")]
RemoteUrl(#[from] UrlError), RemoteUrl(#[from] UrlError),
/// I/O error.
#[error("i/o error: {0}")]
Io(#[from] io::Error),
/// The `GIT_DIR` env var is not set.
#[error("the `GIT_DIR` environment variable is not set")]
NoGitDir,
/// Git error.
#[error("git: {0}")]
Git(#[from] git::raw::Error),
/// Storage error.
#[error(transparent)]
Storage(#[from] radicle::storage::Error),
/// Fetch error.
#[error(transparent)]
Fetch(#[from] fetch::Error),
/// Push error.
#[error(transparent)]
Push(#[from] push::Error),
/// List error.
#[error(transparent)]
List(#[from] list::Error),
} }
/// Run the radicle remote helper using the given profile. /// Run the radicle remote helper using the given profile.
pub fn run(profile: radicle::Profile) -> Result<(), Box<dyn std::error::Error + 'static>> { pub fn run(profile: radicle::Profile) -> Result<(), Error> {
// `GIT_DIR` is expected to be set, though we aren't using it right now.
let _git_dir = env::var("GIT_DIR").map(PathBuf::from)?;
let url: Url = { let url: Url = {
let args = env::args().skip(1).take(2).collect::<Vec<_>>(); let args = env::args().skip(1).take(2).collect::<Vec<_>>();
@ -52,104 +62,87 @@ pub fn run(profile: radicle::Profile) -> Result<(), Box<dyn std::error::Error +
[_, url] => url.parse(), [_, url] => url.parse(),
_ => { _ => {
return Err(Error::InvalidArguments(args).into()); return Err(Error::InvalidArguments(args));
} }
} }
}?; }?;
let proj = profile.storage.repository_mut(url.repo)?; let stored = profile.storage.repository_mut(url.repo)?;
if proj.is_empty()? { if stored.is_empty()? {
return Err(Error::RepositoryNotFound(proj.path().to_path_buf()).into()); return Err(Error::RepositoryNotFound(stored.path().to_path_buf()));
} }
// `GIT_DIR` is expected to be set by Git tooling, and points to the working copy.
let working = env::var("GIT_DIR")
.map(PathBuf::from)
.map_err(|_| Error::NoGitDir)?;
let stdin = io::stdin(); let stdin = io::stdin();
loop { let mut line = String::new();
let mut line = String::new();
let read = stdin.read_line(&mut line)?; loop {
if read == 0 { let tokens = read_line(&stdin, &mut line)?;
break;
}
let tokens = line.trim().split(' ').collect::<Vec<_>>();
match tokens.as_slice() { match tokens.as_slice() {
// First we are asked about capabilities.
["capabilities"] => { ["capabilities"] => {
println!("connect"); println!("option");
println!("push"); // Implies `list` command.
println!("fetch");
println!(); println!();
} }
// Since we send a `connect` back, this is what is requested next. ["option", "verbosity"] => {
["connect", service] => { println!("ok");
// Don't allow push if either of these conditions is true: }
// ["option", "push-option", _opt] => {
// 1. Our key is not in ssh-agent, which means we won't be able to sign the refs. println!("unsupported");
// 2. Our key is not the one loaded in the profile, which means that the signed refs }
// won't match the remote we're pushing to. ["option", "progress", ..] => {
// 3. The URL namespace is not set, which is used for fetching canonical refs. println!("unsupported");
let signer = if *service == GIT_RECEIVE_PACK { }
match url.namespace { ["option", ..] => {
Some(namespace) => { println!("unsupported");
if profile.public_key != namespace { }
return Err(Error::KeyMismatch(profile.public_key).into()); ["fetch", _oid, refstr] => {
} return fetch::run(vec![refstr.to_string()], &working, url, stored, &stdin)
} .map_err(Error::from);
None => return Err(Error::NoKey.into()), }
} ["push", refspec] => {
return push::run(
let signer = profile.signer()?; vec![refspec.to_string()],
&working,
Some(signer) url,
} else { stored,
None &profile,
}; &stdin,
)
if *service == GIT_UPLOAD_PACK { .map_err(Error::from);
// TODO: Fetch from network. }
} ["list"] => {
println!(); // Empty line signifies connection is established. list::for_fetch(&url, &stored)?;
}
let mut child = process::Command::new(service) ["list", "for-push"] => {
.arg(proj.path()) list::for_push(&profile, &stored)?;
.env("GIT_DIR", proj.path())
.env(
"GIT_NAMESPACE",
url.namespace.map(|ns| ns.to_string()).unwrap_or_default(),
)
.stdout(process::Stdio::inherit())
.stderr(process::Stdio::inherit())
.stdin(process::Stdio::inherit())
.spawn()?;
if child.wait()?.success() && *service == GIT_RECEIVE_PACK {
if let Some(signer) = signer {
proj.sign_refs(&signer)?;
proj.set_head()?;
// Connect to local node and announce refs to the network.
// If our node is not running, we simply skip this step, as the
// refs will be announced eventually, when the node restarts.
if radicle::Node::new(profile.socket()).is_running() {
let stderr = io::stderr().as_raw_fd();
process::Command::new("rad")
.arg("sync")
.arg(proj.id.to_string())
.arg("--verbose")
.stdout(unsafe { process::Stdio::from_raw_fd(stderr) })
.stderr(process::Stdio::inherit())
.spawn()?
.wait()?;
}
}
}
} }
// An empty line means end of input.
[] => { [] => {
break; return Ok(());
} }
_ => { _ => {
return Err(Error::InvalidCommand(line).into()); return Err(Error::InvalidCommand(line.trim().to_owned()));
} }
} }
} }
}
Ok(())
/// Read one line from stdin, and split it into tokens.
pub(crate) fn read_line<'a>(stdin: &io::Stdin, line: &'a mut String) -> io::Result<Vec<&'a str>> {
line.clear();
let read = stdin.read_line(line)?;
if read == 0 {
return Ok(vec![]);
}
let line = line.trim();
let tokens = line.split(' ').filter(|t| !t.is_empty()).collect();
Ok(tokens)
} }

View File

@ -0,0 +1,54 @@
use thiserror::Error;
use radicle::git;
use radicle::storage::git::transport::local::Url;
use radicle::storage::ReadRepository;
use radicle::Profile;
#[derive(Debug, Error)]
pub enum Error {
/// Storage error.
#[error(transparent)]
Storage(#[from] radicle::storage::Error),
/// Identity error.
#[error(transparent)]
Identity(#[from] radicle::identity::IdentityError),
/// Git error.
#[error(transparent)]
Git(#[from] radicle::git::ext::Error),
}
/// List refs for fetching (`git fetch` and `git ls-remote`).
pub fn for_fetch<R: ReadRepository>(url: &Url, stored: &R) -> Result<(), Error> {
if let Some(namespace) = url.namespace {
// Listing namespaced refs.
for (name, oid) in stored.references_of(&namespace)? {
println!("{oid} {name}");
}
} else {
// Listing canonical refs.
// We skip over `refs/rad/*`, since those are not meant to be fetched into a working copy.
for glob in [
git::refspec::pattern!("refs/heads/*"),
git::refspec::pattern!("refs/tags/*"),
] {
for (name, oid) in stored.references_glob(&glob)? {
println!("{oid} {name}");
}
}
}
println!();
Ok(())
}
/// List refs for pushing (`git push`).
pub fn for_push<R: ReadRepository>(profile: &Profile, stored: &R) -> Result<(), Error> {
// Only our own refs can be pushed to.
for (name, oid) in stored.references_of(profile.id())? {
println!("{oid} {name}");
}
println!();
Ok(())
}

View File

@ -0,0 +1,355 @@
use std::collections::HashSet;
use std::ffi::OsStr;
use std::os::fd::{AsRawFd, FromRawFd};
use std::path::Path;
use std::str::FromStr;
use std::{io, process};
use radicle::storage::git::cob::object::ParseObjectId;
use thiserror::Error;
use radicle::crypto::PublicKey;
use radicle::node::{Handle, NodeId};
use radicle::storage::git::transport::local::Url;
use radicle::storage::WriteRepository;
use radicle::Profile;
use radicle::{git, rad};
use crate::read_line;
#[derive(Debug, Error)]
pub enum Error {
/// Public key doesn't match the remote namespace we're pushing to.
#[error("public key `{0}` does not match remote namespace")]
KeyMismatch(PublicKey),
/// No public key is given
#[error("no public key given as a remote namespace, perhaps you are attempting to push to restricted refs")]
NoKey,
/// Invalid command received.
#[error("invalid command `{0}`")]
InvalidCommand(String),
/// I/O error.
#[error("i/o error: {0}")]
Io(#[from] io::Error),
/// A command exited with an error code.
#[error("command '{0}' failed with status {1}")]
CommandFailed(String, i32),
/// Invalid reference name.
#[error("invalid ref: {0}")]
InvalidRef(#[from] radicle::git::fmt::Error),
/// Git error.
#[error("git: {0}")]
Git(#[from] git::raw::Error),
/// Storage error.
#[error(transparent)]
Storage(#[from] radicle::storage::Error),
/// Profile error.
#[error(transparent)]
Profile(#[from] radicle::profile::Error),
/// Identity error.
#[error(transparent)]
Identity(#[from] radicle::identity::IdentityError),
/// Parse error for object IDs.
#[error(transparent)]
ParseObjectId(#[from] ParseObjectId),
}
enum Command {
Push(git::Refspec<git::RefString, git::RefString>),
Delete(git::RefString),
}
impl Command {
/// Return the destination refname.
fn dst(&self) -> &git::RefStr {
match self {
Self::Push(rs) => rs.dst.as_refstr(),
Self::Delete(rs) => rs,
}
}
}
impl FromStr for Command {
type Err = git::ext::ref_format::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let Some((src, dst)) = s.split_once(':') else {
return Err(git::ext::ref_format::Error::Empty);
};
let dst = git::RefString::try_from(dst)?;
if src.is_empty() {
Ok(Self::Delete(dst))
} else {
let (src, force) = if let Some(stripped) = src.strip_prefix('+') {
(stripped, true)
} else {
(src, false)
};
let src = git::RefString::try_from(src)?;
Ok(Self::Push(git::Refspec { src, dst, force }))
}
}
}
/// Run a git push command.
pub fn run<R: WriteRepository>(
mut specs: Vec<String>,
working: &Path,
url: Url,
stored: R,
profile: &Profile,
stdin: &io::Stdin,
) -> Result<(), Error> {
// Don't allow push if either of these conditions is true:
//
// 1. Our key is not in ssh-agent, which means we won't be able to sign the refs.
// 2. Our key is not the one loaded in the profile, which means that the signed refs
// won't match the remote we're pushing to.
// 3. The URL namespace is not set.
let nid = url.namespace.ok_or(Error::NoKey).and_then(|ns| {
(profile.public_key == ns)
.then_some(ns)
.ok_or(Error::KeyMismatch(profile.public_key))
})?;
let signer = profile.signer()?;
let mut line = String::new();
let mut ok = HashSet::new();
// Read all the `push` lines.
loop {
let tokens = read_line(stdin, &mut line)?;
match tokens.as_slice() {
["push", spec] => {
specs.push(spec.to_string());
}
// An empty line means end of input.
[] => break,
// Once the first `push` command is received, we don't expect anything else.
_ => return Err(Error::InvalidCommand(line.trim().to_owned())),
}
}
// For each refspec, push a ref or delete a ref.
for spec in specs {
let Ok(cmd) = Command::from_str(&spec) else {
return Err(Error::InvalidCommand(format!("push {spec}")));
};
let result = match &cmd {
Command::Delete(dst) => {
// Delete refs.
let refname = nid.to_namespace().join(dst);
stored
.raw()
.find_reference(&refname)
.and_then(|mut r| r.delete())
.map_err(Error::from)
}
Command::Push(git::Refspec { src, dst, force }) => {
let working = git::raw::Repository::open(working)?;
let stored = stored.raw();
if let Some(oid) = dst.strip_prefix(git::refname!("refs/heads/patches")) {
let oid = git::Oid::from_str(oid)?;
patch_update(src, dst, *force, &oid, &nid, &working, stored)
} else if dst == &*rad::PATCHES_REFNAME {
patch_open(src, &nid, &working, stored)
} else {
push_ref(src, dst, *force, &nid, &working, stored)
}
}
};
match result {
// Let Git tooling know that this ref has been pushed.
Ok(()) => {
println!("ok {}", cmd.dst());
ok.insert(spec);
}
// Let Git tooling know that there was an error pushing the ref.
Err(e) => println!("error {} {e}", cmd.dst()),
}
}
// Sign refs and sync if at least one ref pushed successfully.
if !ok.is_empty() {
stored.sign_refs(&signer)?;
stored.set_head()?;
// Connect to local node and announce refs to the network.
// If our node is not running, we simply skip this step, as the
// refs will be announced eventually, when the node restarts.
if radicle::Node::new(profile.socket()).is_running() {
let rid = stored.id().to_string();
let stderr = io::stderr().as_raw_fd();
// Nb. allow this to fail. The push to local storage was still successful.
execute("rad", ["sync", &rid, "--verbose"], unsafe {
process::Stdio::from_raw_fd(stderr)
})
.ok();
}
}
// Done.
println!();
Ok(())
}
/// Open a new patch.
fn patch_open(
src: &git::RefStr,
nid: &NodeId,
working: &git::raw::Repository,
stored: &git::raw::Repository,
) -> Result<(), Error> {
let reference = working.find_reference(src.as_str())?;
let commit = reference.peel_to_commit()?;
let dst = &*git::refs::storage::staging::patch(nid, commit.id());
// Before creating the patch, we must push the associated git objects to storage.
// Unfortunately, we don't have an easy way to transfer the missing objects without
// creating a temporary reference on the remote. The temporary reference is deleted
// once the patch is open, or in case of error.
//
// In case the reference is not properly deleted, the next attempt to open a patch should
// not fail, since the reference will already exist with the correct OID.
push_ref(src, dst, false, nid, working, stored)?;
let result = match execute(
"rad",
["patch", "open", "--quiet", "--no-push"],
process::Stdio::piped(),
) {
Ok(patch) => {
let patch = patch.trim();
let patch = radicle::cob::ObjectId::from_str(patch)?;
// Create long-lived patch head reference, now that we know the Patch ID.
//
// refs/namespaces/<nid>/refs/heads/patches/<patch-id>
//
let refname = git::refs::storage::patch(nid, &patch);
let _ = stored.reference(
refname.as_str(),
commit.id(),
true,
"Create reference for patch head",
)?;
let head = working.head()?;
if head.peel_to_commit()?.id() == commit.id() {
if let Ok(r) = head.resolve() {
let branch = git::raw::Branch::wrap(r);
let name: Option<git::RefString> =
branch.name()?.and_then(|b| b.try_into().ok());
working.reference(
&git::refs::workdir::patch_upstream(&patch),
commit.id(),
// The patch shouldn't exist yet, and so neither should
// this ref.
false,
"Create remote tracking branch for patch",
)?;
if let Some(name) = name {
if branch.upstream().is_err() {
git::set_upstream(
working,
&*radicle::rad::REMOTE_NAME,
name.as_str(),
git::refs::workdir::patch(&patch),
)?;
}
}
}
}
Ok(())
}
Err(e) => Err(e),
};
// Delete short-lived patch head reference.
stored.find_reference(dst).map(|mut r| r.delete()).ok();
result
}
/// Update an existing patch.
fn patch_update(
src: &git::RefStr,
dst: &git::RefStr,
force: bool,
oid: &git::Oid,
nid: &NodeId,
working: &git::raw::Repository,
stored: &git::raw::Repository,
) -> Result<(), Error> {
push_ref(src, dst, force, nid, working, stored)?;
// Patch ID.
let oid = radicle::cob::ObjectId::from(oid);
let stderr = io::stderr().as_raw_fd();
// Nb. Git tooling checks that we aren't attempting a forced update without the `--force`
// option of `git push`. There's no need for us to check here.
execute(
"rad",
["patch", "update", "--quiet", "--no-push", &oid.to_string()],
unsafe { process::Stdio::from_raw_fd(stderr) },
)
.map(|_| ())
.map_err(Error::from)
}
/// Push a single reference to storage.
fn push_ref(
src: &git::RefStr,
dst: &git::RefStr,
force: bool,
nid: &NodeId,
working: &git::raw::Repository,
stored: &git::raw::Repository,
) -> Result<(), Error> {
let mut remote = working.remote_anonymous(&git::url::File::new(stored.path()).to_string())?;
let dst = nid.to_namespace().join(dst);
let refspec = git::Refspec { src, dst, force };
// Nb. The *force* indicator (`+`) is processed by Git tooling before we even reach this code.
// This happens during the `list for-push` phase.
remote.push(&[refspec.to_string().as_str()], None)?;
Ok(())
}
/// Execute a command as a child process, redirecting its stdout to the given `Stdio`.
fn execute<S: AsRef<std::ffi::OsStr>>(
name: &str,
args: impl IntoIterator<Item = S>,
stdout: process::Stdio,
) -> Result<String, Error> {
let mut cmd = process::Command::new(name);
cmd.args(args)
.stdout(stdout)
.stderr(process::Stdio::inherit());
let child = cmd.spawn()?;
let output = child.wait_with_output()?;
let status = output.status;
if !status.success() {
let cmd = format!(
"{} {}",
cmd.get_program().to_string_lossy(),
cmd.get_args()
.collect::<Vec<_>>()
.join(OsStr::new(" "))
.to_string_lossy()
);
return Err(Error::CommandFailed(cmd, status.code().unwrap_or(-1)));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}

View File

@ -1,5 +1,6 @@
use std::ffi::OsString; use std::ffi::OsString;
use std::io::Write; use std::io::Write;
use std::os::fd::{AsRawFd, FromRawFd};
use std::path::PathBuf; use std::path::PathBuf;
use std::process; use std::process;
use std::{env, fs, io}; use std::{env, fs, io};
@ -66,7 +67,11 @@ impl Editor {
) )
); );
}; };
process::Command::new(cmd).arg(&self.path).spawn()?.wait()?; process::Command::new(cmd)
.stdout(unsafe { process::Stdio::from_raw_fd(io::stderr().as_raw_fd()) })
.arg(&self.path)
.spawn()?
.wait()?;
let text = fs::read_to_string(&self.path)?; let text = fs::read_to_string(&self.path)?;
if text.trim().is_empty() { if text.trim().is_empty() {

View File

@ -20,7 +20,7 @@ pub use git2 as raw;
pub use git_ext::ref_format as fmt; pub use git_ext::ref_format as fmt;
pub use git_ext::ref_format::{ pub use git_ext::ref_format::{
component, lit, name, qualified, refname, refspec, component, lit, name, qualified, refname, refspec,
refspec::{PatternStr, PatternString}, refspec::{PatternStr, PatternString, Refspec},
Component, Namespaced, Qualified, RefStr, RefString, Component, Namespaced, Qualified, RefStr, RefString,
}; };
pub use radicle_git_ext as ext; pub use radicle_git_ext as ext;
@ -136,6 +136,7 @@ pub enum ListRefsError {
pub mod refs { pub mod refs {
use super::*; use super::*;
use radicle_cob as cob;
/// Try to get a qualified reference from a generic reference. /// Try to get a qualified reference from a generic reference.
pub fn qualified_from<'a>(r: &'a git2::Reference) -> Result<(Qualified<'a>, Oid), RefError> { pub fn qualified_from<'a>(r: &'a git2::Reference) -> Result<(Qualified<'a>, Oid), RefError> {
@ -157,8 +158,6 @@ pub mod refs {
refspec::{self, PatternString}, refspec::{self, PatternString},
}; };
use radicle_cob as cob;
use super::*; use super::*;
/// Where the project's identity document is stored. /// Where the project's identity document is stored.
@ -220,10 +219,46 @@ pub mod refs {
.join(Component::from(typename)) .join(Component::from(typename))
.join(Component::from(object_id)) .join(Component::from(object_id))
} }
/// A patch reference.
///
/// `refs/namespaces/<remote>/refs/heads/patches/<object_id>`
///
pub fn patch<'a>(remote: &RemoteId, object_id: &cob::ObjectId) -> Namespaced<'a> {
Qualified::from_components(
component!("heads"),
component!("patches"),
Some(object_id.into()),
)
.with_namespace(remote.into())
}
/// Staging/temporary references.
pub mod staging {
use super::*;
/// Where patches are pushed initially, when they don't have an object-id yet.
/// This is a short-lived reference, which is deleted after the patch has been opened.
/// The `<oid>` is the commit proposed in the patch.
///
/// `refs/namespaces/<remote>/refs/patch/heads/<oid>`
///
pub fn patch<'a>(remote: &RemoteId, oid: impl Into<Oid>) -> Namespaced<'a> {
// SAFETY: OIDs are valid reference names and valid path component.
#[allow(clippy::unwrap_used)]
let oid = RefString::try_from(oid.into().to_string()).unwrap();
#[allow(clippy::unwrap_used)]
let oid = Component::from_refstr(oid).unwrap();
Qualified::from_components(component!("patch"), component!("heads"), Some(oid))
.with_namespace(remote.into())
}
}
} }
pub mod workdir { pub mod workdir {
use super::*; use super::*;
use format::name::component;
/// Create a [`RefString`] that corresponds to `refs/heads/<branch>`. /// Create a [`RefString`] that corresponds to `refs/heads/<branch>`.
pub fn branch(branch: &RefStr) -> RefString { pub fn branch(branch: &RefStr) -> RefString {
@ -244,6 +279,30 @@ pub mod refs {
pub fn tag(name: &RefStr) -> RefString { pub fn tag(name: &RefStr) -> RefString {
refname!("refs/tags").join(name) refname!("refs/tags").join(name)
} }
/// A patch head.
///
/// `refs/heads/patches/<patch-id>`
///
pub fn patch<'a>(patch_id: &cob::ObjectId) -> Qualified<'a> {
Qualified::from_components(
component!("heads"),
component!("patches"),
Some(patch_id.into()),
)
}
/// A patch head.
///
/// `refs/remotes/rad/patches/<patch-id>`
///
pub fn patch_upstream<'a>(patch_id: &cob::ObjectId) -> Qualified<'a> {
Qualified::from_components(
component!("remotes"),
crate::rad::REMOTE_COMPONENT.clone(),
[component!("patches"), patch_id.into()],
)
}
} }
} }
@ -365,12 +424,23 @@ pub fn write_tree<'r>(
Ok(tree) Ok(tree)
} }
/// Configure a radicle repository.
///
/// * Sets `push.default = upstream`.
pub fn configure_repository(repo: &git2::Repository) -> Result<(), git2::Error> {
let mut cfg = repo.config()?;
cfg.set_str("push.default", "upstream")?;
Ok(())
}
/// Configure a repository's radicle remote. /// Configure a repository's radicle remote.
/// ///
/// The entry for this remote will be: /// The entry for this remote will be:
/// ```text /// ```text
/// [remote.<name>] /// [remote.<name>]
/// url = <url> /// url = <fetch>
/// pushurl = <push>
/// fetch +refs/heads/*:refs/remotes/<name>/* /// fetch +refs/heads/*:refs/remotes/<name>/*
/// ``` /// ```
pub fn configure_remote<'r>( pub fn configure_remote<'r>(
@ -381,7 +451,36 @@ pub fn configure_remote<'r>(
) -> Result<git2::Remote<'r>, git2::Error> { ) -> Result<git2::Remote<'r>, git2::Error> {
let fetchspec = format!("+refs/heads/*:refs/remotes/{name}/*"); let fetchspec = format!("+refs/heads/*:refs/remotes/{name}/*");
let remote = repo.remote_with_fetch(name, fetch.to_string().as_str(), &fetchspec)?; let remote = repo.remote_with_fetch(name, fetch.to_string().as_str(), &fetchspec)?;
repo.remote_set_pushurl(name, Some(push.to_string().as_str()))?;
if push != fetch {
repo.remote_set_pushurl(name, Some(push.to_string().as_str()))?;
}
Ok(remote)
}
/// Configure a repository's patches remote.
///
/// The entry for this remote will be:
/// ```text
/// [remote.<name>]
/// url = <url>
/// pushurl = <url>
/// push HEAD:refs/patches
/// ```
pub fn configure_patches_remote<'r>(
repo: &'r git2::Repository,
name: &str,
refspec: &Refspec<RefString, RefString>,
push: &Url,
) -> Result<git2::Remote<'r>, git2::Error> {
let push = push.to_string();
let remote = repo.remote(name, &push)?;
// This fetchspec basically prevents fetching from this remote,
// as it shouldn't be used for fetching.
repo.remote_add_fetch(name, "refs/patches:refs/patches")?;
repo.remote_set_pushurl(name, Some(&push))?;
repo.remote_add_push(name, refspec.to_string().as_str())?;
Ok(remote) Ok(remote)
} }
@ -428,10 +527,14 @@ pub fn push<'a>(
/// ``` /// ```
pub fn set_upstream( pub fn set_upstream(
repo: &git2::Repository, repo: &git2::Repository,
remote: &str, remote: impl AsRef<str>,
branch: &str, branch: impl AsRef<str>,
merge: &str, merge: impl AsRef<str>,
) -> Result<(), git2::Error> { ) -> Result<(), git2::Error> {
let remote = remote.as_ref();
let branch = branch.as_ref();
let merge = merge.as_ref();
let mut config = repo.config()?; let mut config = repo.config()?;
let branch_remote = format!("branch.{branch}.remote"); let branch_remote = format!("branch.{branch}.remote");
let branch_merge = format!("branch.{branch}.merge"); let branch_merge = format!("branch.{branch}.merge");
@ -500,8 +603,8 @@ pub mod url {
impl File { impl File {
/// Create a new file URL pointing to the given path. /// Create a new file URL pointing to the given path.
pub fn new(path: PathBuf) -> Self { pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path } Self { path: path.into() }
} }
} }
@ -515,8 +618,7 @@ pub mod url {
/// Git environment variables. /// Git environment variables.
pub mod env { pub mod env {
/// Set of environment vars to reset git's configuration to default. /// Set of environment vars to reset git's configuration to default.
pub const GIT_DEFAULT_CONFIG: [(&str, &str); 3] = [ pub const GIT_DEFAULT_CONFIG: [(&str, &str); 2] = [
("GIT_CONFIG", "/dev/null"),
("GIT_CONFIG_GLOBAL", "/dev/null"), ("GIT_CONFIG_GLOBAL", "/dev/null"),
("GIT_CONFIG_NOSYSTEM", "1"), ("GIT_CONFIG_NOSYSTEM", "1"),
]; ];

View File

@ -20,6 +20,12 @@ use crate::{identity, storage};
/// Name of the radicle storage remote. /// Name of the radicle storage remote.
pub static REMOTE_NAME: Lazy<git::RefString> = Lazy::new(|| git::refname!("rad")); pub static REMOTE_NAME: Lazy<git::RefString> = Lazy::new(|| git::refname!("rad"));
/// Name of the radicle storage remote.
pub static REMOTE_COMPONENT: Lazy<git::Component> = Lazy::new(|| git::fmt::name::component!("rad"));
/// Name of the radicle patches remote.
pub static PATCHES_REMOTE_NAME: Lazy<git::RefString> = Lazy::new(|| git::refname!("rad/patches"));
/// Refname used for pushing patches.
pub static PATCHES_REFNAME: Lazy<git::RefString> = Lazy::new(|| git::refname!("refs/patches"));
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum InitError { pub enum InitError {
@ -72,7 +78,19 @@ pub fn init<G: Signer, S: WriteStorage>(
let (project, _) = Repository::init(&doc, pk, storage, signer)?; let (project, _) = Repository::init(&doc, pk, storage, signer)?;
let url = git::Url::from(project.id); let url = git::Url::from(project.id);
git::configure_repository(repo)?;
git::configure_remote(repo, &REMOTE_NAME, &url, &url.clone().with_namespace(*pk))?; git::configure_remote(repo, &REMOTE_NAME, &url, &url.clone().with_namespace(*pk))?;
git::configure_patches_remote(
repo,
&PATCHES_REMOTE_NAME,
&git::Refspec {
src: git::refname!("HEAD"),
dst: PATCHES_REFNAME.clone(),
force: false,
},
&url.with_namespace(*pk),
)?;
git::push( git::push(
repo, repo,
&REMOTE_NAME, &REMOTE_NAME,
@ -249,7 +267,7 @@ pub fn checkout<P: AsRef<Path>, S: storage::ReadStorage>(
repo.checkout_head(None)?; repo.checkout_head(None)?;
// Setup remote tracking for default branch. // Setup remote tracking for default branch.
git::set_upstream(&repo, &REMOTE_NAME, project.default_branch(), branch_ref)?; git::set_upstream(&repo, &*REMOTE_NAME, project.default_branch(), branch_ref)?;
} }
Ok(repo) Ok(repo)

View File

@ -385,7 +385,7 @@ pub trait ReadRepository {
/// Get the [`git2::Commit`] found using its `oid`. /// Get the [`git2::Commit`] found using its `oid`.
/// ///
/// Returns `None` if the commit did not exist. /// Returns `None` if the commit did not exist.
fn commit(&self, oid: Oid) -> Result<git2::Commit, git_ext::Error>; fn commit(&self, oid: Oid) -> Result<git2::Commit, git::ext::Error>;
/// Perform a revision walk of a commit history starting from the given head. /// Perform a revision walk of a commit history starting from the given head.
fn revwalk(&self, head: Oid) -> Result<git2::Revwalk, git2::Error>; fn revwalk(&self, head: Oid) -> Result<git2::Revwalk, git2::Error>;
@ -400,6 +400,16 @@ pub trait ReadRepository {
/// Get all references of the given remote. /// Get all references of the given remote.
fn references_of(&self, remote: &RemoteId) -> Result<Refs, Error>; fn references_of(&self, remote: &RemoteId) -> Result<Refs, Error>;
/// Get all references following a pattern.
/// Skips references with names that are not parseable into [`Qualified`].
///
/// This function always peels reference to the commit. For tags, this means the [`Oid`] of the
/// commit pointed to by the tag is returned, and not the [`Oid`] of the tag itsself.
fn references_glob(
&self,
pattern: &git::PatternStr,
) -> Result<Vec<(Qualified, Oid)>, git::ext::Error>;
/// Get the given remote. /// Get the given remote.
fn remote(&self, remote: &RemoteId) -> Result<Remote<Verified>, refs::Error>; fn remote(&self, remote: &RemoteId) -> Result<Remote<Verified>, refs::Error>;

View File

@ -457,6 +457,27 @@ impl ReadRepository for Repository {
Ok(refs.into()) Ok(refs.into())
} }
fn references_glob(
&self,
pattern: &self::PatternStr,
) -> Result<Vec<(Qualified, Oid)>, self::ext::Error> {
let mut refs = Vec::new();
for r in self.backend.references_glob(pattern)? {
let r = r?;
let c = r.peel_to_commit()?;
if let Some(name) = r
.name()
.and_then(|n| git::RefStr::try_from_str(n).ok())
.and_then(git::Qualified::from_refstr)
{
refs.push((name.to_owned(), c.id().into()));
}
}
Ok(refs)
}
fn remotes(&self) -> Result<Remotes<Verified>, refs::Error> { fn remotes(&self) -> Result<Remotes<Verified>, refs::Error> {
let mut remotes = Vec::new(); let mut remotes = Vec::new();
for remote in Repository::remotes(self)? { for remote in Repository::remotes(self)? {

View File

@ -201,6 +201,13 @@ impl ReadRepository for MockRepository {
todo!() todo!()
} }
fn references_glob(
&self,
_pattern: &git::PatternStr,
) -> Result<Vec<(fmt::Qualified, Oid)>, git::ext::Error> {
todo!()
}
fn identity_doc( fn identity_doc(
&self, &self,
) -> Result<(Oid, crate::identity::Doc<crate::crypto::Unverified>), IdentityError> { ) -> Result<(Oid, crate::identity::Doc<crate::crypto::Unverified>), IdentityError> {