From 37ea81766ec8a008420c43091364130bd61016f1 Mon Sep 17 00:00:00 2001 From: Sekhat Temporus Date: Mon, 28 Apr 2025 19:51:02 +0100 Subject: [PATCH] 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. --- .../examples/rad-init-detached-head.md | 29 +++++++++ .../radicle-cli/examples/rad-init-no-git.md | 7 ++- crates/radicle-cli/src/commands/init.rs | 61 ++++++++++++++++--- crates/radicle-cli/tests/commands.rs | 12 ++++ crates/radicle/src/git.rs | 16 +++++ 5 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 crates/radicle-cli/examples/rad-init-detached-head.md diff --git a/crates/radicle-cli/examples/rad-init-detached-head.md b/crates/radicle-cli/examples/rad-init-detached-head.md new file mode 100644 index 00000000..8935abd0 --- /dev/null +++ b/crates/radicle-cli/examples/rad-init-detached-head.md @@ -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 ` or set `git config set --local init.defaultBranch ` +✗ 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. diff --git a/crates/radicle-cli/examples/rad-init-no-git.md b/crates/radicle-cli/examples/rad-init-no-git.md index 505c426a..b39e914d 100644 --- a/crates/radicle-cli/examples/rad-init-no-git.md +++ b/crates/radicle-cli/examples/rad-init-no-git.md @@ -16,7 +16,10 @@ Now we try again. ``` (fail) $ 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. diff --git a/crates/radicle-cli/src/commands/init.rs b/crates/radicle-cli/src/commands/init.rs index b837f558..76822e0b 100644 --- a/crates/radicle-cli/src/commands/init.rs +++ b/crates/radicle-cli/src/commands/init.rs @@ -12,6 +12,7 @@ use serde_json as json; use radicle::crypto::ssh; use radicle::explorer::ExplorerUrl; +use radicle::git::raw; use radicle::git::RefString; use radicle::identity::project::ProjectName; use radicle::identity::{Doc, RepoId, Visibility}; @@ -216,11 +217,21 @@ pub fn init( .unwrap_or_else(|| repo.path()) .canonicalize()?; let interactive = options.interactive; - let head: String = repo - .head() - .ok() - .and_then(|head| head.shorthand().map(|h| h.to_owned())) - .ok_or_else(|| anyhow!("repository head must point to a commit"))?; + + let default_branch = match find_default_branch(&repo) { + Err(err @ DefaultBranchError::Head) => { + term::error(err); + term::hint("try `git checkout ` or set `git config set --local init.defaultBranch `"); + 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!( "Initializing{}radicle 👾 repository in {}..", @@ -252,10 +263,10 @@ pub fn init( Some(branch) => branch, None if interactive.yes() => term::input( "Default branch", - Some(head), + Some(default_branch), Some("Please specify an existing branch"), )?, - None => head, + None => default_branch, }; let branch = RefString::try_from(branch.clone()) .map_err(|e| anyhow!("invalid branch name {:?}: {}", branch, e))?; @@ -671,3 +682,39 @@ pub fn setup_signing( } 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 { + 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, 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 { + 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()), + } +} diff --git a/crates/radicle-cli/tests/commands.rs b/crates/radicle-cli/tests/commands.rs index 51699600..9ef56f75 100644 --- a/crates/radicle-cli/tests/commands.rs +++ b/crates/radicle-cli/tests/commands.rs @@ -212,6 +212,18 @@ fn rad_init_no_git() { 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] fn rad_inspect() { let mut environment = Environment::new(); diff --git a/crates/radicle/src/git.rs b/crates/radicle/src/git.rs index 7a67f4fa..e7d13117 100644 --- a/crates/radicle/src/git.rs +++ b/crates/radicle/src/git.rs @@ -701,6 +701,22 @@ pub fn set_upstream( Ok(()) } +pub fn init_default_branch(repo: &git2::Repository) -> Result, 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, 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. pub fn run( repo: P,