Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
radicle: Detect current repository using `jj`
Merged lorenz opened 7 months ago

When using Jujutsu and a non-colocated Git repository, the detection using libgit2 directly fails (just as git in a shell would fail).

Add an invocation of jj git root as a fallback.

10 files changed +300 -23 fafb3493 9e1d6b1f
modified crates/radicle-cli-test/src/lib.rs
@@ -124,6 +124,7 @@ impl<'a> TestRunner<'a> {

        if let Some(ref h) = test.home {
            if let Some(home) = self.homes.get(h) {
+
                env.insert("USER".to_owned(), h.to_owned());
                return TestRun {
                    home: home.clone(),
                    env,
@@ -420,7 +421,7 @@ impl TestFormula {
            let mut run = runner.run(test);

            // For each command.
-
            for assertion in &test.assertions {
+
            for (i, assertion) in test.assertions.iter().enumerate() {
                // Expand environment variables.
                let mut args = assertion.args.clone();
                for arg in &mut args {
@@ -464,6 +465,16 @@ impl TestFormula {
                    fs::create_dir_all(run.path())?;
                }

+
                let jj_envs = if assertion.command == "jj" {
+
                    vec![
+
                        ("JJ_RANDOMNESS_SEED", i.to_string()),
+
                        ("JJ_TIMESTAMP", "2001-02-03T04:05:06+07:00".to_string()),
+
                        ("JJ_OP_TIMESTAMP", "2001-02-03T04:05:06+07:00".to_string()),
+
                    ]
+
                } else {
+
                    vec![]
+
                };
+

                let bins = self
                    .bins
                    .iter()
@@ -474,6 +485,7 @@ impl TestFormula {
                    .env_clear()
                    .env("PATH", &bins)
                    .env("RUST_BACKTRACE", "1")
+
                    .envs(jj_envs)
                    .envs(run.envs())
                    .current_dir(run.path())
                    .args(args)
added crates/radicle-cli/examples/jj-config.md
@@ -0,0 +1,19 @@
+
Let's make sure that the config is exactly what we expect.
+

+
```
+
$ jj config list
+
ui.editor = "true"
+
user.name = "Test User"
+
user.email = "test.user@example.com"
+
debug.commit-timestamp = "2001-02-03T04:05:06+07:00"
+
debug.randomness-seed = 0
+
debug.operation-timestamp = "2001-02-03T04:05:06+07:00"
+
operation.hostname = "host.example.com"
+
operation.username = "test-username"
+
```
+

+
We enable writing Change ID headers to our commits.
+

+
```
+
$ jj config set --user git.write-change-id-header true
+
```

\ No newline at end of file
added crates/radicle-cli/examples/jj-init-bare.md
@@ -0,0 +1,19 @@
+
We initialize Jujutusu for our repository for use with a bare Git repo.
+

+
```(stderr)
+
$ jj git init --git-repo heartwood heartwood.jj
+
Done importing changes from the underlying Git repo.
+
Working copy  (@) now at: lvxkkpmk 9ec513df (empty) (no description set)
+
Parent commit (@-)      : xpnzuzwn f2de534b master | Second commit
+
Added 1 files, modified 0 files, removed 0 files
+
Initialized repo in "heartwood.jj"
+
```
+

+
```
+
$ cd heartwood.jj
+
```
+

+
```
+
$ rad .
+
rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
```

\ No newline at end of file
added crates/radicle-cli/examples/jj-init-colocate.md
@@ -0,0 +1,10 @@
+
We initialize Jujutusu for our repository by colocating with Git.
+

+
```(stderr)
+
$ jj git init --colocate
+
Done importing changes from the underlying Git repo.
+
Hint: The following remote bookmarks aren't associated with the existing local bookmarks:
+
  master@rad
+
Hint: Run `jj bookmark track master@rad` to keep local bookmarks updated on future pulls.
+
Initialized repo in "."
+
```

\ No newline at end of file
added crates/radicle-cli/examples/rad-patch-jj.md
@@ -0,0 +1,91 @@
+
The scenario in this file is a variation of the one in `rad-patch.md`,
+
but uses Jujutsu.
+

+
```
+
$ touch REQUIREMENTS
+
$ jj describe --message "Define power requirements"
+
$ jj status
+
Working copy changes:
+
A REQUIREMENTS
+
Working copy  (@) : lvxkkpmk a6ea7b72 Define power requirements
+
Parent commit (@-): xpnzuzwn f2de534b master master@rad | Second commit
+
```
+

+
```
+
$ jj new
+
```
+

+
Just making sure that Git sees the Change ID…
+

+
```
+
$ git cat-file commit a6ea7b72
+
tree [..]
+
parent f2de534b[..]
+
author Test User <test.user@example.com> 981147906 +0700
+
committer Test User <test.user@example.com> 981147906 +0700
+
change-id lvxkkpmk[..]
+

+
Define power requirements
+
```
+

+
As of 2025-05 we can't use `jj` to do push with options directly, see:
+

+
 - <https://github.com/jj-vcs/jj/issues/4075>
+
 - <https://github.com/jj-vcs/jj/pull/2098>
+

+
However, since we initialized Jujutusu to colocate with Git, we can just use
+
Git to push.
+

+
``` (stderr)
+
$ git push rad -o patch.message="Define power requirements" -o patch.message="See details." HEAD:refs/patches
+
✓ Patch 1e31055ed3c41a48f2a71ba5317feb863b089700 opened
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new reference]   HEAD -> refs/patches
+
```
+

+
It will now be listed as one of the open patches.
+

+
```
+
$ rad patch
+
╭─────────────────────────────────────────────────────────────────────────────────────────╮
+
│ ●  ID       Title                      Author         Reviews  Head     +   -   Updated │
+
├─────────────────────────────────────────────────────────────────────────────────────────┤
+
│ ●  1e31055  Define power requirements  alice   (you)  -        a6ea7b7  +0  -0  now     │
+
╰─────────────────────────────────────────────────────────────────────────────────────────╯
+
```
+

+
Let's also create a bookmark for it.
+

+
```
+
$ jj bookmark create flux-capacitor-power
+
```
+

+
```
+
$ rad patch show 1e31055 -p
+
╭───────────────────────────────────────────────────╮
+
│ Title    Define power requirements                │
+
│ Patch    1e31055[..                             ] │
+
│ Author   alice (you)                              │
+
│ Head     a6ea7b7[..                             ] │
+
│ Base     f2de534[..                             ] │
+
│ Commits  ahead 1, behind 0                        │
+
│ Status   open                                     │
+
│                                                   │
+
│ See details.                                      │
+
├───────────────────────────────────────────────────┤
+
│ a6ea7b7 Define power requirements                 │
+
├───────────────────────────────────────────────────┤
+
│ ● Revision 1e31055 @ a6ea7b7 by alice (you) now   │
+
╰───────────────────────────────────────────────────╯
+

+
commit a6ea7b7[..]
+
Author: Test User <test.user@example.com>
+
Date:   Sat Feb 3 04:05:06 2001 +0700
+

+
    Define power requirements
+

+
diff --git a/REQUIREMENTS b/REQUIREMENTS
+
new file mode 100644
+
index 0000000..e69de29
+

+
```

\ No newline at end of file
modified crates/radicle-cli/tests/commands.rs
@@ -1,3 +1,4 @@
+
use core::panic;
use std::path::Path;
use std::str::FromStr;
use std::{net, thread, time};
@@ -36,20 +37,55 @@ pub(crate) fn test<'a>(
    envs: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> Result<(), Box<dyn std::error::Error>> {
    let tmp = tempfile::tempdir().unwrap();
-
    let home = if let Some(home) = home {
-
        home.path().to_path_buf()
+

+
    let (unix_home, rad_home) = if let Some(home) = home {
+
        let unix_home = home.path().to_path_buf();
+
        let unix_home = unix_home.parent().unwrap().to_path_buf();
+
        (unix_home, home.path().to_path_buf())
    } else {
-
        tmp.path().to_path_buf()
+
        let mut rad_home = tmp.path().to_path_buf();
+
        rad_home.push(".radicle");
+
        (tmp.path().to_path_buf(), rad_home)
    };

    formula(cwd.as_ref(), test)?
-
        .env("RAD_HOME", home.to_string_lossy())
+
        .env("RAD_HOME", rad_home.to_string_lossy())
+
        .env(
+
            "JJ_CONFIG",
+
            unix_home.join(".jjconfig.toml").to_string_lossy(),
+
        )
        .envs(envs)
        .run()?;

    Ok(())
}

+
/// A utility to check that some program can be executed with a `--version`
+
/// argument and exits successfully.
+
///
+
/// # Panics
+
///
+
/// If there is an error executing the program other than the program not being
+
/// found, or the program does not exit successfully.
+
fn program_reports_version(program: &str) -> bool {
+
    use std::io::ErrorKind;
+
    use std::process::{Command, Stdio};
+

+
    match Command::new(program)
+
        .arg("--version")
+
        .stdout(Stdio::null())
+
        .status()
+
    {
+
        Err(e) if e.kind() == ErrorKind::NotFound => {
+
            log::warn!(target: "test", "`{program}` not found.");
+
            false
+
        }
+
        Err(e) => panic!("failure to execute `{program}`: {e}"),
+
        Ok(status) if status.success() => true,
+
        Ok(status) => panic!("executing `{program}` resulted in status {status}"),
+
    }
+
}
+

#[test]
fn rad_auth() {
    test("examples/rad-auth.md", Path::new("."), None, []).unwrap();
@@ -97,21 +133,11 @@ fn rad_cob_update_identity() {

#[test]
fn rad_cob_multiset() {
-
    {
-
        // `rad-cob-multiset` is a `jq` script, which requires `jq` to be installed.
-
        // We test whether `jq` is installed, and have this test succeed if it is not.
-
        // Programmatic skipping of tests is not supported as of 2024-08.
-
        use std::io::ErrorKind;
-
        use std::process::{Command, Stdio};
-

-
        match Command::new("jq").arg("-V").stdout(Stdio::null()).status() {
-
            Err(e) if e.kind() == ErrorKind::NotFound => {
-
                log::warn!(target: "test", "`jq` not found. Succeeding prematurely.");
-
                return;
-
            }
-
            Err(e) => panic!("while checking for jq: {e}"),
-
            Ok(_) => {}
-
        }
+
    // `rad-cob-multiset` is a `jq` script, which requires `jq` to be installed.
+
    // We test whether `jq` is installed, and have this test succeed if it is not.
+
    // Programmatic skipping of tests is not supported as of 2024-08.
+
    if !program_reports_version("jq") {
+
        return;
    }

    let mut environment = Environment::new();
@@ -819,6 +845,48 @@ fn rad_patch() {
}

#[test]
+
fn rad_jj_bare() {
+
    // We test whether `jj` is installed, and have this test succeed if it is not.
+
    // Programmatic skipping of tests is not supported as of 2024-08.
+
    if !program_reports_version("jj") {
+
        return;
+
    }
+

+
    let mut environment = Environment::new();
+
    let mut profile = environment.node("alice");
+
    let rid = profile.project("heartwood", "Radicle Heartwood Protocol & Stack");
+

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

+
    environment
+
        .tests(["jj-config", "jj-init-bare"], &profile)
+
        .unwrap();
+
}
+

+
#[test]
+
fn rad_jj_colocated_patch() {
+
    // We test whether `jj` is installed, and have this test succeed if it is not.
+
    // Programmatic skipping of tests is not supported as of 2024-08.
+
    if !program_reports_version("jj") {
+
        return;
+
    }
+

+
    Environment::alice(["rad-init", "jj-config", "jj-init-colocate", "rad-patch-jj"])
+
}
+

+
#[test]
fn rad_patch_diff() {
    Environment::alice(["rad-init", "rad-patch-diff"]);
}
modified crates/radicle-cli/tests/util/environment.rs
@@ -246,6 +246,17 @@ impl Environment {
            "RAD_HOME",
            subject.home().path().to_path_buf().to_string_lossy(),
        )
+
        .env(
+
            "JJ_CONFIG",
+
            subject
+
                .home()
+
                .path()
+
                .parent()
+
                .unwrap()
+
                .to_path_buf()
+
                .join(".jjconfig.toml")
+
                .to_string_lossy(),
+
        )
        .run()?;

        Ok(())
modified crates/radicle-cli/tests/util/formula.rs
@@ -20,6 +20,11 @@ pub(crate) fn formula(
        .env("GIT_COMMITTER_DATE", "1671125284")
        .env("GIT_COMMITTER_EMAIL", "radicle@localhost")
        .env("GIT_COMMITTER_NAME", "radicle")
+
        .env("JJ_USER", "Test User")
+
        .env("JJ_EMAIL", "test.user@example.com")
+
        .env("JJ_OP_HOSTNAME", "host.example.com")
+
        .env("JJ_OP_USERNAME", "test-username")
+
        .env("JJ_TZ_OFFSET_MINS", "660")
        .env("EDITOR", "true")
        .env("TZ", "UTC")
        .env("LANG", "C")
modified crates/radicle/src/rad.rs
@@ -372,6 +372,18 @@ pub fn remove_remote(repo: &git2::Repository) -> Result<(), RemoteError> {
    Ok(())
}

+
#[derive(Error, Debug)]
+
pub enum CwdError {
+
    #[error(transparent)]
+
    Remote(#[from] RemoteError),
+

+
    #[error("Detection failed (git: '{git}', jj: '{jj}')")]
+
    Detection {
+
        git: git2::Error,
+
        jj: JujutsuGitRootError,
+
    },
+
}
+

/// Get the RID of the repository in current working directory
///
/// It will atempt to search parent directories if `path` did not find
@@ -382,10 +394,11 @@ pub fn remove_remote(repo: &git2::Repository) -> Result<(), RemoteError> {
/// This function should only perform read operations since we do not
/// want to modify the wrong repository in the case that it found a
/// Git repository that is not a Radicle repository.
-
pub fn cwd() -> Result<(git2::Repository, RepoId), RemoteError> {
-
    let repo = repo()?;
-
    let (_, id) = remote(&repo)?;
+
pub fn cwd() -> Result<(git2::Repository, RepoId), CwdError> {
+
    let repo =
+
        repo().or_else(|git| repo_jj_git_root().map_err(|jj| CwdError::Detection { git, jj }))?;

+
    let (_, id) = remote(&repo)?;
    Ok((repo, id))
}

@@ -411,6 +424,34 @@ pub fn repo() -> Result<git2::Repository, git2::Error> {
    Ok(repo)
}

+
#[derive(Error, Debug)]
+
pub enum JujutsuGitRootError {
+
    #[error("git: {0}")]
+
    Git(#[from] git2::Error),
+

+
    #[error("i/o: {0}")]
+
    Io(#[from] io::Error),
+

+
    #[error("exited with status {status}")]
+
    CommandFailure { status: std::process::ExitStatus },
+
}
+

+
/// Get the Git repo underlying the current Jujutsu repository.
+
pub fn repo_jj_git_root() -> Result<git2::Repository, JujutsuGitRootError> {
+
    let output = std::process::Command::new("jj")
+
        .args(["git", "root"])
+
        .output()?;
+

+
    if !output.status.success() {
+
        return Err(JujutsuGitRootError::CommandFailure {
+
            status: output.status,
+
        });
+
    }
+

+
    let path = std::path::PathBuf::from(String::from_utf8_lossy(&output.stdout).to_string().trim());
+
    Ok(git2::Repository::open(path)?)
+
}
+

/// Setup patch upstream branch such that `git push` updates the patch.
pub fn setup_patch_upstream<'a>(
    patch: &ObjectId,
modified flake.nix
@@ -102,6 +102,7 @@
          ]);
          nativeCheckInputs = with pkgs; [
            jq
+
            jujutsu
          ];

          env =