use crate::engine::config::BranchNameConfig;
use crate::engine::error::GitError;
use crate::engine::git::{
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 std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::thread;
use std::time::Duration;
#[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>,
}
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 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)
}
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, worktree_path, worktree_path_with_config, WorktreeState,
};
use std::collections::HashSet;
use std::path::Path;
#[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");
}
}