use crate::config::Config;
use crate::engine::BranchMetadata;
use crate::git::GitRepo;
use crate::remote;
use anyhow::{bail, Result};
use colored::Colorize;
use console::Term;
use dialoguer::{theme::ColorfulTheme, Input, Select};
use std::path::Path;
use std::process::Command;
pub fn run(
name: Option<String>,
message: Option<String>,
from: Option<String>,
prefix: Option<String>,
all: bool,
) -> Result<()> {
let repo = GitRepo::open()?;
let config = Config::load()?;
let current = repo.current_branch()?;
let parent_branch = from.unwrap_or_else(|| current.clone());
let generated_from_message = name.is_none() && message.is_some();
if repo.branch_commit(&parent_branch).is_err() {
anyhow::bail!("Branch '{}' does not exist", parent_branch);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StageMode {
None,
ExistingOnly,
All,
}
let (input, commit_message, stage_mode) = match (&name, &message) {
(Some(n), _) => (
n.clone(),
None,
if all { StageMode::All } else { StageMode::None },
),
(None, Some(m)) => (
m.clone(),
Some(m.clone()),
if all {
StageMode::All
} else {
StageMode::ExistingOnly
},
),
(None, None) => {
if !Term::stderr().is_term() {
bail!(
"Branch name required. Use: stax create <name> or stax create -m \"message\""
);
}
let (wizard_name, wizard_msg, wizard_stage_all) =
run_wizard(repo.workdir()?, &parent_branch)?;
(
wizard_name,
wizard_msg,
if wizard_stage_all {
StageMode::All
} else {
StageMode::None
},
)
}
};
let branch_name = match prefix.as_deref() {
Some(_) => config.format_branch_name_with_prefix_override(&input, prefix.as_deref()),
None => config.format_branch_name(&input),
};
let existing_branches = repo.list_branches()?;
let branch_name =
resolve_branch_name_conflicts(&branch_name, &existing_branches, generated_from_message)?;
if parent_branch == current {
repo.create_branch(&branch_name)?;
} else {
repo.create_branch_at(&branch_name, &parent_branch)?;
}
let parent_rev = repo.branch_commit(&parent_branch)?;
let meta = BranchMetadata::new(&parent_branch, &parent_rev);
meta.write(repo.inner(), &branch_name)?;
repo.checkout(&branch_name)?;
if let Ok(remote_branches) = remote::get_remote_branches(repo.workdir()?, config.remote_name())
{
if !remote_branches.contains(&parent_branch) {
println!(
"{}",
format!(
"Warning: parent '{}' is not on remote '{}'.",
parent_branch,
config.remote_name()
)
.yellow()
);
}
}
println!(
"Created and switched to branch '{}' (stacked on {})",
branch_name.green(),
parent_branch.blue()
);
if stage_mode != StageMode::None {
let workdir = repo.workdir()?;
if stage_mode == StageMode::All {
let add_status = Command::new("git")
.args(["add", "-A"])
.current_dir(workdir)
.status()?;
if !add_status.success() {
bail!("Failed to stage changes");
}
}
if let Some(msg) = commit_message {
let diff_output = Command::new("git")
.args(["diff", "--cached", "--quiet"])
.current_dir(workdir)
.status()?;
if !diff_output.success() {
let commit_status = Command::new("git")
.args(["commit", "-m", &msg])
.current_dir(workdir)
.status()?;
if !commit_status.success() {
bail!("Failed to commit changes");
}
println!("Committed: {}", msg.cyan());
} else {
println!("{}", "No changes to commit".dimmed());
}
} else if stage_mode == StageMode::All {
println!("{}", "Changes staged".dimmed());
}
}
Ok(())
}
#[derive(Clone, Copy)]
enum BranchNameConflict<'a> {
Exact(&'a str),
ExistingIsAncestor(&'a str),
ExistingIsDescendant(&'a str),
}
fn resolve_branch_name_conflicts(
branch_name: &str,
existing_branches: &[String],
generated_from_message: bool,
) -> Result<String> {
match detect_branch_name_conflict(branch_name, existing_branches) {
None => Ok(branch_name.to_string()),
Some(BranchNameConflict::Exact(_) | BranchNameConflict::ExistingIsDescendant(_))
if generated_from_message =>
{
for suffix in 2..1000 {
let candidate = append_branch_suffix(branch_name, suffix);
if detect_branch_name_conflict(&candidate, existing_branches).is_none() {
return Ok(candidate);
}
}
bail!(
"Cannot create a unique branch name from '{}'. Too many similarly named branches already exist.",
branch_name
);
}
Some(conflict) => bail!("{}", branch_name_conflict_message(branch_name, conflict)),
}
}
fn detect_branch_name_conflict<'a>(
branch_name: &str,
existing_branches: &'a [String],
) -> Option<BranchNameConflict<'a>> {
for existing in existing_branches {
if branch_name == existing {
return Some(BranchNameConflict::Exact(existing));
}
if branch_name.starts_with(&format!("{}/", existing)) {
return Some(BranchNameConflict::ExistingIsAncestor(existing));
}
if existing.starts_with(&format!("{}/", branch_name)) {
return Some(BranchNameConflict::ExistingIsDescendant(existing));
}
}
None
}
fn branch_name_conflict_message(branch_name: &str, conflict: BranchNameConflict<'_>) -> String {
match conflict {
BranchNameConflict::Exact(existing) => format!(
"Cannot create '{}': branch '{}' already exists.\n\
Use `st checkout {}` or choose a different name.",
branch_name, existing, existing
),
BranchNameConflict::ExistingIsAncestor(existing) => format!(
"Cannot create '{}': branch '{}' already exists.\n\
Git doesn't allow a branch and its sub-path to coexist.\n\
Either delete '{}' first, or use a different name like '{}-ui'.",
branch_name, existing, existing, existing
),
BranchNameConflict::ExistingIsDescendant(existing) => format!(
"Cannot create '{}': branch '{}' already exists.\n\
Git doesn't allow a branch and its sub-path to coexist.\n\
Either delete '{}' first, or use a different name.",
branch_name, existing, existing
),
}
}
fn append_branch_suffix(branch_name: &str, suffix: usize) -> String {
match branch_name.rsplit_once('/') {
Some((prefix, leaf)) => format!("{}/{}-{}", prefix, leaf, suffix),
None => format!("{}-{}", branch_name, suffix),
}
}
fn run_wizard(workdir: &Path, parent_branch: &str) -> Result<(String, Option<String>, bool)> {
println!();
println!("╭─ Create Stacked Branch ─────────────────────────────╮");
println!(
"│ Parent: {:<43} │",
format!("{} (current branch)", parent_branch.cyan())
);
println!("╰─────────────────────────────────────────────────────╯");
println!();
let name: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Branch name")
.interact_text()?;
if name.trim().is_empty() {
bail!("Branch name cannot be empty");
}
let has_changes = has_uncommitted_changes(workdir);
let change_count = count_uncommitted_changes(workdir);
let (should_stage, commit_message) = if has_changes {
println!();
let stage_label = if change_count > 0 {
format!("Stage all changes ({} files modified)", change_count)
} else {
"Stage all changes".to_string()
};
let options = vec![stage_label.as_str(), "Empty branch (no changes)"];
let choice = Select::with_theme(&ColorfulTheme::default())
.with_prompt("What to include")
.items(&options)
.default(0)
.interact()?;
let stage = choice == 0;
let msg = if stage {
println!();
let m: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Commit message (Enter to skip)")
.allow_empty(true)
.interact_text()?;
if m.is_empty() {
None
} else {
Some(m)
}
} else {
None
};
(stage, msg)
} else {
(false, None)
};
println!();
Ok((name, commit_message, should_stage))
}
fn has_uncommitted_changes(workdir: &Path) -> bool {
Command::new("git")
.args(["status", "--porcelain"])
.current_dir(workdir)
.output()
.map(|o| !o.stdout.is_empty())
.unwrap_or(false)
}
fn count_uncommitted_changes(workdir: &Path) -> usize {
Command::new("git")
.args(["status", "--porcelain"])
.current_dir(workdir)
.output()
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.lines()
.filter(|l| !l.is_empty())
.count()
})
.unwrap_or(0)
}