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