use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::error::PawError;
use crate::specs::SpecEntry;
pub fn validate_repo(path: &Path) -> Result<PathBuf, PawError> {
let output = Command::new("git")
.current_dir(path)
.args(["rev-parse", "--show-toplevel"])
.output()
.map_err(|e| PawError::BranchError(format!("failed to run git: {e}")))?;
if !output.status.success() {
return Err(PawError::NotAGitRepo);
}
let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(PathBuf::from(root))
}
pub fn list_branches(repo_root: &Path) -> Result<Vec<String>, PawError> {
let output = Command::new("git")
.current_dir(repo_root)
.args(["branch", "-a", "--format=%(refname:short)"])
.output()
.map_err(|e| PawError::BranchError(format!("failed to run git branch: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PawError::BranchError(format!(
"git branch failed: {stderr}"
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let branches: BTreeSet<String> = stdout
.lines()
.filter(|line| !line.trim().is_empty() && !line.contains("HEAD"))
.map(|line| {
let mut branch_name = line.trim().to_string();
if let Some(stripped) = branch_name.strip_prefix("refs/remotes/") {
branch_name = stripped.to_string();
}
if let Some(stripped) = branch_name.strip_prefix("origin/") {
branch_name = stripped.to_string();
}
branch_name
})
.collect();
let mut unique: Vec<String> = branches.into_iter().collect();
unique.sort();
Ok(unique)
}
pub fn worktree_dir_name(project: &str, branch: &str) -> String {
let project_safe: String = project
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect();
let branch_safe: String = branch
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect();
format!("{project_safe}-{branch_safe}")
}
pub fn default_branch(repo_root: &Path) -> Result<String, PawError> {
let output = Command::new("git")
.current_dir(repo_root)
.args(["symbolic-ref", "refs/remotes/origin/HEAD"])
.output()
.map_err(|e| PawError::BranchError(format!("failed to run git symbolic-ref: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PawError::BranchError(format!(
"git symbolic-ref failed: {stderr}"
)));
}
let ref_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if let Some(branch) = ref_name.strip_prefix("refs/remotes/origin/") {
Ok(branch.to_string())
} else {
Err(PawError::BranchError(format!(
"unexpected ref format: {ref_name}"
)))
}
}
pub fn current_branch(repo_root: &Path) -> Result<String, PawError> {
let output = Command::new("git")
.current_dir(repo_root)
.args(["branch", "--show-current"])
.output()
.map_err(|e| PawError::BranchError(format!("failed to run git branch: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PawError::BranchError(format!(
"git branch failed: {stderr}"
)));
}
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
if branch.is_empty() {
return Err(PawError::BranchError(
"not on any branch (detached HEAD)".to_string(),
));
}
Ok(branch)
}
pub fn project_name(repo_root: &Path) -> String {
repo_root
.file_name()
.and_then(std::ffi::OsStr::to_str)
.unwrap_or("unknown")
.to_string()
}
#[derive(Debug)]
pub struct WorktreeCreation {
pub path: PathBuf,
pub branch_created: bool,
}
pub fn create_worktree(repo_root: &Path, branch: &str) -> Result<WorktreeCreation, PawError> {
let project = project_name(repo_root);
let dir_name = worktree_dir_name(&project, branch);
let parent = repo_root.parent().ok_or_else(|| {
PawError::WorktreeError("cannot determine parent directory of repo".to_string())
})?;
let worktree_path = parent.join(&dir_name);
let output = Command::new("git")
.current_dir(repo_root)
.args(["worktree", "add", &worktree_path.to_string_lossy(), branch])
.output()
.map_err(|e| PawError::WorktreeError(format!("failed to run git worktree add: {e}")))?;
if output.status.success() {
return Ok(WorktreeCreation {
path: worktree_path,
branch_created: false,
});
}
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("invalid reference") {
let output = Command::new("git")
.current_dir(repo_root)
.args([
"worktree",
"add",
"-b",
branch,
&worktree_path.to_string_lossy(),
])
.output()
.map_err(|e| {
PawError::WorktreeError(format!("failed to run git worktree add -b: {e}"))
})?;
if output.status.success() {
return Ok(WorktreeCreation {
path: worktree_path,
branch_created: true,
});
}
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PawError::WorktreeError(format!(
"git worktree add -b failed for branch '{branch}': {stderr}"
)));
}
Err(PawError::WorktreeError(format!(
"git worktree add failed for branch '{branch}': {stderr}"
)))
}
pub fn remove_worktree(repo_root: &Path, worktree_path: &Path) -> Result<(), PawError> {
let output = Command::new("git")
.current_dir(repo_root)
.args([
"worktree",
"remove",
"--force",
worktree_path.to_str().unwrap(),
])
.output()
.map_err(|e| {
PawError::WorktreeError(format!(
"failed to remove worktree at {}: {e}",
worktree_path.display()
))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PawError::WorktreeError(format!(
"git worktree remove failed for worktree at {}: {stderr}",
worktree_path.display()
)));
}
Ok(())
}
pub fn prune_worktrees(repo_root: &Path) -> Result<(), PawError> {
let output = Command::new("git")
.current_dir(repo_root)
.args(["worktree", "prune"])
.output()
.map_err(|e| PawError::WorktreeError(format!("failed to prune worktrees: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PawError::WorktreeError(format!(
"git worktree prune failed: {stderr}"
)));
}
Ok(())
}
pub fn check_uncommitted_specs(
repo_root: &Path,
specs: &[SpecEntry],
) -> Result<Vec<String>, PawError> {
let mut uncommitted_specs = Vec::new();
let specs_dir = repo_root.join("specs");
for spec in specs {
let dir_path = specs_dir.join(&spec.id);
let file_path = specs_dir.join(format!("{}.md", spec.id));
let porcelain_target = if dir_path.is_dir() {
format!("specs/{}", spec.id)
} else if file_path.is_file() {
format!("specs/{}.md", spec.id)
} else {
continue;
};
let output = Command::new("git")
.current_dir(repo_root)
.args(["status", "--porcelain", "--", &porcelain_target])
.output()
.map_err(|e| {
PawError::BranchError(format!(
"failed to run git status for spec {}: {e}",
spec.id
))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PawError::BranchError(format!(
"git status failed for spec {}: {stderr}",
spec.id
)));
}
let status_output = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !status_output.is_empty() {
uncommitted_specs.push(spec.id.clone());
}
}
Ok(uncommitted_specs)
}
pub fn merge_branch(repo_root: &Path, branch: &str) -> Result<bool, PawError> {
let output = Command::new("git")
.current_dir(repo_root)
.args(["merge", "--no-ff", "--no-commit", branch])
.output()
.map_err(|e| {
PawError::WorktreeError(format!("failed to run git merge for branch {branch}: {e}"))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.code() == Some(1) {
return Ok(false);
}
return Err(PawError::WorktreeError(format!(
"git merge failed for branch {branch}: {stderr}"
)));
}
Ok(true)
}
pub fn delete_branch(repo_root: &Path, branch: &str) -> Result<(), PawError> {
let output = Command::new("git")
.current_dir(repo_root)
.args(["branch", "-D", branch])
.output()
.map_err(|e| PawError::BranchError(format!("failed to delete branch {branch}: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PawError::BranchError(format!(
"git branch -D failed for branch {branch}: {stderr}"
)));
}
Ok(())
}
pub fn exclude_from_git(worktree_root: &Path, filename: &str) -> Result<(), PawError> {
let exclude_file = worktree_root.join(".git/info/exclude");
let existing = if exclude_file.exists() {
std::fs::read_to_string(&exclude_file).unwrap_or_default()
} else {
String::new()
};
if !existing.lines().any(|line| line.trim() == filename) {
let mut updated = existing;
if !updated.ends_with('\n') && !updated.is_empty() {
updated.push('\n');
}
updated.push_str(filename);
updated.push('\n');
if let Some(parent) = exclude_file.parent() {
if let Some(git_dir) = parent.parent()
&& git_dir.is_file()
{
let main_git_dir = std::fs::read_to_string(git_dir)
.ok()
.and_then(|s| s.strip_prefix("gitdir: ").map(|s| s.trim().to_owned()))
.unwrap_or_default();
let main_git_info = PathBuf::from(main_git_dir).join("info");
if !main_git_info.try_exists().unwrap_or(false) {
std::fs::create_dir_all(&main_git_info).map_err(|e| {
PawError::SessionError(format!("failed to create main .git/info: {e}"))
})?;
}
let main_exclude = main_git_info.join("exclude");
std::fs::write(&main_exclude, updated).map_err(|e| {
PawError::SessionError(format!(
"failed to write to main .git/info/exclude: {e}"
))
})?;
return Ok(());
}
if parent.exists() && parent.is_file() {
std::fs::remove_file(parent).map_err(|e| {
PawError::SessionError(format!("failed to remove .git/info file: {e}"))
})?;
}
std::fs::create_dir_all(parent).map_err(|e| {
PawError::SessionError(format!("failed to create .git/info directory: {e}"))
})?;
}
std::fs::write(&exclude_file, updated).map_err(|e| {
PawError::SessionError(format!("failed to write to .git/info/exclude: {e}"))
})?;
}
Ok(())
}
pub fn assume_unchanged(worktree_root: &Path, filename: &str) -> Result<(), PawError> {
let _ = std::process::Command::new("git")
.current_dir(worktree_root)
.args(["update-index", "--assume-unchanged", filename])
.status();
Ok(())
}