use crate::engine::config::{load_config, BranchNameConfig};
use crate::engine::error::GitError;
use crate::engine::git::{
current_branch, get_default_branch, has_commits_beyond, is_ancestor, is_clean_ignoring_scratch,
is_squash_merged, rev_parse, sync_main, worktree_add, worktree_move, WorktreeBranch,
};
use crate::engine::naming::{format_branch_name, generate_timestamp, wave_name_from_branch};
use crate::lfd::security::sanitize_fs_component;
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::thread;
use std::time::Duration;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeSegment(String);
impl WorktreeSegment {
pub fn parse(raw: &str) -> Result<Self, PlacementError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(PlacementError::EmptySegment);
}
if trimmed.contains('.') {
return Err(PlacementError::DotsReserved(trimmed.to_string()));
}
Ok(Self(trimmed.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StackBranch {
pub name: String,
pub segments: Vec<WorktreeSegment>,
}
impl StackBranch {
pub fn parse(branch: &str, default_branch: &str) -> Option<Self> {
if branch == default_branch || branch.trim().is_empty() {
return None;
}
let segments = branch
.split('.')
.map(WorktreeSegment::parse)
.collect::<Result<Vec<_>, _>>()
.ok()?;
if segments.is_empty() {
return None;
}
Some(Self {
name: branch.to_string(),
segments,
})
}
pub fn parent(&self) -> Option<String> {
if self.segments.len() < 2 {
return None;
}
Some(
self.segments[..self.segments.len() - 1]
.iter()
.map(WorktreeSegment::as_str)
.collect::<Vec<_>>()
.join("."),
)
}
pub fn child(&self, segment: &WorktreeSegment) -> String {
format!("{}.{}", self.name, segment.as_str())
}
pub fn depth(&self) -> usize {
self.segments.len()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlacementRequest {
Default {
segment: WorktreeSegment,
},
Main {
segment: WorktreeSegment,
},
Stack {
parent: String,
segment: WorktreeSegment,
},
Fork {
segment: WorktreeSegment,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PlacementStrategy {
CreateRoot,
CreateStackChild,
CheckoutExisting,
UseExistingWorktree,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PlacementPlan {
pub base_ref: String,
pub parent_branch: Option<String>,
pub branch: String,
pub worktree_path: PathBuf,
pub stack_depth: usize,
pub strategy: PlacementStrategy,
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum PlacementError {
#[error("worktree segment cannot be empty")]
EmptySegment,
#[error(
"\"{0}\" is not a worktree segment. Dots are reserved for stack ancestry. Use a hyphen, or create ancestry with --stack."
)]
DotsReserved(String),
}
#[derive(Debug, Clone, Serialize)]
pub struct WorktreeState {
pub branch: Option<String>,
pub path: PathBuf,
pub base_branch: Option<String>,
pub merged: bool,
pub squash_merged: bool,
pub prunable: bool,
pub fresh: bool,
pub dirty: bool,
pub remote_gone: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct CreateWorktreeResult {
pub path: PathBuf,
pub branch: String,
pub base_branch: Option<String>,
pub base_commit: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeLease {
pub path: PathBuf,
pub branch: String,
}
pub fn main_repo_root(repo: &Path) -> Result<PathBuf, GitError> {
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
.output()?;
if !output.status.success() {
return Err(GitError::CommandFailed {
command: "git rev-parse --git-common-dir".to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
});
}
let common_dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
let common_path = PathBuf::from(common_dir);
let repo_root =
common_path
.parent()
.map(PathBuf::from)
.ok_or_else(|| GitError::CommandFailed {
command: "git rev-parse --git-common-dir".to_string(),
stderr: "unable to resolve common dir parent".to_string(),
})?;
Ok(repo_root)
}
pub fn worktree_path(repo: &Path, name: &str) -> PathBuf {
worktree_path_with_config(repo, name, None)
}
pub fn ensure_wave_worktree(main_repo: &Path, wave_name: &str) -> anyhow::Result<WorktreeLease> {
let path = worktree_path(main_repo, wave_name);
if path.exists() && path.join(".git").exists() {
let branch = current_branch(&path)?.unwrap_or_default();
return Ok(WorktreeLease { path, branch });
}
let config = load_config(Some(main_repo)).ok().flatten();
let branch_config = config.as_ref().and_then(|c| c.branch_names.as_ref());
let result = create_with_schema_synced(main_repo, wave_name, None, branch_config)?;
Ok(WorktreeLease {
path: result.path,
branch: result.branch,
})
}
pub fn short_run_id(run_id: &str) -> String {
let hex: String = run_id
.chars()
.filter(|ch| ch.is_ascii_hexdigit())
.take(8)
.collect();
if hex.len() == 8 {
hex
} else {
short_hash(run_id, 8)
}
}
pub fn run_worktree_path(main_repo: &Path, wave_name: &str, run_id: &str) -> PathBuf {
let base_wt = worktree_path(main_repo, wave_name);
let base_name = base_wt
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("wave");
base_wt.with_file_name(format!("{base_name}.{}", short_run_id(run_id)))
}
fn short_hash(value: &str, chars: usize) -> String {
let digest = Sha256::digest(value.as_bytes());
let mut hash = hex::encode(digest);
hash.truncate(chars);
hash
}
pub fn worktree_path_with_config(
repo: &Path,
name: &str,
branch_config: Option<&BranchNameConfig>,
) -> PathBuf {
let repo_root = main_repo_root(repo).unwrap_or_else(|_| repo.to_path_buf());
let dir_name =
wave_name_from_branch(name, branch_config).unwrap_or_else(|| sanitize_fs_component(name));
let repo_name = repo_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("repo");
repo_root
.parent()
.unwrap_or(repo_root.as_path())
.join(format!("{repo_name}.{dir_name}"))
}
pub fn wave_name_from_worktree(repo: &Path) -> Option<String> {
let main_repo = main_repo_root(repo).ok()?;
wave_name_from_worktree_and_main(repo, &main_repo)
}
pub fn wave_name_from_worktree_and_main(repo: &Path, main_repo: &Path) -> Option<String> {
if repo == main_repo {
return None;
}
let repo_parent = repo.parent()?.canonicalize().ok()?;
let main_parent = main_repo.parent()?.canonicalize().ok()?;
if repo_parent != main_parent {
return None;
}
let main_name = main_repo.file_name()?.to_str()?;
let dir_name = repo.file_name()?.to_str()?;
let prefix = format!("{main_name}.");
let short_name = dir_name.strip_prefix(&prefix)?;
(!short_name.is_empty()).then(|| short_name.to_string())
}
pub fn branch_exists(repo: &Path, branch: &str) -> Result<bool, GitError> {
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(["show-ref", "--verify", &format!("refs/heads/{branch}")])
.output()?;
Ok(output.status.success())
}
pub(crate) fn list_porcelain(repo: &Path) -> Result<Vec<(PathBuf, Option<String>)>, GitError> {
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(["worktree", "list", "--porcelain"])
.output()?;
if !output.status.success() {
return Err(GitError::CommandFailed {
command: "git worktree list --porcelain".to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut items = Vec::new();
let mut current_path: Option<PathBuf> = None;
let mut current_branch: Option<String> = None;
for line in stdout.lines() {
if let Some(path) = line.strip_prefix("worktree ") {
if let Some(path) = current_path.take() {
items.push((path, current_branch.take()));
}
current_path = Some(PathBuf::from(path.trim()));
current_branch = None;
} else if let Some(branch) = line.strip_prefix("branch ") {
let branch = branch.trim().strip_prefix("refs/heads/").unwrap_or(branch);
current_branch = Some(branch.to_string());
} else if line.trim() == "detached" {
current_branch = None;
}
}
if let Some(path) = current_path.take() {
items.push((path, current_branch.take()));
}
Ok(items)
}
fn upstream_branch(worktree: &Path) -> Option<String> {
let output = Command::new("git")
.arg("-C")
.arg(worktree)
.args([
"rev-parse",
"--abbrev-ref",
"--symbolic-full-name",
"@{upstream}",
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
if raw.is_empty() {
return None;
}
let branch = raw.split('/').next_back().unwrap_or(&raw).to_string();
Some(branch)
}
fn github_repo_nwo(repo: &Path) -> Option<(String, String)> {
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(["config", "--get", "remote.origin.url"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
let path = url
.strip_prefix("git@github.com:")
.or_else(|| url.strip_prefix("https://github.com/"))?;
let path = path.strip_suffix(".git").unwrap_or(path);
let (owner, name) = path.split_once('/')?;
Some((owner.to_string(), name.to_string()))
}
fn merged_pr_branches(repo: &Path, branches: &[String]) -> HashSet<String> {
if branches.is_empty() {
return HashSet::new();
}
let (owner, name) = match github_repo_nwo(repo) {
Some(nwo) => nwo,
None => return HashSet::new(),
};
let mut fields = String::new();
for (i, branch) in branches.iter().enumerate() {
let escaped = branch.replace('\\', "\\\\").replace('"', "\\\"");
fields.push_str(&format!(
"b{i}: pullRequests(first: 1, headRefName: \"{escaped}\", states: MERGED) {{ nodes {{ headRefName }} }}\n"
));
}
let query =
format!("query {{ repository(owner: \"{owner}\", name: \"{name}\") {{ {fields} }} }}");
let output = Command::new("gh")
.current_dir(repo)
.args(["api", "graphql", "-f", &format!("query={query}")])
.output();
let stdout = match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
_ => return HashSet::new(),
};
let mut result = HashSet::new();
for branch in branches {
if stdout.contains(&format!("\"headRefName\":\"{branch}\"")) {
result.insert(branch.clone());
}
}
result
}
fn list_remote_branches(repo: &Path) -> HashSet<String> {
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(["ls-remote", "--heads", "origin"])
.output();
match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.filter_map(|line| {
line.split('\t')
.nth(1)?
.strip_prefix("refs/heads/")
.map(|b| b.to_string())
})
.collect(),
_ => HashSet::new(),
}
}
pub fn list_worktrees_local(repo: &Path) -> Result<(String, Vec<WorktreeState>), GitError> {
let default_branch = get_default_branch(repo)?;
let merge_target = format!("origin/{default_branch}");
let items = list_porcelain(repo)?;
let branches_to_check: Vec<String> = items
.iter()
.filter_map(|(_, branch)| branch.as_ref())
.filter(|b| *b != &default_branch)
.cloned()
.collect();
let repo_for_squash = repo.to_path_buf();
let target_for_squash = merge_target.clone();
let squash_handle = thread::spawn(move || {
let handles: Vec<_> = branches_to_check
.into_iter()
.map(|branch| {
let r = repo_for_squash.clone();
let t = target_for_squash.clone();
thread::spawn(move || {
if is_squash_merged(&r, &branch, &t).unwrap_or(false) {
Some(branch)
} else {
None
}
})
})
.collect();
handles
.into_iter()
.filter_map(|h| h.join().ok().flatten())
.collect::<HashSet<String>>()
});
let squash_merged = squash_handle.join().unwrap_or_default();
let mut results = Vec::new();
for (path, branch) in items {
let base = upstream_branch(&path);
let base_branch = base.filter(|b| b != &default_branch);
let is_default = branch.as_deref() == Some(&default_branch);
let has_commits = if is_default {
true
} else {
branch
.as_deref()
.map(|b| has_commits_beyond(repo, b, &merge_target).unwrap_or(true))
.unwrap_or(false)
};
let merged = branch.as_deref().is_some_and(|b| {
!is_default && has_commits && is_ancestor(repo, b, &merge_target).unwrap_or(false)
});
let squash_merged_flag = branch
.as_deref()
.is_some_and(|b| !is_default && has_commits && squash_merged.contains(b));
let dirty = !is_clean_ignoring_scratch(&path).unwrap_or(true);
let fresh = !is_default && !merged && (!has_commits || squash_merged_flag);
let prunable = !is_default && (merged || (squash_merged_flag && !fresh));
results.push(WorktreeState {
branch,
path,
base_branch,
merged,
squash_merged: squash_merged_flag,
prunable,
fresh,
dirty,
remote_gone: false,
});
}
Ok((default_branch, results))
}
pub fn enrich_worktrees_network(repo: &Path, default_branch: &str, states: &mut [WorktreeState]) {
let branches: Vec<String> = states
.iter()
.filter_map(|wt| wt.branch.as_ref())
.filter(|b| b.as_str() != default_branch)
.cloned()
.collect();
if branches.is_empty() {
return;
}
let repo_for_pr = repo.to_path_buf();
let pr_branches = branches;
let pr_handle = thread::spawn(move || merged_pr_branches(&repo_for_pr, &pr_branches));
let repo_for_remote = repo.to_path_buf();
let remote_handle = thread::spawn(move || list_remote_branches(&repo_for_remote));
let pr_merged = pr_handle.join().unwrap_or_default();
let remote_branches = remote_handle.join().unwrap_or_default();
apply_network_enrichment(states, default_branch, &pr_merged, &remote_branches);
}
fn apply_network_enrichment(
states: &mut [WorktreeState],
default_branch: &str,
pr_merged: &HashSet<String>,
remote_branches: &HashSet<String>,
) {
for state in states.iter_mut() {
let is_default = state.branch.as_deref() == Some(default_branch);
if is_default {
continue;
}
if !state.merged
&& state
.branch
.as_deref()
.is_some_and(|b| pr_merged.contains(b))
{
state.merged = true;
}
if !remote_branches.is_empty() {
state.remote_gone = state
.branch
.as_deref()
.is_some_and(|b| !remote_branches.contains(b));
}
if !state.prunable && state.merged {
state.prunable = true;
state.fresh = false;
}
if !state.prunable && state.squash_merged && !state.fresh {
state.prunable = true;
}
if !state.prunable && !state.fresh && state.remote_gone && !state.dirty {
state.prunable = true;
}
}
}
pub fn list_worktrees(repo: &Path) -> Result<Vec<WorktreeState>, GitError> {
let (default_branch, mut states) = list_worktrees_local(repo)?;
enrich_worktrees_network(repo, &default_branch, &mut states);
Ok(states)
}
pub fn create_with_schema(
repo: &Path,
short_name: &str,
base: Option<&str>,
branch_config: Option<&BranchNameConfig>,
) -> Result<CreateWorktreeResult, GitError> {
create_with_schema_internal(repo, short_name, base, branch_config, false)
}
pub fn create_with_schema_synced(
repo: &Path,
short_name: &str,
base: Option<&str>,
branch_config: Option<&BranchNameConfig>,
) -> Result<CreateWorktreeResult, GitError> {
create_with_schema_internal(repo, short_name, base, branch_config, true)
}
pub fn plan_placement(
repo: &Path,
current_branch: Option<&str>,
request: PlacementRequest,
branch_config: Option<&BranchNameConfig>,
) -> Result<PlacementPlan, GitError> {
let default_branch = get_default_branch(repo)?;
let (branch, base_ref, parent_branch, root_segment, stack_depth) = match request {
PlacementRequest::Default { segment } => {
if let Some(current) = current_branch {
if current != default_branch {
let parent = StackBranch::parse(current, &default_branch);
let branch = parent
.as_ref()
.map(|stack| stack.child(&segment))
.unwrap_or_else(|| format!("{current}.{}", segment.as_str()));
let depth = parent.map(|stack| stack.depth() + 1).unwrap_or(2);
(
branch,
current.to_string(),
Some(current.to_string()),
segment,
depth,
)
} else {
let branch = planned_root_branch(repo, segment.as_str(), branch_config)?;
(branch, default_branch.clone(), None, segment, 1)
}
} else {
let branch = planned_root_branch(repo, segment.as_str(), branch_config)?;
(branch, default_branch.clone(), None, segment, 1)
}
}
PlacementRequest::Main { segment } | PlacementRequest::Fork { segment } => {
let branch = planned_root_branch(repo, segment.as_str(), branch_config)?;
(branch, default_branch.clone(), None, segment, 1)
}
PlacementRequest::Stack { parent, segment } => {
let parent_stack = StackBranch::parse(&parent, &default_branch);
let base_ref = if branch_exists(repo, &parent)? {
parent.clone()
} else {
let remote_parent = format!("origin/{parent}");
if rev_parse(repo, &remote_parent).is_ok() {
remote_parent
} else {
parent.clone()
}
};
let branch = parent_stack
.as_ref()
.map(|stack| stack.child(&segment))
.unwrap_or_else(|| format!("{parent}.{}", segment.as_str()));
let depth = parent_stack.map(|stack| stack.depth() + 1).unwrap_or(2);
(branch, base_ref, Some(parent), segment, depth)
}
};
let planned_path = if parent_branch.is_some() {
stack_worktree_path(repo, &branch)
} else {
worktree_path_with_config(repo, root_segment.as_str(), branch_config)
};
let existing_worktree_path =
list_porcelain(repo)?
.into_iter()
.find_map(|(path, existing_branch)| {
(existing_branch.as_deref() == Some(&branch)).then_some(path)
});
let strategy = if existing_worktree_path.is_some() {
PlacementStrategy::UseExistingWorktree
} else if branch_exists(repo, &branch)? || rev_parse(repo, &format!("origin/{branch}")).is_ok()
{
PlacementStrategy::CheckoutExisting
} else if parent_branch.is_some() {
PlacementStrategy::CreateStackChild
} else {
PlacementStrategy::CreateRoot
};
Ok(PlacementPlan {
base_ref,
parent_branch,
branch,
worktree_path: existing_worktree_path.unwrap_or(planned_path),
stack_depth,
strategy,
})
}
fn planned_root_branch(
repo: &Path,
short_name: &str,
branch_config: Option<&BranchNameConfig>,
) -> Result<String, GitError> {
let remote_branch = format!("origin/{short_name}");
if rev_parse(repo, &remote_branch).is_ok() {
return Ok(short_name.to_string());
}
format_branch_name(short_name, branch_config, repo)
}
fn stack_worktree_path(repo: &Path, branch: &str) -> PathBuf {
let repo_root = main_repo_root(repo).unwrap_or_else(|_| repo.to_path_buf());
let dir_name = branch
.split('.')
.map(sanitize_fs_component)
.collect::<Vec<_>>()
.join(".");
let repo_name = repo_root
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("repo");
repo_root
.parent()
.unwrap_or(repo_root.as_path())
.join(format!("{repo_name}.{dir_name}"))
}
pub fn create_from_placement_plan(
repo: &Path,
plan: &PlacementPlan,
) -> Result<CreateWorktreeResult, GitError> {
match plan.strategy {
PlacementStrategy::UseExistingWorktree => Ok(CreateWorktreeResult {
path: plan.worktree_path.clone(),
branch: plan.branch.clone(),
base_branch: plan.parent_branch.clone(),
base_commit: None,
}),
PlacementStrategy::CheckoutExisting => {
if plan.worktree_path.exists() {
return Err(GitError::CommandFailed {
command: "git worktree add".to_string(),
stderr: format!("worktree path already exists: {:?}", plan.worktree_path),
});
}
let remote_branch = format!("origin/{}", plan.branch);
let mode = if branch_exists(repo, &plan.branch)? {
WorktreeBranch::Existing
} else {
WorktreeBranch::Track {
remote: &remote_branch,
}
};
worktree_add(repo, &plan.worktree_path, &plan.branch, mode)?;
Ok(CreateWorktreeResult {
path: plan.worktree_path.clone(),
branch: plan.branch.clone(),
base_branch: plan.parent_branch.clone(),
base_commit: None,
})
}
PlacementStrategy::CreateRoot | PlacementStrategy::CreateStackChild => {
if plan.worktree_path.exists() {
return Err(GitError::CommandFailed {
command: "git worktree add".to_string(),
stderr: format!("worktree path already exists: {:?}", plan.worktree_path),
});
}
if branch_exists(repo, &plan.branch)? {
return Err(GitError::CommandFailed {
command: "git worktree add".to_string(),
stderr: format!("branch exists without worktree: {}", plan.branch),
});
}
let base_commit = if plan.parent_branch.is_some() {
rev_parse(repo, &plan.base_ref).ok()
} else {
None
};
worktree_add(
repo,
&plan.worktree_path,
&plan.branch,
WorktreeBranch::New {
start_point: &plan.base_ref,
},
)?;
schedule_upstream_sync(plan.worktree_path.clone(), plan.branch.clone());
Ok(CreateWorktreeResult {
path: plan.worktree_path.clone(),
branch: plan.branch.clone(),
base_branch: plan.parent_branch.clone(),
base_commit,
})
}
}
}
fn create_with_schema_internal(
repo: &Path,
short_name: &str,
base: Option<&str>,
branch_config: Option<&BranchNameConfig>,
sync_default_base: bool,
) -> Result<CreateWorktreeResult, GitError> {
if sync_default_base {
if let Ok(default_branch) = get_default_branch(repo) {
let _ = sync_main(repo, &default_branch);
}
}
let remote_branch = format!("origin/{short_name}");
let has_remote_branch = rev_parse(repo, &remote_branch).is_ok();
let branch_name = if has_remote_branch {
short_name.to_string()
} else {
format_branch_name(short_name, branch_config, repo)?
};
let worktree_path = worktree_path_with_config(repo, short_name, branch_config);
if worktree_path.exists() {
return Err(GitError::CommandFailed {
command: "git worktree add".to_string(),
stderr: format!("worktree path already exists: {worktree_path:?}"),
});
}
if list_porcelain(repo)?
.into_iter()
.filter_map(|(_, branch)| branch)
.any(|branch| branch == branch_name)
{
return Err(GitError::CommandFailed {
command: "git worktree add".to_string(),
stderr: format!("branch already exists: {branch_name}"),
});
}
if has_remote_branch {
let mode = if branch_exists(repo, &branch_name)? {
WorktreeBranch::Existing
} else {
WorktreeBranch::Track {
remote: &remote_branch,
}
};
worktree_add(repo, &worktree_path, &branch_name, mode)?;
return Ok(CreateWorktreeResult {
path: worktree_path,
branch: branch_name,
base_branch: None,
base_commit: None,
});
}
if branch_exists(repo, &branch_name)? {
return Err(GitError::CommandFailed {
command: "git worktree add".to_string(),
stderr: format!("branch exists without worktree: {branch_name}"),
});
}
let default_branch = get_default_branch(repo)?;
let base_ref = base.unwrap_or(default_branch.as_str());
let base_branch = base.and_then(|value| {
if value != default_branch {
Some(value.to_string())
} else {
None
}
});
let base_commit = if base_branch.is_some() {
rev_parse(repo, base_ref).ok()
} else {
None
};
worktree_add(
repo,
&worktree_path,
&branch_name,
WorktreeBranch::New {
start_point: base_ref,
},
)?;
schedule_upstream_sync(worktree_path.clone(), branch_name.clone());
Ok(CreateWorktreeResult {
path: worktree_path,
branch: branch_name,
base_branch,
base_commit,
})
}
pub fn schedule_upstream_sync(worktree: PathBuf, branch: String) {
thread::spawn(move || {
for backoff_secs in [0_u64, 2, 5, 15, 30, 60] {
if backoff_secs > 0 {
thread::sleep(Duration::from_secs(backoff_secs));
}
if upstream_branch(&worktree).is_some() {
return;
}
if push_branch_with_upstream(&worktree, &branch).is_ok() {
return;
}
}
});
}
pub fn push_branch_with_upstream(worktree: &Path, branch: &str) -> Result<(), GitError> {
let output = Command::new("git")
.arg("-C")
.arg(worktree)
.env("GIT_TERMINAL_PROMPT", "0")
.env("GCM_INTERACTIVE", "Never")
.args(["push", "-u", "origin", branch])
.output()?;
if !output.status.success() {
return Err(GitError::CommandFailed {
command: format!("git push -u origin {branch}"),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
});
}
Ok(())
}
pub fn preserve_worktree(
repo: &Path,
worktree: &Path,
suffix: Option<&str>,
) -> Result<PathBuf, GitError> {
let ts = suffix
.map(|s| s.to_string())
.unwrap_or_else(generate_timestamp);
let name = worktree
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("worktree");
let new_path = worktree
.parent()
.unwrap_or(worktree)
.join(format!("{name}.{ts}"));
worktree_move(repo, worktree, &new_path)?;
Ok(new_path)
}
#[cfg(test)]
mod tests {
use super::{
apply_network_enrichment, plan_placement, worktree_path, worktree_path_with_config,
PlacementError, PlacementRequest, PlacementStrategy, StackBranch, WorktreeSegment,
WorktreeState,
};
use crate::engine::config::BranchNameConfig;
use std::collections::HashSet;
use std::path::Path;
use std::process::Command;
fn init_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("create temp dir");
let output = Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["init", "-b", "main"])
.output()
.expect("git init");
assert!(output.status.success());
dir
}
fn literal_branch_names() -> BranchNameConfig {
BranchNameConfig {
schema_: "{name}".to_string(),
}
}
#[test]
fn worktree_path_sanitizes_wave_name_for_filesystem_component() {
let path = worktree_path(Path::new("/tmp/repo"), "feature/new*wave");
assert_eq!(path, Path::new("/tmp/repo.feature-new-wave"));
}
#[test]
fn worktree_path_uses_wave_fallback_for_empty_sanitized_component() {
let path = worktree_path(Path::new("/tmp/repo"), "../..");
assert_eq!(path, Path::new("/tmp/repo.wave"));
}
#[test]
fn worktree_path_extracts_wave_name_from_branch_style_input() {
let path = worktree_path_with_config(
Path::new("/tmp/repo"),
"jack-heart.mobile.20260225_1122",
None,
);
assert_eq!(path, Path::new("/tmp/repo.mobile"));
}
#[test]
fn network_enrichment_keeps_squash_fresh_branch_unprunable() {
let mut states = vec![
WorktreeState {
branch: Some("old".to_string()),
path: Path::new("/tmp/repo.old").to_path_buf(),
base_branch: None,
merged: false,
squash_merged: true,
prunable: false,
fresh: true,
dirty: false,
remote_gone: false,
},
WorktreeState {
branch: Some("new".to_string()),
path: Path::new("/tmp/repo.new").to_path_buf(),
base_branch: None,
merged: false,
squash_merged: true,
prunable: false,
fresh: true,
dirty: false,
remote_gone: false,
},
];
let pr_merged = HashSet::from(["old".to_string()]);
let remote_branches = HashSet::from(["old".to_string(), "new".to_string()]);
apply_network_enrichment(&mut states, "main", &pr_merged, &remote_branches);
let old = states
.iter()
.find(|state| state.branch.as_deref() == Some("old"))
.unwrap();
assert!(old.merged, "merged PR branch should be marked merged");
assert!(old.prunable, "merged PR branch should be prunable");
assert!(!old.fresh, "merged PR branch should not stay fresh");
let new = states
.iter()
.find(|state| state.branch.as_deref() == Some("new"))
.unwrap();
assert!(
!new.prunable,
"new squashed-equivalent branch should stay unprunable while fresh"
);
assert!(new.fresh, "new branch should remain fresh");
}
#[test]
fn worktree_segment_rejects_dots() {
let err = WorktreeSegment::parse("api.v2").unwrap_err();
assert_eq!(err, PlacementError::DotsReserved("api.v2".to_string()));
}
#[test]
fn stack_branch_derives_parent_and_child() {
let branch = StackBranch::parse("a.b.c", "main").expect("stack branch");
assert_eq!(branch.parent().as_deref(), Some("a.b"));
assert_eq!(branch.depth(), 3);
let segment = WorktreeSegment::parse("d").unwrap();
assert_eq!(branch.child(&segment), "a.b.c.d");
}
#[test]
fn default_placement_from_stack_creates_child_branch() {
let repo = init_repo();
let segment = WorktreeSegment::parse("child").unwrap();
let config = literal_branch_names();
let plan = plan_placement(
repo.path(),
Some("a.b"),
PlacementRequest::Default { segment },
Some(&config),
)
.expect("plan placement");
assert_eq!(plan.branch, "a.b.child");
assert_eq!(plan.base_ref, "a.b");
assert_eq!(plan.parent_branch.as_deref(), Some("a.b"));
assert_eq!(plan.stack_depth, 3);
assert_eq!(plan.strategy, PlacementStrategy::CreateStackChild);
let file_name = plan
.worktree_path
.file_name()
.and_then(|name| name.to_str())
.expect("worktree path file name");
assert!(file_name.ends_with(".a.b.child"), "{file_name}");
}
#[test]
fn main_placement_from_stack_creates_root_branch() {
let repo = init_repo();
let segment = WorktreeSegment::parse("child").unwrap();
let config = literal_branch_names();
let plan = plan_placement(
repo.path(),
Some("a.b"),
PlacementRequest::Main { segment },
Some(&config),
)
.expect("plan placement");
assert_eq!(plan.branch, "child");
assert_eq!(plan.base_ref, "main");
assert_eq!(plan.parent_branch, None);
assert_eq!(plan.stack_depth, 1);
assert_eq!(plan.strategy, PlacementStrategy::CreateRoot);
}
}