cli: improve default branch pick

When using `rad init` the default branch for the project can be predetermined by
inspecting the working copy repository.

To improve the experience, it will first check `init.defaultBranch` in the Git
configuration file.

If it does not find this, it falls back to choosing the default head of the
repository at the time. This may be HEAD, and in this case it will error and
provide helpful information.

The repository may also not have any default head at the time, and this also
results in an error and hint.
This commit is contained in:
Sekhat Temporus 2025-04-28 19:51:02 +01:00 committed by Fintan Halpenny
parent 85ddcace0a
commit 37ea81766e
5 changed files with 116 additions and 9 deletions

View File

@ -0,0 +1,29 @@
Let's assume you created a Git repository containing two commits and checked out
the first one, leaving you in a detached `HEAD` state:
```
$ git init -q
$ touch file1.txt
$ git add .
$ git commit -m "Create file" -q
$ touch file2.txt
$ git add .
$ git commit -m "Create a second file" -q
$ git checkout HEAD~1
```
If you try to `rad init` the repository in this state, it will attempt to
determine a default branch. However, it cannot because we are currently not on
any branch nor have we set the `init.defaultBranch` option in our `git config`.
Using `rad init` will fail, providing a hint on how to fix the issue:
``` (fail)
$ rad init
✗ Error: in detached HEAD state
✗ Hint: try `git checkout <default branch>` or set `git config set --local init.defaultBranch <default branch>`
✗ Error: aborting `rad init`
```
Alternatively, if you know a branch that already exists, and you would like to
use, you can use the `--default-branch` option.

View File

@ -16,7 +16,10 @@ Now we try again.
``` (fail) ``` (fail)
$ rad init $ rad init
✗ Error: repository head must point to a commit ✗ Error: could not determine default branch in repository
✗ Hint: perhaps you need to create a branch?
✗ Error: aborting `rad init`
``` ```
Looks like we need a commit. Looks like we need to get to work and start working on a branch and add commits
to it.

View File

@ -12,6 +12,7 @@ use serde_json as json;
use radicle::crypto::ssh; use radicle::crypto::ssh;
use radicle::explorer::ExplorerUrl; use radicle::explorer::ExplorerUrl;
use radicle::git::raw;
use radicle::git::RefString; use radicle::git::RefString;
use radicle::identity::project::ProjectName; use radicle::identity::project::ProjectName;
use radicle::identity::{Doc, RepoId, Visibility}; use radicle::identity::{Doc, RepoId, Visibility};
@ -216,11 +217,21 @@ pub fn init(
.unwrap_or_else(|| repo.path()) .unwrap_or_else(|| repo.path())
.canonicalize()?; .canonicalize()?;
let interactive = options.interactive; let interactive = options.interactive;
let head: String = repo
.head() let default_branch = match find_default_branch(&repo) {
.ok() Err(err @ DefaultBranchError::Head) => {
.and_then(|head| head.shorthand().map(|h| h.to_owned())) term::error(err);
.ok_or_else(|| anyhow!("repository head must point to a commit"))?; term::hint("try `git checkout <default branch>` or set `git config set --local init.defaultBranch <default branch>`");
anyhow::bail!("aborting `rad init`")
}
Err(err @ DefaultBranchError::NoHead) => {
term::error(err);
term::hint("perhaps you need to create a branch?");
anyhow::bail!("aborting `rad init`")
}
Err(err) => anyhow::bail!(err),
Ok(branch) => branch,
};
term::headline(format!( term::headline(format!(
"Initializing{}radicle 👾 repository in {}..", "Initializing{}radicle 👾 repository in {}..",
@ -252,10 +263,10 @@ pub fn init(
Some(branch) => branch, Some(branch) => branch,
None if interactive.yes() => term::input( None if interactive.yes() => term::input(
"Default branch", "Default branch",
Some(head), Some(default_branch),
Some("Please specify an existing branch"), Some("Please specify an existing branch"),
)?, )?,
None => head, None => default_branch,
}; };
let branch = RefString::try_from(branch.clone()) let branch = RefString::try_from(branch.clone())
.map_err(|e| anyhow!("invalid branch name {:?}: {}", branch, e))?; .map_err(|e| anyhow!("invalid branch name {:?}: {}", branch, e))?;
@ -671,3 +682,39 @@ pub fn setup_signing(
} }
Ok(()) Ok(())
} }
#[derive(Debug, thiserror::Error)]
enum DefaultBranchError {
#[error("could not determine default branch in repository")]
NoHead,
#[error("in detached HEAD state")]
Head,
#[error("could not determine default branch in repository: {0}")]
Git(raw::Error),
}
fn find_default_branch(repo: &raw::Repository) -> Result<String, DefaultBranchError> {
match find_init_default_branch(repo).ok().flatten() {
Some(refname) => Ok(refname),
None => Ok(find_repository_head(repo)?),
}
}
fn find_init_default_branch(repo: &raw::Repository) -> Result<Option<String>, raw::Error> {
let config = repo.config().and_then(|mut c| c.snapshot())?;
let default_branch = config.get_str("init.defaultbranch")?;
let branch = repo.find_branch(default_branch, raw::BranchType::Local)?;
Ok(branch.into_reference().shorthand().map(ToOwned::to_owned))
}
fn find_repository_head(repo: &raw::Repository) -> Result<String, DefaultBranchError> {
match repo.head() {
Err(e) if e.code() == raw::ErrorCode::UnbornBranch => Err(DefaultBranchError::NoHead),
Err(e) => Err(DefaultBranchError::Git(e)),
Ok(head) => head
.shorthand()
.filter(|refname| *refname != "HEAD")
.ok_or(DefaultBranchError::Head)
.map(|refname| refname.to_owned()),
}
}

View File

@ -212,6 +212,18 @@ fn rad_init_no_git() {
environment.test("rad-init-no-git", &profile).unwrap(); environment.test("rad-init-no-git", &profile).unwrap();
} }
#[test]
fn rad_init_detached_head() {
let mut environment = Environment::new();
let profile = environment.profile("alice");
// NOTE: There is no repository set up here.
environment
.test("rad-init-detached-head", &profile)
.unwrap();
}
#[test] #[test]
fn rad_inspect() { fn rad_inspect() {
let mut environment = Environment::new(); let mut environment = Environment::new();

View File

@ -701,6 +701,22 @@ pub fn set_upstream(
Ok(()) Ok(())
} }
pub fn init_default_branch(repo: &git2::Repository) -> Result<Option<String>, git2::Error> {
let config = repo.config().and_then(|mut c| c.snapshot())?;
let default_branch = config.get_str("init.defaultbranch")?;
let branch = repo.find_branch(default_branch, git2::BranchType::Local)?;
Ok(branch.into_reference().shorthand().map(ToOwned::to_owned))
}
pub fn head_refname(repo: &git2::Repository) -> Result<Option<String>, git2::Error> {
let head = repo.head()?;
match head.shorthand() {
Some("HEAD") => Ok(None),
Some(refname) => Ok(Some(refname.to_owned())),
None => Ok(None),
}
}
/// Execute a git command by spawning a child process. /// Execute a git command by spawning a child process.
pub fn run<P, S, K, V>( pub fn run<P, S, K, V>(
repo: P, repo: P,