Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
Revisit bare repositories
Merged lorenz opened 7 months ago

This patchset starts off with changes to radicle-remote-helper: Maintainence such as making the crate binary-only and a small clean up. Then, handling of GIT_DIR is simplified throughout radicle{,-remote-helper,-cli}, which unlocks usage of the remote helper with bare repositories. Next, the commands rad init and rad clone learn to handle bare repositories. Finally, code paths that would error or degrade upon detecting a bare repository are removed or fixed.

Overall, this patch should significantly improve interoperability with bare repositories.

32 files changed +841 -550 66adbffd ee9e6de5
modified CHANGELOG.md
@@ -13,6 +13,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## New Features

+
- `rad clone` now supports the flag `--bare` which works analoguously to 
+
  `git clone --bare`.
+
- `rad init --setup-signing` now works on bare repositories.
+
- `git-remote-rad` now correctly reports the default branch to Git by listing
+
  the symbolic reference `HEAD`.
+

+
## Fixed Bugs
+

+
- `rad init --setup-signing` now works in combination with `--existing`.
+

+
## 1.4.0
+

+
## Release Highlights
+

+
## Deprecations
+

+
## New Features
+

- `rad cob log` now supports the arguments `--from` and `--to` which can be used
  to range over particular operations on a COB.

added crates/radicle-cli/examples/git/git-is-bare-repository.md
@@ -0,0 +1,4 @@
+
```
+
$ git rev-parse --is-bare-repository
+
true
+
```

\ No newline at end of file
modified crates/radicle-cli/examples/git/git-push-converge.md
@@ -35,6 +35,7 @@ pushing to their `rad` remote -- but they won't sync to the network just yet:
$ git commit -m "Alice's commit" --allow-empty -q
$ git push rad -o no-sync
$ git ls-remote rad
+
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	HEAD
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	refs/heads/master
```

@@ -43,6 +44,7 @@ $ git add README
$ git commit -m "Bob's commit" -q
$ git push rad -o no-sync
$ git ls-remote rad
+
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	HEAD
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	refs/heads/master
```

@@ -51,6 +53,7 @@ $ git add README
$ git commit -m "Eve's commit" -q
$ git push rad -o no-sync
$ git ls-remote rad
+
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	HEAD
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	refs/heads/master
```

modified crates/radicle-cli/examples/git/git-push.md
@@ -54,6 +54,7 @@ List the canonical refs:

```
$ git ls-remote rad
+
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	HEAD
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	refs/heads/master
```

added crates/radicle-cli/examples/rad-clone-bare.md
@@ -0,0 +1,81 @@
+
To create a local bare copy of a repository on the radicle network, we use the
+
`clone` command, followed by the identifier or *RID* of the repository:
+

+
```
+
$ rad clone rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --scope followed --bare
+
✓ Seeding policy updated for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji with scope 'followed'
+
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found [..] potential seed(s).
+
✓ Target met: [..] seed(s)
+
✓ Creating checkout in ./heartwood..
+
✓ Remote alice@z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi added
+
✓ Remote-tracking branch alice@z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/master created for z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
✓ Repository successfully cloned under [..]/heartwood/
+
╭────────────────────────────────────╮
+
│ heartwood                          │
+
│ Radicle Heartwood Protocol & Stack │
+
│ 0 issues · 0 patches               │
+
╰────────────────────────────────────╯
+
Run `cd ./heartwood` to go to the repository directory.
+
```
+

+
We can now have a look at the new directory that was created from the cloned
+
repository:
+

+
```
+
$ cd heartwood
+
$ ls
+
FETCH_HEAD
+
HEAD
+
config
+
description
+
hooks
+
info
+
objects
+
refs
+
```
+

+
As expected, some `git` commands fail:
+
``` (stderr) (fail)
+
$ git status
+
fatal: this operation must be run in a work tree
+
```
+

+
Let's check that the remote tracking branch was setup correctly:
+

+
```
+
$ git branch --remotes
+
  alice@z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/master
+
  rad/master
+
```
+

+
The first branch is ours, and the second points to the repository delegate.
+
We can also take a look at the remotes:
+

+
```
+
$ git remote -v
+
alice@z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi	rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (fetch)
+
alice@z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi	rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (push)
+
rad	rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji (fetch)
+
rad	rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk (push)
+
```
+

+
Let's check the last commit!
+

+
```
+
$ git log -n 1
+
commit f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
+
Author: anonymous <anonymous@radicle.xyz>
+
Date:   Mon Jan 1 14:39:16 2018 +0000
+

+
    Second commit
+
```
+

+
Cloned repositories show up in `rad ls`:
+
```
+
$ rad ls --seeded
+
╭───────────────────────────────────────────────────────────────────────────────────────────────────────────╮
+
│ Name        RID                                 Visibility   Head      Description                        │
+
├───────────────────────────────────────────────────────────────────────────────────────────────────────────┤
+
│ heartwood   rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji   public       f2de534   Radicle Heartwood Protocol & Stack │
+
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────╯
+
```
added crates/radicle-cli/examples/rad-init-existing-bare.md
@@ -0,0 +1,48 @@
+
Let's clone a regular repository via plain Git:
+
```
+
$ git clone --bare $URL heartwood
+
$ cd heartwood
+
$ git rev-parse HEAD
+
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
+
```
+

+
We can see it's not a Radicle working copy:
+
``` (fail)
+
$ rad .
+
✗ Error: Current directory is not a Radicle repository
+
```
+

+
Let's pick an existing repository:
+
```
+
$ rad inspect rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
```
+

+
And initialize this working copy as that existing repository:
+
```
+
$ rad init --setup-signing --existing rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+

+
Configuring radicle signing key SHA256:UIedaL6Cxm6OUErh9GQUzzglSk7VpQlVTI1TAFB/HWA...
+

+
✓ Signing configured in [..]/heartwood/config
+
! Not writing .gitsigners file.
+
✓ Initialized existing repository rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji in [..]/heartwood/..
+
```
+

+
The warning about not writing `.gitsigners` is expected, as this requires a
+
working directory, which a bare repository does not have.
+

+
We can confirm that the working copy is initialized:
+
```
+
$ rad .
+
rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
$ git remote show rad
+
* remote rad
+
  Fetch URL: rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
  Push  URL: rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
  HEAD branch: master
+
  Remote branch:
+
    master new (next fetch will store in remotes/rad)
+
  Local ref configured for 'git push':
+
    master pushes to master (up to date)
+
```
modified crates/radicle-cli/examples/rad-init-existing.md
@@ -20,7 +20,12 @@ rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji

And initialize this working copy as that existing repository:
```
-
$ rad init --existing rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
$ rad init --setup-signing --existing rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+

+
Configuring radicle signing key SHA256:UIedaL6Cxm6OUErh9GQUzzglSk7VpQlVTI1TAFB/HWA...
+

+
✓ Signing configured in [..]/heartwood/.git/config
+
✓ Created .gitsigners file
✓ Initialized existing repository rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji in [..]/heartwood/..
```

@@ -32,7 +37,7 @@ $ git remote show rad
* remote rad
  Fetch URL: rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji
  Push  URL: rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-
  HEAD branch: (unknown)
+
  HEAD branch: master
  Remote branch:
    master new (next fetch will store in remotes/rad)
  Local ref configured for 'git push':
modified crates/radicle-cli/examples/rad-patch-fetch-2.md
@@ -22,6 +22,7 @@ $ git branch -r
$ git pull
Already up to date.
$ git branch -r
+
  rad/HEAD -> rad/master
  rad/master
  rad/patches/5e2dedcc5d515fcbc1cca483d3376609fe889bfb
```
modified crates/radicle-cli/examples/rad-patch-pull-update.md
@@ -112,6 +112,7 @@ $ rad patch show 55b9721
│ ↑ updated to f91e056da05b2d9a58af1160c76245bc3debf7a8 (cad2666) now │
╰─────────────────────────────────────────────────────────────────────╯
$ git ls-remote rad
+
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	HEAD
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	refs/heads/master
cad2666a8a2250e4dee175ed5044be2c251ff08b	refs/heads/patches/55b9721ed7f6bfec38f43729e9b6631c5dc812fb
```
modified crates/radicle-cli/examples/rad-patch-via-push.md
@@ -61,6 +61,7 @@ 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/HEAD
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/remotes/rad/master
42d894a83c9c356552a57af09ccdbd5587a99045 refs/remotes/rad/patches/6035d2f582afbe01ff23ea87528ae523d76875b6
```
modified crates/radicle-cli/src/commands/checkout.rs
@@ -98,7 +98,7 @@ fn execute(options: Options, profile: &Profile) -> anyhow::Result<PathBuf> {
    }

    let mut spinner = term::spinner("Performing checkout...");
