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,
}
fn find_worktree_for_branch(repo_root: &Path, branch: &str) -> Result<Option<PathBuf>, PawError> {
let list = Command::new("git")
.current_dir(repo_root)
.args(["worktree", "list", "--porcelain"])
.output()
.map_err(|e| PawError::WorktreeError(format!("failed to run git worktree list: {e}")))?;
if !list.status.success() {
return Ok(None);
}
let listing = String::from_utf8_lossy(&list.stdout);
let expected_branch_ref = format!("refs/heads/{branch}");
let mut current_path: Option<PathBuf> = None;
for line in listing.lines() {
if let Some(rest) = line.strip_prefix("worktree ") {
current_path = Some(PathBuf::from(rest));
} else if let Some(rest) = line.strip_prefix("branch ")
&& rest == expected_branch_ref
&& let Some(p) = current_path.take()
{
return Ok(Some(p));
}
}
Ok(None)
}
fn rebase_branch_onto_default(repo_root: &Path, branch: &str) -> Result<(), PawError> {
let default = default_branch(repo_root)?;
let occupied_at = find_worktree_for_branch(repo_root, branch)?;
let (workdir, original_head): (PathBuf, Option<String>) = if let Some(wt) = occupied_at {
(wt, None)
} else {
let original = Command::new("git")
.current_dir(repo_root)
.args(["symbolic-ref", "--short", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
(repo_root.to_path_buf(), original)
};
let mut invocation = Command::new("git");
invocation.current_dir(&workdir);
if original_head.is_some() {
invocation.args(["rebase", &default, branch]);
} else {
invocation.args(["rebase", &default]);
}
let output = invocation
.output()
.map_err(|e| PawError::WorktreeError(format!("failed to run git rebase: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
let _ = Command::new("git")
.current_dir(&workdir)
.args(["rebase", "--abort"])
.output();
if let Some(orig) = &original_head
&& orig != branch
{
let _ = Command::new("git")
.current_dir(repo_root)
.args(["checkout", orig])
.output();
}
return Err(PawError::WorktreeError(format!(
"rebase onto main failed: {stderr}"
)));
}
if let Some(orig) = original_head
&& orig != branch
{
let _ = Command::new("git")
.current_dir(repo_root)
.args(["checkout", &orig])
.output();
}
Ok(())
}
pub fn create_worktree(
repo_root: &Path,
branch: &str,
rebase_onto_main: bool,
) -> 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);
if rebase_onto_main {
let branch_exists = Command::new("git")
.current_dir(repo_root)
.args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
.output()
.is_ok_and(|o| o.status.success());
if branch_exists {
rebase_branch_onto_default(repo_root, branch)?;
}
}
if worktree_path.exists() {
let expected_canonical = std::fs::canonicalize(&worktree_path).ok();
let list = Command::new("git")
.current_dir(repo_root)
.args(["worktree", "list", "--porcelain"])
.output()
.map_err(|e| {
PawError::WorktreeError(format!("failed to run git worktree list: {e}"))
})?;
if list.status.success() {
let listing = String::from_utf8_lossy(&list.stdout);
let expected_branch_ref = format!("refs/heads/{branch}");
let mut current_path: Option<PathBuf> = None;
for line in listing.lines() {
if let Some(rest) = line.strip_prefix("worktree ") {
current_path = std::fs::canonicalize(PathBuf::from(rest)).ok();
} else if let Some(rest) = line.strip_prefix("branch ") {
let path_matches = match (¤t_path, &expected_canonical) {
(Some(p), Some(e)) => p == e,
_ => false,
};
if path_matches && rest == expected_branch_ref {
return Ok(WorktreeCreation {
path: worktree_path,
branch_created: false,
});
}
}
}
}
}
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"])
.arg(worktree_path.as_os_str())
.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 uncommitted_files(worktree_root: &Path) -> Result<Vec<String>, PawError> {
let output = Command::new("git")
.current_dir(worktree_root)
.args(["status", "--porcelain"])
.output()
.map_err(|e| {
PawError::WorktreeError(format!(
"failed to run git status in {}: {e}",
worktree_root.display()
))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PawError::WorktreeError(format!(
"git status failed in {}: {stderr}",
worktree_root.display()
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut files = Vec::new();
for line in stdout.lines() {
if line.len() <= 3 {
continue;
}
let path = &line[3..];
let reported = path.rsplit(" -> ").next().unwrap_or(path);
files.push(reported.trim().to_string());
}
Ok(files)
}
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])
.output();
Ok(())
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
use crate::error::PawError;
use crate::git::{WorktreeCreation, create_worktree};
struct RebaseRepo {
_sandbox: TempDir,
repo: PathBuf,
}
impl RebaseRepo {
fn path(&self) -> &Path {
&self.repo
}
}
fn run_git(dir: &Path, args: &[&str]) {
let output = Command::new("git")
.current_dir(dir)
.args(args)
.output()
.expect("run git command");
assert!(
output.status.success(),
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr)
);
}
fn capture_git(dir: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.current_dir(dir)
.args(args)
.output()
.expect("run git command");
assert!(
output.status.success(),
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
fn setup_rebase_repo() -> RebaseRepo {
let sandbox = TempDir::new().expect("tempdir");
let bare = sandbox.path().join("bare.git");
let repo = sandbox.path().join("repo");
std::fs::create_dir_all(&bare).unwrap();
run_git(&bare, &["init", "--bare", "-b", "main"]);
let status = Command::new("git")
.args([
"clone",
bare.to_str().unwrap(),
repo.to_str().unwrap(),
"--origin",
"origin",
])
.status()
.expect("git clone");
assert!(status.success());
run_git(&repo, &["config", "user.email", "test@test.com"]);
run_git(&repo, &["config", "user.name", "Test"]);
run_git(&repo, &["checkout", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "one\n").unwrap();
run_git(&repo, &["add", "."]);
run_git(&repo, &["commit", "-m", "init"]);
run_git(&repo, &["push", "-u", "origin", "main"]);
run_git(&bare, &["symbolic-ref", "HEAD", "refs/heads/main"]);
run_git(&repo, &["remote", "set-head", "origin", "main"]);
run_git(&repo, &["branch", "feat/example"]);
RebaseRepo {
_sandbox: sandbox,
repo,
}
}
fn advance_main(repo: &Path, commits: usize) {
for i in 0..commits {
std::fs::write(repo.join(format!("main-{i}.txt")), format!("v{i}\n")).unwrap();
run_git(repo, &["add", "."]);
run_git(repo, &["commit", "-m", &format!("main commit {i}")]);
}
}
fn head_sha(repo: &Path, branch: &str) -> String {
capture_git(repo, &["rev-parse", branch])
}
#[test]
fn create_worktree_rebases_branch_when_behind_main() {
let r = setup_rebase_repo();
advance_main(r.path(), 2);
let result = create_worktree(r.path(), "feat/example", true).expect("rebase succeeds");
assert!(
matches!(
result,
WorktreeCreation {
branch_created: false,
..
}
),
"branch existed, branch_created must be false"
);
assert!(result.path.exists(), "worktree directory must be created");
let count = capture_git(r.path(), &["rev-list", "--count", "feat/example..main"]);
assert_eq!(count, "0", "feat/example must include main's commits");
}
#[test]
fn create_worktree_rebase_noop_when_branch_up_to_date() {
let r = setup_rebase_repo();
let before = head_sha(r.path(), "feat/example");
let _result =
create_worktree(r.path(), "feat/example", true).expect("noop rebase succeeds");
let after = head_sha(r.path(), "feat/example");
assert_eq!(before, after, "noop rebase must not change HEAD");
}
#[test]
fn create_worktree_rebase_conflict_aborts_and_errors() {
let r = setup_rebase_repo();
run_git(r.path(), &["checkout", "feat/example"]);
std::fs::write(r.path().join("a.txt"), "feat-version\n").unwrap();
run_git(r.path(), &["add", "."]);
run_git(r.path(), &["commit", "-m", "feat edit"]);
run_git(r.path(), &["checkout", "main"]);
std::fs::write(r.path().join("a.txt"), "main-version\n").unwrap();
run_git(r.path(), &["add", "."]);
run_git(r.path(), &["commit", "-m", "main edit"]);
let pre = head_sha(r.path(), "feat/example");
let result = create_worktree(r.path(), "feat/example", true);
let err = result.expect_err("rebase must error on conflict");
match err {
PawError::WorktreeError(msg) => assert!(
msg.contains("rebase onto main failed"),
"expected 'rebase onto main failed' in error, got: {msg}"
),
other => panic!("expected WorktreeError, got {other:?}"),
}
let post = head_sha(r.path(), "feat/example");
assert_eq!(pre, post, "branch HEAD must be restored after abort");
let git_dir = r.path().join(".git");
assert!(
!git_dir.join("rebase-merge").exists(),
"rebase-merge dir must not survive abort"
);
assert!(
!git_dir.join("rebase-apply").exists(),
"rebase-apply dir must not survive abort"
);
}
#[test]
fn create_worktree_no_rebase_preserves_v0_5_behaviour() {
let r = setup_rebase_repo();
advance_main(r.path(), 2);
let before = head_sha(r.path(), "feat/example");
let result =
create_worktree(r.path(), "feat/example", false).expect("no-rebase path succeeds");
let after = head_sha(r.path(), "feat/example");
assert_eq!(before, after, "rebase_onto_main=false must not change HEAD");
assert!(result.path.exists(), "worktree directory must be created");
}
#[test]
fn create_worktree_new_branch_skips_rebase_regardless_of_flag() {
let r = setup_rebase_repo();
let result =
create_worktree(r.path(), "feat/new", true).expect("new-branch creation succeeds");
assert!(
matches!(
result,
WorktreeCreation {
branch_created: true,
..
}
),
"new branch must report branch_created=true"
);
assert!(result.path.exists(), "worktree directory must be created");
}
#[cfg(unix)]
#[test]
fn remove_worktree_does_not_panic_on_non_utf8_path() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
use std::path::PathBuf;
use super::remove_worktree;
let repo = tempfile::tempdir().expect("tempdir");
let non_utf8 = OsString::from_vec(vec![b'f', 0x80, b'f']);
let worktree_path = PathBuf::from(non_utf8);
let result = remove_worktree(repo.path(), &worktree_path);
assert!(result.is_err(), "expected Err for non-existent worktree");
}
}