diff --git a/Cargo.lock b/Cargo.lock index 344c8668..6e6e1dee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2093,6 +2093,7 @@ version = "0.2.0" dependencies = [ "radicle", "radicle-crypto", + "radicle-git-ext", "thiserror", ] diff --git a/radicle-cli/examples/git/git-pull.md b/radicle-cli/examples/git/git-pull.md new file mode 100644 index 00000000..31b434c8 --- /dev/null +++ b/radicle-cli/examples/git/git-pull.md @@ -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 +``` diff --git a/radicle-cli/examples/git/git-push-delete.md b/radicle-cli/examples/git/git-push-delete.md new file mode 100644 index 00000000..b2b7c606 --- /dev/null +++ b/radicle-cli/examples/git/git-push-delete.md @@ -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 +``` diff --git a/radicle-cli/examples/git/git-push.md b/radicle-cli/examples/git/git-push.md new file mode 100644 index 00000000..82a35633 --- /dev/null +++ b/radicle-cli/examples/git/git-push.md @@ -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) +``` diff --git a/radicle-cli/examples/rad-patch-via-push.md b/radicle-cli/examples/rad-patch-via-push.md new file mode 100644 index 00000000..b4c2407f --- /dev/null +++ b/radicle-cli/examples/rad-patch-via-push.md @@ -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 +`:` 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 │ +╰─────────────────────────────────────────────────────────────────────────────────────────╯ +``` diff --git a/radicle-cli/examples/rad-remote.md b/radicle-cli/examples/rad-remote.md index d29b60e4..a7a57821 100644 --- a/radicle-cli/examples/rad-remote.md +++ b/radicle-cli/examples/rad-remote.md @@ -9,9 +9,10 @@ Now, we can see that there is a new remote in the list of remotes: ``` $ rad remote list -bob z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk (fetch) -rad the canonical upstream (fetch) -rad z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (push) +bob z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk (fetch) +rad (canonical upstream) (fetch) +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. diff --git a/radicle-cli/examples/workflow/4-patching-contributor.md b/radicle-cli/examples/workflow/4-patching-contributor.md index d75fc9a0..ef3c2f0e 100644 --- a/radicle-cli/examples/workflow/4-patching-contributor.md +++ b/radicle-cli/examples/workflow/4-patching-contributor.md @@ -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. ``` diff --git a/radicle-cli/examples/workflow/5-patching-maintainer.md b/radicle-cli/examples/workflow/5-patching-maintainer.md index 7184d345..fb3466f0 100644 --- a/radicle-cli/examples/workflow/5-patching-maintainer.md +++ b/radicle-cli/examples/workflow/5-patching-maintainer.md @@ -16,7 +16,13 @@ peer. Upcoming versions of radicle will not require this step. ``` $ rad remote add z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --name bob ✓ Remote bob added with rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk +``` + +``` (stderr) $ 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. diff --git a/radicle-cli/src/commands/init.rs b/radicle-cli/src/commands/init.rs index 74d9e473..fe5a0327 100644 --- a/radicle-cli/src/commands/init.rs +++ b/radicle-cli/src/commands/init.rs @@ -239,9 +239,9 @@ pub fn init(options: Options, profile: &profile::Profile) -> anyhow::Result<()> // Setup eg. `master` -> `rad/master` radicle::git::set_upstream( &repo, - &radicle::rad::REMOTE_NAME, + &*radicle::rad::REMOTE_NAME, proj.default_branch(), - &radicle::git::refs::workdir::branch(proj.default_branch()), + radicle::git::refs::workdir::branch(proj.default_branch()), )?; } else { push_cmd = format!("git push {}", *radicle::rad::REMOTE_NAME); diff --git a/radicle-cli/src/commands/patch/create.rs b/radicle-cli/src/commands/patch/create.rs index c1e47813..6ab87e99 100644 --- a/radicle-cli/src/commands/patch/create.rs +++ b/radicle-cli/src/commands/patch/create.rs @@ -60,11 +60,12 @@ pub fn run( let (target_ref, target_oid) = get_merge_target(storage, &head_branch)?; if head_branch.upstream().is_err() { + // TODO(cloudhead): Disable this when invoked from remote helper. radicle::git::set_upstream( workdir, - &radicle::rad::REMOTE_NAME, + &*radicle::rad::REMOTE_NAME, branch_name(&head_branch)?, - &head_branch_name, + head_branch_name, )?; } diff --git a/radicle-cli/src/commands/remote/list.rs b/radicle-cli/src/commands/remote/list.rs index 55b44c02..fd7c8e60 100644 --- a/radicle-cli/src/commands/remote/list.rs +++ b/radicle-cli/src/commands/remote/list.rs @@ -1,3 +1,4 @@ +use radicle::rad; use radicle_term::{Element, Table}; use crate::git; @@ -11,13 +12,22 @@ pub fn run(repo: &git::Repository) -> anyhow::Result<()> { let Some(url) = url else { continue; }; - let nid = url.namespace.map_or( - term::format::dim("the canonical upstream".to_string()).italic(), - |namespace| term::format::tertiary(namespace.to_string()), - ); + + let description = if r.name == rad::PATCHES_REMOTE_NAME.as_str() { + 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([ term::format::bold(r.name.clone()), - nid, + description, term::format::parens(term::format::secondary(dir.to_owned())), ]); } diff --git a/radicle-cli/src/commands/sync.rs b/radicle-cli/src/commands/sync.rs index 0f50e03c..d4b85ec0 100644 --- a/radicle-cli/src/commands/sync.rs +++ b/radicle-cli/src/commands/sync.rs @@ -136,7 +136,7 @@ fn announce(rid: Id, mut node: Node, timeout: time::Duration) -> anyhow::Result< let mut seeds = seeds.connected().collect::>(); if seeds.is_empty() { - term::info!("Not connected to any seeds"); + term::info!("Not connected to any seeds."); return Ok(()); } node.announce_refs(rid)?; diff --git a/radicle-cli/src/project.rs b/radicle-cli/src/project.rs index 4705f413..e42b1998 100644 --- a/radicle-cli/src/project.rs +++ b/radicle-cli/src/project.rs @@ -37,7 +37,7 @@ impl<'a> SetupRemote<'a> { let local_branch = radicle::git::refs::workdir::branch( 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))); } diff --git a/radicle-cli/tests/commands.rs b/radicle-cli/tests/commands.rs index d53b15e4..babb0b93 100644 --- a/radicle-cli/tests/commands.rs +++ b/radicle-cli/tests/commands.rs @@ -292,6 +292,28 @@ fn rad_patch_draft() { .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] fn rad_rm() { let mut environment = Environment::new(); @@ -791,6 +813,60 @@ fn rad_remote() { .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] fn rad_workflow() { let mut environment = Environment::new(); diff --git a/radicle-remote-helper/Cargo.toml b/radicle-remote-helper/Cargo.toml index cf4ff16b..d21c41b6 100644 --- a/radicle-remote-helper/Cargo.toml +++ b/radicle-remote-helper/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" [dependencies] thiserror = "1" +radicle-git-ext = { version = "0" } [dependencies.radicle] path = "../radicle" diff --git a/radicle-remote-helper/src/fetch.rs b/radicle-remote-helper/src/fetch.rs new file mode 100644 index 00000000..72c31292 --- /dev/null +++ b/radicle-remote-helper/src/fetch.rs @@ -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( + mut refs: Vec, + 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(()) +} diff --git a/radicle-remote-helper/src/lib.rs b/radicle-remote-helper/src/lib.rs index 8f283a21..acdaf797 100644 --- a/radicle-remote-helper/src/lib.rs +++ b/radicle-remote-helper/src/lib.rs @@ -1,34 +1,25 @@ -#![allow(clippy::collapsible_if)] -use std::os::fd::{AsRawFd, FromRawFd}; +//! The Radicle Git remote helper. +//! +//! 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::{env, io, process}; +use std::{env, io}; use thiserror::Error; -use radicle::crypto::PublicKey; -use radicle::node::Handle; +use radicle::git; use radicle::storage::git::transport::local::{Url, UrlError}; -use radicle::storage::{ReadRepository, WriteRepository, 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"; +use radicle::storage::{ReadRepository, WriteStorage}; #[derive(Debug, Error)] pub enum Error { /// Remote repository not found (or empty). #[error("remote repository `{0}` not found")] 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. #[error("invalid command `{0}`")] InvalidCommand(String), @@ -38,12 +29,31 @@ pub enum Error { /// Error with the remote url. #[error("invalid remote url: {0}")] 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. -pub fn run(profile: radicle::Profile) -> Result<(), Box> { - // `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)?; +pub fn run(profile: radicle::Profile) -> Result<(), Error> { let url: Url = { let args = env::args().skip(1).take(2).collect::>(); @@ -52,104 +62,87 @@ pub fn run(profile: radicle::Profile) -> Result<(), Box url.parse(), _ => { - return Err(Error::InvalidArguments(args).into()); + return Err(Error::InvalidArguments(args)); } } }?; - let proj = profile.storage.repository_mut(url.repo)?; - if proj.is_empty()? { - return Err(Error::RepositoryNotFound(proj.path().to_path_buf()).into()); + let stored = profile.storage.repository_mut(url.repo)?; + if stored.is_empty()? { + 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(); - loop { - let mut line = String::new(); - let read = stdin.read_line(&mut line)?; - if read == 0 { - break; - } + let mut line = String::new(); + + loop { + let tokens = read_line(&stdin, &mut line)?; - let tokens = line.trim().split(' ').collect::>(); match tokens.as_slice() { - // First we are asked about capabilities. ["capabilities"] => { - println!("connect"); + println!("option"); + println!("push"); // Implies `list` command. + println!("fetch"); println!(); } - // Since we send a `connect` back, this is what is requested next. - ["connect", service] => { - // 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, which is used for fetching canonical refs. - let signer = if *service == GIT_RECEIVE_PACK { - match url.namespace { - Some(namespace) => { - if profile.public_key != namespace { - return Err(Error::KeyMismatch(profile.public_key).into()); - } - } - None => return Err(Error::NoKey.into()), - } - - let signer = profile.signer()?; - - Some(signer) - } else { - None - }; - - if *service == GIT_UPLOAD_PACK { - // TODO: Fetch from network. - } - println!(); // Empty line signifies connection is established. - - let mut child = process::Command::new(service) - .arg(proj.path()) - .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()?; - } - } - } + ["option", "verbosity"] => { + println!("ok"); + } + ["option", "push-option", _opt] => { + println!("unsupported"); + } + ["option", "progress", ..] => { + println!("unsupported"); + } + ["option", ..] => { + println!("unsupported"); + } + ["fetch", _oid, refstr] => { + return fetch::run(vec![refstr.to_string()], &working, url, stored, &stdin) + .map_err(Error::from); + } + ["push", refspec] => { + return push::run( + vec![refspec.to_string()], + &working, + url, + stored, + &profile, + &stdin, + ) + .map_err(Error::from); + } + ["list"] => { + list::for_fetch(&url, &stored)?; + } + ["list", "for-push"] => { + list::for_push(&profile, &stored)?; } - // 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> { + 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) } diff --git a/radicle-remote-helper/src/list.rs b/radicle-remote-helper/src/list.rs new file mode 100644 index 00000000..b578013d --- /dev/null +++ b/radicle-remote-helper/src/list.rs @@ -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(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(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(()) +} diff --git a/radicle-remote-helper/src/push.rs b/radicle-remote-helper/src/push.rs new file mode 100644 index 00000000..35b4c2f3 --- /dev/null +++ b/radicle-remote-helper/src/push.rs @@ -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), + 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 { + 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( + mut specs: Vec, + 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//refs/heads/patches/ + // + 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 = + 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>( + name: &str, + args: impl IntoIterator, + stdout: process::Stdio, +) -> Result { + 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::>() + .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()) +} diff --git a/radicle-term/src/editor.rs b/radicle-term/src/editor.rs index a349085d..1ee79c08 100644 --- a/radicle-term/src/editor.rs +++ b/radicle-term/src/editor.rs @@ -1,5 +1,6 @@ use std::ffi::OsString; use std::io::Write; +use std::os::fd::{AsRawFd, FromRawFd}; use std::path::PathBuf; use std::process; 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)?; if text.trim().is_empty() { diff --git a/radicle/src/git.rs b/radicle/src/git.rs index 30b91650..5e31db57 100644 --- a/radicle/src/git.rs +++ b/radicle/src/git.rs @@ -20,7 +20,7 @@ pub use git2 as raw; pub use git_ext::ref_format as fmt; pub use git_ext::ref_format::{ component, lit, name, qualified, refname, refspec, - refspec::{PatternStr, PatternString}, + refspec::{PatternStr, PatternString, Refspec}, Component, Namespaced, Qualified, RefStr, RefString, }; pub use radicle_git_ext as ext; @@ -136,6 +136,7 @@ pub enum ListRefsError { pub mod refs { use super::*; + use radicle_cob as cob; /// Try to get a qualified reference from a generic reference. pub fn qualified_from<'a>(r: &'a git2::Reference) -> Result<(Qualified<'a>, Oid), RefError> { @@ -157,8 +158,6 @@ pub mod refs { refspec::{self, PatternString}, }; - use radicle_cob as cob; - use super::*; /// Where the project's identity document is stored. @@ -220,10 +219,46 @@ pub mod refs { .join(Component::from(typename)) .join(Component::from(object_id)) } + + /// A patch reference. + /// + /// `refs/namespaces//refs/heads/patches/` + /// + 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 `` is the commit proposed in the patch. + /// + /// `refs/namespaces//refs/patch/heads/` + /// + pub fn patch<'a>(remote: &RemoteId, oid: impl Into) -> 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 { use super::*; + use format::name::component; /// Create a [`RefString`] that corresponds to `refs/heads/`. pub fn branch(branch: &RefStr) -> RefString { @@ -244,6 +279,30 @@ pub mod refs { pub fn tag(name: &RefStr) -> RefString { refname!("refs/tags").join(name) } + + /// A patch head. + /// + /// `refs/heads/patches/` + /// + 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/` + /// + 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) } +/// 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. /// /// The entry for this remote will be: /// ```text /// [remote.] -/// url = +/// url = +/// pushurl = /// fetch +refs/heads/*:refs/remotes//* /// ``` pub fn configure_remote<'r>( @@ -381,7 +451,36 @@ pub fn configure_remote<'r>( ) -> Result, git2::Error> { let fetchspec = format!("+refs/heads/*:refs/remotes/{name}/*"); 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.] +/// url = +/// pushurl = +/// push HEAD:refs/patches +/// ``` +pub fn configure_patches_remote<'r>( + repo: &'r git2::Repository, + name: &str, + refspec: &Refspec, + push: &Url, +) -> Result, 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) } @@ -428,10 +527,14 @@ pub fn push<'a>( /// ``` pub fn set_upstream( repo: &git2::Repository, - remote: &str, - branch: &str, - merge: &str, + remote: impl AsRef, + branch: impl AsRef, + merge: impl AsRef, ) -> Result<(), git2::Error> { + let remote = remote.as_ref(); + let branch = branch.as_ref(); + let merge = merge.as_ref(); + let mut config = repo.config()?; let branch_remote = format!("branch.{branch}.remote"); let branch_merge = format!("branch.{branch}.merge"); @@ -500,8 +603,8 @@ pub mod url { impl File { /// Create a new file URL pointing to the given path. - pub fn new(path: PathBuf) -> Self { - Self { path } + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } } } @@ -515,8 +618,7 @@ pub mod url { /// Git environment variables. pub mod env { /// Set of environment vars to reset git's configuration to default. - pub const GIT_DEFAULT_CONFIG: [(&str, &str); 3] = [ - ("GIT_CONFIG", "/dev/null"), + pub const GIT_DEFAULT_CONFIG: [(&str, &str); 2] = [ ("GIT_CONFIG_GLOBAL", "/dev/null"), ("GIT_CONFIG_NOSYSTEM", "1"), ]; diff --git a/radicle/src/rad.rs b/radicle/src/rad.rs index ee9a83f7..96703ea0 100644 --- a/radicle/src/rad.rs +++ b/radicle/src/rad.rs @@ -20,6 +20,12 @@ use crate::{identity, storage}; /// Name of the radicle storage remote. pub static REMOTE_NAME: Lazy = Lazy::new(|| git::refname!("rad")); +/// Name of the radicle storage remote. +pub static REMOTE_COMPONENT: Lazy = Lazy::new(|| git::fmt::name::component!("rad")); +/// Name of the radicle patches remote. +pub static PATCHES_REMOTE_NAME: Lazy = Lazy::new(|| git::refname!("rad/patches")); +/// Refname used for pushing patches. +pub static PATCHES_REFNAME: Lazy = Lazy::new(|| git::refname!("refs/patches")); #[derive(Error, Debug)] pub enum InitError { @@ -72,7 +78,19 @@ pub fn init( let (project, _) = Repository::init(&doc, pk, storage, signer)?; 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_patches_remote( + repo, + &PATCHES_REMOTE_NAME, + &git::Refspec { + src: git::refname!("HEAD"), + dst: PATCHES_REFNAME.clone(), + force: false, + }, + &url.with_namespace(*pk), + )?; + git::push( repo, &REMOTE_NAME, @@ -249,7 +267,7 @@ pub fn checkout, S: storage::ReadStorage>( repo.checkout_head(None)?; // 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) diff --git a/radicle/src/storage.rs b/radicle/src/storage.rs index 00c34d57..561a7a13 100644 --- a/radicle/src/storage.rs +++ b/radicle/src/storage.rs @@ -385,7 +385,7 @@ pub trait ReadRepository { /// Get the [`git2::Commit`] found using its `oid`. /// /// Returns `None` if the commit did not exist. - fn commit(&self, oid: Oid) -> Result; + fn commit(&self, oid: Oid) -> Result; /// Perform a revision walk of a commit history starting from the given head. fn revwalk(&self, head: Oid) -> Result; @@ -400,6 +400,16 @@ pub trait ReadRepository { /// Get all references of the given remote. fn references_of(&self, remote: &RemoteId) -> Result; + /// 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, git::ext::Error>; + /// Get the given remote. fn remote(&self, remote: &RemoteId) -> Result, refs::Error>; diff --git a/radicle/src/storage/git.rs b/radicle/src/storage/git.rs index 6820e05c..9c329594 100644 --- a/radicle/src/storage/git.rs +++ b/radicle/src/storage/git.rs @@ -457,6 +457,27 @@ impl ReadRepository for Repository { Ok(refs.into()) } + fn references_glob( + &self, + pattern: &self::PatternStr, + ) -> Result, 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, refs::Error> { let mut remotes = Vec::new(); for remote in Repository::remotes(self)? { diff --git a/radicle/src/test/storage.rs b/radicle/src/test/storage.rs index a56c9c79..83625c27 100644 --- a/radicle/src/test/storage.rs +++ b/radicle/src/test/storage.rs @@ -201,6 +201,13 @@ impl ReadRepository for MockRepository { todo!() } + fn references_glob( + &self, + _pattern: &git::PatternStr, + ) -> Result, git::ext::Error> { + todo!() + } + fn identity_doc( &self, ) -> Result<(Oid, crate::identity::Doc), IdentityError> {