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/// Returns the list of files with uncommitted changes in `worktree_root`.
509///
510/// Runs `git status --porcelain` inside the worktree and parses each line's
511/// path (the portion after the 3-character XY-status prefix). Untracked,
512/// modified, staged, and renamed entries are all included. An empty vec means
513/// the worktree is clean. Used by `git paw remove`'s uncommitted-work safety
514/// check (design D7) to tell the user exactly what would be lost.
515pub fn uncommitted_files(worktree_root: &Path) -> Result<Vec<String>, PawError> {
516    let output = Command::new("git")
517        .current_dir(worktree_root)
518        .args(["status", "--porcelain"])
519        .output()
520        .map_err(|e| {
521            PawError::WorktreeError(format!(
522                "failed to run git status in {}: {e}",
523                worktree_root.display()
524            ))
525        })?;
526
527    if !output.status.success() {
528        let stderr = String::from_utf8_lossy(&output.stderr);
529        return Err(PawError::WorktreeError(format!(
530            "git status failed in {}: {stderr}",
531            worktree_root.display()
532        )));
533    }
534
535    let stdout = String::from_utf8_lossy(&output.stdout);
536    let mut files = Vec::new();
537    for line in stdout.lines() {
538        if line.len() <= 3 {
539            continue;
540        }
541        // Porcelain v1 format: `XY <path>` (or `XY <old> -> <new>` for
542        // renames). The path starts at byte 3. For renames, report the new
543        // path (the portion after `-> `).
544        let path = &line[3..];
545        let reported = path.rsplit(" -> ").next().unwrap_or(path);
546        files.push(reported.trim().to_string());
547    }
548    Ok(files)
549}
550
551/// Merges the specified branch into the current branch.
552///
553/// Returns `true` if the merge was successful, `false` if there were conflicts.
554pub fn merge_branch(repo_root: &Path, branch: &str) -> Result<bool, PawError> {
555    let output = Command::new("git")
556        .current_dir(repo_root)
557        .args(["merge", "--no-ff", "--no-commit", branch])
558        .output()
559        .map_err(|e| {
560            PawError::WorktreeError(format!("failed to run git merge for branch {branch}: {e}"))
561        })?;
562
563    if !output.status.success() {
564        let stderr = String::from_utf8_lossy(&output.stderr);
565        // Check if this is a conflict (exit code 1) vs other error
566        if output.status.code() == Some(1) {
567            return Ok(false);
568        }
569        return Err(PawError::WorktreeError(format!(
570            "git merge failed for branch {branch}: {stderr}"
571        )));
572    }
573
574    Ok(true)
575}
576
577/// Deletes a branch.
578pub fn delete_branch(repo_root: &Path, branch: &str) -> Result<(), PawError> {
579    let output = Command::new("git")
580        .current_dir(repo_root)
581        .args(["branch", "-D", branch])
582        .output()
583        .map_err(|e| PawError::BranchError(format!("failed to delete branch {branch}: {e}")))?;
584
585    if !output.status.success() {
586        let stderr = String::from_utf8_lossy(&output.stderr);
587        return Err(PawError::BranchError(format!(
588            "git branch -D failed for branch {branch}: {stderr}"
589        )));
590    }
591
592    Ok(())
593}
594
595/// Excludes a file from git tracking by adding it to `.git/info/exclude`.
596///
597/// This prevents the file from being tracked by git without modifying the
598/// repository's `.gitignore` file, which is useful for worktree-specific
599/// files that should not be committed.
600pub fn exclude_from_git(worktree_root: &Path, filename: &str) -> Result<(), PawError> {
601    let exclude_file = worktree_root.join(".git/info/exclude");
602
603    // Read existing exclude patterns
604    let existing = if exclude_file.exists() {
605        std::fs::read_to_string(&exclude_file).unwrap_or_default()
606    } else {
607        String::new()
608    };
609
610    // Add the filename if not already present
611    if !existing.lines().any(|line| line.trim() == filename) {
612        let mut updated = existing;
613        if !updated.ends_with('\n') && !updated.is_empty() {
614            updated.push('\n');
615        }
616        updated.push_str(filename);
617        updated.push('\n');
618
619        // Create .git/info directory if it doesn't exist
620        if let Some(parent) = exclude_file.parent() {
621            // Check if .git (the grandparent) is a file (worktree case)
622            if let Some(git_dir) = parent.parent()
623                && git_dir.is_file()
624            {
625                // This is a worktree - .git is a file pointing to main repo
626                // The actual git directory is inside the main repo
627                let main_git_dir = std::fs::read_to_string(git_dir)
628                    .ok()
629                    .and_then(|s| s.strip_prefix("gitdir: ").map(|s| s.trim().to_owned()))
630                    .unwrap_or_default();
631                let main_git_info = PathBuf::from(main_git_dir).join("info");
632                if !main_git_info.try_exists().unwrap_or(false) {
633                    std::fs::create_dir_all(&main_git_info).map_err(|e| {
634                        PawError::SessionError(format!("failed to create main .git/info: {e}"))
635                    })?;
636                }
637                let main_exclude = main_git_info.join("exclude");
638                std::fs::write(&main_exclude, updated).map_err(|e| {
639                    PawError::SessionError(format!(
640                        "failed to write to main .git/info/exclude: {e}"
641                    ))
642                })?;
643                return Ok(());
644            }
645            if parent.exists() && parent.is_file() {
646                std::fs::remove_file(parent).map_err(|e| {
647                    PawError::SessionError(format!("failed to remove .git/info file: {e}"))
648                })?;
649            }
650            std::fs::create_dir_all(parent).map_err(|e| {
651                PawError::SessionError(format!("failed to create .git/info directory: {e}"))
652            })?;
653        }
654
655        std::fs::write(&exclude_file, updated).map_err(|e| {
656            PawError::SessionError(format!("failed to write to .git/info/exclude: {e}"))
657        })?;
658    }
659
660    Ok(())
661}
662
663/// Marks a file as assume-unchanged in git's index.
664///
665/// This prevents `git add -A`, `git add .`, and `git commit -a` from
666/// staging the file. Returns `Ok` even if the command fails, as this
667/// is a belt-and-suspenders measure.
668pub fn assume_unchanged(worktree_root: &Path, filename: &str) -> Result<(), PawError> {
669    // `.output()` rather than `.status()` so git's "fatal: Unable to mark
670    // file" stderr (emitted when the file isn't tracked) doesn't bleed
671    // through to the parent process. This is belt-and-suspenders — failure
672    // is silent by design because `exclude_from_git` is the primary
673    // protection for untracked AGENTS.md.
674    let _ = std::process::Command::new("git")
675        .current_dir(worktree_root)
676        .args(["update-index", "--assume-unchanged", filename])
677        .output();
678    Ok(())
679}
680
681#[cfg(test)]
682mod tests {
683    use std::path::{Path, PathBuf};
684    use std::process::Command;
685
686    use tempfile::TempDir;
687
688    use crate::error::PawError;
689    use crate::git::{WorktreeCreation, create_worktree};
690
691    /// Sets up a temp repo with `origin/HEAD` pointing to `refs/heads/main`,
692    /// an initial commit on `main`, and the `feat/example` branch at the same
693    /// commit. The fixture is what `create_worktree` expects when called with
694    /// `rebase_onto_main = true`.
695    struct RebaseRepo {
696        _sandbox: TempDir,
697        repo: PathBuf,
698    }
699
700    impl RebaseRepo {
701        fn path(&self) -> &Path {
702            &self.repo
703        }
704    }
705
706    fn run_git(dir: &Path, args: &[&str]) {
707        let output = Command::new("git")
708            .current_dir(dir)
709            .args(args)
710            .output()
711            .expect("run git command");
712        assert!(
713            output.status.success(),
714            "git {} failed: {}",
715            args.join(" "),
716            String::from_utf8_lossy(&output.stderr)
717        );
718    }
719
720    fn capture_git(dir: &Path, args: &[&str]) -> String {
721        let output = Command::new("git")
722            .current_dir(dir)
723            .args(args)
724            .output()
725            .expect("run git command");
726        assert!(
727            output.status.success(),
728            "git {} failed: {}",
729            args.join(" "),
730            String::from_utf8_lossy(&output.stderr)
731        );
732        String::from_utf8_lossy(&output.stdout).trim().to_string()
733    }
734
735    /// Builds a repo with `origin/main` tracking set up so `default_branch()`
736    /// resolves cleanly. The repo is on `main` at one commit; `feat/example`
737    /// is created at the same commit (caller advances either side as needed).
738    fn setup_rebase_repo() -> RebaseRepo {
739        let sandbox = TempDir::new().expect("tempdir");
740        let bare = sandbox.path().join("bare.git");
741        let repo = sandbox.path().join("repo");
742        std::fs::create_dir_all(&bare).unwrap();
743
744        run_git(&bare, &["init", "--bare", "-b", "main"]);
745
746        // Clone the bare repo as a worktree-capable working repo.
747        let status = Command::new("git")
748            .args([
749                "clone",
750                bare.to_str().unwrap(),
751                repo.to_str().unwrap(),
752                "--origin",
753                "origin",
754            ])
755            .status()
756            .expect("git clone");
757        assert!(status.success());
758
759        run_git(&repo, &["config", "user.email", "test@test.com"]);
760        run_git(&repo, &["config", "user.name", "Test"]);
761        run_git(&repo, &["checkout", "-b", "main"]);
762        std::fs::write(repo.join("a.txt"), "one\n").unwrap();
763        run_git(&repo, &["add", "."]);
764        run_git(&repo, &["commit", "-m", "init"]);
765        run_git(&repo, &["push", "-u", "origin", "main"]);
766        run_git(&bare, &["symbolic-ref", "HEAD", "refs/heads/main"]);
767        run_git(&repo, &["remote", "set-head", "origin", "main"]);
768        run_git(&repo, &["branch", "feat/example"]);
769
770        RebaseRepo {
771            _sandbox: sandbox,
772            repo,
773        }
774    }
775
776    fn advance_main(repo: &Path, commits: usize) {
777        for i in 0..commits {
778            std::fs::write(repo.join(format!("main-{i}.txt")), format!("v{i}\n")).unwrap();
779            run_git(repo, &["add", "."]);
780            run_git(repo, &["commit", "-m", &format!("main commit {i}")]);
781        }
782    }
783
784    fn head_sha(repo: &Path, branch: &str) -> String {
785        capture_git(repo, &["rev-parse", branch])
786    }
787
788    #[test]
789    fn create_worktree_rebases_branch_when_behind_main() {
790        let r = setup_rebase_repo();
791        advance_main(r.path(), 2);
792
793        let result = create_worktree(r.path(), "feat/example", true).expect("rebase succeeds");
794        assert!(
795            matches!(
796                result,
797                WorktreeCreation {
798                    branch_created: false,
799                    ..
800                }
801            ),
802            "branch existed, branch_created must be false"
803        );
804        assert!(result.path.exists(), "worktree directory must be created");
805
806        // feat/example contains main's commits → 0 commits in feat..main.
807        let count = capture_git(r.path(), &["rev-list", "--count", "feat/example..main"]);
808        assert_eq!(count, "0", "feat/example must include main's commits");
809    }
810
811    #[test]
812    fn create_worktree_rebase_noop_when_branch_up_to_date() {
813        let r = setup_rebase_repo();
814        // Branch is already at main HEAD — rebase is a no-op.
815        let before = head_sha(r.path(), "feat/example");
816        let _result =
817            create_worktree(r.path(), "feat/example", true).expect("noop rebase succeeds");
818        let after = head_sha(r.path(), "feat/example");
819        assert_eq!(before, after, "noop rebase must not change HEAD");
820    }
821
822    #[test]
823    fn create_worktree_rebase_conflict_aborts_and_errors() {
824        let r = setup_rebase_repo();
825
826        // Diverge: modify a.txt on feat/example, then modify the same line on
827        // main with a different content. Rebase will conflict.
828        run_git(r.path(), &["checkout", "feat/example"]);
829        std::fs::write(r.path().join("a.txt"), "feat-version\n").unwrap();
830        run_git(r.path(), &["add", "."]);
831        run_git(r.path(), &["commit", "-m", "feat edit"]);
832        run_git(r.path(), &["checkout", "main"]);
833        std::fs::write(r.path().join("a.txt"), "main-version\n").unwrap();
834        run_git(r.path(), &["add", "."]);
835        run_git(r.path(), &["commit", "-m", "main edit"]);
836
837        let pre = head_sha(r.path(), "feat/example");
838        let result = create_worktree(r.path(), "feat/example", true);
839        let err = result.expect_err("rebase must error on conflict");
840        match err {
841            PawError::WorktreeError(msg) => assert!(
842                msg.contains("rebase onto main failed"),
843                "expected 'rebase onto main failed' in error, got: {msg}"
844            ),
845            other => panic!("expected WorktreeError, got {other:?}"),
846        }
847
848        let post = head_sha(r.path(), "feat/example");
849        assert_eq!(pre, post, "branch HEAD must be restored after abort");
850
851        let git_dir = r.path().join(".git");
852        assert!(
853            !git_dir.join("rebase-merge").exists(),
854            "rebase-merge dir must not survive abort"
855        );
856        assert!(
857            !git_dir.join("rebase-apply").exists(),
858            "rebase-apply dir must not survive abort"
859        );
860    }
861
862    #[test]
863    fn create_worktree_no_rebase_preserves_v0_5_behaviour() {
864        let r = setup_rebase_repo();
865        advance_main(r.path(), 2);
866
867        let before = head_sha(r.path(), "feat/example");
868        let result =
869            create_worktree(r.path(), "feat/example", false).expect("no-rebase path succeeds");
870        let after = head_sha(r.path(), "feat/example");
871        assert_eq!(before, after, "rebase_onto_main=false must not change HEAD");
872        assert!(result.path.exists(), "worktree directory must be created");
873    }
874
875    #[test]
876    fn create_worktree_new_branch_skips_rebase_regardless_of_flag() {
877        let r = setup_rebase_repo();
878        // feat/new does NOT exist locally.
879        let result =
880            create_worktree(r.path(), "feat/new", true).expect("new-branch creation succeeds");
881        assert!(
882            matches!(
883                result,
884                WorktreeCreation {
885                    branch_created: true,
886                    ..
887                }
888            ),
889            "new branch must report branch_created=true"
890        );
891        assert!(result.path.exists(), "worktree directory must be created");
892    }
893
894    #[cfg(unix)]
895    #[test]
896    fn remove_worktree_does_not_panic_on_non_utf8_path() {
897        // Regression test for the previous `worktree_path.to_str().unwrap()`
898        // panic at the call site in `remove_worktree`. A `PathBuf` built from
899        // non-UTF-8 bytes (legal on Unix) must flow through `Command::arg(...)`
900        // via `as_os_str()` without ever unwrapping to `&str`. The `git`
901        // invocation is expected to fail (the path does not exist); the test
902        // asserts only that we reach the failure path without panicking.
903        use std::ffi::OsString;
904        use std::os::unix::ffi::OsStringExt;
905        use std::path::PathBuf;
906
907        use super::remove_worktree;
908
909        let repo = tempfile::tempdir().expect("tempdir");
910
911        // 0x66 0x80 0x66 — 0x80 is an invalid UTF-8 start byte.
912        let non_utf8 = OsString::from_vec(vec![b'f', 0x80, b'f']);
913        let worktree_path = PathBuf::from(non_utf8);
914
915        // The call must return Err, not panic. `git worktree remove` will
916        // fail because the path doesn't exist, but argv must be constructed
917        // without unwrapping a non-UTF-8 path.
918        let result = remove_worktree(repo.path(), &worktree_path);
919        assert!(result.is_err(), "expected Err for non-existent worktree");
920    }
921}