Skip to content

Commit f05b8b3

Browse files
fix: resolve correct default branch name from git
1 parent 7166efc commit f05b8b3

4 files changed

Lines changed: 120 additions & 54 deletions

File tree

src/commands/git.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ pub fn command() -> Command {
5151
.value_name("BRANCH"),
5252
),
5353
)
54+
.subcommand(
55+
Command::new("default-branch")
56+
.about("Print the default branch branp would use for this repo")
57+
.arg(Arg::new("remote").short('r').long("remote").help("Git remote to use").default_value("origin")),
58+
)
5459
.subcommand(
5560
Command::new("ignore")
5661
.about("Add repo-local ignore patterns without modifying .gitignore")
@@ -94,6 +99,7 @@ pub fn exec(gctx: &mut GlobalContext, args: &ArgMatches) -> CliResult {
9499
Some(("prs", sub)) => prs(gctx, sub),
95100
Some(("check", sub)) => check(gctx, sub),
96101
Some(("fork", sub)) => fork(gctx, sub),
102+
Some(("default-branch", sub)) => default_branch(gctx, sub),
97103
Some(("ignore", sub)) => ignore(gctx, sub),
98104
Some(("open", sub)) => open(gctx, sub),
99105
_ => Err(CliError::from("no `git` subcommand provided")),
@@ -128,6 +134,11 @@ fn fork(gctx: &mut GlobalContext, args: &ArgMatches) -> CliResult {
128134
ops::fork(gctx, &options)
129135
}
130136

137+
fn default_branch(gctx: &mut GlobalContext, args: &ArgMatches) -> CliResult {
138+
let remote = args.get_one::<String>("remote").unwrap();
139+
ops::default_branch(gctx, remote)
140+
}
141+
131142
fn ignore(gctx: &mut GlobalContext, args: &ArgMatches) -> CliResult {
132143
let options = ops::IgnoreOptions {
133144
patterns: args.get_many::<String>("patterns").map(|values| values.map(String::as_str).collect()).unwrap_or_default(),

src/commands/worktree.rs

Lines changed: 1 addition & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -367,34 +367,7 @@ fn sync_links(gctx: &mut GlobalContext, repo: &Repo, only_worktree: Option<&Path
367367
}
368368

369369
fn default_branch(repo: &Path) -> Result<String, CliError> {
370-
choose_default_branch(
371-
local_config_value(repo, "init.defaultBranch"),
372-
remote_head_branch(repo, "origin"),
373-
git::current_branch(repo).ok().flatten(),
374-
)
375-
}
376-
377-
fn choose_default_branch(local_config: Option<String>, remote_head: Option<String>, current_branch: Option<String>) -> Result<String, CliError> {
378-
local_config
379-
.or(remote_head)
380-
.or(current_branch)
381-
.ok_or_else(|| CliError::from("could not determine default branch from local config, origin/HEAD, or the base worktree branch"))
382-
}
383-
384-
fn local_config_value(repo: &Path, key: &str) -> Option<String> {
385-
git::output(repo, &["config", "--local", "--get", key]).ok().and_then(|value| non_empty_trimmed(&value))
386-
}
387-
388-
fn remote_head_branch(repo: &Path, remote: &str) -> Option<String> {
389-
git::output(repo, &["symbolic-ref", "--short", &format!("refs/remotes/{remote}/HEAD")])
390-
.ok()
391-
.and_then(|value| value.trim().strip_prefix(&format!("{remote}/")).map(str::to_string))
392-
.and_then(|branch| (!branch.is_empty()).then_some(branch))
393-
}
394-
395-
fn non_empty_trimmed(value: &str) -> Option<String> {
396-
let value = value.trim();
397-
(!value.is_empty()).then(|| value.to_string())
370+
git::default_branch(repo, "origin")
398371
}
399372

400373
fn session_name_for(base: &Path, name: &str) -> String {
@@ -957,16 +930,3 @@ fn required_many<'a>(args: &'a ArgMatches, name: &str) -> Result<Vec<&'a str>, C
957930
fn path_arg(path: &Path) -> String {
958931
path.to_string_lossy().to_string()
959932
}
960-
961-
#[cfg(test)]
962-
mod tests {
963-
use super::*;
964-
965-
#[test]
966-
fn chooses_default_branch_from_best_local_signal() {
967-
assert_eq!(choose_default_branch(Some("dev".to_string()), Some("master".to_string()), Some("feature".to_string())).unwrap(), "dev");
968-
assert_eq!(choose_default_branch(None, Some("dev".to_string()), Some("feature".to_string())).unwrap(), "dev");
969-
assert_eq!(choose_default_branch(None, None, Some("dev".to_string())).unwrap(), "dev");
970-
assert!(choose_default_branch(None, None, None).is_err());
971-
}
972-
}

src/ops/git.rs

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ pub fn fork(gctx: &mut GlobalContext, options: &ForkOptions<'_>) -> CliResult {
9797

9898
let branch = match options.branch {
9999
Some(branch) => branch.to_string(),
100-
None => default_branch(gctx, options.remote)?,
100+
None => crate::utils::git::default_branch(gctx.cwd(), options.remote)?,
101101
};
102102

103103
switch_to_branch(gctx, options.remote, &branch)?;
@@ -110,6 +110,12 @@ pub fn fork(gctx: &mut GlobalContext, options: &ForkOptions<'_>) -> CliResult {
110110
Ok(())
111111
}
112112

113+
pub fn default_branch(gctx: &mut GlobalContext, remote: &str) -> CliResult {
114+
let branch = crate::utils::git::default_branch(gctx.cwd(), remote)?;
115+
gctx.shell().note(branch);
116+
Ok(())
117+
}
118+
113119
pub fn ignore(gctx: &mut GlobalContext, options: &IgnoreOptions<'_>) -> CliResult {
114120
let repo_root = repo_root(gctx.cwd())?;
115121
let exclude_path = local_exclude_path(gctx.cwd())?;
@@ -500,18 +506,6 @@ fn add_or_reuse_remote(gctx: &mut GlobalContext, remote: &str, url: &str) -> Cli
500506
}
501507
}
502508

503-
fn default_branch(gctx: &mut GlobalContext, remote: &str) -> Result<String, CliError> {
504-
let output = crate::utils::git::output(gctx.cwd(), &["remote", "show", remote])
505-
.map_err(|e| CliError::from(format!("failed to determine default branch for `{remote}`: {}; pass --branch", e.message)))?;
506-
507-
output
508-
.lines()
509-
.find_map(|line| line.trim().strip_prefix("HEAD branch: "))
510-
.filter(|branch| !branch.is_empty() && *branch != "(unknown)")
511-
.map(str::to_string)
512-
.ok_or_else(|| CliError::from(format!("failed to determine default branch for `{remote}`; pass --branch")))
513-
}
514-
515509
fn switch_to_branch(gctx: &mut GlobalContext, remote: &str, branch: &str) -> CliResult {
516510
match crate::utils::git::run(gctx.cwd(), &["switch", branch]) {
517511
Ok(()) => Ok(()),

src/utils/git.rs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,63 @@ pub fn remote_branches(repo: &Path, remote: &str) -> Result<HashSet<String>, Cli
4343
.collect())
4444
}
4545

46+
pub fn default_branch(repo: &Path, remote: &str) -> Result<String, CliError> {
47+
choose_default_branch(
48+
local_config_value(repo, "branp.worktree.defaultBranch"),
49+
live_remote_head_branch(repo, remote),
50+
current_branch(repo).ok().flatten(),
51+
cached_remote_head_branch(repo, remote),
52+
local_config_value(repo, "init.defaultBranch"),
53+
)
54+
}
55+
56+
fn choose_default_branch(
57+
explicit_config: Option<String>,
58+
live_remote_head: Option<String>,
59+
current_branch: Option<String>,
60+
cached_remote_head: Option<String>,
61+
init_default_branch: Option<String>,
62+
) -> Result<String, CliError> {
63+
explicit_config
64+
.and_then(|branch| non_empty_trimmed(&branch))
65+
.or_else(|| live_remote_head.and_then(|branch| non_empty_trimmed(&branch)))
66+
.or_else(|| current_branch.and_then(|branch| non_empty_trimmed(&branch)))
67+
.or_else(|| cached_remote_head.and_then(|branch| non_empty_trimmed(&branch)))
68+
.or_else(|| init_default_branch.and_then(|branch| non_empty_trimmed(&branch)))
69+
.ok_or_else(|| {
70+
CliError::from(
71+
"could not determine default branch from branp.worktree.defaultBranch, origin HEAD, the current branch, origin/HEAD, or init.defaultBranch",
72+
)
73+
})
74+
}
75+
76+
fn local_config_value(repo: &Path, key: &str) -> Option<String> {
77+
output(repo, &["config", "--local", "--get", key]).ok().and_then(|value| non_empty_trimmed(&value))
78+
}
79+
80+
fn live_remote_head_branch(repo: &Path, remote: &str) -> Option<String> {
81+
output(repo, &["ls-remote", "--symref", remote, "HEAD"]).ok().and_then(|output| parse_remote_head(&output))
82+
}
83+
84+
fn parse_remote_head(output: &str) -> Option<String> {
85+
output
86+
.lines()
87+
.find_map(|line| line.strip_prefix("ref: refs/heads/").and_then(|line| line.strip_suffix("\tHEAD")).map(str::to_string))
88+
.and_then(|branch| (!branch.is_empty()).then_some(branch))
89+
}
90+
91+
fn cached_remote_head_branch(repo: &Path, remote: &str) -> Option<String> {
92+
output(repo, &["symbolic-ref", "--short", &format!("refs/remotes/{remote}/HEAD")])
93+
.ok()
94+
.and_then(|value| value.trim().strip_prefix(&format!("{remote}/")).map(str::to_string))
95+
.and_then(|branch| (!branch.is_empty()).then_some(branch))
96+
}
97+
98+
fn non_empty_trimmed(value: &str) -> Option<String> {
99+
let value = value.trim();
100+
(!value.is_empty()).then(|| value.to_string())
101+
}
102+
46103
pub fn delete_local_branch(repo: &Path, branch: &str, force: bool) -> CliResult {
47104
if !local_branch_exists(repo, branch)? {
48105
return Ok(());
@@ -72,3 +129,47 @@ fn parse_github_url(url: &str) -> Option<GitHubRepo> {
72129

73130
Some(GitHubRepo { owner: owner.to_string(), name: name.to_string() })
74131
}
132+
133+
#[cfg(test)]
134+
mod tests {
135+
use super::*;
136+
137+
#[test]
138+
fn chooses_default_branch_from_best_available_signal() {
139+
assert_eq!(
140+
choose_default_branch(
141+
Some("configured".to_string()),
142+
Some("remote".to_string()),
143+
Some("base".to_string()),
144+
Some("cached".to_string()),
145+
Some("init".to_string()),
146+
)
147+
.unwrap(),
148+
"configured"
149+
);
150+
assert_eq!(
151+
choose_default_branch(None, Some("remote".to_string()), Some("base".to_string()), Some("cached".to_string()), Some("init".to_string()))
152+
.unwrap(),
153+
"remote"
154+
);
155+
assert_eq!(
156+
choose_default_branch(None, None, Some("base".to_string()), Some("cached".to_string()), Some("init".to_string())).unwrap(),
157+
"base"
158+
);
159+
assert_eq!(choose_default_branch(None, None, None, Some("cached".to_string()), Some("init".to_string())).unwrap(), "cached");
160+
assert_eq!(choose_default_branch(None, None, None, None, Some("init".to_string())).unwrap(), "init");
161+
assert!(choose_default_branch(None, None, None, None, None).is_err());
162+
}
163+
164+
#[test]
165+
fn current_branch_beats_stale_cached_remote_head() {
166+
assert_eq!(choose_default_branch(None, None, Some("dev".to_string()), Some("master".to_string()), None).unwrap(), "dev");
167+
}
168+
169+
#[test]
170+
fn parses_live_remote_head() {
171+
let output = "ref: refs/heads/dev\tHEAD\nf7dbd99e3616315c360db815f2a09e03d38ac669\tHEAD\n";
172+
assert_eq!(parse_remote_head(output).as_deref(), Some("dev"));
173+
assert_eq!(parse_remote_head("f7dbd99e3616315c360db815f2a09e03d38ac669\tHEAD\n"), None);
174+
}
175+
}

0 commit comments

Comments
 (0)