use anyhow::{Context, Result, anyhow};
use std::collections::HashSet;
use std::path::Path;
use tracing::debug;
use crate::cmd::Cmd;
use super::repo::has_commits;
use super::{ForkBranchSpec, RemoteBranchSpec};
pub fn get_default_branch() -> Result<String> {
get_default_branch_in(None)
}
pub fn get_default_branch_in(workdir: Option<&Path>) -> Result<String> {
let cmd = Cmd::new("git").args(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
let cmd = match workdir {
Some(path) => cmd.workdir(path),
None => cmd,
};
if let Ok(ref_name) = cmd.run_and_capture_stdout()
&& let Some(branch) = ref_name.strip_prefix("refs/remotes/origin/")
{
debug!(branch = branch, "git:default branch from remote HEAD");
return Ok(branch.to_string());
}
if branch_exists_in("main", workdir)? {
debug!("git:default branch 'main' (local fallback)");
return Ok("main".to_string());
}
if branch_exists_in("master", workdir)? {
debug!("git:default branch 'master' (local fallback)");
return Ok("master".to_string());
}
if !has_commits()? {
return Err(anyhow!(
"The repository has no commits yet. Please make an initial commit before using workmux, \
or specify the main branch in .workmux.yaml using the 'main_branch' key."
));
}
Err(anyhow!(
"Could not determine the default branch (e.g., 'main' or 'master'). \
Please specify it in .workmux.yaml using the 'main_branch' key."
))
}
pub fn branch_exists(branch_name: &str) -> Result<bool> {
branch_exists_in(branch_name, None)
}
pub fn branch_exists_in(branch_name: &str, workdir: Option<&Path>) -> Result<bool> {
let cmd = Cmd::new("git").args(&["rev-parse", "--verify", "--quiet", branch_name]);
let cmd = match workdir {
Some(path) => cmd.workdir(path),
None => cmd,
};
cmd.run_as_check()
}
pub fn parse_remote_branch_spec(spec: &str) -> Result<RemoteBranchSpec> {
let mut parts = spec.splitn(2, '/');
let remote = parts.next().unwrap_or("");
let branch = parts.next().unwrap_or("");
if remote.is_empty() || branch.is_empty() {
return Err(anyhow!(
"Invalid remote branch '{}'. Use the format <remote>/<branch> (e.g., origin/feature/foo).",
spec
));
}
Ok(RemoteBranchSpec {
remote: remote.to_string(),
branch: branch.to_string(),
})
}
pub fn parse_fork_branch_spec(input: &str) -> Option<ForkBranchSpec> {
if input.contains("://") || input.starts_with("git@") {
return None;
}
let (owner, branch) = input.split_once(':')?;
if owner.is_empty() || branch.is_empty() {
return None;
}
Some(ForkBranchSpec {
owner: owner.to_string(),
branch: branch.to_string(),
})
}
pub fn get_current_branch() -> Result<String> {
Cmd::new("git")
.args(&["branch", "--show-current"])
.run_and_capture_stdout()
}
pub fn get_current_branch_in(workdir: &Path) -> Result<String> {
Cmd::new("git")
.workdir(workdir)
.args(&["branch", "--show-current"])
.run_and_capture_stdout()
}
pub fn list_checkout_branches() -> Result<Vec<String>> {
let output = Cmd::new("git")
.args(&[
"for-each-ref",
"--format=%(refname:short)",
"refs/heads/",
"refs/remotes/",
])
.run_and_capture_stdout()
.context("Failed to list git branches")?;
let worktree_branches: HashSet<String> = super::list_worktrees()
.unwrap_or_default()
.into_iter()
.map(|(_, branch)| branch)
.collect();
Ok(output
.lines()
.map(str::trim)
.filter(|s| !s.is_empty() && *s != "HEAD" && !s.ends_with("/HEAD"))
.filter(|s| !worktree_branches.contains(*s))
.map(String::from)
.collect())
}
pub fn list_local_branches_in(workdir: Option<&Path>) -> Result<Vec<String>> {
let cmd = Cmd::new("git").args(&[
"for-each-ref",
"--format=%(refname:short)",
"--sort=refname",
"refs/heads/",
]);
let cmd = match workdir {
Some(path) => cmd.workdir(path),
None => cmd,
};
let output = cmd
.run_and_capture_stdout()
.context("Failed to list local branches")?;
Ok(output
.lines()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect())
}
pub fn delete_branch_in(branch_name: &str, force: bool, git_common_dir: &Path) -> Result<()> {
let mut cmd = Cmd::new("git").workdir(git_common_dir).arg("branch");
if force {
cmd = cmd.arg("-D");
} else {
cmd = cmd.arg("-d");
}
cmd.arg(branch_name)
.run()
.context("Failed to delete branch")?;
Ok(())
}
pub fn get_merge_base(main_branch: &str) -> Result<String> {
get_merge_base_in(None, main_branch)
}
pub fn get_merge_base_in(workdir: Option<&Path>, main_branch: &str) -> Result<String> {
if branch_exists_in(main_branch, workdir)? {
return Ok(main_branch.to_string());
}
let remote_main = format!("origin/{}", main_branch);
if branch_exists_in(&remote_main, workdir)? {
Ok(remote_main)
} else {
Ok(main_branch.to_string())
}
}
pub fn get_unmerged_branches(base_branch: &str) -> Result<HashSet<String>> {
get_unmerged_branches_in(None, base_branch)
}
pub fn get_unmerged_branches_in(
workdir: Option<&Path>,
base_branch: &str,
) -> Result<HashSet<String>> {
let no_merged_arg = format!("--no-merged={}", base_branch);
let cmd = Cmd::new("git").args(&[
"for-each-ref",
"--format=%(refname:short)",
&no_merged_arg,
"refs/heads/",
]);
let cmd = match workdir {
Some(path) => cmd.workdir(path),
None => cmd,
};
let result = cmd.run_and_capture_stdout();
match result {
Ok(stdout) => {
let branches: HashSet<String> = stdout.lines().map(String::from).collect();
Ok(branches)
}
Err(e) => {
let err_msg = e.to_string();
if err_msg.contains("malformed object name") || err_msg.contains("unknown commit") {
Ok(HashSet::new())
} else {
Err(e)
}
}
}
}
pub fn get_branch_for_worktree(worktree_path: &Path) -> Result<String> {
Cmd::new("git")
.workdir(worktree_path)
.args(&["branch", "--show-current"])
.run_and_capture_stdout()
}
pub fn get_gone_branches() -> Result<HashSet<String>> {
let output = Cmd::new("git")
.args(&[
"for-each-ref",
"--format=%(refname:short)|%(upstream:track)",
"refs/heads",
])
.run_and_capture_stdout()?;
let mut gone = HashSet::new();
for line in output.lines() {
if let Some((branch, track)) = line.split_once('|')
&& track.trim() == "[gone]"
{
gone.insert(branch.to_string());
}
}
Ok(gone)
}
pub fn unset_branch_upstream(branch_name: &str) -> Result<()> {
if !branch_has_upstream(branch_name)? {
return Ok(());
}
Cmd::new("git")
.args(&["branch", "--unset-upstream", branch_name])
.run()
.context("Failed to unset branch upstream")?;
Ok(())
}
pub(super) fn branch_has_upstream(branch_name: &str) -> Result<bool> {
let has_merge = Cmd::new("git")
.args(&["config", "--get", &format!("branch.{}.merge", branch_name)])
.run_as_check()?;
if has_merge {
return Ok(true);
}
Cmd::new("git")
.args(&["config", "--get", &format!("branch.{}.remote", branch_name)])
.run_as_check()
}
pub fn set_branch_base(branch: &str, base: &str) -> Result<()> {
set_branch_base_in(branch, base, None)
}
pub fn set_branch_base_in(branch: &str, base: &str, workdir: Option<&Path>) -> Result<()> {
let config_key = format!("branch.{}.workmux-base", branch);
let cmd = Cmd::new("git").args(&["config", "--local", &config_key, base]);
let cmd = match workdir {
Some(path) => cmd.workdir(path),
None => cmd,
};
cmd.run().context("Failed to set workmux-base config")?;
Ok(())
}
pub fn get_branch_base(branch: &str) -> Result<String> {
get_branch_base_in(branch, None)
}
pub fn get_branch_base_in(branch: &str, workdir: Option<&Path>) -> Result<String> {
let config_key = format!("branch.{}.workmux-base", branch);
let cmd = Cmd::new("git").args(&["config", "--local", &config_key]);
let cmd = match workdir {
Some(path) => cmd.workdir(path),
None => cmd,
};
let output = cmd
.run_and_capture_stdout()
.context("Failed to get workmux-base config")?;
if output.is_empty() {
return Err(anyhow!("No workmux-base found for branch '{}'", branch));
}
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_fork_branch_spec_valid() {
let spec = parse_fork_branch_spec("someuser:feature-branch").unwrap();
assert_eq!(spec.owner, "someuser");
assert_eq!(spec.branch, "feature-branch");
}
#[test]
fn test_parse_fork_branch_spec_with_slashes() {
let spec = parse_fork_branch_spec("user:feature/some-feature").unwrap();
assert_eq!(spec.owner, "user");
assert_eq!(spec.branch, "feature/some-feature");
}
#[test]
fn test_parse_fork_branch_spec_empty_owner() {
assert!(parse_fork_branch_spec(":branch").is_none());
}
#[test]
fn test_parse_fork_branch_spec_empty_branch() {
assert!(parse_fork_branch_spec("owner:").is_none());
}
#[test]
fn test_parse_fork_branch_spec_no_colon() {
assert!(parse_fork_branch_spec("just-a-branch").is_none());
}
#[test]
fn test_parse_fork_branch_spec_url_https() {
assert!(parse_fork_branch_spec("https://github.com/owner/repo").is_none());
}
#[test]
fn test_parse_fork_branch_spec_url_ssh() {
assert!(parse_fork_branch_spec("git@github.com:owner/repo").is_none());
}
#[test]
fn test_parse_fork_branch_spec_remote_branch_format() {
assert!(parse_fork_branch_spec("origin/feature").is_none());
}
}