pub mod repo;
use serde::Serialize;
use tokio::process::Command;
#[derive(Debug, Clone, Serialize)]
pub struct WorktreeInfo {
pub path: String,
pub branch: Option<String>,
pub bare: bool,
pub detached: bool,
}
pub async fn is_git_repo(path: &str) -> bool {
let Ok(output) =
Command::new("git").args(["-C", path, "rev-parse", "--is-inside-work-tree"]).output().await
else {
return false;
};
String::from_utf8_lossy(&output.stdout).trim() == "true"
}
pub fn has_gitignore(path: &str) -> bool {
std::path::Path::new(path).join(".gitignore").is_file()
}
pub async fn init_repo(path: &str) -> anyhow::Result<()> {
let init = Command::new("git").args(["-C", path, "init"]).output().await?;
if !init.status.success() {
let stderr = String::from_utf8_lossy(&init.stderr);
anyhow::bail!("git init failed: {}", stderr.trim());
}
let head =
Command::new("git").args(["-C", path, "rev-parse", "--verify", "HEAD"]).output().await?;
if head.status.success() {
return Ok(());
}
for key in ["user.name", "user.email"] {
let probe = Command::new("git").args(["-C", path, "config", key]).output().await?;
let missing = !probe.status.success() || probe.stdout.is_empty();
if missing {
let fallback = if key == "user.name" { "omniterm" } else { "omniterm@localhost" };
let set =
Command::new("git").args(["-C", path, "config", key, fallback]).output().await?;
if !set.status.success() {
let stderr = String::from_utf8_lossy(&set.stderr);
anyhow::bail!("git config {} failed: {}", key, stderr.trim());
}
}
}
let add = Command::new("git").args(["-C", path, "add", "-A"]).output().await?;
if !add.status.success() {
let stderr = String::from_utf8_lossy(&add.stderr);
anyhow::bail!("git add failed: {}", stderr.trim());
}
let commit = Command::new("git")
.args(["-C", path, "commit", "--allow-empty", "-m", "Initial commit"])
.output()
.await?;
if !commit.status.success() {
let stderr = String::from_utf8_lossy(&commit.stderr);
anyhow::bail!("git commit failed: {}", stderr.trim());
}
Ok(())
}
pub async fn discover_worktrees(path: &str) -> anyhow::Result<Vec<WorktreeInfo>> {
let output =
Command::new("git").args(["-C", path, "worktree", "list", "--porcelain"]).output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git worktree list failed: {}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(parse_worktree_list(&stdout))
}
pub async fn current_branch(repo_path: &str) -> anyhow::Result<String> {
let output = Command::new("git")
.args(["-C", repo_path, "rev-parse", "--abbrev-ref", "HEAD"])
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git rev-parse failed: {}", stderr.trim());
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub async fn list_branches(repo_path: &str) -> anyhow::Result<Vec<String>> {
let output = Command::new("git")
.args(["-C", repo_path, "branch", "--format=%(refname:short)"])
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git branch failed: {}", stderr.trim());
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect())
}
pub async fn remove_worktree(repo_path: &str, target_path: &str) -> anyhow::Result<()> {
let output = Command::new("git")
.args(["-C", repo_path, "worktree", "remove", "--force", target_path])
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git worktree remove failed: {}", stderr.trim());
}
Ok(())
}
pub async fn delete_branch(repo_path: &str, branch: &str) -> anyhow::Result<()> {
let output =
Command::new("git").args(["-C", repo_path, "branch", "-D", "--", branch]).output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git branch -D failed: {}", stderr.trim());
}
Ok(())
}
pub async fn add_worktree(
repo_path: &str,
branch: &str,
target_path: &str,
base: Option<&str>,
detach: bool,
) -> anyhow::Result<()> {
let mut args: Vec<&str> = vec!["-C", repo_path, "worktree", "add"];
if detach {
args.push("--detach");
}
args.push("-b");
args.push(branch);
args.push(target_path);
if let Some(base_ref) = base {
args.push(base_ref);
}
let output = Command::new("git").args(&args).output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git worktree add failed: {}", stderr.trim());
}
Ok(())
}
fn parse_worktree_list(raw: &str) -> Vec<WorktreeInfo> {
raw.trim()
.split("\n\n")
.filter(|chunk| !chunk.is_empty())
.filter_map(|chunk| {
let mut info =
WorktreeInfo { path: String::new(), branch: None, bare: false, detached: false };
for line in chunk.lines() {
let mut parts = line.splitn(2, ' ');
let key = parts.next().unwrap_or("");
let value = parts.next().unwrap_or("").trim();
match key {
"worktree" => info.path = value.to_string(),
"branch" => {
info.branch =
Some(value.strip_prefix("refs/heads/").unwrap_or(value).to_string());
}
"bare" => info.bare = true,
"detached" => info.detached = true,
_ => {}
}
}
if info.path.is_empty() { None } else { Some(info) }
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
static TMP_COUNTER: AtomicU32 = AtomicU32::new(0);
fn temp_dir() -> std::path::PathBuf {
let n = TMP_COUNTER.fetch_add(1, Ordering::SeqCst);
let dir =
std::env::temp_dir().join(format!("omniterm-git-test-{}-{}", std::process::id(), n));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[tokio::test]
async fn test_init_repo_creates_head() {
let dir = temp_dir();
init_repo(dir.to_str().unwrap()).await.unwrap();
assert!(is_git_repo(dir.to_str().unwrap()).await);
let output = Command::new("git")
.args(["-C", dir.to_str().unwrap(), "rev-parse", "--verify", "HEAD"])
.output()
.await
.unwrap();
assert!(output.status.success(), "HEAD should exist after init_repo");
std::fs::remove_dir_all(&dir).unwrap();
}
#[tokio::test]
async fn test_init_repo_stages_existing_files() {
let dir = temp_dir();
std::fs::write(dir.join("hello.txt"), "world").unwrap();
init_repo(dir.to_str().unwrap()).await.unwrap();
let output = Command::new("git")
.args(["-C", dir.to_str().unwrap(), "ls-files"])
.output()
.await
.unwrap();
let files = String::from_utf8_lossy(&output.stdout);
assert!(files.contains("hello.txt"), "existing file should be committed");
std::fs::remove_dir_all(&dir).unwrap();
}
#[tokio::test]
async fn test_init_repo_idempotent_on_existing_repo() {
let dir = temp_dir();
init_repo(dir.to_str().unwrap()).await.unwrap();
let first_head = Command::new("git")
.args(["-C", dir.to_str().unwrap(), "rev-parse", "HEAD"])
.output()
.await
.unwrap();
let first = String::from_utf8_lossy(&first_head.stdout).to_string();
init_repo(dir.to_str().unwrap()).await.unwrap();
let second_head = Command::new("git")
.args(["-C", dir.to_str().unwrap(), "rev-parse", "HEAD"])
.output()
.await
.unwrap();
let second = String::from_utf8_lossy(&second_head.stdout).to_string();
assert_eq!(first, second, "init_repo must not create a second commit");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn test_has_gitignore_detects_file() {
let dir = temp_dir();
assert!(!has_gitignore(dir.to_str().unwrap()), "no .gitignore yet");
std::fs::write(dir.join(".gitignore"), "node_modules\n").unwrap();
assert!(has_gitignore(dir.to_str().unwrap()), ".gitignore should be detected");
std::fs::remove_dir_all(&dir).unwrap();
}
#[tokio::test]
async fn test_remove_worktree_keeps_branch_until_delete_branch() {
let dir = temp_dir();
let repo = dir.to_str().unwrap().to_string();
init_repo(&repo).await.unwrap();
let wt_path = dir.with_extension("wt");
let wt = wt_path.to_str().unwrap().to_string();
add_worktree(&repo, "feature-xyz", &wt, None, false).await.unwrap();
assert!(list_branches(&repo).await.unwrap().contains(&"feature-xyz".to_string()));
remove_worktree(&repo, &wt).await.unwrap();
assert!(!wt_path.exists(), "worktree directory should be gone");
assert!(
list_branches(&repo).await.unwrap().contains(&"feature-xyz".to_string()),
"git keeps the branch ref after `worktree remove` — this is the bug's root cause"
);
delete_branch(&repo, "feature-xyz").await.unwrap();
assert!(
!list_branches(&repo).await.unwrap().contains(&"feature-xyz".to_string()),
"delete_branch must remove the branch from the base-branch list"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[tokio::test]
async fn test_discover_worktrees_resolves_branch_for_created_path() {
let dir = temp_dir();
let repo = dir.to_str().unwrap().to_string();
init_repo(&repo).await.unwrap();
let wt_path = dir.with_extension("wt");
let wt = wt_path.to_str().unwrap().to_string();
add_worktree(&repo, "feature-abc", &wt, None, false).await.unwrap();
let list = discover_worktrees(&repo).await.unwrap();
let entry = list
.iter()
.find(|w| w.path == wt)
.expect("created worktree path must match discover_worktrees output verbatim");
assert_eq!(entry.branch.as_deref(), Some("feature-abc"));
std::fs::remove_dir_all(&wt_path).ok();
std::fs::remove_dir_all(&dir).unwrap();
}
#[tokio::test]
async fn test_delete_branch_fails_for_unknown_branch() {
let dir = temp_dir();
let repo = dir.to_str().unwrap().to_string();
init_repo(&repo).await.unwrap();
assert!(
delete_branch(&repo, "no-such-branch").await.is_err(),
"deleting a missing branch must surface an error, not silently succeed"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn test_parse_worktree_list_single() {
let input = "worktree /home/user/repo\nHEAD abc123\nbranch refs/heads/main\n";
let result = parse_worktree_list(input);
assert_eq!(result.len(), 1);
assert_eq!(result[0].path, "/home/user/repo");
assert_eq!(result[0].branch.as_deref(), Some("main"));
assert!(!result[0].bare);
assert!(!result[0].detached);
}
#[test]
fn test_parse_worktree_list_multiple() {
let input = "\
worktree /home/user/repo
HEAD abc123
branch refs/heads/main
worktree /home/user/repo-dev
HEAD def456
branch refs/heads/dev
worktree /home/user/repo-feature
HEAD ghi789
detached
";
let result = parse_worktree_list(input);
assert_eq!(result.len(), 3);
assert_eq!(result[0].branch.as_deref(), Some("main"));
assert_eq!(result[1].branch.as_deref(), Some("dev"));
assert!(result[2].detached);
assert_eq!(result[2].branch, None);
}
}