Skip to main content

git_paw/
git.rs

1//! Git operations.
2//!
3//! Validates git repositories, lists branches, creates and removes worktrees,
4//! and derives worktree directory names from project and branch names.
5
6use std::collections::BTreeSet;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use crate::error::PawError;
11use crate::specs::SpecEntry;
12
13/// Validates that the given path is inside a git repository.
14///
15/// Returns the absolute path to the repository root.
16pub fn validate_repo(path: &Path) -> Result<PathBuf, PawError> {
17    let output = Command::new("git")
18        .current_dir(path)
19        .args(["rev-parse", "--show-toplevel"])
20        .output()
21        .map_err(|e| PawError::BranchError(format!("failed to run git: {e}")))?;
22
23    if !output.status.success() {
24        return Err(PawError::NotAGitRepo);
25    }
26
27    let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
28    Ok(PathBuf::from(root))
29}
30
31/// Lists all branches (local and remote), deduplicated, sorted, with remote
32/// prefixes stripped.
33///
34/// Remote branches like `origin/main` are included as `main`. If a branch
35/// exists both locally and remotely, only one entry appears. `HEAD` pointers
36/// are excluded.
37pub fn list_branches(repo_root: &Path) -> Result<Vec<String>, PawError> {
38    let output = Command::new("git")
39        .current_dir(repo_root)
40        .args(["branch", "-a", "--format=%(refname:short)"])
41        .output()
42        .map_err(|e| PawError::BranchError(format!("failed to run git branch: {e}")))?;
43
44    if !output.status.success() {
45        let stderr = String::from_utf8_lossy(&output.stderr);
46        return Err(PawError::BranchError(format!(
47            "git branch failed: {stderr}"
48        )));
49    }
50
51    let stdout = String::from_utf8_lossy(&output.stdout);
52    let branches: BTreeSet<String> = stdout
53        .lines()
54        .filter(|line| !line.trim().is_empty() && !line.contains("HEAD"))
55        .map(|line| {
56            // Strip remote prefix (e.g., "origin/main" -> "main")
57            let mut branch_name = line.trim().to_string();
58
59            // Handle full ref format: refs/remotes/origin/branch -> branch
60            if let Some(stripped) = branch_name.strip_prefix("refs/remotes/") {
61                branch_name = stripped.to_string();
62            }
63            // Handle short format: origin/branch -> branch
64            if let Some(stripped) = branch_name.strip_prefix("origin/") {
65                branch_name = stripped.to_string();
66            }
67
68            branch_name
69        })
70        .collect();
71
72    // Remove duplicates that can arise from local+remote branches with same name
73    let mut unique: Vec<String> = branches.into_iter().collect();
74    unique.sort();
75    Ok(unique)
76}
77
78/// Derives a worktree directory name from project and branch names.
79///
80/// The format is: `<project>-<branch>` with non-alphanumeric characters replaced by `-`.
81pub fn worktree_dir_name(project: &str, branch: &str) -> String {
82    let project_safe: String = project
83        .chars()
84        .map(|c| if c.is_alphanumeric() { c } else { '-' })
85        .collect();
86    let branch_safe: String = branch
87        .chars()
88        .map(|c| if c.is_alphanumeric() { c } else { '-' })
89        .collect();
90    format!("{project_safe}-{branch_safe}")
91}
92
93/// Returns the name of the default branch (usually "main" or "master").
94pub fn default_branch(repo_root: &Path) -> Result<String, PawError> {
95    let output = Command::new("git")
96        .current_dir(repo_root)
97        .args(["symbolic-ref", "refs/remotes/origin/HEAD"])
98        .output()
99        .map_err(|e| PawError::BranchError(format!("failed to run git symbolic-ref: {e}")))?;
100
101    if !output.status.success() {
102        let stderr = String::from_utf8_lossy(&output.stderr);
103        return Err(PawError::BranchError(format!(
104            "git symbolic-ref failed: {stderr}"
105        )));
106    }
107
108    let ref_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
109    if let Some(branch) = ref_name.strip_prefix("refs/remotes/origin/") {
110        Ok(branch.to_string())
111    } else {
112        Err(PawError::BranchError(format!(
113            "unexpected ref format: {ref_name}"
114        )))
115    }
116}
117
118/// Returns the short name of the current branch (e.g., "main", "feat/add-auth").
119pub fn current_branch(repo_root: &Path) -> Result<String, PawError> {
120    let output = Command::new("git")
121        .current_dir(repo_root)
122        .args(["branch", "--show-current"])
123        .output()
124        .map_err(|e| PawError::BranchError(format!("failed to run git branch: {e}")))?;
125
126    if !output.status.success() {
127        let stderr = String::from_utf8_lossy(&output.stderr);
128        return Err(PawError::BranchError(format!(
129            "git branch failed: {stderr}"
130        )));
131    }
132
133    let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
134    if branch.is_empty() {
135        return Err(PawError::BranchError(
136            "not on any branch (detached HEAD)".to_string(),
137        ));
138    }
139    Ok(branch)
140}
141
142/// Returns the name of the project (directory name of the git repository).
143pub fn project_name(repo_root: &Path) -> String {
144    repo_root
145        .file_name()
146        .and_then(std::ffi::OsStr::to_str)
147        .unwrap_or("unknown")
148        .to_string()
149}
150
151/// Result of creating a worktree, including whether the branch was newly created.
152#[derive(Debug)]
153pub struct WorktreeCreation {
154    /// Path to the created worktree directory.
155    pub path: PathBuf,
156    /// Whether git-paw created the branch (true) or it already existed (false).
157    pub branch_created: bool,
158}
159
160/// Returns the path of the worktree (main repo or any sibling worktree) that
161/// currently has `branch` checked out, if any.
162fn find_worktree_for_branch(repo_root: &Path, branch: &str) -> Result<Option<PathBuf>, PawError> {
163    let list = Command::new("git")
164        .current_dir(repo_root)
165        .args(["worktree", "list", "--porcelain"])
166        .output()
167        .map_err(|e| PawError::WorktreeError(format!("failed to run git worktree list: {e}")))?;
168    if !list.status.success() {
169        return Ok(None);
170    }
171    let listing = String::from_utf8_lossy(&list.stdout);
172    let expected_branch_ref = format!("refs/heads/{branch}");
173    let mut current_path: Option<PathBuf> = None;
174    for line in listing.lines() {
175        if let Some(rest) = line.strip_prefix("worktree ") {
176            current_path = Some(PathBuf::from(rest));
177        } else if let Some(rest) = line.strip_prefix("branch ")
178            && rest == expected_branch_ref
179            && let Some(p) = current_path.take()
180        {
181            return Ok(Some(p));
182        }
183    }
184    Ok(None)
185}
186
187/// Rebases `branch` onto the repo's default branch.
188///
189/// Runs the rebase inside the worktree where `branch` is currently checked out
190/// (the main repo or one of its sibling worktrees). If `branch` is not checked
191/// out anywhere, the main repo's HEAD is switched to it for the rebase and
192/// restored afterwards so the subsequent `git worktree add` call still works.
193///
194/// On rebase failure, runs `git rebase --abort` (best-effort), restores the
195/// main repo's HEAD if it was switched, and returns a `WorktreeError`
196/// containing git's stderr. The branch is left at its pre-rebase HEAD.
197fn rebase_branch_onto_default(repo_root: &Path, branch: &str) -> Result<(), PawError> {
198    let default = default_branch(repo_root)?;
199
200    let occupied_at = find_worktree_for_branch(repo_root, branch)?;
201    let (workdir, original_head): (PathBuf, Option<String>) = if let Some(wt) = occupied_at {
202        (wt, None)
203    } else {
204        let original = Command::new("git")
205            .current_dir(repo_root)
206            .args(["symbolic-ref", "--short", "HEAD"])
207            .output()
208            .ok()
209            .filter(|o| o.status.success())
210            .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
211        (repo_root.to_path_buf(), original)
212    };
213
214    let mut invocation = Command::new("git");
215    invocation.current_dir(&workdir);
216    if original_head.is_some() {
217        invocation.args(["rebase", &default, branch]);
218    } else {
219        invocation.args(["rebase", &default]);
220    }
221    let output = invocation
222        .output()
223        .map_err(|e| PawError::WorktreeError(format!("failed to run git rebase: {e}")))?;
224
225    if !output.status.success() {
226        let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
227        let _ = Command::new("git")
228            .current_dir(&workdir)
229            .args(["rebase", "--abort"])
230            .output();
231        if let Some(orig) = &original_head
232            && orig != branch
233        {
234            let _ = Command::new("git")
235                .current_dir(repo_root)
236                .args(["checkout", orig])
237                .output();
238        }
239        return Err(PawError::WorktreeError(format!(
240            "rebase onto main failed: {stderr}"
241        )));
242    }
243
244    if let Some(orig) = original_head
245        && orig != branch
246    {
247        let _ = Command::new("git")
248            .current_dir(repo_root)
249            .args(["checkout", &orig])
250            .output();
251    }
252
253    Ok(())
254}
255
256/// Creates a git worktree for `branch`.
257///
258/// If the branch already exists, checks it out in a new worktree. If the
259/// branch does not exist, creates it from HEAD with `git worktree add -b`.
260/// Returns both the worktree path and whether the branch was newly created,
261/// so the session can track which branches to delete on purge.
262///
263/// When `rebase_onto_main` is `true` and the target branch already exists in
264/// the local repository, the branch is rebased onto `default_branch()` BEFORE
265/// the existence check. The rebase resolves drift between supervisor work on
266/// main and live agent branches (MILESTONE.md drift item 48: agents otherwise
267/// commit on a stale baseline). On rebase conflict the function runs
268/// `git rebase --abort` and returns `PawError::WorktreeError`; the branch is
269/// left at its pre-rebase HEAD. When `rebase_onto_main` is `false` or the
270/// branch does not yet exist locally, the rebase step is skipped.
271pub fn create_worktree(
272    repo_root: &Path,
273    branch: &str,
274    rebase_onto_main: bool,
275) -> Result<WorktreeCreation, PawError> {
276    let project = project_name(repo_root);
277    let dir_name = worktree_dir_name(&project, branch);
278
279    let parent = repo_root.parent().ok_or_else(|| {
280        PawError::WorktreeError("cannot determine parent directory of repo".to_string())
281    })?;
282    let worktree_path = parent.join(&dir_name);
283
284    // Rebase agent branch onto the repo's default branch BEFORE the
285    // idempotency check. Resolves MILESTONE.md drift item 48: the supervisor
286    // advances main while agents are running, so on resume (or fresh launch
287    // of an existing branch) the agent's worktree would otherwise be N
288    // commits behind main and every subsequent commit chains from a stale
289    // baseline. Order matters: rebasing before the idempotency check means a
290    // surviving worktree's branch ref is updated transparently on resume.
291    if rebase_onto_main {
292        let branch_exists = Command::new("git")
293            .current_dir(repo_root)
294            .args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
295            .output()
296            .is_ok_and(|o| o.status.success());
297        if branch_exists {
298            rebase_branch_onto_default(repo_root, branch)?;
299        }
300    }
301
302    // If a worktree already exists at this path AND is registered with git for
303    // the same branch, treat it as a successful (idempotent) creation. This is
304    // the resume / crash-recovery path — the worktree survived a previous
305    // session and `git paw start` should reuse it instead of bailing on
306    // "already exists".
307    if worktree_path.exists() {
308        // Canonicalize the expected path so symlink-resolved porcelain output
309        // (e.g. macOS's `/private/var/folders/...` vs `/var/folders/...`)
310        // compares equal to the path git-paw computed for the worktree.
311        let expected_canonical = std::fs::canonicalize(&worktree_path).ok();
312        let list = Command::new("git")
313            .current_dir(repo_root)
314            .args(["worktree", "list", "--porcelain"])
315            .output()
316            .map_err(|e| {
317                PawError::WorktreeError(format!("failed to run git worktree list: {e}"))
318            })?;
319        if list.status.success() {
320            let listing = String::from_utf8_lossy(&list.stdout);
321            let expected_branch_ref = format!("refs/heads/{branch}");
322            // Parse porcelain blocks separated by blank lines. Each block has
323            // `worktree <path>` and `branch <ref>` lines.
324            let mut current_path: Option<PathBuf> = None;
325            for line in listing.lines() {
326                if let Some(rest) = line.strip_prefix("worktree ") {
327                    current_path = std::fs::canonicalize(PathBuf::from(rest)).ok();
328                } else if let Some(rest) = line.strip_prefix("branch ") {
329                    let path_matches = match (&current_path, &expected_canonical) {
330                        (Some(p), Some(e)) => p == e,
331                        _ => false,
332                    };
333                    if path_matches && rest == expected_branch_ref {
334                        return Ok(WorktreeCreation {
335                            path: worktree_path,
336                            branch_created: false,
337                        });
338                    }
339                }
340            }
341        }
342        // Path exists but not as a git worktree for this branch — let the
343        // `git worktree add` call below produce its usual error so the user
344        // sees something actionable.
345    }
346
347    // Try with existing branch first.
348    let output = Command::new("git")
349        .current_dir(repo_root)
350        .args(["worktree", "add", &worktree_path.to_string_lossy(), branch])
351        .output()
352        .map_err(|e| PawError::WorktreeError(format!("failed to run git worktree add: {e}")))?;
353
354    if output.status.success() {
355        return Ok(WorktreeCreation {
356            path: worktree_path,
357            branch_created: false,
358        });
359    }
360
361    let stderr = String::from_utf8_lossy(&output.stderr);
362
363    // If the branch doesn't exist, create it with -b.
364    if stderr.contains("invalid reference") {
365        let output = Command::new("git")
366            .current_dir(repo_root)
367            .args([
368                "worktree",
369                "add",
370                "-b",
371                branch,
372                &worktree_path.to_string_lossy(),
373            ])
374            .output()
375            .map_err(|e| {
376                PawError::WorktreeError(format!("failed to run git worktree add -b: {e}"))
377            })?;
378
379        if output.status.success() {
380            return Ok(WorktreeCreation {
381                path: worktree_path,
382                branch_created: true,
383            });
384        }
385
386        let stderr = String::from_utf8_lossy(&output.stderr);
387        return Err(PawError::WorktreeError(format!(
388            "git worktree add -b failed for branch '{branch}': {stderr}"
389        )));
390    }
391
392    Err(PawError::WorktreeError(format!(
393        "git worktree add failed for branch '{branch}': {stderr}"
394    )))
395}
396
397/// Removes the worktree at the given path.
398///
399/// The path should be the worktree directory path, not a branch name.
400pub fn remove_worktree(repo_root: &Path, worktree_path: &Path) -> Result<(), PawError> {
401    // Always pass --force per `git-operations/spec.md`'s "SHALL force-remove a
402    // worktree" requirement. `remove_worktree` is only called from purge,
403    // which is destructive by nature: an agent that produced uncommitted or
404    // untracked files in its worktree would otherwise trip "contains modified
405    // or untracked files, use --force to delete it" and leak the worktree on
406    // disk even though the user already typed `--force` at the CLI.
407    let output = Command::new("git")
408        .current_dir(repo_root)
409        .args(["worktree", "remove", "--force"])
410        .arg(worktree_path.as_os_str())
411        .output()
412        .map_err(|e| {
413            PawError::WorktreeError(format!(
414                "failed to remove worktree at {}: {e}",
415                worktree_path.display()
416            ))
417        })?;
418
419    if !output.status.success() {
420        let stderr = String::from_utf8_lossy(&output.stderr);
421        return Err(PawError::WorktreeError(format!(
422            "git worktree remove failed for worktree at {}: {stderr}",
423            worktree_path.display()
424        )));
425    }
426
427    Ok(())
428}
429
430/// Prunes stale worktree registrations from the git worktree list.
431///
432/// This should be called before creating new worktrees to avoid conflicts.
433pub fn prune_worktrees(repo_root: &Path) -> Result<(), PawError> {
434    let output = Command::new("git")
435        .current_dir(repo_root)
436        .args(["worktree", "prune"])
437        .output()
438        .map_err(|e| PawError::WorktreeError(format!("failed to prune worktrees: {e}")))?;
439
440    if !output.status.success() {
441        let stderr = String::from_utf8_lossy(&output.stderr);
442        return Err(PawError::WorktreeError(format!(
443            "git worktree prune failed: {stderr}"
444        )));
445    }
446
447    Ok(())
448}
449
450/// Checks for uncommitted changes in spec directories or files.
451///
452/// Returns a list of spec IDs that have uncommitted changes (modified, added,
453/// or untracked files). Uses `git status --porcelain` against the spec's path.
454///
455/// Supports both spec layouts:
456/// - `OpenSpec`: `specs/<id>/` directory; the whole directory is probed.
457/// - `Markdown`: `specs/<id>.md` file; the single file is probed.
458///
459/// If neither layout exists for a spec id, it is silently skipped.
460pub fn check_uncommitted_specs(
461    repo_root: &Path,
462    specs: &[SpecEntry],
463) -> Result<Vec<String>, PawError> {
464    let mut uncommitted_specs = Vec::new();
465
466    let specs_dir = repo_root.join("specs");
467
468    for spec in specs {
469        let dir_path = specs_dir.join(&spec.id);
470        let file_path = specs_dir.join(format!("{}.md", spec.id));
471
472        let porcelain_target = if dir_path.is_dir() {
473            format!("specs/{}", spec.id)
474        } else if file_path.is_file() {
475            format!("specs/{}.md", spec.id)
476        } else {
477            continue;
478        };
479
480        let output = Command::new("git")
481            .current_dir(repo_root)
482            .args(["status", "--porcelain", "--", &porcelain_target])
483            .output()
484            .map_err(|e| {
485                PawError::BranchError(format!(
486                    "failed to run git status for spec {}: {e}",
487                    spec.id
488                ))
489            })?;
490
491        if !output.status.success() {
492            let stderr = String::from_utf8_lossy(&output.stderr);
493            return Err(PawError::BranchError(format!(
494                "git status failed for spec {}: {stderr}",
495                spec.id
496            )));
497        }
498
499        let status_output = String::from_utf8_lossy(&output.stdout).trim().to_string();
500        if !status_output.is_empty() {
501            uncommitted_specs.push(spec.id.clone());
502        }
503    }
504
505    Ok(uncommitted_specs)
506}
507
508/// Merges the specified branch into the current branch.
509///
510/// Returns `true` if the merge was successful, `false` if there were conflicts.
511pub fn merge_branch(repo_root: &Path, branch: &str) -> Result<bool, PawError> {
512    let output = Command::new("git")
513        .current_dir(repo_root)
514        .args(["merge", "--no-ff", "--no-commit", branch])
515        .output()
516        .map_err(|e| {
517            PawError::WorktreeError(format!("failed to run git merge for branch {branch}: {e}"))
518        })?;
519
520    if !output.status.success() {
521        let stderr = String::from_utf8_lossy(&output.stderr);
522        // Check if this is a conflict (exit code 1) vs other error
523        if output.status.code() == Some(1) {
524            return Ok(false);
525        }
526        return Err(PawError::WorktreeError(format!(
527            "git merge failed for branch {branch}: {stderr}"
528        )));
529    }
530
531    Ok(true)
532}
533
534/// Deletes a branch.
535pub fn delete_branch(repo_root: &Path, branch: &str) -> Result<(), PawError> {
536    let output = Command::new("git")
537        .current_dir(repo_root)
538        .args(["branch", "-D", branch])
539        .output()
540        .map_err(|e| PawError::BranchError(format!("failed to delete branch {branch}: {e}")))?;
541
542    if !output.status.success() {
543        let stderr = String::from_utf8_lossy(&output.stderr);
544        return Err(PawError::BranchError(format!(
545            "git branch -D failed for branch {branch}: {stderr}"
546        )));
547    }
548
549    Ok(())
550}
551
552/// Excludes a file from git tracking by adding it to `.git/info/exclude`.
553///
554/// This prevents the file from being tracked by git without modifying the
555/// repository's `.gitignore` file, which is useful for worktree-specific
556/// files that should not be committed.
557pub fn exclude_from_git(worktree_root: &Path, filename: &str) -> Result<(), PawError> {
558    let exclude_file = worktree_root.join(".git/info/exclude");
559
560    // Read existing exclude patterns
561    let existing = if exclude_file.exists() {
562        std::fs::read_to_string(&exclude_file).unwrap_or_default()
563    } else {
564        String::new()
565    };
566
567    // Add the filename if not already present
568    if !existing.lines().any(|line| line.trim() == filename) {
569        let mut updated = existing;
570        if !updated.ends_with('\n') && !updated.is_empty() {
571            updated.push('\n');
572        }
573        updated.push_str(filename);
574        updated.push('\n');
575
576        // Create .git/info directory if it doesn't exist
577        if let Some(parent) = exclude_file.parent() {
578            // Check if .git (the grandparent) is a file (worktree case)
579            if let Some(git_dir) = parent.parent()
580                && git_dir.is_file()
581            {
582                // This is a worktree - .git is a file pointing to main repo
583                // The actual git directory is inside the main repo
584                let main_git_dir = std::fs::read_to_string(git_dir)
585                    .ok()
586                    .and_then(|s| s.strip_prefix("gitdir: ").map(|s| s.trim().to_owned()))
587                    .unwrap_or_default();
588                let main_git_info = PathBuf::from(main_git_dir).join("info");
589                if !main_git_info.try_exists().unwrap_or(false) {
590                    std::fs::create_dir_all(&main_git_info).map_err(|e| {
591                        PawError::SessionError(format!("failed to create main .git/info: {e}"))
592                    })?;
593                }
594                let main_exclude = main_git_info.join("exclude");
595                std::fs::write(&main_exclude, updated).map_err(|e| {
596                    PawError::SessionError(format!(
597                        "failed to write to main .git/info/exclude: {e}"
598                    ))
599                })?;
600                return Ok(());
601            }
602            if parent.exists() && parent.is_file() {
603                std::fs::remove_file(parent).map_err(|e| {
604                    PawError::SessionError(format!("failed to remove .git/info file: {e}"))
605                })?;
606            }
607            std::fs::create_dir_all(parent).map_err(|e| {
608                PawError::SessionError(format!("failed to create .git/info directory: {e}"))
609            })?;
610        }
611
612        std::fs::write(&exclude_file, updated).map_err(|e| {
613            PawError::SessionError(format!("failed to write to .git/info/exclude: {e}"))
614        })?;
615    }
616
617    Ok(())
618}
619
620/// Marks a file as assume-unchanged in git's index.
621///
622/// This prevents `git add -A`, `git add .`, and `git commit -a` from
623/// staging the file. Returns `Ok` even if the command fails, as this
624/// is a belt-and-suspenders measure.
625pub fn assume_unchanged(worktree_root: &Path, filename: &str) -> Result<(), PawError> {
626    // `.output()` rather than `.status()` so git's "fatal: Unable to mark
627    // file" stderr (emitted when the file isn't tracked) doesn't bleed
628    // through to the parent process. This is belt-and-suspenders — failure
629    // is silent by design because `exclude_from_git` is the primary
630    // protection for untracked AGENTS.md.
631    let _ = std::process::Command::new("git")
632        .current_dir(worktree_root)
633        .args(["update-index", "--assume-unchanged", filename])
634        .output();
635    Ok(())
636}
637
638#[cfg(test)]
639mod tests {
640    use std::path::{Path, PathBuf};
641    use std::process::Command;
642
643    use tempfile::TempDir;
644
645    use crate::error::PawError;
646    use crate::git::{WorktreeCreation, create_worktree};
647
648    /// Sets up a temp repo with `origin/HEAD` pointing to `refs/heads/main`,
649    /// an initial commit on `main`, and the `feat/example` branch at the same
650    /// commit. The fixture is what `create_worktree` expects when called with
651    /// `rebase_onto_main = true`.
652    struct RebaseRepo {
653        _sandbox: TempDir,
654        repo: PathBuf,
655    }
656
657    impl RebaseRepo {
658        fn path(&self) -> &Path {
659            &self.repo
660        }
661    }
662
663    fn run_git(dir: &Path, args: &[&str]) {
664        let output = Command::new("git")
665            .current_dir(dir)
666            .args(args)
667            .output()
668            .expect("run git command");
669        assert!(
670            output.status.success(),
671            "git {} failed: {}",
672            args.join(" "),
673            String::from_utf8_lossy(&output.stderr)
674        );
675    }
676
677    fn capture_git(dir: &Path, args: &[&str]) -> String {
678        let output = Command::new("git")
679            .current_dir(dir)
680            .args(args)
681            .output()
682            .expect("run git command");
683        assert!(
684            output.status.success(),
685            "git {} failed: {}",
686            args.join(" "),
687            String::from_utf8_lossy(&output.stderr)
688        );
689        String::from_utf8_lossy(&output.stdout).trim().to_string()
690    }
691
692    /// Builds a repo with `origin/main` tracking set up so `default_branch()`
693    /// resolves cleanly. The repo is on `main` at one commit; `feat/example`
694    /// is created at the same commit (caller advances either side as needed).
695    fn setup_rebase_repo() -> RebaseRepo {
696        let sandbox = TempDir::new().expect("tempdir");
697        let bare = sandbox.path().join("bare.git");
698        let repo = sandbox.path().join("repo");
699        std::fs::create_dir_all(&bare).unwrap();
700
701        run_git(&bare, &["init", "--bare", "-b", "main"]);
702
703        // Clone the bare repo as a worktree-capable working repo.
704        let status = Command::new("git")
705            .args([
706                "clone",
707                bare.to_str().unwrap(),
708                repo.to_str().unwrap(),
709                "--origin",
710                "origin",
711            ])
712            .status()
713            .expect("git clone");
714        assert!(status.success());
715
716        run_git(&repo, &["config", "user.email", "test@test.com"]);
717        run_git(&repo, &["config", "user.name", "Test"]);
718        run_git(&repo, &["checkout", "-b", "main"]);
719        std::fs::write(repo.join("a.txt"), "one\n").unwrap();
720        run_git(&repo, &["add", "."]);
721        run_git(&repo, &["commit", "-m", "init"]);
722        run_git(&repo, &["push", "-u", "origin", "main"]);
723        run_git(&bare, &["symbolic-ref", "HEAD", "refs/heads/main"]);
724        run_git(&repo, &["remote", "set-head", "origin", "main"]);
725        run_git(&repo, &["branch", "feat/example"]);
726
727        RebaseRepo {
728            _sandbox: sandbox,
729            repo,
730        }
731    }
732
733    fn advance_main(repo: &Path, commits: usize) {
734        for i in 0..commits {
735            std::fs::write(repo.join(format!("main-{i}.txt")), format!("v{i}\n")).unwrap();
736            run_git(repo, &["add", "."]);
737            run_git(repo, &["commit", "-m", &format!("main commit {i}")]);
738        }
739    }
740
741    fn head_sha(repo: &Path, branch: &str) -> String {
742        capture_git(repo, &["rev-parse", branch])
743    }
744
745    #[test]
746    fn create_worktree_rebases_branch_when_behind_main() {
747        let r = setup_rebase_repo();
748        advance_main(r.path(), 2);
749
750        let result = create_worktree(r.path(), "feat/example", true).expect("rebase succeeds");
751        assert!(
752            matches!(
753                result,
754                WorktreeCreation {
755                    branch_created: false,
756                    ..
757                }
758            ),
759            "branch existed, branch_created must be false"
760        );
761        assert!(result.path.exists(), "worktree directory must be created");
762
763        // feat/example contains main's commits → 0 commits in feat..main.
764        let count = capture_git(r.path(), &["rev-list", "--count", "feat/example..main"]);
765        assert_eq!(count, "0", "feat/example must include main's commits");
766    }
767
768    #[test]
769    fn create_worktree_rebase_noop_when_branch_up_to_date() {
770        let r = setup_rebase_repo();
771        // Branch is already at main HEAD — rebase is a no-op.
772        let before = head_sha(r.path(), "feat/example");
773        let _result =
774            create_worktree(r.path(), "feat/example", true).expect("noop rebase succeeds");
775        let after = head_sha(r.path(), "feat/example");
776        assert_eq!(before, after, "noop rebase must not change HEAD");
777    }
778
779    #[test]
780    fn create_worktree_rebase_conflict_aborts_and_errors() {
781        let r = setup_rebase_repo();
782
783        // Diverge: modify a.txt on feat/example, then modify the same line on
784        // main with a different content. Rebase will conflict.
785        run_git(r.path(), &["checkout", "feat/example"]);
786        std::fs::write(r.path().join("a.txt"), "feat-version\n").unwrap();
787        run_git(r.path(), &["add", "."]);
788        run_git(r.path(), &["commit", "-m", "feat edit"]);
789        run_git(r.path(), &["checkout", "main"]);
790        std::fs::write(r.path().join("a.txt"), "main-version\n").unwrap();
791        run_git(r.path(), &["add", "."]);
792        run_git(r.path(), &["commit", "-m", "main edit"]);
793
794        let pre = head_sha(r.path(), "feat/example");
795        let result = create_worktree(r.path(), "feat/example", true);
796        let err = result.expect_err("rebase must error on conflict");
797        match err {
798            PawError::WorktreeError(msg) => assert!(
799                msg.contains("rebase onto main failed"),
800                "expected 'rebase onto main failed' in error, got: {msg}"
801            ),
802            other => panic!("expected WorktreeError, got {other:?}"),
803        }
804
805        let post = head_sha(r.path(), "feat/example");
806        assert_eq!(pre, post, "branch HEAD must be restored after abort");
807
808        let git_dir = r.path().join(".git");
809        assert!(
810            !git_dir.join("rebase-merge").exists(),
811            "rebase-merge dir must not survive abort"
812        );
813        assert!(
814            !git_dir.join("rebase-apply").exists(),
815            "rebase-apply dir must not survive abort"
816        );
817    }
818
819    #[test]
820    fn create_worktree_no_rebase_preserves_v0_5_behaviour() {
821        let r = setup_rebase_repo();
822        advance_main(r.path(), 2);
823
824        let before = head_sha(r.path(), "feat/example");
825        let result =
826            create_worktree(r.path(), "feat/example", false).expect("no-rebase path succeeds");
827        let after = head_sha(r.path(), "feat/example");
828        assert_eq!(before, after, "rebase_onto_main=false must not change HEAD");
829        assert!(result.path.exists(), "worktree directory must be created");
830    }
831
832    #[test]
833    fn create_worktree_new_branch_skips_rebase_regardless_of_flag() {
834        let r = setup_rebase_repo();
835        // feat/new does NOT exist locally.
836        let result =
837            create_worktree(r.path(), "feat/new", true).expect("new-branch creation succeeds");
838        assert!(
839            matches!(
840                result,
841                WorktreeCreation {
842                    branch_created: true,
843                    ..
844                }
845            ),
846            "new branch must report branch_created=true"
847        );
848        assert!(result.path.exists(), "worktree directory must be created");
849    }
850
851    #[cfg(unix)]
852    #[test]
853    fn remove_worktree_does_not_panic_on_non_utf8_path() {
854        // Regression test for the previous `worktree_path.to_str().unwrap()`
855        // panic at the call site in `remove_worktree`. A `PathBuf` built from
856        // non-UTF-8 bytes (legal on Unix) must flow through `Command::arg(...)`
857        // via `as_os_str()` without ever unwrapping to `&str`. The `git`
858        // invocation is expected to fail (the path does not exist); the test
859        // asserts only that we reach the failure path without panicking.
860        use std::ffi::OsString;
861        use std::os::unix::ffi::OsStringExt;
862        use std::path::PathBuf;
863
864        use super::remove_worktree;
865
866        let repo = tempfile::tempdir().expect("tempdir");
867
868        // 0x66 0x80 0x66 — 0x80 is an invalid UTF-8 start byte.
869        let non_utf8 = OsString::from_vec(vec![b'f', 0x80, b'f']);
870        let worktree_path = PathBuf::from(non_utf8);
871
872        // The call must return Err, not panic. `git worktree remove` will
873        // fail because the path doesn't exist, but argv must be constructed
874        // without unwrapping a non-UTF-8 path.
875        let result = remove_worktree(repo.path(), &worktree_path);
876        assert!(result.is_err(), "expected Err for non-existent worktree");
877    }
878}