-
    let repo = match radicle::rad::checkout(options.id, &remote, path.clone(), &storage) {
+
    let repo = match radicle::rad::checkout(options.id, &remote, path.clone(), &storage, false) {
        Ok(repo) => repo,
        Err(err) => {
            spinner.failed();
modified crates/radicle-cli/src/commands/clone.rs
@@ -46,6 +46,7 @@ Usage

Options

+
        --bare              Make a bare repository
        --scope <scope>     Follow scope: `followed` or `all` (default: all)
    -s, --seed <nid>        Clone from this seed (may be specified multiple times)
        --timeout <secs>    Timeout for fetching repository (default: 9)
@@ -64,6 +65,7 @@ pub struct Options {
    scope: Scope,
    /// Sync settings.
    sync: SyncSettings,
+
    bare: bool,
}

impl Args for Options {
@@ -75,6 +77,7 @@ impl Args for Options {
        let mut scope = Scope::All;
        let mut sync = SyncSettings::default();
        let mut directory = None;
+
        let mut bare = false;

        while let Some(arg) = parser.next()? {
            match arg {
@@ -99,6 +102,9 @@ impl Args for Options {
                    // We keep this flag here for consistency though it doesn't have any effect,
                    // since the command is fully non-interactive.
                }
+
                Long("bare") => {
+
                    bare = true;
+
                }
                Long("help") | Short('h') => {
                    return Err(Error::Help.into());
                }
@@ -125,6 +131,7 @@ impl Args for Options {
                directory,
                scope,
                sync,
+
                bare,
            },
            vec![],
        ))
@@ -153,6 +160,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        options.sync.with_profile(&profile),
        &mut node,
        &profile,
+
        options.bare,
    )?
    .print_or_success()
    .ok_or_else(|| anyhow::anyhow!("failed to clone {}", options.id))?;
@@ -163,7 +171,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        .filter(|id| id != profile.id())
        .collect::<Vec<_>>();
    let default_branch = proj.default_branch().clone();
-
    let path = working.workdir().unwrap(); // SAFETY: The working copy is not bare.
+
    let path = if !options.bare {
+
        working.workdir().unwrap()
+
    } else {
+
        working.path()
+
    };

    // Configure repository and setup tracking for repository delegates.
    radicle::git::configure_repository(&working)?;
@@ -229,6 +241,7 @@ struct Checkout {
    repository: storage::git::Repository,
    doc: Doc,
    project: Project,
+
    bare: bool,
}

impl Checkout {
@@ -236,6 +249,7 @@ impl Checkout {
        repository: storage::git::Repository,
        profile: &Profile,
        directory: Option<PathBuf>,
+
        bare: bool,
    ) -> Result<Self, CheckoutFailure> {
        let rid = repository.rid();
        let doc = repository
@@ -257,6 +271,7 @@ impl Checkout {
            repository,
            doc: doc.doc,
            project: proj,
+
            bare,
        })
    }

@@ -274,7 +289,7 @@ impl Checkout {
            "Creating checkout in ./{}..",
            term::format::tertiary(destination.display())
        ));
-
        match rad::checkout(self.id, &self.remote, self.path, storage) {
+
        match rad::checkout(self.id, &self.remote, self.path, storage, self.bare) {
            Err(err) => {
                spinner.message(format!(
                    "Failed to checkout in ./{}",
@@ -303,6 +318,7 @@ fn clone(
    settings: SyncSettings,
    node: &mut Node,
    profile: &Profile,
+
    bare: bool,
) -> Result<CloneResult, CloneError> {
    // Seed repository.
    if node.seed(id, scope)? {
@@ -322,7 +338,7 @@ fn clone(
                node::sync::FetcherResult::TargetReached(_) => {
                    profile.storage.repository(id).map_or_else(
                        |err| Ok(CloneResult::RepositoryMissing { rid: id, err }),
-
                        |repository| Ok(perform_checkout(repository, profile, directory)?),
+
                        |repository| Ok(perform_checkout(repository, profile, directory, bare)?),
                    )
                }
                node::sync::FetcherResult::TargetError(failure) => {
@@ -330,7 +346,7 @@ fn clone(
                }
            }
        }
-
        Ok(repository) => Ok(perform_checkout(repository, profile, directory)?),
+
        Ok(repository) => Ok(perform_checkout(repository, profile, directory, bare)?),
    }
}

@@ -338,8 +354,9 @@ fn perform_checkout(
    repository: storage::git::Repository,
    profile: &Profile,
    directory: Option<PathBuf>,
+
    bare: bool,
) -> Result<CloneResult, rad::CheckoutError> {
-
    Checkout::new(repository, profile, directory).map_or_else(
+
    Checkout::new(repository, profile, directory, bare).map_or_else(
        |failure| Ok(CloneResult::Failure(failure)),
        |checkout| checkout.run(&profile.storage),
    )
modified crates/radicle-cli/src/commands/init.rs
@@ -403,6 +403,11 @@ pub fn init_existing(
        )?;
    }

+
    if options.setup_signing {
+
        // Setup radicle signing key.
+
        self::setup_signing(profile.id(), &working, options.interactive)?;
+
    }
+

    term::success!(
        "Initialized existing repository {} in {}..",
        term::format::tertiary(rid),
@@ -633,11 +638,13 @@ pub fn setup_signing(
    repo: &git::Repository,
    interactive: Interactive,
) -> anyhow::Result<()> {
-
    let repo = repo
-
        .workdir()
-
        .ok_or(anyhow!("cannot setup signing in bare repository"))?;
+
    const SIGNERS: &str = ".gitsigners";
+

+
    let path = repo.path();
+
    let config = path.join("config");
+

    let key = ssh::fmt::fingerprint(node_id);
-
    let yes = if !git::is_signing_configured(repo)? {
+
    let yes = if !git::is_signing_configured(path)? {
        term::headline(format!(
            "Configuring radicle signing key {}...",
            term::format::tertiary(key)
@@ -645,14 +652,25 @@ pub fn setup_signing(
        true
    } else if interactive.yes() {
        term::confirm(format!(
-
            "Configure radicle signing key {} in local checkout?",
+
            "Configure radicle signing key {} in {}?",
            term::format::tertiary(key),
+
            term::format::tertiary(config.display()),
        ))
    } else {
        true
    };

-
    if yes {
+
    if !yes {
+
        return Ok(());
+
    }
+

+
    git::configure_signing(path, node_id)?;
+
    term::success!(
+
        "Signing configured in {}",
+
        term::format::tertiary(config.display())
+
    );
+

+
    if let Some(repo) = repo.workdir() {
        match git::write_gitsigners(repo, [node_id]) {
            Ok(file) => {
                git::ignore(repo, file.as_path())?;
@@ -661,11 +679,11 @@ pub fn setup_signing(
            }
            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
                let ssh_key = ssh::fmt::key(node_id);
-
                let gitsigners = term::format::tertiary(".gitsigners");
+
                let gitsigners = term::format::tertiary(SIGNERS);
                term::success!("Found existing {} file", gitsigners);

                let ssh_keys =
-
                    git::read_gitsigners(repo).context("error reading .gitsigners file")?;
+
                    git::read_gitsigners(repo).context(format!("error reading {SIGNERS} file"))?;

                if ssh_keys.contains(&ssh_key) {
                    term::success!("Signing key is already in {gitsigners} file");
@@ -677,13 +695,10 @@ pub fn setup_signing(
                return Err(err.into());
            }
        }
-
        git::configure_signing(repo, node_id)?;
-

-
        term::success!(
-
            "Signing configured in {}",
-
            term::format::tertiary(".git/config")
-
        );
+
    } else {
+
        term::notice!("Not writing {SIGNERS} file.")
    }
+

    Ok(())
}

modified crates/radicle-cli/src/commands/patch/checkout.rs
@@ -125,14 +125,26 @@ fn find_patch_commit<'a>(
    working: &'a git::raw::Repository,
) -> anyhow::Result<git::raw::Commit<'a>> {
    let head = *revision.head();
-
    let workdir = working
-
        .workdir()
-
        .ok_or(anyhow::anyhow!("repository is a bare git repository "))?;

    match working.find_commit(head) {
        Ok(commit) => Ok(commit),
        Err(e) if git::ext::is_not_found_err(&e) => {
-
            git::process::fetch_local(workdir, stored, [head.into()], git::Verbosity::default())?;
+
            let output = git::process::fetch_local(
+
                Some(working.path()),
+
                stored,
+
                [head.into()],
+
                git::Verbosity::default(),
+
            )?;
+

+
            if !output.status.success() {
+
                anyhow::bail!(
+
                    "`git fetch` exited with status {}, stderr and stdout follow:\n{}\n{}\n",
+
                    output.status,
+
                    String::from_utf8_lossy(&output.stderr),
+
                    String::from_utf8_lossy(&output.stdout)
+
                );
+
            }
+

            working.find_commit(head).map_err(|e| e.into())
        }
        Err(e) => Err(e.into()),
modified crates/radicle-cli/src/git.rs
@@ -137,7 +137,7 @@ pub fn git<S: AsRef<std::ffi::OsStr>>(
    repo: &std::path::Path,
    args: impl IntoIterator<Item = S>,
) -> anyhow::Result<std::process::Output> {
-
    let output = radicle::git::run::<_, _, &str, &str>(repo, args, [])?;
+
    let output = radicle::git::run(Some(repo), args)?;

    if !output.status.success() {
        anyhow::bail!(
@@ -351,28 +351,6 @@ pub fn parse_remote(refspec: &str) -> Option<(NodeId, &str)> {
        .and_then(|(peer, r)| NodeId::from_str(peer).ok().map(|p| (p, r)))
}

-
pub fn view_diff(
-
    repo: &git2::Repository,
-
    left: &git2::Oid,
-
    right: &git2::Oid,
-
) -> anyhow::Result<()> {
-
    // TODO(erikli): Replace with repo.diff()
-
    let workdir = repo
-
        .workdir()
-
        .ok_or_else(|| anyhow!("Could not get workdir current repository."))?;
-

-
    let left = format!("{:.7}", left.to_string());
-
    let right = format!("{:.7}", right.to_string());
-

-
    let mut git = Command::new("git")
-
        .current_dir(workdir)
-
        .args(["diff", &left, &right])
-
        .spawn()?;
-
    git.wait()?;
-

-
    Ok(())
-
}
-

pub fn add_tag(
    repo: &git2::Repository,
    message: &str,
modified crates/radicle-cli/tests/commands.rs
@@ -177,6 +177,15 @@ fn rad_init() {
}

#[test]
+
fn rad_init_bare() {
+
    let mut env = Environment::new();
+
    let alice = env.profile("alice");
+
    radicle::test::fixtures::bare_repository(env.work(&alice).as_path());
+
    env.tests(["git/git-is-bare-repository", "rad-init"], &alice)
+
        .unwrap();
+
}
+

+
#[test]
fn rad_init_existing() {
    let mut environment = Environment::new();
    let mut profile = environment.node("alice");
@@ -199,6 +208,28 @@ fn rad_init_existing() {
}

#[test]
+
fn rad_init_existing_bare() {
+
    let mut environment = Environment::new();
+
    let mut profile = environment.node("alice");
+
    let working = tempfile::tempdir().unwrap();
+
    let rid = profile.project("heartwood", "Radicle Heartwood Protocol & Stack");
+

+
    test(
+
        "examples/rad-init-existing-bare.md",
+
        working.path(),
+
        Some(&profile.home),
+
        [(
+
            "URL",
+
            git::url::File::new(profile.storage.path())
+
                .rid(rid)
+
                .to_string()
+
                .as_str(),
+
        )],
+
    )
+
    .unwrap();
+
}
+

+
#[test]
fn rad_init_no_seed() {
    Environment::alice(["rad-init-no-seed"]);
}
@@ -1125,6 +1156,26 @@ fn rad_clone() {
}

#[test]
+
fn rad_clone_bare() {
+
    let mut environment = Environment::new();
+
    let mut alice = environment.node("alice");
+
    let bob = environment.node("bob");
+
    let working = environment.tempdir().join("working");
+

+
    // Setup a test project.
+
    let acme = alice.project("heartwood", "Radicle Heartwood Protocol & Stack");
+

+
    let mut alice = alice.spawn();
+
    let mut bob = bob.spawn();
+
    // Prevent Alice from fetching Bob's fork, as we're not testing that and it may cause errors.
+
    alice.handle.seed(acme, Scope::Followed).unwrap();
+

+
    bob.connect(&alice).converge([&alice]);
+

+
    test("examples/rad-clone-bare.md", working, Some(&bob.home), []).unwrap();
+
}
+

+
#[test]
fn rad_clone_directory() {
    let mut environment = Environment::new();
    let mut alice = environment.node("alice");
modified crates/radicle-node/src/tests/e2e.rs
@@ -562,6 +562,7 @@ fn test_clone() {
        alice.signer.public_key(),
        tmp.path().join("clone"),
        &alice.storage,
+
        false,
    )
    .unwrap();

modified crates/radicle-remote-helper/Cargo.toml
@@ -11,7 +11,7 @@ rust-version.workspace = true

[[bin]]
name = "git-remote-rad"
-
path = "src/git-remote-rad.rs"
+
path = "src/main.rs"

[dependencies]
dunce = { workspace = true }
modified crates/radicle-remote-helper/src/fetch.rs
@@ -1,5 +1,4 @@
use std::io;
-
use std::path::Path;
use std::str::FromStr;

use thiserror::Error;
@@ -28,7 +27,6 @@ pub enum Error {
/// Run a git fetch command.
pub fn run<R: ReadRepository>(
    mut refs: Vec<(git::Oid, git::RefString)>,
-
    working: &Path,
    stored: R,
    stdin: &io::Stdin,
    verbosity: Verbosity,
@@ -54,6 +52,9 @@ pub fn run<R: ReadRepository>(
    // Verify them and prepare the final refspecs.
    let oids = refs.into_iter().map(|(oid, _)| oid);

+
    // Rely on the environment variable `GIT_DIR` pointing at the repository.
+
    let working = None;
+

    // N.b. we shell out to `git`, avoiding using `git2`. This is to
    // avoid an issue where somewhere within the fetch there is an
    // attempt to lookup a `rad/sigrefs` object, which says that the
deleted crates/radicle-remote-helper/src/git-remote-rad.rs
@@ -1,42 +0,0 @@
-
use std::env;
-
use std::process;
-

-
use radicle::version::Version;
-

-
pub const VERSION: Version = Version {
-
    name: "git-remote-rad",
-
    commit: env!("GIT_HEAD"),
-
    version: env!("RADICLE_VERSION"),
-
    timestamp: env!("SOURCE_DATE_EPOCH"),
-
};
-

-
fn main() {
-
    let mut args = env::args();
-

-
    if let Some(lvl) = radicle::logger::env_level() {
-
        let logger = radicle::logger::StderrLogger::new(lvl);
-
        log::set_boxed_logger(Box::new(logger))
-
            .expect("no other logger should have been set already");
-
        log::set_max_level(lvl.to_level_filter());
-
    }
-
    if args.nth(1).as_deref() == Some("--version") {
-
        if let Err(e) = VERSION.write(std::io::stdout()) {
-
            eprintln!("error: {e}");
-
            process::exit(1);
-
        };
-
        process::exit(0);
-
    }
-

-
    let profile = match radicle::Profile::load() {
-
        Ok(profile) => profile,
-
        Err(err) => {
-
            eprintln!("error: couldn't load profile: {err}");
-
            process::exit(1);
-
        }
-
    };
-

-
    if let Err(err) = radicle_remote_helper::run(profile) {
-
        eprintln!("error: {err}");
-
        process::exit(1);
-
    }
-
}
deleted crates/radicle-remote-helper/src/lib.rs
@@ -1,337 +0,0 @@
-
#![warn(clippy::unwrap_used)]
-
//! 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::str::FromStr;
-
use std::{env, fmt, io};
-

-
use thiserror::Error;
-

-
use radicle::prelude::NodeId;
-
use radicle::storage::git::transport::local::{Url, UrlError};
-
use radicle::storage::{ReadRepository, WriteStorage};
-
use radicle::{cob, profile};
-
use radicle::{git, storage, Profile};
-
use radicle_cli::git::Rev;
-
use radicle_cli::terminal as cli;
-

-
#[derive(Debug, Error)]
-
pub enum Error {
-
    /// Failed to parse `base`.
-
    #[error("failed to parse base revision: {0}")]
-
    Base(Box<dyn std::error::Error>),
-
    /// Remote repository not found (or empty).
-
    #[error("remote repository `{0}` not found")]
-
    RepositoryNotFound(PathBuf),
-
    /// Invalid command received.
-
    #[error("invalid command `{0}`")]
-
    InvalidCommand(String),
-
    /// Invalid arguments received.
-
    #[error("invalid arguments: {0:?}")]
-
    InvalidArguments(Vec<String>),
-
    /// Unknown push option received.
-
    #[error("unknown push option {0:?}")]
-
    UnsupportedPushOption(String),
-
    /// 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,
-
    /// No parent of `GIT_DIR` was found.
-
    #[error("expected parent of .git but found {path:?}")]
-
    NoWorkingCopy { path: PathBuf },
-
    /// Git error.
-
    #[error("git: {0}")]
-
    Git(#[from] git::raw::Error),
-
    /// Invalid reference name.
-
    #[error("invalid ref: {0}")]
-
    InvalidRef(#[from] radicle::git::fmt::Error),
-
    /// Repository error.
-
    #[error(transparent)]
-
    Repository(#[from] radicle::storage::RepositoryError),
-
    /// Fetch error.
-
    #[error(transparent)]
-
    Fetch(#[from] fetch::Error),
-
    /// Push error.
-
    #[error(transparent)]
-
    Push(#[from] push::Error),
-
    /// List error.
-
    #[error(transparent)]
-
    List(#[from] list::Error),
-
}
-

-
/// Models values for the `verbosity` option, see
-
/// <https://git-scm.com/docs/gitremote-helpers#Documentation/gitremote-helpers.txt-optionverbosityn>.
-
#[derive(Copy, Clone, Debug)]
-
struct Verbosity(u8);
-

-
impl From<Verbosity> for radicle::git::Verbosity {
-
    /// Converts the verbosity option passed to a Git remote helper to
-
    /// one that can be passed to other Git commands via command line.
-
    /// Note that these scales are one off: While the default verbosity
-
    /// for remote helpers is 1, the default verbosity via command line
-
    /// (omitting the flag) is 0.
-
    /// This implementation also cuts off verbosities greater than [`i8::MAX`].
-
    fn from(val: Verbosity) -> Self {
-
        radicle::git::Verbosity::from(i8::try_from(val.0).unwrap_or(i8::MAX) - 1)
-
    }
-
}
-

-
/// The documentation on Git remote helpers, see
-
/// <https://git-scm.com/docs/gitremote-helpers#Documentation/gitremote-helpers.txt-optionverbosityn>
-
/// says: "1 is the default level of verbosity".
-
impl Default for Verbosity {
-
    fn default() -> Self {
-
        Self(1)
-
    }
-
}
-

-
impl FromStr for Verbosity {
-
    type Err = std::num::ParseIntError;
-

-
    fn from_str(s: &str) -> Result<Self, Self::Err> {
-
        u8::from_str(s).map(Self)
-
    }
-
}
-

-
#[derive(Debug, Default, Clone)]
-
pub struct Options {
-
    /// Don't sync after push.
-
    no_sync: bool,
-
    /// Sync debugging.
-
    sync_debug: bool,
-
    /// Enable hints.
-
    hints: bool,
-
    /// Open patch in draft mode.
-
    draft: bool,
-
    /// Patch base to use, when opening or updating a patch.
-
    base: Option<Rev>,
-
    /// Patch message.
-
    message: cli::patch::Message,
-
    verbosity: Verbosity,
-
}
-

-
/// Run the radicle remote helper using the given profile.
-
pub fn run(profile: radicle::Profile) -> Result<(), Error> {
-
    // Since we're going to be writing user output to `stderr`, make sure the paint
-
    // module is aware of that.
-
    cli::Paint::set_terminal(cli::TerminalFile::Stderr);
-

-
    let (remote, url): (Option<git::RefString>, Url) = {
-
        let args = env::args().skip(1).take(2).collect::<Vec<_>>();
-

-
        match args.as_slice() {
-
            [url] => (None, url.parse()?),
-
            [remote, url] => (git::RefString::try_from(remote.as_str()).ok(), url.parse()?),
-

-
            _ => {
-
                return Err(Error::InvalidArguments(args));
-
            }
-
        }
-
    };
-

-
    let stored = profile.storage.repository_mut(url.repo)?;
-
    if stored.is_empty()? {
-
        return Err(Error::RepositoryNotFound(stored.path().to_path_buf()));
-
    }
-

-
    // `GIT_DIR` is set by Git tooling, if we're in a working copy.
-
    let working = env::var("GIT_DIR").map(PathBuf::from);
-
    // Whether we should output debug logs.
-
    let debug = radicle::profile::env::debug();
-

-
    let stdin = io::stdin();
-
    let mut line = String::new();
-
    let mut opts = Options::default();
-

-
    if let Err(e) = radicle::io::set_file_limit(4096) {
-
        if debug {
-
            eprintln!("git-remote-rad: unable to set open file limit: {e}");
-
        }
-
    }
-

-
    loop {
-
        let tokens = read_line(&stdin, &mut line)?;
-

-
        if debug {
-
            eprintln!("git-remote-rad: {:?}", &tokens);
-
        }
-

-
        match tokens.as_slice() {
-
            ["capabilities"] => {
-
                println!("option");
-
                println!("push"); // Implies `list` command.
-
                println!("fetch");
-
                println!();
-
            }
-
            ["option", "verbosity", verbosity] => match verbosity.parse::<Verbosity>() {
-
                Ok(verbosity) => {
-
                    opts.verbosity = verbosity;
-
                    println!("ok");
-
                }
-
                Err(err) => {
-
                    println!("error {err}");
-
                }
-
            },
-
            ["option", "push-option", args @ ..] => {
-
                // Nb. Git documentation says that we can print `error <msg>` or `unsupported`
-
                // for options that are not supported, but this results in Git saying that
-
                // "push-option" itself is an unsupported option, which is not helpful or correct.
-
                // Hence, we just exit with an error in this case.
-
                push_option(args, &mut opts)?;
-
                println!("ok");
-
            }
-
            ["option", "progress", ..] => {
-
                println!("unsupported");
-
            }
-
            ["option", ..] => {
-
                println!("unsupported");
-
            }
-
            ["fetch", oid, refstr] => {
-
                let oid = git::Oid::from_str(oid)?;
-
                let refstr = git::RefString::try_from(*refstr)?;
-

-
                // N.b. `working` is the `.git` folder and `fetch::run`
-
                // requires the working directory.
-
                let working = dunce::canonicalize(working.map_err(|_| Error::NoGitDir)?)?;
-
                let working = working.parent().ok_or_else(|| Error::NoWorkingCopy {
-
                    path: working.clone(),
-
                })?;
-

-
                return fetch::run(vec![(oid, refstr)], working, stored, &stdin, opts.verbosity)
-
                    .map_err(Error::from);
-
            }
-
            ["push", refspec] => {
-
                // We have to be in a working copy to push.
-
                let working = working.map_err(|_| Error::NoGitDir)?;
-

-
                return push::run(
-
                    vec![refspec.to_string()],
-
                    &working,
-
                    // N.b. assume the default remote if there was no remote
-
                    remote.unwrap_or((*radicle::rad::REMOTE_NAME).clone()),
-
                    url,
-
                    &stored,
-
                    &profile,
-
                    &stdin,
-
                    opts,
-
                )
-
                .map_err(Error::from);
-
            }
-
            ["list"] => {
-
                list::for_fetch(&url, &profile, &stored)?;
-
            }
-
            ["list", "for-push"] => {
-
                list::for_push(&profile, &stored)?;
-
            }
-
            [] => {
-
                return Ok(());
-
            }
-
            _ => {
-
                return Err(Error::InvalidCommand(line.trim().to_owned()));
-
            }
-
        }
-
    }
-
}
-

-
/// Parse a single push option. Returns `Ok` if it was successful.
-
/// Note that some push options can contain spaces, eg. `patch.message="Hello World!"`,
-
/// hence the arguments are passed as a slice.
-
fn push_option(args: &[&str], opts: &mut Options) -> Result<(), Error> {
-
    match args {
-
        ["hints"] => opts.hints = true,
-
        ["sync"] => opts.no_sync = false,
-
        ["sync.debug"] => opts.sync_debug = true,
-
        ["no-sync"] => opts.no_sync = true,
-
        ["patch.draft"] => opts.draft = true,
-
        _ => {
-
            let args = args.join(" ");
-

-
            if let Some((key, val)) = args.split_once('=') {
-
                match key {
-
                    "patch.message" => {
-
                        opts.message.append(val);
-
                    }
-
                    "patch.base" => {
-
                        let base =
-
                            cli::args::rev(&val.into()).map_err(|e| Error::Base(e.into()))?;
-
                        opts.base = Some(base);
-
                    }
-
                    other => {
-
                        return Err(Error::UnsupportedPushOption(other.to_owned()));
-
                    }
-
                }
-
            } else {
-
                return Err(Error::UnsupportedPushOption(args.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)
-
}
-

-
/// Write a hint to the user.
-
pub(crate) fn hint(s: impl fmt::Display) {
-
    eprintln!("{}", cli::format::hint(format!("hint: {s}")));
-
}
-

-
/// Write a warning to the user.
-
pub(crate) fn warn(s: impl fmt::Display) {
-
    eprintln!("{}", cli::format::hint(format!("warn: {s}")));
-
}
-

-
/// Get the patch store.
-
pub(crate) fn patches<'a, R: ReadRepository + cob::Store<Namespace = NodeId>>(
-
    profile: &Profile,
-
    repo: &'a R,
-
) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreReader>, list::Error> {
-
    match profile.patches(repo) {
-
        Ok(patches) => Ok(patches),
-
        Err(err @ profile::Error::CobsCache(cob::cache::Error::OutOfDate)) => {
-
            hint(cli::cob::MIGRATION_HINT);
-
            Err(err.into())
-
        }
-
        Err(err) => Err(err.into()),
-
    }
-
}
-

-
/// Get the mutable patch store.
-
pub(crate) fn patches_mut<'a>(
-
    profile: &Profile,
-
    repo: &'a storage::git::Repository,
-
) -> Result<
-
    cob::patch::Cache<cob::patch::Patches<'a, storage::git::Repository>, cob::cache::StoreWriter>,
-
    push::Error,
-
> {
-
    match profile.patches_mut(repo) {
-
        Ok(patches) => Ok(patches),
-
        Err(err @ profile::Error::CobsCache(cob::cache::Error::OutOfDate)) => {
-
            hint(cli::cob::MIGRATION_HINT);
-
            Err(err.into())
-
        }
-
        Err(err) => Err(err.into()),
-
    }
-
}
modified crates/radicle-remote-helper/src/list.rs
@@ -46,20 +46,36 @@ pub fn for_fetch<R: ReadRepository + cob::Store<Namespace = NodeId> + 'static>(
            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}");
+
        {
+
            // List the symbolic reference `HEAD`, which is interpreted by
+
            // Git clients to determine the default branch.
+
            let head = git::refname!("HEAD");
+

+
            if let Some(target) = stored
+
                .find_reference(&head)?
+
                .symbolic_target() { println!("@{target} {head}") }
+
        }
+

+
        {
+
            // List canonical references.
+
            // 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}");
+
                }
            }
        }
-
        // List the patch refs, but don't abort if there's an error, as this would break
-
        // all fetch behavior. Instead, just output an error to the user.
-
        if let Err(e) = patch_refs(profile, stored) {
-
            eprintln!("remote: error listing patch refs: {e}");
+

+
        {
+
            // List the patch refs, but do not abort if there is an error,
+
            // as this would break all fetch behavior.
+
            // Instead, just output an error to the user.
+
            if let Err(e) = patch_refs(profile, stored) {
+
                eprintln!("remote: error listing patch refs: {e}");
+
            }
        }
    }
    println!();
added crates/radicle-remote-helper/src/main.rs
@@ -0,0 +1,369 @@
+
//! A Git remote helper for interacting with Radicle storage and notifying
+
//! `radicle-node`.
+
//!
+
//! Refer to <https://git-scm.com/docs/gitremote-helpers.html> for documentation
+
//! on Git remote helpers.
+
//!
+
//! Usage of standard streams:
+
//!  - Standard Error ([`eprintln`]) is used for communicating with the user.
+
//!  - Standard Output ([`println`]) is used for communicating with Git tooling.
+
//!
+
//! This process assumes that the environment variable `GIT_DIR` is set
+
//! appropriately (to the repository being pushed from or fetched to), as
+
//! mentioned in the documentation on Git remote helpers.
+
//!
+
//! For example, the following two mechanisms rely on `GIT_DIR` being set:
+
//!  - [`git::raw::Repository::open_from_env`] to open the repository
+
//!  - [`radicle::git::run`] (with [`None`] as first argument) to invoke `git`
+

+
mod fetch;
+
mod list;
+
mod push;
+

+
use std::path::PathBuf;
+
use std::process;
+
use std::str::FromStr;
+
use std::{env, fmt, io};
+

+
use thiserror::Error;
+

+
use radicle::prelude::NodeId;
+
use radicle::storage::git::transport::local::{Url, UrlError};
+
use radicle::storage::{ReadRepository, WriteStorage};
+
use radicle::version::Version;
+
use radicle::{cob, profile};
+
use radicle::{git, storage, Profile};
+
use radicle_cli::git::Rev;
+
use radicle_cli::terminal as cli;
+

+
pub const VERSION: Version = Version {
+
    name: env!("CARGO_BIN_NAME"),
+
    commit: env!("GIT_HEAD"),
+
    version: env!("RADICLE_VERSION"),
+
    timestamp: env!("SOURCE_DATE_EPOCH"),
+
};
+

+
fn main() {
+
    let mut args = env::args();
+

+
    if let Some(lvl) = radicle::logger::env_level() {
+
        let logger = radicle::logger::StderrLogger::new(lvl);
+
        log::set_boxed_logger(Box::new(logger))
+
            .expect("no other logger should have been set already");
+
        log::set_max_level(lvl.to_level_filter());
+
    }
+
    if args.nth(1).as_deref() == Some("--version") {
+
        if let Err(e) = VERSION.write(std::io::stdout()) {
+
            eprintln!("error: {e}");
+
            process::exit(1);
+
        };
+
        process::exit(0);
+
    }
+

+
    let profile = match radicle::Profile::load() {
+
        Ok(profile) => profile,
+
        Err(err) => {
+
            eprintln!("error: couldn't load profile: {err}");
+
            process::exit(1);
+
        }
+
    };
+

+
    if let Err(err) = run(profile) {
+
        eprintln!("error: {err}");
+
        process::exit(1);
+
    }
+
}
+

+
#[derive(Debug, Error)]
+
pub enum Error {
+
    /// Failed to parse `base`.
+
    #[error("failed to parse base revision: {0}")]
+
    Base(Box<dyn std::error::Error>),
+
    /// Remote repository not found (or empty).
+
    #[error("remote repository `{0}` not found")]
+
    RepositoryNotFound(PathBuf),
+
    /// Invalid command received.
+
    #[error("invalid command `{0}`")]
+
    InvalidCommand(String),
+
    /// Invalid arguments received.
+
    #[error("invalid arguments: {0:?}")]
+
    InvalidArguments(Vec<String>),
+
    /// Unknown push option received.
+
    #[error("unknown push option {0:?}")]
+
    UnsupportedPushOption(String),
+
    /// 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),
+
    /// Git error.
+
    #[error("git: {0}")]
+
    Git(#[from] git::raw::Error),
+
    /// Invalid reference name.
+
    #[error("invalid ref: {0}")]
+
    InvalidRef(#[from] radicle::git::fmt::Error),
+
    /// Repository error.
+
    #[error(transparent)]
+
    Repository(#[from] radicle::storage::RepositoryError),
+
    /// Fetch error.
+
    #[error(transparent)]
+
    Fetch(#[from] fetch::Error),
+
    /// Push error.
+
    #[error(transparent)]
+
    Push(#[from] push::Error),
+
    /// List error.
+
    #[error(transparent)]
+
    List(#[from] list::Error),
+
}
+

+
/// Models values for the `verbosity` option, see
+
/// <https://git-scm.com/docs/gitremote-helpers#Documentation/gitremote-helpers.txt-optionverbosityn>.
+
#[derive(Copy, Clone, Debug)]
+
struct Verbosity(u8);
+

+
impl From<Verbosity> for radicle::git::Verbosity {
+
    /// Converts the verbosity option passed to a Git remote helper to
+
    /// one that can be passed to other Git commands via command line.
+
    /// Note that these scales are one off: While the default verbosity
+
    /// for remote helpers is 1, the default verbosity via command line
+
    /// (omitting the flag) is 0.
+
    /// This implementation also cuts off verbosities greater than [`i8::MAX`].
+
    fn from(val: Verbosity) -> Self {
+
        radicle::git::Verbosity::from(i8::try_from(val.0).unwrap_or(i8::MAX) - 1)
+
    }
+
}
+

+
/// The documentation on Git remote helpers, see
+
/// <https://git-scm.com/docs/gitremote-helpers#Documentation/gitremote-helpers.txt-optionverbosityn>
+
/// says: "1 is the default level of verbosity".
+
impl Default for Verbosity {
+
    fn default() -> Self {
+
        Self(1)
+
    }
+
}
+

+
impl FromStr for Verbosity {
+
    type Err = std::num::ParseIntError;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        u8::from_str(s).map(Self)
+
    }
+
}
+

+
#[derive(Debug, Default, Clone)]
+
pub struct Options {
+
    /// Don't sync after push.
+
    no_sync: bool,
+
    /// Sync debugging.
+
    sync_debug: bool,
+
    /// Enable hints.
+
    hints: bool,
+
    /// Open patch in draft mode.
+
    draft: bool,
+
    /// Patch base to use, when opening or updating a patch.
+
    base: Option<Rev>,
+
    /// Patch message.
+
    message: cli::patch::Message,
+
    verbosity: Verbosity,
+
}
+

+
/// Run the radicle remote helper using the given profile.
+
pub fn run(profile: radicle::Profile) -> Result<(), Error> {
+
    // Since we're going to be writing user output to `stderr`, make sure the paint
+
    // module is aware of that.
+
    cli::Paint::set_terminal(cli::TerminalFile::Stderr);
+

+
    let (remote, url): (Option<git::RefString>, Url) = {
+
        let args = env::args().skip(1).take(2).collect::<Vec<_>>();
+

+
        match args.as_slice() {
+
            [url] => (None, url.parse()?),
+
            [remote, url] => (git::RefString::try_from(remote.as_str()).ok(), url.parse()?),
+

+
            _ => {
+
                return Err(Error::InvalidArguments(args));
+
            }
+
        }
+
    };
+

+
    let stored = profile.storage.repository_mut(url.repo)?;
+
    if stored.is_empty()? {
+
        return Err(Error::RepositoryNotFound(stored.path().to_path_buf()));
+
    }
+

+
    // Whether we should output debug logs.
+
    let debug = radicle::profile::env::debug();
+

+
    let stdin = io::stdin();
+
    let mut line = String::new();
+
    let mut opts = Options::default();
+

+
    if let Err(e) = radicle::io::set_file_limit(4096) {
+
        if debug {
+
            eprintln!("{}: unable to set open file limit: {e}", VERSION.name);
+
        }
+
    }
+

+
    loop {
+
        let tokens = read_line(&stdin, &mut line)?;
+

+
        if debug {
+
            eprintln!("{}: {}", VERSION.name, &tokens.join(" "));
+
        }
+

+
        match tokens.as_slice() {
+
            ["capabilities"] => {
+
                println!("option");
+
                println!("push"); // Implies `list` command.
+
                println!("fetch");
+
                println!();
+
            }
+
            ["option", "verbosity", verbosity] => match verbosity.parse::<Verbosity>() {
+
                Ok(verbosity) => {
+
                    opts.verbosity = verbosity;
+
                    println!("ok");
+
                }
+
                Err(err) => {
+
                    println!("error {err}");
+
                }
+
            },
+
            ["option", "push-option", args @ ..] => {
+
                // Nb. Git documentation says that we can print `error <msg>` or `unsupported`
+
                // for options that are not supported, but this results in Git saying that
+
                // "push-option" itself is an unsupported option, which is not helpful or correct.
+
                // Hence, we just exit with an error in this case.
+
                push_option(args, &mut opts)?;
+
                println!("ok");
+
            }
+
            ["option", "progress", ..] | ["option", ..] => {
+
                println!("unsupported");
+
            }
+
            ["fetch", oid, refstr] => {
+
                let oid = git::Oid::from_str(oid)?;
+
                let refstr = git::RefString::try_from(*refstr)?;
+

+
                return Ok(fetch::run(
+
                    vec![(oid, refstr)],
+
                    stored,
+
                    &stdin,
+
                    opts.verbosity,
+
                )?);
+
            }
+
            ["push", refspec] => {
+
                return Ok(push::run(
+
                    vec![refspec.to_string()],
+
                    remote,
+
                    url,
+
                    &stored,
+
                    &profile,
+
                    &stdin,
+
                    opts,
+
                )?);
+
            }
+
            ["list"] => {
+
                list::for_fetch(&url, &profile, &stored)?;
+
            }
+
            ["list", "for-push"] => {
+
                list::for_push(&profile, &stored)?;
+
            }
+
            [] => {
+
                return Ok(());
+
            }
+
            _ => {
+
                return Err(Error::InvalidCommand(line.trim().to_owned()));
+
            }
+
        }
+
    }
+
}
+

+
/// Parse a single push option. Returns `Ok` if it was successful.
+
/// Note that some push options can contain spaces, eg. `patch.message="Hello World!"`,
+
/// hence the arguments are passed as a slice.
+
fn push_option(args: &[&str], opts: &mut Options) -> Result<(), Error> {
+
    match args {
+
        ["hints"] => opts.hints = true,
+
        ["sync"] => opts.no_sync = false,
+
        ["sync.debug"] => opts.sync_debug = true,
+
        ["no-sync"] => opts.no_sync = true,
+
        ["patch.draft"] => opts.draft = true,
+
        _ => {
+
            let args = args.join(" ");
+

+
            let (key, val) = args
+
                .split_once('=')
+
                .ok_or_else(|| Error::UnsupportedPushOption(args.to_owned()))?;
+

+
            match key {
+
                "patch.message" => {
+
                    opts.message.append(val);
+
                }
+
                "patch.base" => {
+
                    let base = cli::args::rev(&val.into()).map_err(|e| Error::Base(e.into()))?;
+
                    opts.base = Some(base);
+
                }
+
                other => {
+
                    return Err(Error::UnsupportedPushOption(other.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)
+
}
+

+
/// Write a hint to the user.
+
pub(crate) fn hint(s: impl fmt::Display) {
+
    eprintln!("{}", cli::format::hint(format!("hint: {s}")));
+
}
+

+
/// Write a warning to the user.
+
pub(crate) fn warn(s: impl fmt::Display) {
+
    eprintln!("{}", cli::format::hint(format!("warn: {s}")));
+
}
+

+
/// Get the patch store.
+
pub(crate) fn patches<'a, R: ReadRepository + cob::Store<Namespace = NodeId>>(
+
    profile: &Profile,
+
    repo: &'a R,
+
) -> Result<cob::patch::Cache<cob::patch::Patches<'a, R>, cob::cache::StoreReader>, list::Error> {
+
    match profile.patches(repo) {
+
        Ok(patches) => Ok(patches),
+
        Err(err @ profile::Error::CobsCache(cob::cache::Error::OutOfDate)) => {
+
            hint(cli::cob::MIGRATION_HINT);
+
            Err(err.into())
+
        }
+
        Err(err) => Err(err.into()),
+
    }
+
}
+

+
/// Get the mutable patch store.
+
pub(crate) fn patches_mut<'a>(
+
    profile: &Profile,
+
    repo: &'a storage::git::Repository,
+
) -> Result<
+
    cob::patch::Cache<cob::patch::Patches<'a, storage::git::Repository>, cob::cache::StoreWriter>,
+
    push::Error,
+
> {
+
    match profile.patches_mut(repo) {
+
        Ok(patches) => Ok(patches),
+
        Err(err @ profile::Error::CobsCache(cob::cache::Error::OutOfDate)) => {
+
            hint(cli::cob::MIGRATION_HINT);
+
            Err(err.into())
+
        }
+
        Err(err) => Err(err.into()),
+
    }
+
}
modified crates/radicle-remote-helper/src/push.rs
@@ -5,7 +5,6 @@ mod error;

use std::collections::HashMap;
use std::io::IsTerminal;
-
use std::path::{Path, PathBuf};
use std::process::ExitStatus;
use std::str::FromStr;
use std::{assert_eq, io};
@@ -36,10 +35,6 @@ use crate::{hint, read_line, Options, Verbosity};

#[derive(Debug, Error)]
pub enum Error {
-
    #[error(
-
        "the Git repository found at {path:?} is a bare repository, expected a working directory"
-
    )]
-
    BareRepository { path: PathBuf },
    /// Public key doesn't match the remote namespace we're pushing to.
    #[error("cannot push to remote namespace owned by {0}")]
    KeyMismatch(Did),
@@ -251,8 +246,7 @@ impl PushAction {
/// Run a git push command.
pub fn run(
    mut specs: Vec<String>,
-
    working: &Path,
-
    remote: git::RefString,
+
    remote: Option<git::RefString>,
    url: Url,
    stored: &storage::git::Repository,
    profile: &Profile,
@@ -296,7 +290,9 @@ pub fn run(
    let canonical_ref = git::refs::branch(project.default_branch());
    let mut set_canonical_refs: Vec<(git::Qualified, git::canonical::Object)> =
        Vec::with_capacity(specs.len());
-
    let working = git::raw::Repository::open(working)?;
+

+
    // Rely on the environment variable `GIT_DIR`.
+
    let working = git::raw::Repository::open_from_env()?;

    // For each refspec, push a ref or delete a ref.
    for spec in specs {
@@ -481,7 +477,7 @@ pub fn run(
/// Open a new patch.
fn patch_open<G>(
    src: &git::Oid,
-
    upstream: &git::RefString,
+
    upstream: &Option<git::RefString>,
    nid: &NodeId,
    working: &git::raw::Repository,
    stored: &storage::git::Repository,
@@ -506,7 +502,7 @@ where
    //
    // 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, working, stored.raw(), opts.verbosity)?;
+
    push_ref(src, &dst, false, stored.raw(), opts.verbosity)?;

    let (_, target) = stored.canonical_head()?;
    let base = if let Some(base) = opts.base {
@@ -568,17 +564,20 @@ where
                "Create reference for patch head",
            )?;

-
            // Setup current branch so that pushing updates the patch.
-
            if let Some(branch) = rad::setup_patch_upstream(&patch, head, working, upstream, false)?
-
            {
-
                if let Some(name) = branch.name()? {
-
                    if profile.hints() {
-
                        // Remove the remote portion of the name, i.e.
-
                        // rad/patches/deadbeef -> patches/deadbeef
-
                        let name = name.split('/').skip(1).collect::<Vec<_>>().join("/");
-
                        hint(format!(
-
                            "to update, run `git push` or `git push rad -f HEAD:{name}`"
-
                        ));
+
            if let Some(upstream) = upstream {
+
                // Setup current branch so that pushing updates the patch.
+
                if let Some(branch) =
+
                    rad::setup_patch_upstream(&patch, head, working, upstream, false)?
+
                {
+
                    if let Some(name) = branch.name()? {
+
                        if profile.hints() {
+
                            // Remove the remote portion of the name, i.e.
+
                            // rad/patches/deadbeef -> patches/deadbeef
+
                            let name = name.split('/').skip(1).collect::<Vec<_>>().join("/");
+
                            hint(format!(
+
                                "to update, run `git push` or `git push rad -f HEAD:{name}`"
+
                            ));
+
                        }
                    }
                }
            }
@@ -620,7 +619,7 @@ where
    let commit = *src;
    let dst = dst.with_namespace(nid.into());

-
    push_ref(src, &dst, force, working, stored.raw(), opts.verbosity)?;
+
    push_ref(src, &dst, force, stored.raw(), opts.verbosity)?;

    let Ok(Some(patch)) = patches.get(&patch_id) else {
        return Err(Error::NotFound(patch_id));
@@ -700,7 +699,7 @@ where
    // It's ok for the destination reference to be unknown, eg. when pushing a new branch.
    let old = stored.backend.find_reference(dst.as_str()).ok();

-
    push_ref(src, &dst, force, working, stored.raw(), verbosity)?;
+
    push_ref(src, &dst, force, stored.raw(), verbosity)?;

    if let Some(old) = old {
        let proj = stored.project()?;
@@ -881,7 +880,6 @@ fn push_ref(
    src: &git::Oid,
    dst: &git::Namespaced,
    force: bool,
-
    working: &git::raw::Repository,
    stored: &git::raw::Repository,
    verbosity: Verbosity,
) -> Result<(), Error> {
@@ -889,7 +887,6 @@ fn push_ref(
    // Nb. The *force* indicator (`+`) is processed by Git tooling before we even reach this code.
    // This happens during the `list for-push` phase.
    let refspec = git::Refspec { src, dst, force };
-
    let repo = working.workdir().unwrap_or_else(|| working.path());

    let mut args = vec![
        "push".to_string(),
@@ -908,7 +905,10 @@ fn push_ref(

    args.extend([url.to_string(), refspec.to_string()]);

-
    let output = radicle::git::run::<_, _, &str, &str>(repo, args, [])?;
+
    // Rely on the environment variable `GIT_DIR`.
+
    let working = None;
+

+
    let output = radicle::git::run(working, args)?;

    if !output.status.success() {
        return Err(Error::InternalPushFailed {
modified crates/radicle/src/git.rs
@@ -756,23 +756,24 @@ pub fn head_refname(repo: &git2::Repository) -> Result<Option<String>, git2::Err
    }
}

-
/// Execute a git command by spawning a child process.
-
pub fn run<P, S, K, V>(
-
    repo: P,
+
/// Execute a `git` command by spawning a child process and collect its output.
+
/// If `working` is [`Some`], the command is run as if `git` was started in
+
/// `working` instead of the current working directory, by prepending
+
/// `-C <working>` to the command line.
+
pub fn run<S>(
+
    working: Option<&std::path::Path>,
    args: impl IntoIterator<Item = S>,
-
    envs: impl IntoIterator<Item = (K, V)>,
) -> io::Result<std::process::Output>
where
-
    P: AsRef<Path>,
    S: AsRef<std::ffi::OsStr>,
-
    K: AsRef<std::ffi::OsStr>,
-
    V: AsRef<std::ffi::OsStr>,
{
-
    Command::new("git")
-
        .current_dir(repo)
-
        .envs(envs)
-
        .args(args)
-
        .output()
+
    let mut cmd = Command::new("git");
+

+
    if let Some(working) = working {
+
        cmd.arg("-C").arg(dunce::canonicalize(working)?);
+
    }
+

+
    cmd.args(args).output()
}

/// Functions that call to the `git` CLI instead of `git2`.
@@ -789,7 +790,7 @@ pub mod process {
    /// `oids` are the set of [`Oid`]s that are being fetched from the
    /// `storage`.
    pub fn fetch_local<R>(
-
        working: &Path,
+
        working: Option<&Path>,
        storage: &R,
        oids: impl IntoIterator<Item = Oid>,
        verbosity: Verbosity,
@@ -797,16 +798,15 @@ pub mod process {
    where
        R: ReadRepository,
    {
-
        let mut fetch = vec![
+
        let mut args = vec![
            "fetch".to_string(),
            // Avoid writing fetch head since we're only fetching objects
            "--no-write-fetch-head".to_string(),
        ];
-
        fetch.extend(verbosity.into_flag());
-
        fetch.push(url::File::new(storage.path()).to_string());
-
        fetch.extend(oids.into_iter().map(|oid| oid.to_string()));
-
        // N.b. `.` is used since we're fetching within the working copy
-
        run::<_, _, &str, &str>(working, fetch, [])
+
        args.extend(verbosity.into_flag());
+
        args.push(url::File::new(storage.path()).to_string());
+
        args.extend(oids.into_iter().map(|oid| oid.to_string()));
+
        run(working, args)
    }
}

modified crates/radicle/src/rad.rs
@@ -1,6 +1,6 @@
#![allow(clippy::let_unit_value)]
use std::io;
-
use std::path::{Path, PathBuf};
+
use std::path::Path;
use std::str::FromStr;
use std::sync::LazyLock;

@@ -32,10 +32,6 @@ pub static PATCHES_REFNAME: LazyLock<git::RefString> =

#[derive(Error, Debug)]
pub enum InitError {
-
    #[error(
-
        "the Git repository found at {path:?} is a bare repository, expected a working directory"
-
    )]
-
    BareRepository { path: PathBuf },
    #[error("doc: {0}")]
    Doc(#[from] DocError),
    #[error("repository: {0}")]
@@ -115,23 +111,22 @@ where
    git::configure_repository(repo)?;
    git::configure_remote(repo, &REMOTE_NAME, url, &url.clone().with_namespace(*pk))?;
    let branch = git::Qualified::from(git::fmt::lit::refs_heads(default_branch));
-
    // Pushes to default branch to the namespace of the `signer`
-
    let pushspec = git::Refspec {
-
        src: branch.clone(),
-
        dst: branch.with_namespace(git::Component::from(pk)),
-
        force: false,
-
    };
-
    git::run::<_, _, &str, &str>(
-
        repo.workdir().ok_or(InitError::BareRepository {
-
            path: repo.path().to_path_buf(),
-
        })?,
-
        [
-
            "push",
-
            &format!("{}", dunce::canonicalize(stored.path())?.display()),
-
            &pushspec.to_string(),
-
        ],
-
        [],
-
    )?;
+

+
    {
+
        // Push branch to storage.
+
        let stored = dunce::canonicalize(stored.path())?.display().to_string();
+

+
        // Pushes to default branch to the namespace of the `signer`.
+
        let pushspec = git::Refspec {
+
            src: branch.clone(),
+
            dst: branch.with_namespace(git::Component::from(pk)),
+
            force: false,
+
        }
+
        .to_string();
+

+
        git::run(Some(repo.path()), ["push".to_string(), stored, pushspec])?;
+
    }
+

    // N.b. we need to create the remote branch for the default branch
    let rad_remote =
        git::Qualified::from(git::lit::refs_remotes(&*REMOTE_COMPONENT)).join(default_branch);
@@ -231,12 +226,14 @@ where

#[derive(Error, Debug)]
pub enum CheckoutError {
-
    #[error(
-
        "the Git repository found at {path:?} is a bare repository, expected a working directory"
-
    )]
-
    BareRepository { path: PathBuf },
-
    #[error("failed to fetch to working copy")]
-
    Fetch(#[source] std::io::Error),
+
    #[error("failed to fetch to working copy: {0}")]
+
    FetchIo(#[source] std::io::Error),
+
    #[error("internal fetch failed with exit status {status}, stderr and stdout follow:\n{stderr}\n{stdout}")]
+
    FetchGit {
+
        status: std::process::ExitStatus,
+
        stderr: String,
+
        stdout: String,
+
    },
    #[error("git: {0}")]
    Git(#[from] git2::Error),
    #[error("payload: {0}")]
@@ -254,6 +251,7 @@ pub fn checkout<P: AsRef<Path>, S: storage::ReadStorage>(
    remote: &RemoteId,
    path: P,
    storage: &S,
+
    bare: bool,
) -> Result<git2::Repository, CheckoutError> {
    // TODO: Decide on whether we can use `clone_local`
    // TODO: Look into sharing object databases.
@@ -263,7 +261,8 @@ pub fn checkout<P: AsRef<Path>, S: storage::ReadStorage>(
    let mut opts = git2::RepositoryInitOptions::new();
    opts.no_reinit(true)
        .external_template(false)
-
        .description(project.description());
+
        .description(project.description())
+
        .bare(bare);

    let repo = git2::Repository::init_opts(path.as_ref(), &opts)?;
    let url = git::Url::from(proj);
@@ -277,33 +276,35 @@ pub fn checkout<P: AsRef<Path>, S: storage::ReadStorage>(
        &url,
        &url.clone().with_namespace(*remote),
    )?;
-
    let fetchspec = git::Refspec {
-
        src: git::refspec::pattern!("refs/heads/*"),
-
        dst: git::Qualified::from(git::lit::refs_remotes(&*REMOTE_NAME))
-
            .to_pattern(git::refspec::STAR)
-
            .into_patternstring(),
-
        force: false,
-
    };
-
    let stored = storage.repository(proj)?;
-
    let workdir = repo.workdir().ok_or(CheckoutError::BareRepository {
-
        path: repo.path().to_path_buf(),
-
    })?;

-
    git::run::<_, _, &str, &str>(
-
        workdir,
-
        [
-
            "fetch",
-
            &format!(
-
                "{}",
-
                dunce::canonicalize(stored.path())
-
                    .map_err(CheckoutError::Fetch)?
-
                    .display()
-
            ),
-
            &fetchspec.to_string(),
-
        ],
-
        [],
-
    )
-
    .map_err(CheckoutError::Fetch)?;
+
    {
+
        // Fetch remote head to working copy.
+

+
        let fetchspec = git::Refspec {
+
            src: git::refspec::pattern!("refs/heads/*"),
+
            dst: git::Qualified::from(git::lit::refs_remotes(&*REMOTE_NAME))
+
                .to_pattern(git::refspec::STAR)
+
                .into_patternstring(),
+
            force: false,
+
        }
+
        .to_string();
+

+
        let stored = dunce::canonicalize(storage.repository(proj)?.path())
+
            .map_err(CheckoutError::FetchIo)?
+
            .display()
+
            .to_string();
+

+
        let output = git::run(Some(repo.path()), ["fetch", &stored, &fetchspec])
+
            .map_err(CheckoutError::FetchIo)?;
+

+
        if !output.status.success() {
+
            return Err(CheckoutError::FetchGit {
+
                status: output.status,
+
                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
+
                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
+
            });
+
        }
+
    }

    {
        // Setup default branch.
@@ -319,7 +320,9 @@ pub fn checkout<P: AsRef<Path>, S: storage::ReadStorage>(
            .expect("checkout: default branch name is valid UTF-8");

        repo.set_head(branch_ref)?;
-
        repo.checkout_head(None)?;
+
        if !bare {
+
            repo.checkout_head(None)?;
+
        }

        // Setup remote tracking for default branch.
        git::set_upstream(&repo, &*REMOTE_NAME, project.default_branch(), branch_ref)?;
@@ -550,7 +553,7 @@ mod tests {

        // Bob forks it and creates a checkout.
        fork(id, &bob, &storage).unwrap();
-
        checkout(id, bob_id, tempdir.path().join("copy"), &storage).unwrap();
+
        checkout(id, bob_id, tempdir.path().join("copy"), &storage, false).unwrap();

        let bob_remote = storage.repository(id).unwrap().remote(bob_id).unwrap();

@@ -585,7 +588,7 @@ mod tests {
        .unwrap();
        git::set_upstream(&original, "rad", "master", "refs/heads/master").unwrap();

-
        let copy = checkout(id, remote_id, tempdir.path().join("copy"), &storage).unwrap();
+
        let copy = checkout(id, remote_id, tempdir.path().join("copy"), &storage, false).unwrap();

        assert_eq!(
            copy.head().unwrap().target(),
modified crates/radicle/src/storage.rs
@@ -588,6 +588,12 @@ pub trait ReadRepository: Sized + ValidateRepository {
    /// Check whether the given commit is an ancestor of another commit.
    fn is_ancestor_of(&self, ancestor: Oid, head: Oid) -> Result<bool, git::ext::Error>;

+
    /// Look up a reference to one of the objects in a repository.
+
    fn find_reference(
+
        &self,
+
        reference: &git::RefStr,
+
    ) -> Result<git2::Reference<'_>, git::ext::Error>;
+

    /// Get the object id of a reference under the given remote.
    fn reference_oid(
        &self,
modified crates/radicle/src/storage/git.rs
@@ -27,7 +27,7 @@ use crate::storage::{
use crate::{git, node};

pub use crate::git::{
-
    ext, raw, refname, refspec, Oid, PatternStr, Qualified, RefError, RefString, UserInfo,
+
    ext, raw, refname, refspec, Oid, PatternStr, Qualified, RefError, RefStr, RefString, UserInfo,
};
pub use crate::storage::{Error, RepositoryError};

@@ -695,6 +695,13 @@ impl ReadRepository for Repository {
        Ok(oid.into())
    }

+
    fn find_reference(
+
        &self,
+
        reference: &git::RefStr,
+
    ) -> Result<git2::Reference<'_>, git::ext::Error> {
+
        Ok(self.backend.find_reference(reference.as_str())?)
+
    }
+

    fn commit(&self, oid: Oid) -> Result<git2::Commit, git::Error> {
        self.backend
            .find_commit(oid.into())
modified crates/radicle/src/storage/git/cob.rs
@@ -376,6 +376,13 @@ impl<R: storage::ReadRepository> ReadRepository for DraftStore<'_, R> {
    fn merge_base(&self, left: &Oid, right: &Oid) -> Result<Oid, git::ext::Error> {
        self.repo.merge_base(left, right)
    }
+

+
    fn find_reference(
+
        &self,
+
        _reference: &super::RefStr,
+
    ) -> Result<git2::Reference<'_>, git::ext::Error> {
+
        todo!()
+
    }
}

impl<R: storage::WriteRepository> cob::object::Storage for DraftStore<'_, R> {
modified crates/radicle/src/storage/refs.rs
@@ -570,6 +570,7 @@ mod tests {
                bob.public_key(),
                tmp.path().join("working"),
                &storage,
+
                false,
            )
            .unwrap();

modified crates/radicle/src/test/fixtures.rs
@@ -95,11 +95,28 @@ where

/// Creates a regular repository at the given path with a couple of commits.
pub fn repository<P: AsRef<Path>>(path: P) -> (git2::Repository, git2::Oid) {
-
    let repo = git2::Repository::init_opts(
+
    let (repo, oid) = repository_with(
        path,
        git2::RepositoryInitOptions::new().external_template(false),
+
    );
+
    repo.checkout_head(None).unwrap();
+
    (repo, oid)
+
}
+

+
pub fn bare_repository<P: AsRef<Path>>(path: P) -> (git2::Repository, git2::Oid) {
+
    repository_with(
+
        path,
+
        git2::RepositoryInitOptions::new()
+
            .external_template(false)
+
            .bare(true),
    )
-
    .unwrap();
+
}
+

+
fn repository_with<P: AsRef<Path>>(
+
    path: P,
+
    opts: &mut git2::RepositoryInitOptions,
+
) -> (git2::Repository, git2::Oid) {
+
    let repo = git2::Repository::init_opts(path, opts).unwrap();

    {
        let mut config = repo.config().unwrap();
@@ -124,7 +141,6 @@ pub fn repository<P: AsRef<Path>>(path: P) -> (git2::Repository, git2::Oid) {
        commit.id()
    };
    repo.set_head("refs/heads/master").unwrap();
-
    repo.checkout_head(None).unwrap();

    drop(tree);
    drop(head);
modified crates/radicle/src/test/storage.rs
@@ -319,6 +319,13 @@ impl ReadRepository for MockRepository {
    fn merge_base(&self, _left: &Oid, _right: &Oid) -> Result<Oid, git::ext::Error> {
        todo!()
    }
+

+
    fn find_reference(
+
        &self,
+
        _reference: &git::RefStr,
+
    ) -> Result<git2::Reference<'_>, git::ext::Error> {
+
        todo!()
+
    }
}

impl WriteRepository for MockRepository {