use crate::error::{Result, ToriiError};
use git2::{BranchType, Repository, Worktree, WorktreeAddOptions, WorktreePruneOptions};
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone)]
pub enum BranchSpec {
New(String),
Existing(String),
}
#[derive(Debug, Default)]
pub struct AddOpts {
pub explicit_path: Option<PathBuf>,
}
#[derive(Debug, Default)]
pub struct RemoveOpts {
pub force: bool,
pub no_snapshot: bool,
}
pub fn add(repo_path: &Path, spec: BranchSpec, opts: &AddOpts) -> Result<()> {
let repo = Repository::open(repo_path).map_err(ToriiError::Git)?;
let target_path = resolve_target_path(&repo, repo_path, &spec, opts)?;
if target_path.exists() {
return Err(ToriiError::InvalidConfig(format!(
"target path already exists: {}. Pick a different path or remove it first.",
target_path.display()
)));
}
let branch_name = match &spec {
BranchSpec::New(name) => {
let head = repo
.head()
.map_err(|e| ToriiError::InvalidConfig(format!("repo has no HEAD: {e}")))?;
let target_commit = head
.peel_to_commit()
.map_err(|e| ToriiError::InvalidConfig(format!("HEAD is not a commit: {e}")))?;
if repo.find_branch(name, BranchType::Local).is_ok() {
return Err(ToriiError::InvalidConfig(format!(
"branch '{name}' already exists. To check it out in a worktree, drop -b: \
torii worktree add <path> {name}"
)));
}
repo.branch(name, &target_commit, false)
.map_err(ToriiError::Git)?;
name.clone()
}
BranchSpec::Existing(name) => {
repo.find_branch(name, BranchType::Local).map_err(|e| {
ToriiError::InvalidConfig(format!(
"branch '{name}' not found locally: {e}. \
Create it first with: torii branch {name} -c"
))
})?;
name.clone()
}
};
let branch_ref_name = format!("refs/heads/{}", branch_name);
let branch_ref = repo
.find_reference(&branch_ref_name)
.map_err(ToriiError::Git)?;
let mut wt_opts = WorktreeAddOptions::new();
wt_opts.reference(Some(&branch_ref));
let wt_name = derive_worktree_name(&target_path, &branch_name);
repo.worktree(&wt_name, &target_path, Some(&wt_opts))
.map_err(ToriiError::Git)?;
println!(
"🌳 Worktree created\n path: {}\n branch: {}",
target_path.display(),
branch_name
);
let cfg = crate::config::ToriiConfig::load_global().unwrap_or_default();
let inherited = inherit_paths(repo_path, &target_path, &cfg.worktree.inherit_paths);
for line in inherited {
println!(" {line}");
}
println!("\n💡 Enter it with: torii worktree open {}", target_path.display());
Ok(())
}
fn inherit_paths(source_root: &Path, target_root: &Path, paths: &[String]) -> Vec<String> {
let mut out = Vec::new();
let source_abs = match source_root.canonicalize() {
Ok(p) => p,
Err(_) => return out,
};
for entry in paths {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let src = source_abs.join(entry);
let dst = target_root.join(entry);
let meta = match std::fs::symlink_metadata(&src) {
Ok(m) => m,
Err(_) => continue, };
if let Some(parent) = dst.parent() {
let _ = std::fs::create_dir_all(parent);
}
if meta.is_dir() {
#[cfg(unix)]
let res = std::os::unix::fs::symlink(&src, &dst);
#[cfg(not(unix))]
let res = std::os::windows::fs::symlink_dir(&src, &dst);
match res {
Ok(_) => out.push(format!("🔗 symlinked: {} → {}", entry, src.display())),
Err(e) => out.push(format!("⚠ symlink {} failed: {}", entry, e)),
}
} else if meta.is_file() {
match std::fs::copy(&src, &dst) {
Ok(_) => out.push(format!("📄 copied: {}", entry)),
Err(e) => out.push(format!("⚠ copy {} failed: {}", entry, e)),
}
}
}
out
}
fn resolve_target_path(
_repo: &Repository,
repo_path: &Path,
spec: &BranchSpec,
opts: &AddOpts,
) -> Result<PathBuf> {
if let Some(explicit) = &opts.explicit_path {
return Ok(expand_tilde(explicit));
}
let cfg = crate::config::ToriiConfig::load_global().unwrap_or_default();
let base = expand_tilde(Path::new(&cfg.worktree.base_dir));
let base = if base.is_relative() {
repo_path
.canonicalize()
.map_err(|e| ToriiError::InvalidConfig(format!("canonicalize repo: {e}")))?
.join(base)
} else {
base
};
let repo_name = repo_path
.canonicalize()
.ok()
.and_then(|p| {
p.file_name()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
})
.unwrap_or_else(|| "repo".to_string());
let branch = match spec {
BranchSpec::New(n) | BranchSpec::Existing(n) => n,
};
let leaf = format!("{}-{}", repo_name, sanitize_branch(branch));
Ok(base.join(leaf))
}
fn expand_tilde(p: &Path) -> PathBuf {
let s = p.to_string_lossy();
if let Some(stripped) = s.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(stripped);
}
}
if s == "~" {
if let Some(home) = dirs::home_dir() {
return home;
}
}
p.to_path_buf()
}
pub fn sanitize_branch(branch: &str) -> String {
branch.replace('/', "-")
}
fn derive_worktree_name(path: &Path, branch: &str) -> String {
path.file_name()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| sanitize_branch(branch))
}
pub fn list(repo_path: &Path) -> Result<()> {
let repo = Repository::open(repo_path).map_err(ToriiError::Git)?;
let here = repo_path.canonicalize().unwrap_or_else(|_| repo_path.to_path_buf());
println!("🌳 Worktrees:\n");
let main_path = repo
.workdir()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| repo_path.to_path_buf());
let main_path = main_path.canonicalize().unwrap_or(main_path);
print_worktree_row(&main_path, "(main)", &here, None)?;
let names = repo.worktrees().map_err(ToriiError::Git)?;
for i in 0..names.len() {
let name = match names.get(i) {
Some(n) => n,
None => continue,
};
let wt = match repo.find_worktree(name) {
Ok(w) => w,
Err(_) => continue,
};
let wt_path = wt.path().canonicalize().unwrap_or_else(|_| wt.path().to_path_buf());
print_worktree_row(&wt_path, name, &here, Some(&wt))?;
}
Ok(())
}
fn print_worktree_row(
path: &Path,
name: &str,
here: &Path,
wt: Option<&Worktree>,
) -> Result<()> {
let is_here = path == here;
let marker = if is_here { "📍" } else { " " };
let (branch, state) = describe_worktree(path).unwrap_or_else(|e| {
("?".to_string(), format!("error: {}", e))
});
let locked = wt
.and_then(|w| w.is_locked().ok())
.and_then(|s| match s {
git2::WorktreeLockStatus::Locked(reason) => {
Some(reason.unwrap_or_else(|| "(no reason)".to_string()))
}
git2::WorktreeLockStatus::Unlocked => None,
});
let suffix = match (is_here, locked) {
(true, Some(r)) => format!("(this one, locked: {r:?})"),
(true, None) => "(this one)".to_string(),
(false, Some(r)) => format!("(locked: {r:?})"),
(false, None) => String::new(),
};
println!(
" {marker} {path}\n name: {name} branch: {branch} {state} {suffix}",
path = path.display(),
);
Ok(())
}
fn describe_worktree(path: &Path) -> Result<(String, String)> {
let repo = Repository::open(path).map_err(ToriiError::Git)?;
let head = repo.head().ok();
let branch = head
.as_ref()
.and_then(|h| h.shorthand().map(|s| s.to_string()))
.unwrap_or_else(|| "(detached)".to_string());
let mut so = git2::StatusOptions::new();
so.include_untracked(true).include_ignored(false);
let statuses = repo.statuses(Some(&mut so)).map_err(ToriiError::Git)?;
let dirty_count = statuses
.iter()
.filter(|s| !s.status().contains(git2::Status::IGNORED))
.count();
let dirty_part = if dirty_count == 0 {
"clean".to_string()
} else {
format!("{} change(s)", dirty_count)
};
let upstream_part = head
.as_ref()
.and_then(|h| h.shorthand())
.and_then(|name| repo.find_branch(name, BranchType::Local).ok())
.and_then(|b| b.upstream().ok())
.and_then(|upstream| {
let local_oid = head.as_ref().and_then(|h| h.target())?;
let up_oid = upstream.into_reference().target()?;
repo.graph_ahead_behind(local_oid, up_oid)
.ok()
.map(|(ahead, behind)| format_ahead_behind(ahead, behind))
})
.flatten();
let state = match upstream_part {
Some(ab) => format!("{dirty_part} · {ab}"),
None => dirty_part,
};
Ok((branch, state))
}
fn format_ahead_behind(ahead: usize, behind: usize) -> Option<String> {
match (ahead, behind) {
(0, 0) => None,
(a, 0) => Some(format!("{a} ahead")),
(0, b) => Some(format!("{b} behind")),
(a, b) => Some(format!("{a} ahead, {b} behind")),
}
}
pub fn remove(repo_path: &Path, target_path: &Path, opts: &RemoveOpts) -> Result<()> {
let repo = Repository::open(repo_path).map_err(ToriiError::Git)?;
let target = target_path
.canonicalize()
.map_err(|e| ToriiError::InvalidConfig(format!("path not found: {}: {}", target_path.display(), e)))?;
let names = repo.worktrees().map_err(ToriiError::Git)?;
let mut wt_match: Option<(String, Worktree)> = None;
for i in 0..names.len() {
let name = match names.get(i) {
Some(n) => n.to_string(),
None => continue,
};
let wt = match repo.find_worktree(&name) {
Ok(w) => w,
Err(_) => continue,
};
let wt_canon = wt
.path()
.canonicalize()
.unwrap_or_else(|_| wt.path().to_path_buf());
if wt_canon == target {
wt_match = Some((name, wt));
break;
}
}
let (wt_name, wt) = wt_match.ok_or_else(|| {
ToriiError::InvalidConfig(format!(
"{} is not a known worktree of this repo. \
Use 'torii worktree list' to see the canonical paths.",
target.display()
))
})?;
let dirty = match Repository::open(&target) {
Ok(wt_repo) => {
let mut so = git2::StatusOptions::new();
so.include_untracked(true);
wt_repo
.statuses(Some(&mut so))
.map(|s| s.iter().any(|x| !x.status().contains(git2::Status::IGNORED)))
.unwrap_or(false)
}
Err(_) => false,
};
if dirty && !opts.force {
return Err(ToriiError::InvalidConfig(format!(
"worktree {} has uncommitted changes. \
Commit/stash there or pass --force to drop them.",
target.display()
)));
}
if !opts.no_snapshot {
match crate::snapshot::SnapshotManager::new(&target) {
Ok(mgr) => match mgr.create_snapshot(Some(&format!("pre-worktree-remove-{wt_name}"))) {
Ok(id) => println!(
"📸 Snapshot: {} (revert with: torii snapshot restore {})",
id, id
),
Err(e) => eprintln!("⚠ Snapshot failed (proceeding anyway): {e}"),
},
Err(e) => eprintln!("⚠ Snapshot setup failed (proceeding anyway): {e}"),
}
}
std::fs::remove_dir_all(&target).map_err(|e| {
ToriiError::InvalidConfig(format!("rm -rf {}: {}", target.display(), e))
})?;
let mut prune_opts = WorktreePruneOptions::new();
prune_opts.valid(true).working_tree(true);
wt.prune(Some(&mut prune_opts)).map_err(ToriiError::Git)?;
println!("🗑 Worktree '{}' removed from {}", wt_name, target.display());
Ok(())
}
pub fn prune(repo_path: &Path) -> Result<()> {
let repo = Repository::open(repo_path).map_err(ToriiError::Git)?;
let names = repo.worktrees().map_err(ToriiError::Git)?;
let mut pruned = 0;
for i in 0..names.len() {
let name = match names.get(i) {
Some(n) => n,
None => continue,
};
let wt = match repo.find_worktree(name) {
Ok(w) => w,
Err(_) => continue,
};
if wt.is_prunable(None).unwrap_or(false) {
wt.prune(None).map_err(ToriiError::Git)?;
println!("🧹 Pruned: {}", name);
pruned += 1;
}
}
if pruned == 0 {
println!("✨ Nothing to prune.");
} else {
println!("\n✅ Pruned {} stale worktree entries.", pruned);
}
Ok(())
}
pub fn lock(repo_path: &Path, target: &Path, reason: Option<&str>) -> Result<()> {
let (name, wt) = find_worktree_by_path(repo_path, target)?;
match wt.is_locked().map_err(ToriiError::Git)? {
git2::WorktreeLockStatus::Locked(_) => {
return Err(ToriiError::InvalidConfig(format!(
"worktree '{name}' is already locked"
)));
}
git2::WorktreeLockStatus::Unlocked => {}
}
wt.lock(reason).map_err(ToriiError::Git)?;
let suffix = reason
.map(|r| format!(" ({r})"))
.unwrap_or_default();
println!("🔒 Locked worktree '{name}'{suffix}");
Ok(())
}
pub fn unlock(repo_path: &Path, target: &Path) -> Result<()> {
let (name, wt) = find_worktree_by_path(repo_path, target)?;
match wt.is_locked().map_err(ToriiError::Git)? {
git2::WorktreeLockStatus::Unlocked => {
return Err(ToriiError::InvalidConfig(format!(
"worktree '{name}' is not locked"
)));
}
git2::WorktreeLockStatus::Locked(_) => {}
}
wt.unlock().map_err(ToriiError::Git)?;
println!("🔓 Unlocked worktree '{name}'");
Ok(())
}
pub fn move_wt(repo_path: &Path, old: &Path, new: &Path) -> Result<()> {
let (name, _wt) = find_worktree_by_path(repo_path, old)?;
let old_canon = old
.canonicalize()
.map_err(|e| ToriiError::InvalidConfig(format!("{}: {}", old.display(), e)))?;
if new.exists() {
return Err(ToriiError::InvalidConfig(format!(
"target {} already exists",
new.display()
)));
}
if let Some(parent) = new.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
ToriiError::InvalidConfig(format!("mkdir parent {}: {}", parent.display(), e))
})?;
}
if let Err(e) = std::fs::rename(&old_canon, new) {
if e.raw_os_error() == Some(libc_exdev()) {
copy_dir_recursive(&old_canon, new)?;
std::fs::remove_dir_all(&old_canon).map_err(|e| {
ToriiError::InvalidConfig(format!("rm {} after copy: {}", old_canon.display(), e))
})?;
} else {
return Err(ToriiError::InvalidConfig(format!(
"rename {} -> {}: {}",
old_canon.display(),
new.display(),
e
)));
}
}
let new_canon = new
.canonicalize()
.map_err(|e| ToriiError::InvalidConfig(format!("canonicalize {}: {}", new.display(), e)))?;
let repo = Repository::open(repo_path).map_err(ToriiError::Git)?;
let admin = repo.path().join("worktrees").join(&name).join("gitdir");
if admin.exists() {
let new_git_file = new_canon.join(".git");
std::fs::write(&admin, format!("{}\n", new_git_file.display())).map_err(|e| {
ToriiError::InvalidConfig(format!("write {}: {}", admin.display(), e))
})?;
}
println!(
"📦 Moved worktree '{}'\n {} → {}",
name,
old_canon.display(),
new_canon.display()
);
Ok(())
}
fn libc_exdev() -> i32 {
18
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
std::fs::create_dir_all(dst).map_err(|e| {
ToriiError::InvalidConfig(format!("mkdir {}: {}", dst.display(), e))
})?;
for entry in std::fs::read_dir(src).map_err(|e| {
ToriiError::InvalidConfig(format!("read {}: {}", src.display(), e))
})? {
let entry = entry.map_err(|e| {
ToriiError::InvalidConfig(format!("read entry: {}", e))
})?;
let ty = entry.file_type().map_err(|e| {
ToriiError::InvalidConfig(format!("file_type: {}", e))
})?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if ty.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else if ty.is_symlink() {
#[cfg(unix)]
{
let target = std::fs::read_link(&src_path).map_err(|e| {
ToriiError::InvalidConfig(format!("readlink {}: {}", src_path.display(), e))
})?;
std::os::unix::fs::symlink(&target, &dst_path).map_err(|e| {
ToriiError::InvalidConfig(format!("symlink {}: {}", dst_path.display(), e))
})?;
}
} else {
std::fs::copy(&src_path, &dst_path).map_err(|e| {
ToriiError::InvalidConfig(format!("copy {}: {}", src_path.display(), e))
})?;
}
}
Ok(())
}
pub fn repair(repo_path: &Path) -> Result<()> {
let repo = Repository::open(repo_path).map_err(ToriiError::Git)?;
let names = repo.worktrees().map_err(ToriiError::Git)?;
if names.is_empty() {
println!("🌳 No linked worktrees to inspect.");
return Ok(());
}
let mut healthy = 0;
let mut broken = 0;
for i in 0..names.len() {
let name = match names.get(i) {
Some(n) => n,
None => continue,
};
let wt = match repo.find_worktree(name) {
Ok(w) => w,
Err(_) => continue,
};
match wt.validate() {
Ok(_) => {
println!("✓ {name} ({})", wt.path().display());
healthy += 1;
}
Err(e) => {
println!("✗ {name} ({}) — {e}", wt.path().display());
broken += 1;
}
}
}
println!("\n{healthy} healthy, {broken} broken.");
if broken > 0 {
println!(
"\n💡 Broken entries usually mean the working-tree directory was deleted\n \
or moved outside torii. Use 'torii worktree prune' to drop the dead\n \
metadata, or recreate the working directory at the recorded path."
);
}
Ok(())
}
fn find_worktree_by_path<'a>(repo_path: &Path, target: &Path) -> Result<(String, git2::Worktree)> {
let canon = target
.canonicalize()
.map_err(|e| ToriiError::InvalidConfig(format!("{}: {}", target.display(), e)))?;
let repo = Repository::open(repo_path).map_err(ToriiError::Git)?;
let names = repo.worktrees().map_err(ToriiError::Git)?;
for i in 0..names.len() {
if let Some(name) = names.get(i) {
if let Ok(wt) = repo.find_worktree(name) {
let p = wt
.path()
.canonicalize()
.unwrap_or_else(|_| wt.path().to_path_buf());
if p == canon {
return Ok((name.to_string(), wt));
}
}
}
}
Err(ToriiError::InvalidConfig(format!(
"{} is not a linked worktree of this repo. \
Use 'torii worktree list' to see what's available.",
canon.display()
)))
}
pub fn open(repo_path: &Path, target: &Path) -> Result<()> {
let target_canon = target
.canonicalize()
.map_err(|e| ToriiError::InvalidConfig(format!("{}: {}", target.display(), e)))?;
let repo = Repository::open(repo_path).map_err(ToriiError::Git)?;
let main = repo
.workdir()
.map(|p| p.canonicalize().unwrap_or_else(|_| p.to_path_buf()));
let mut is_known = main.as_ref().map(|m| *m == target_canon).unwrap_or(false);
if !is_known {
let names = repo.worktrees().map_err(ToriiError::Git)?;
for i in 0..names.len() {
if let Some(name) = names.get(i) {
if let Ok(wt) = repo.find_worktree(name) {
let p = wt
.path()
.canonicalize()
.unwrap_or_else(|_| wt.path().to_path_buf());
if p == target_canon {
is_known = true;
break;
}
}
}
}
}
if !is_known {
return Err(ToriiError::InvalidConfig(format!(
"{} is not a worktree of this repo. \
Use 'torii worktree list' to see what's available.",
target_canon.display()
)));
}
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
println!(
"🚪 Entering {} ({}). Type 'exit' to return.",
target_canon.display(),
shell
);
let status = Command::new(&shell)
.current_dir(&target_canon)
.status()
.map_err(|e| ToriiError::InvalidConfig(format!("spawn {shell}: {e}")))?;
if !status.success() {
return Err(ToriiError::InvalidConfig(format!(
"shell exited with status {status}"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_branch_replaces_slashes() {
assert_eq!(sanitize_branch("feature/auth"), "feature-auth");
assert_eq!(sanitize_branch("release/v1/hotfix"), "release-v1-hotfix");
assert_eq!(sanitize_branch("main"), "main");
assert_eq!(sanitize_branch(""), "");
}
#[test]
fn expand_tilde_home() {
let home = dirs::home_dir().expect("HOME must be set in tests");
assert_eq!(expand_tilde(Path::new("~/foo")), home.join("foo"));
assert_eq!(expand_tilde(Path::new("~")), home);
assert_eq!(expand_tilde(Path::new("/abs/path")), PathBuf::from("/abs/path"));
assert_eq!(expand_tilde(Path::new("rel/path")), PathBuf::from("rel/path"));
}
#[test]
fn derive_name_uses_leaf() {
let p = Path::new("/tmp/foo/bar-feat");
assert_eq!(derive_worktree_name(p, "ignored"), "bar-feat");
}
#[test]
fn derive_name_falls_back_to_sanitized_branch() {
assert_eq!(
derive_worktree_name(Path::new("/"), "feature/auth"),
"feature-auth"
);
}
}