Skip to main content

git_stk/
git.rs

1use std::io::Write;
2use std::process::{Command, Stdio};
3use std::sync::atomic::{AtomicBool, Ordering};
4
5use anyhow::{Context, Result, anyhow, bail};
6
7static VERBOSE: AtomicBool = AtomicBool::new(false);
8
9/// Pass raw git output through instead of capturing it.
10pub fn set_verbose(verbose: bool) {
11    VERBOSE.store(verbose, Ordering::Relaxed);
12}
13
14fn verbose() -> bool {
15    VERBOSE.load(Ordering::Relaxed)
16}
17
18pub fn current_branch() -> Result<String> {
19    output(&["symbolic-ref", "--quiet", "--short", "HEAD"])
20        .context("failed to determine current branch")
21}
22
23/// Whether the working directory is inside a git work tree. Used for a clean
24/// "not a git repository" message instead of letting git's raw error surface
25/// from the first command that needs the repo.
26pub fn is_in_repo() -> bool {
27    Command::new("git")
28        .args(["rev-parse", "--is-inside-work-tree"])
29        .stdout(Stdio::piped())
30        .stderr(Stdio::piped())
31        .output()
32        .is_ok_and(|out| out.status.success() && out.stdout.starts_with(b"true"))
33}
34
35pub fn local_branches() -> Result<Vec<String>> {
36    let output = output(&["for-each-ref", "--format=%(refname:short)", "refs/heads"])?;
37    Ok(output.lines().map(str::to_owned).collect())
38}
39
40pub fn git_path(path: &str) -> Result<String> {
41    output(&["rev-parse", "--git-path", path])
42}
43
44/// The repository's top-level working-tree directory.
45pub fn repo_root() -> Result<std::path::PathBuf> {
46    Ok(std::path::PathBuf::from(output(&[
47        "rev-parse",
48        "--show-toplevel",
49    ])?))
50}
51
52/// Resolve `path` under the repo's *common* git dir, which all linked
53/// worktrees share, rather than the per-worktree dir `git_path` returns. Use
54/// this for state that guards or mirrors the shared config (`branch.*`), so
55/// every worktree of a repo agrees on one file.
56pub fn git_common_path(path: &str) -> Result<String> {
57    let common_dir = output(&["rev-parse", "--git-common-dir"])?;
58    Ok(std::path::Path::new(&common_dir)
59        .join(path)
60        .to_string_lossy()
61        .into_owned())
62}
63
64/// Branches checked out in linked worktrees *other than this one*, paired with
65/// the directory holding each. Git refuses to switch to, rebase, or delete a
66/// branch another worktree holds, so callers check this before those.
67pub fn worktree_branches() -> Result<Vec<(String, std::path::PathBuf)>> {
68    let porcelain = output(&["worktree", "list", "--porcelain"])?;
69    Ok(parse_worktree_branches(
70        &porcelain,
71        repo_root().ok().as_deref(),
72    ))
73}
74
75/// Add a detached worktree at `path`, parked on `commit`. Detached on purpose:
76/// it holds no branch, so it cannot collide with the user's checkout or any
77/// other worktree.
78///
79/// `--force` because `path` is a scratch directory git-stk owns outright: a
80/// killed run can leave the directory gone but still registered, and git then
81/// refuses the path as "a missing but already registered worktree". Forcing is
82/// scoped to that one path - `git worktree prune` would also clear entries for
83/// the user's own worktrees that happen to be on unmounted volumes.
84pub fn worktree_add_detached(path: &std::path::Path, commit: &str) -> Result<()> {
85    let path = path.to_string_lossy().into_owned();
86    status(&[
87        "worktree", "add", "--detach", "--force", "--quiet", &path, commit,
88    ])
89    .with_context(|| format!("failed to create a worktree at {path}"))
90}
91
92/// Add a worktree at `path` holding a new branch created off `start`.
93pub fn worktree_add_new_branch(path: &std::path::Path, branch: &str, start: &str) -> Result<()> {
94    let path = path.to_string_lossy().into_owned();
95    status(&["worktree", "add", "--quiet", "-b", branch, &path, start])
96        .with_context(|| format!("failed to create a worktree for {branch} at {path}"))
97}
98
99/// Whether a worktree has uncommitted changes. Used before removing one git-stk
100/// created, so work in it is never silently discarded.
101pub fn worktree_has_changes(path: &std::path::Path) -> bool {
102    let dir = path.to_string_lossy().into_owned();
103    // Fails safe: if the state cannot be read at all, assume there is work to
104    // lose. The caller removes with --force, so guessing "clean" here would
105    // discard exactly what this guard exists to protect.
106    output(&["-C", &dir, "status", "--porcelain"]).map_or(true, |out| !out.is_empty())
107}
108
109/// Remove a worktree, discarding anything in it. Only for worktrees git-stk
110/// created and owns.
111pub fn worktree_remove(path: &std::path::Path) -> Result<()> {
112    let path = path.to_string_lossy().into_owned();
113    status(&["worktree", "remove", "--force", &path])
114        .with_context(|| format!("failed to remove the worktree at {path}"))
115}
116
117/// Move an existing worktree's detached HEAD to `commit`, without touching any
118/// branch.
119pub fn checkout_detached_in(worktree: &std::path::Path, commit: &str) -> Result<()> {
120    let dir = worktree.to_string_lossy().into_owned();
121    status(&["-C", &dir, "checkout", "--detach", "--quiet", commit])
122        .with_context(|| format!("failed to check out {commit} in {dir}"))
123}
124
125/// An absolute path under the repo's common git dir. Callers that hand a path to
126/// another process (a worktree location, a command's working directory) need it
127/// absolute, since a relative one would be read against the wrong directory.
128pub fn git_common_path_absolute(path: &str) -> Result<std::path::PathBuf> {
129    let joined = git_common_path(path)?;
130    std::path::absolute(&joined).with_context(|| format!("failed to resolve {joined}"))
131}
132
133/// The worktree holding `branch`, if one other than this one does.
134pub fn worktree_holding(branch: &str) -> Result<Option<std::path::PathBuf>> {
135    Ok(worktree_branches()?
136        .into_iter()
137        .find(|(name, _)| name == branch)
138        .map(|(_, path)| path))
139}
140
141/// Whether this command is running inside a linked worktree rather than the
142/// main checkout. Best effort: an unreadable root reads as the main worktree,
143/// which is where most runs happen.
144pub fn in_linked_worktree() -> bool {
145    repo_root().is_ok_and(|root| !is_main_worktree(&root))
146}
147
148/// Whether `path` is the repo's main worktree. Worth telling apart because
149/// `git worktree remove` refuses on it, so any advice that would free a branch
150/// by removing its worktree is a dead end there.
151pub fn is_main_worktree(path: &std::path::Path) -> bool {
152    // Best effort: an unreadable listing just means the path goes undistinguished
153    // and the advice stays the one that works everywhere.
154    main_worktree().is_some_and(|main| same_path(&main, path))
155}
156
157/// The main worktree - the first record `git worktree list` reports.
158fn main_worktree() -> Option<std::path::PathBuf> {
159    parse_main_worktree(&output(&["worktree", "list", "--porcelain"]).ok()?)
160}
161
162/// The anchor for repo-wide paths that must resolve the same from every
163/// worktree. [`repo_root`] answers "where am I", which inside a linked worktree
164/// is that worktree - so a default derived from it would nest a new worktree
165/// under the one it was created from. Falls back to the root when the listing
166/// cannot be read, which is where a repo with no linked worktrees lands anyway.
167pub fn main_worktree_root() -> Result<std::path::PathBuf> {
168    match main_worktree() {
169        Some(path) => Ok(path),
170        None => repo_root(),
171    }
172}
173
174fn parse_main_worktree(porcelain: &str) -> Option<std::path::PathBuf> {
175    porcelain
176        .lines()
177        .find_map(|line| line.strip_prefix("worktree "))
178        .map(std::path::PathBuf::from)
179}
180
181/// How to hand a branch back, as a command the user can paste. Detaching is what
182/// the guards lead with because it works on every worktree: `git worktree remove`
183/// refuses on the main one, and moving the operation into the holding worktree
184/// only helps when that worktree is the only one in the way.
185pub fn detach_command(path: &std::path::Path) -> String {
186    // Quoted: a worktree path containing a space would otherwise be pasted back
187    // as two arguments.
188    format!("git -C \"{}\" checkout --detach", display_path(path))
189}
190
191/// A worktree path for a message, tagged when it is the main one so the reader
192/// knows why removing it is not among the options.
193pub fn describe_worktree(path: &std::path::Path) -> String {
194    let shown = display_path(path);
195    if is_main_worktree(path) {
196        format!("{shown} (the main worktree)")
197    } else {
198        shown
199    }
200}
201
202/// Collapse worktree paths to the distinct places involved, so a message about
203/// three branches held by one worktree suggests freeing it once, not three times.
204pub fn distinct_paths<'a>(
205    paths: impl IntoIterator<Item = &'a std::path::Path>,
206) -> Vec<std::path::PathBuf> {
207    let mut distinct: Vec<std::path::PathBuf> = Vec::new();
208    for path in paths {
209        if !distinct.iter().any(|seen| same_path(seen, path)) {
210            distinct.push(path.to_path_buf());
211        }
212    }
213    distinct
214}
215
216/// Parse `git worktree list --porcelain` into (branch, path) pairs. Records are
217/// blank-line separated, each opening with `worktree <path>`; only those with a
218/// `branch` line hold a branch, so bare and detached ones drop out. The record
219/// rooted at `current` is excluded, letting callers read a hit as "someone else
220/// holds this".
221fn parse_worktree_branches(
222    porcelain: &str,
223    current: Option<&std::path::Path>,
224) -> Vec<(String, std::path::PathBuf)> {
225    let current = current.map(canonical);
226    let mut held = Vec::new();
227    let mut path: Option<std::path::PathBuf> = None;
228
229    for line in porcelain.lines() {
230        if let Some(rest) = line.strip_prefix("worktree ") {
231            path = Some(std::path::PathBuf::from(rest));
232        } else if let Some(branch) = line.strip_prefix("branch refs/heads/") {
233            // take() so a record without a branch line cannot borrow the next
234            // record's path.
235            if let Some(path) = path.take()
236                && current.as_deref() != Some(canonical(&path).as_path())
237            {
238                held.push((branch.to_owned(), path));
239            }
240        }
241    }
242
243    held
244}
245
246/// Resolve a worktree path for comparison. Symlinked or `/tmp`-style paths
247/// otherwise read as a different worktree than the one we are standing in.
248fn canonical(path: &std::path::Path) -> std::path::PathBuf {
249    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
250}
251
252/// Whether two paths name the same place. Never compare worktree paths with
253/// `==`: git reports its own resolved form, which differs from anything we
254/// build ourselves - `/var` against `/private/var` on macOS, forward against
255/// back slashes on Windows - so exact equality quietly reports "different".
256pub fn same_path(a: &std::path::Path, b: &std::path::Path) -> bool {
257    canonical(a) == canonical(b)
258}
259
260/// Render a worktree path for a message the user may paste back as a command.
261/// Sibling worktrees are the common layout and `../wt-a` reads better than a
262/// long absolute path. Only exact prefix matches are shortened, so the result is
263/// always a usable path - never a guess.
264pub fn display_path(path: &std::path::Path) -> String {
265    let Ok(cwd) = std::env::current_dir() else {
266        return path.display().to_string();
267    };
268
269    if let Ok(rest) = path.strip_prefix(&cwd)
270        && rest.components().next().is_some()
271    {
272        return format!("./{}", rest.display());
273    }
274    if let Some(up) = cwd.parent()
275        && let Ok(rest) = path.strip_prefix(up)
276        && rest.components().next().is_some()
277    {
278        return format!("../{}", rest.display());
279    }
280
281    path.display().to_string()
282}
283
284pub fn remote_url(remote: &str) -> Result<Option<String>> {
285    // git remote get-url exits 2 when the remote does not exist.
286    output_codes(&["remote", "get-url", remote], &[2], "git remote get-url")
287}
288
289/// The explanation for an operation git refuses because another worktree holds
290/// `branch` - checkout and rebase both hit this, and should say the same thing.
291/// Asked structurally rather than by matching git's wording, which varies across
292/// versions and locales. An unanswerable query (very old git, an odd setup)
293/// yields None and the caller falls through to git's own error, as before.
294fn worktree_collision(branch: &str) -> Option<String> {
295    let path = worktree_holding(branch).ok().flatten()?;
296    Some(collision_message(
297        branch,
298        &display_path(&path),
299        is_main_worktree(&path),
300    ))
301}
302
303/// The wording, split out so it can be checked directly. The suggested commands
304/// are quoted: a worktree path containing a space would otherwise be pasted back
305/// as two arguments. Removal is only offered for a linked worktree - git refuses
306/// to remove the main one, so suggesting it there sends the user nowhere.
307fn collision_message(branch: &str, shown: &str, is_main: bool) -> String {
308    let mut free = format!("free it with `git -C \"{shown}\" checkout --detach`");
309    if !is_main {
310        free.push_str(&format!(
311            ", or drop that worktree with `git worktree remove \"{shown}\"`"
312        ));
313    }
314    format!(
315        "{branch} is checked out in the worktree at {shown}\n\
316         work on it there with `cd \"{shown}\"`, or {free}"
317    )
318}
319
320pub fn checkout(branch: &str) -> Result<()> {
321    checkout_silently(branch)?;
322    anstream::println!("switched to {}", switched_to(branch));
323    Ok(())
324}
325
326/// Switch without announcing it on stdout, for callers whose stdout carries a
327/// value a shell will consume. They report the switch themselves, on stderr.
328pub fn checkout_silently(branch: &str) -> Result<()> {
329    if let Some(message) = worktree_collision(branch) {
330        bail!(message);
331    }
332
333    status(&["switch", branch]).with_context(|| format!("failed to check out {branch}"))
334}
335
336/// The "switched to <branch>" wording, so stdout and stderr callers agree.
337pub fn switched_to(branch: &str) -> String {
338    crate::style::paint(crate::style::BRANCH, branch)
339}
340
341pub fn create_branch(branch: &str) -> Result<()> {
342    status(&["switch", "-c", branch]).with_context(|| format!("failed to create branch {branch}"))
343}
344
345/// Create a branch pointing at `sha` without checking it out or touching the
346/// working tree - used by `split` to point new branches at existing commits.
347pub fn create_branch_at(branch: &str, sha: &str) -> Result<()> {
348    status(&["branch", branch, sha])
349        .with_context(|| format!("failed to create branch {branch} at {sha}"))
350}
351
352/// Force-delete a branch. Use only once review state confirms it landed: a
353/// squash merge leaves the commits non-ancestry-merged, so `git branch -d`
354/// would refuse even though the work is in.
355pub fn delete_branch(branch: &str) -> Result<()> {
356    status(&["branch", "-D", branch]).with_context(|| format!("failed to delete branch {branch}"))
357}
358
359/// Rename a branch; git moves its `branch.<name>.*` config along with it.
360pub fn rename_branch(old: &str, new: &str) -> Result<()> {
361    status(&["branch", "-m", old, new]).with_context(|| format!("failed to rename {old} to {new}"))
362}
363
364/// Fast-forward a local branch from its remote without checking it out.
365pub fn fetch_branch(remote: &str, branch: &str) -> Result<()> {
366    let refspec = format!("{branch}:{branch}");
367    status(&["fetch", remote, &refspec])
368        .with_context(|| format!("failed to fetch {branch} from {remote}"))
369}
370
371pub fn pull_ff_only() -> Result<()> {
372    status(&["pull", "--ff-only"]).context("failed to fast-forward from the remote")
373}
374
375/// Force-push `branches` (with lease), returning the branches that actually
376/// landed. Normally that is all of them; the exception is the merge-queue
377/// backstop below, which drops a held-back branch from the returned set so the
378/// caller never reports a branch as both held and pushed.
379pub fn push_force_with_lease(remote: &str, branches: &[String]) -> Result<Vec<String>> {
380    let mut args = vec!["push", "--force-with-lease", remote];
381    args.extend(branches.iter().map(String::as_str));
382
383    run_lease_push(&args, remote, branches)
384}
385
386/// Run a force-with-lease push, returning the branches that actually landed,
387/// and classifying the two rejections git-stk can explain better than raw git
388/// output:
389///
390/// - **Merge queue** (GitHub locks a queued branch): the ref is rejected with
391///   GH006 while its siblings push fine. `restack`/`sync` already freeze
392///   branches they know are queued, so this is the backstop for one enqueued
393///   mid-run - the held ref is reported and dropped from the returned set, the
394///   successful refs stand, and the push is not failed.
395/// - **Stale lease** (the remote moved on, usually because a branch in the
396///   stack merged): the lease no longer matches, so git rejects with `stale
397///   info`/`non-fast-forward`. `git stk sync` reconciles it, so say so instead
398///   of leaving the user with git's plumbing error.
399///
400/// Anything else surfaces with git's own output, unchanged.
401fn run_lease_push(args: &[&str], remote: &str, branches: &[String]) -> Result<Vec<String>> {
402    // Verbose mode streams straight through, so there is no captured stderr to
403    // classify; fall back to the plain path. (A rejection there still shows
404    // git's own message, just without the friendlier translation.)
405    if verbose() {
406        status_passthrough(args).with_context(|| format!("failed to push branches to {remote}"))?;
407        return Ok(branches.to_vec());
408    }
409
410    let output = Command::new("git")
411        .args(args)
412        .output()
413        .context("failed to run git")?;
414    if output.status.success() {
415        return Ok(branches.to_vec());
416    }
417
418    // A GitHub branch sitting in a merge queue is locked, so its ref is rejected
419    // with GH006 while its siblings push fine; git then exits non-zero even
420    // though the rest landed. `restack`/`sync` already freeze branches they know
421    // are queued, so this is the backstop for one enqueued mid-run: report the
422    // held ref, drop it from the landed set, and let the successful refs stand.
423    // Any rejection that is not purely the merge queue (a stale lease, a
424    // non-fast-forward) still surfaces as an error.
425    let stderr = String::from_utf8_lossy(&output.stderr);
426    if let Some(queued) = merge_queue_rejection(&stderr) {
427        anstream::eprintln!(
428            "{}",
429            crate::style::warn(&format!(
430                "{} {} in a merge queue and was not updated (dequeue its review to push it)",
431                queued.join(", "),
432                if queued.len() == 1 { "is" } else { "are" },
433            ))
434        );
435        return Ok(landed_branches(branches, &queued));
436    }
437
438    if let Some(stale) = stale_rejection(&stderr) {
439        // The user asked for a clean message, not raw git/GitHub noise, so the
440        // captured output is dropped in favor of the actionable guidance.
441        bail!(
442            "could not push {} to {remote}: the remote has moved on \
443             (a branch in the stack was likely merged or updated upstream)\n\
444             run `git stk sync` to reconcile your local stack with the remote, then try again",
445            stale.join(", "),
446        );
447    }
448
449    let _ = std::io::stdout().write_all(&output.stdout);
450    let _ = std::io::stderr().write_all(&output.stderr);
451    bail!(
452        "failed to push branches to {remote}: git exited with status {}",
453        output.status
454    )
455}
456
457/// The branches that landed: everything attempted except those held back by
458/// the merge queue, preserving the attempted order.
459fn landed_branches(attempted: &[String], held: &[String]) -> Vec<String> {
460    attempted
461        .iter()
462        .filter(|branch| !held.iter().any(|name| name == *branch))
463        .cloned()
464        .collect()
465}
466
467/// The rejected refs when a push failed *only* because they are in a merge
468/// queue, or None when any other failure is mixed in. A genuine lease/
469/// fast-forward rejection (`stale info`, `non-fast-forward`, `fetch first`)
470/// returns None so it is classified as stale instead; a queue rejection with
471/// no such marker returns the branch names so the caller can report them and
472/// carry on.
473fn merge_queue_rejection(stderr: &str) -> Option<Vec<String>> {
474    let lower = stderr.to_lowercase();
475    let mentions_queue = lower.contains("merge queue") || lower.contains("queued for merging");
476    if !mentions_queue {
477        return None;
478    }
479    // A lease or fast-forward failure is a real problem, not a queue lock - do
480    // not swallow a push that failed for those reasons too.
481    if ["stale info", "non-fast-forward", "fetch first"]
482        .iter()
483        .any(|marker| lower.contains(marker))
484    {
485        return None;
486    }
487    let rejected = rejected_refs(stderr);
488    if rejected.is_empty() {
489        None
490    } else {
491        Some(rejected)
492    }
493}
494
495/// The rejected refs when a push was refused because the local side is behind
496/// the remote: a `--force-with-lease` lease mismatch (`stale info`), or a plain
497/// `non-fast-forward`/`fetch first`. This is the remote having moved on - in a
498/// stack, almost always a lower branch that merged - which `git stk sync`
499/// reconciles.
500///
501/// Returns Some only when *every* rejected ref is stale: the friendly "run
502/// sync" message replaces git's raw output, so a non-stale rejection mixed in
503/// (a permission denial, a declined hook) - which sync would not fix - must
504/// fall through to git's own error instead of being hidden behind sync advice.
505/// None when nothing was rejected, or any rejection was for another reason.
506fn stale_rejection(stderr: &str) -> Option<Vec<String>> {
507    let rejected: Vec<&str> = stderr
508        .lines()
509        .filter(|line| line.contains("[remote rejected]") || line.contains("[rejected]"))
510        .collect();
511    if rejected.is_empty() || !rejected.iter().all(|line| line_is_stale(line)) {
512        return None;
513    }
514    let names: Vec<String> = rejected
515        .iter()
516        .filter_map(|line| rejected_ref_name(line))
517        .collect();
518    if names.is_empty() { None } else { Some(names) }
519}
520
521/// Whether a rejected-ref line was refused because the local side is behind the
522/// remote (a `--force-with-lease` lease mismatch or a non-fast-forward), rather
523/// than a permission/hook refusal. The reason is in the line's trailing `(…)`.
524fn line_is_stale(line: &str) -> bool {
525    let lower = line.to_lowercase();
526    ["stale info", "non-fast-forward", "fetch first"]
527        .iter()
528        .any(|marker| lower.contains(marker))
529}
530
531/// The remote-side ref name from a single `! [remote rejected] <local> ->
532/// <remote> (reason)` line.
533fn rejected_ref_name(line: &str) -> Option<String> {
534    let after = line.split("-> ").nth(1)?;
535    Some(after.split_whitespace().next()?.to_owned())
536}
537
538/// The remote-side ref names from a push's `! [remote rejected]`/`! [rejected]`
539/// lines, regardless of reason.
540fn rejected_refs(stderr: &str) -> Vec<String> {
541    stderr
542        .lines()
543        .filter(|line| line.contains("[remote rejected]") || line.contains("[rejected]"))
544        .filter_map(rejected_ref_name)
545        .collect()
546}
547
548/// Push branches and set upstream tracking; used before submitting so new
549/// branches exist remotely and rebased ones are safely updated.
550pub fn push_set_upstream_force_with_lease(remote: &str, branches: &[String]) -> Result<()> {
551    let mut args = vec!["push", "--set-upstream", "--force-with-lease", remote];
552    args.extend(branches.iter().map(String::as_str));
553
554    // submit does not need the landed set; a held-back branch is still warned
555    // about inside run_lease_push.
556    run_lease_push(&args, remote, branches)?;
557    Ok(())
558}
559
560/// Store `content` as a single-file commit and point `reference` at it, so the
561/// data rides along a normal ref push. Orphan each time: the ref just moves to
562/// the new commit (callers force-push it, as it is regenerable).
563pub fn write_blob_ref(reference: &str, file: &str, content: &str) -> Result<()> {
564    let blob = output_with_stdin(&["hash-object", "-w", "--stdin"], content)
565        .context("failed to hash stack metadata")?;
566    let tree = output_with_stdin(&["mktree"], &format!("100644 blob {blob}\t{file}\n"))
567        .context("failed to write stack metadata tree")?;
568    let commit = output(&["commit-tree", &tree, "-m", "git-stk stack metadata"])
569        .context("failed to commit stack metadata")?;
570    status(&["update-ref", reference, &commit])
571        .with_context(|| format!("failed to update {reference}"))
572}
573
574/// Force-push a single ref to `remote` (the value is regenerable, so
575/// last-writer-wins is fine).
576pub fn push_ref(remote: &str, reference: &str) -> Result<()> {
577    status(&[
578        "push",
579        "--force",
580        remote,
581        &format!("{reference}:{reference}"),
582    ])
583    .with_context(|| format!("failed to push {reference} to {remote}"))
584}
585
586/// Force-fetch a single ref from `remote` into the same local ref.
587pub fn fetch_ref(remote: &str, reference: &str) -> Result<()> {
588    status(&["fetch", remote, &format!("+{reference}:{reference}")])
589        .with_context(|| format!("failed to fetch {reference} from {remote}"))
590}
591
592/// The contents of `file` in the commit `reference` points at, or None when
593/// the ref or file is absent.
594pub fn read_ref_file(reference: &str, file: &str) -> Result<Option<String>> {
595    let output = Command::new("git")
596        .args(["cat-file", "blob", &format!("{reference}:{file}")])
597        .stdout(Stdio::piped())
598        .stderr(Stdio::piped())
599        .output()
600        .context("failed to run git cat-file")?;
601    if output.status.success() {
602        Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()))
603    } else {
604        Ok(None)
605    }
606}
607
608pub fn rebase(parent: &str, branch: &str, update_refs: bool) -> Result<()> {
609    if let Some(message) = worktree_collision(branch) {
610        bail!(message);
611    }
612    let mut args = vec!["rebase"];
613    if update_refs {
614        args.push("--update-refs");
615    }
616    args.extend([parent, branch]);
617
618    status(&args).with_context(|| format!("failed to rebase {branch} onto {parent}"))
619}
620
621/// Rebase only the commits after `base`, replaying `base..branch` onto
622/// `parent`. Used when the recorded fork point is known so commits that
623/// landed upstream by squash or rebase are not replayed.
624pub fn rebase_onto(parent: &str, base: &str, branch: &str, update_refs: bool) -> Result<()> {
625    if let Some(message) = worktree_collision(branch) {
626        bail!(message);
627    }
628    let mut args = vec!["rebase"];
629    if update_refs {
630        args.push("--update-refs");
631    }
632    args.extend(["--onto", parent, base, branch]);
633
634    status(&args).with_context(|| format!("failed to rebase {branch} onto {parent} from {base}"))
635}
636
637pub fn rev_parse(rev: &str) -> Result<String> {
638    let spec = format!("{rev}^{{commit}}");
639    output(&["rev-parse", "--verify", &spec]).with_context(|| format!("failed to resolve {rev}"))
640}
641
642/// The commit a branch points at, or None when the branch does not exist.
643pub fn branch_sha(branch: &str) -> Option<String> {
644    rev_parse(branch).ok()
645}
646
647/// Point a branch at a commit, creating it if absent. Does not touch the
648/// worktree.
649pub fn update_ref(branch: &str, sha: &str) -> Result<()> {
650    status(&["update-ref", &format!("refs/heads/{branch}"), sha])
651        .with_context(|| format!("failed to update {branch} to {sha}"))
652}
653
654/// Reset the worktree and index to HEAD. Safe to lose nothing only on a
655/// clean tree; callers must check [`worktree_is_clean`] first.
656pub fn reset_hard() -> Result<()> {
657    status(&["reset", "--hard"]).context("failed to reset the worktree")
658}
659
660/// Whether the worktree and index have no uncommitted changes.
661pub fn worktree_is_clean() -> Result<bool> {
662    Ok(output(&["status", "--porcelain"])?.is_empty())
663}
664
665/// Default branch of `remote` (from its locally-known HEAD symref), if any.
666pub fn remote_default_branch(remote: &str) -> Option<String> {
667    let reference = format!("refs/remotes/{remote}/HEAD");
668    let full = output(&["symbolic-ref", "--short", &reference]).ok()?;
669    full.strip_prefix(&format!("{remote}/")).map(str::to_owned)
670}
671
672/// How many commits `parent` has that `branch` does not: nonzero means the
673/// branch needs a restack.
674pub fn commits_behind(branch: &str, parent: &str) -> Result<usize> {
675    let range = format!("{branch}..{parent}");
676    let count = output(&["rev-list", "--count", &range])
677        .with_context(|| format!("failed to count commits in {range}"))?;
678    count
679        .trim()
680        .parse()
681        .context("failed to parse rev-list count")
682}
683
684pub fn merge_base(a: &str, b: &str) -> Result<String> {
685    output(&["merge-base", a, b])
686        .with_context(|| format!("failed to find merge base of {a} and {b}"))
687}
688
689/// A unified-0 diff against HEAD: just the staged changes when `cached`,
690/// otherwise all tracked changes (staged and unstaged). Zero context lines
691/// so each hunk's pre-image range pinpoints exactly the lines it touches.
692pub fn diff_against_head(cached: bool) -> Result<String> {
693    // Pin a/ b/ prefixes: diff.mnemonicPrefix / diff.noprefix would otherwise
694    // emit headers absorb's parser and `git apply` cannot read.
695    let mut args = vec!["diff", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"];
696    if cached {
697        args.push("--cached");
698    }
699    args.push("HEAD");
700    output(&args).context("failed to diff against HEAD")
701}
702
703/// The distinct commits that last touched lines `start..start+len` of `file`
704/// in HEAD, newest blame wins per line. An empty range yields nothing.
705pub fn blame_line_shas(file: &str, start: usize, len: usize) -> Result<Vec<String>> {
706    if len == 0 {
707        return Ok(Vec::new());
708    }
709    let range = format!("{start},{}", start + len - 1);
710    let out = output(&[
711        "blame",
712        "HEAD",
713        "-L",
714        &range,
715        "--line-porcelain",
716        "--",
717        file,
718    ])
719    .with_context(|| format!("failed to blame {file}"))?;
720
721    let mut shas = Vec::new();
722    for line in out.lines() {
723        // Each porcelain block opens with "<40-hex sha> <orig> <final> ...";
724        // other fields (author, summary, "previous", the tab-led content) do
725        // not start with a bare 40-hex token.
726        let token = line.split(' ').next().unwrap_or_default();
727        if token.len() == 40
728            && token.bytes().all(|byte| byte.is_ascii_hexdigit())
729            && !shas.iter().any(|seen| seen == token)
730        {
731            shas.push(token.to_owned());
732        }
733    }
734    Ok(shas)
735}
736
737/// The commits in `range` (e.g. "main..HEAD"), newest first.
738pub fn rev_list(range: &str) -> Result<Vec<String>> {
739    Ok(output(&["rev-list", range])
740        .with_context(|| format!("failed to list commits in {range}"))?
741        .lines()
742        .map(str::to_owned)
743        .collect())
744}
745
746/// `(short-sha, subject)` for each commit in `range` (e.g. "main..HEAD"),
747/// newest first - one git call, for listing a branch's own commits.
748pub fn log_oneline(range: &str) -> Result<Vec<(String, String)>> {
749    Ok(output(&["log", "--format=%h%x09%s", range])
750        .with_context(|| format!("failed to log {range}"))?
751        .lines()
752        .filter_map(|line| {
753            line.split_once('\t')
754                .map(|(sha, subject)| (sha.to_owned(), subject.to_owned()))
755        })
756        .collect())
757}
758
759/// A commit's subject line.
760pub fn commit_subject(sha: &str) -> Result<String> {
761    output(&["show", "--no-patch", "--format=%s", sha])
762        .with_context(|| format!("failed to read subject of {sha}"))
763}
764
765/// A commit's body - everything after the subject line; empty when there is none.
766pub fn commit_body(sha: &str) -> Result<String> {
767    output(&["show", "--no-patch", "--format=%b", sha])
768        .with_context(|| format!("failed to read body of {sha}"))
769}
770
771/// Stage a unified-0 patch into the index. `--unidiff-zero` is required for
772/// git to accept the zero-context hunks absorb works with.
773pub fn apply_cached(patch: &str) -> Result<()> {
774    let mut child = Command::new("git")
775        .args(["apply", "--cached", "--unidiff-zero"])
776        .stdin(Stdio::piped())
777        .stdout(Stdio::piped())
778        .stderr(Stdio::piped())
779        .spawn()
780        .context("failed to run git apply")?;
781    {
782        let mut stdin = child.stdin.take().context("git apply has no stdin")?;
783        stdin
784            .write_all(patch.as_bytes())
785            .context("failed to write patch to git apply")?;
786    }
787    let output = child
788        .wait_with_output()
789        .context("failed to run git apply")?;
790    if output.status.success() {
791        Ok(())
792    } else {
793        Err(command_error("git apply", &output.stderr))
794    }
795}
796
797/// Commit the staged index as a `fixup!` of `sha`, for a later autosquash
798/// rebase to fold in. Skips hooks: these are internal, transient commits.
799pub fn commit_fixup(sha: &str) -> Result<()> {
800    status(&["commit", "--no-verify", &format!("--fixup={sha}")])
801        .with_context(|| format!("failed to create fixup commit for {sha}"))
802}
803
804/// Unstage everything, leaving the worktree contents untouched.
805pub fn reset_index() -> Result<()> {
806    status(&["reset", "--quiet"]).context("failed to reset the index")
807}
808
809/// Move HEAD to `sha`, returning any commits after it to the index.
810pub fn reset_soft(sha: &str) -> Result<()> {
811    status(&["reset", "--soft", sha]).with_context(|| format!("failed to reset to {sha}"))
812}
813
814/// Stash tracked worktree changes; pair with [`stash_pop`].
815pub fn stash_push() -> Result<()> {
816    status(&["stash", "push", "--quiet"]).context("failed to stash changes")
817}
818
819/// Restore the most recently stashed changes.
820pub fn stash_pop() -> Result<()> {
821    status(&["stash", "pop", "--quiet"]).context("failed to restore stashed changes")
822}
823
824/// Rebase `base..HEAD`, folding `fixup!` commits into their targets. The
825/// generated todo is accepted unedited, so it needs no terminal.
826pub fn rebase_autosquash(base: &str, update_refs: bool) -> Result<()> {
827    let mut args = vec!["rebase", "--interactive", "--autosquash"];
828    if update_refs {
829        args.push("--update-refs");
830    }
831    args.push(base);
832
833    let output = Command::new("git")
834        .args(&args)
835        .env("GIT_SEQUENCE_EDITOR", "true")
836        .env("GIT_EDITOR", "true")
837        .output()
838        .context("failed to run git rebase")?;
839    if output.status.success() {
840        Ok(())
841    } else {
842        Err(command_error("git rebase --autosquash", &output.stderr))
843    }
844}
845
846pub fn is_ancestor(ancestor: &str, descendant: &str) -> Result<bool> {
847    // merge-base --is-ancestor exits 0 when it is, 1 when it is not.
848    Ok(output_codes(
849        &["merge-base", "--is-ancestor", ancestor, descendant],
850        &[1],
851        "git merge-base --is-ancestor",
852    )?
853    .is_some())
854}
855
856/// Lines added and deleted in `branch` relative to `base`, over the symmetric
857/// `base...branch` range a forge uses for a review diff (the branch's own work
858/// since it diverged). Binary files, which `--numstat` marks with `-`, count
859/// as zero.
860pub fn diff_numstat(base: &str, branch: &str) -> Result<(usize, usize)> {
861    let output = output(&["diff", "--numstat", &format!("{base}...{branch}")])?;
862    let mut added = 0;
863    let mut deleted = 0;
864    for line in output.lines() {
865        let mut columns = line.split('\t');
866        added += column_count(columns.next());
867        deleted += column_count(columns.next());
868    }
869    Ok((added, deleted))
870}
871
872/// A `--numstat` count column: a number, or 0 for `-` (binary) or anything
873/// unparseable.
874fn column_count(column: Option<&str>) -> usize {
875    column
876        .and_then(|value| value.parse::<usize>().ok())
877        .unwrap_or(0)
878}
879
880pub fn supports_rebase_update_refs() -> Result<bool> {
881    let output = Command::new("git")
882        .args(["rebase", "-h"])
883        .stdout(Stdio::piped())
884        .stderr(Stdio::piped())
885        .output()
886        .context("failed to inspect git rebase help")?;
887
888    let help = format!(
889        "{}{}",
890        String::from_utf8_lossy(&output.stdout),
891        String::from_utf8_lossy(&output.stderr)
892    );
893    Ok(help_mentions_update_refs(&help))
894}
895
896/// Whether the short help advertises --update-refs. Match the option name:
897/// git renders it as `--update-refs` or `--[no-]update-refs` by version.
898fn help_mentions_update_refs(help: &str) -> bool {
899    help.contains("update-refs")
900}
901
902/// Whether a rebase is actually paused in this worktree. Distinguishes a real
903/// conflict from git-stk merely having left state on file - git refuses to
904/// rebase a branch another worktree holds, which fails the run without ever
905/// starting a rebase to continue or abort.
906pub fn rebase_in_progress() -> bool {
907    ["rebase-merge", "rebase-apply"].iter().any(|dir| {
908        git_path(dir)
909            .map(|path| std::path::Path::new(&path).exists())
910            .unwrap_or(false)
911    })
912}
913
914pub fn rebase_continue() -> Result<()> {
915    // Passthrough: continuing a rebase can open the user's editor.
916    status_passthrough(&["rebase", "--continue"]).context("failed to continue rebase")
917}
918
919pub fn rebase_abort() -> Result<()> {
920    status(&["rebase", "--abort"]).context("failed to abort rebase")
921}
922
923/// Cherry-pick a commit onto the current branch. On conflict git leaves the
924/// cherry-pick in progress, so the error surfaces for the caller to tell the
925/// user to resolve and `git cherry-pick --continue`.
926pub fn cherry_pick(commit: &str) -> Result<()> {
927    status(&["cherry-pick", commit]).with_context(|| format!("failed to cherry-pick {commit}"))
928}
929
930/// Refresh the remote-tracking refs (`<remote>/<branch>`) for `branches` that
931/// exist on `remote`, in a single fetch. Branches absent from the remote (a
932/// freshly created top of stack that was never pushed) are dropped rather than
933/// failing the whole fetch. A no-op when none of them are on the remote.
934pub fn fetch_tracking(remote: &str, branches: &[String]) -> Result<()> {
935    let present = remote_branches_present(remote, branches)?;
936    if present.is_empty() {
937        return Ok(());
938    }
939    let mut args = vec!["fetch", remote];
940    args.extend(present.iter().map(String::as_str));
941    status(&args).with_context(|| format!("failed to fetch branches from {remote}"))
942}
943
944/// The subset of `branches` that exist as heads on `remote`, learned in one
945/// `ls-remote` so a targeted fetch does not abort on a branch the remote has
946/// never seen.
947fn remote_branches_present(remote: &str, branches: &[String]) -> Result<Vec<String>> {
948    if branches.is_empty() {
949        return Ok(Vec::new());
950    }
951    let mut args = vec!["ls-remote", "--heads", remote];
952    args.extend(branches.iter().map(String::as_str));
953    let listing =
954        output(&args).with_context(|| format!("failed to query {remote} for branch heads"))?;
955    let present: Vec<&str> = listing
956        .lines()
957        .filter_map(|line| line.split_once('\t'))
958        .filter_map(|(_, name)| name.strip_prefix("refs/heads/"))
959        .collect();
960    Ok(branches
961        .iter()
962        .filter(|branch| present.contains(&branch.as_str()))
963        .cloned()
964        .collect())
965}
966
967/// The commits `tracking` (a `<remote>/<branch>` ref) has that `branch` lacks
968/// *and* that have no patch-equivalent already on `branch` - the commits a
969/// force-push would silently drop, e.g. one committed straight on the host's
970/// web UI. `(short-sha, subject)` oldest-first, the order to cherry-pick them.
971/// Empty in the normal post-rebase case, where every remote commit is
972/// reproduced locally under a new hash.
973pub fn remote_only_commits(branch: &str, tracking: &str) -> Result<Vec<(String, String)>> {
974    let range = format!("{branch}...{tracking}");
975    let mut commits: Vec<(String, String)> = output(&[
976        "log",
977        "--cherry-pick",
978        "--right-only",
979        "--no-merges",
980        "--format=%h%x09%s",
981        &range,
982    ])
983    .with_context(|| format!("failed to list remote-only commits in {range}"))?
984    .lines()
985    .filter_map(|line| {
986        line.split_once('\t')
987            .map(|(sha, subject)| (sha.to_owned(), subject.to_owned()))
988    })
989    .collect();
990    // log is newest-first; cherry-pick wants oldest-first.
991    commits.reverse();
992    Ok(commits)
993}
994
995pub fn config_get(key: &str) -> Result<Option<String>> {
996    // git config --get exits 1 when the key is unset.
997    output_codes(&["config", "--get", key], &[1], "git config --get")
998}
999
1000pub fn config_get_bool(key: &str) -> Result<Option<bool>> {
1001    let Some(value) = output_codes(
1002        &["config", "--type=bool", "--get", key],
1003        &[1],
1004        "git config --type=bool --get",
1005    )?
1006    else {
1007        return Ok(None);
1008    };
1009    match value.as_str() {
1010        "true" => Ok(Some(true)),
1011        "false" => Ok(Some(false)),
1012        _ => bail!("git config {key} is not a boolean: {value}"),
1013    }
1014}
1015
1016pub fn config_get_regexp(pattern: &str) -> Result<Vec<(String, String)>> {
1017    // git config --get-regexp exits 1 when nothing matches.
1018    let Some(text) = output_codes(
1019        &["config", "--get-regexp", pattern],
1020        &[1],
1021        "git config --get-regexp",
1022    )?
1023    else {
1024        return Ok(Vec::new());
1025    };
1026    Ok(text
1027        .lines()
1028        .filter_map(|line| {
1029            line.split_once(' ')
1030                .map(|(key, value)| (key.to_owned(), value.to_owned()))
1031        })
1032        .collect())
1033}
1034
1035pub fn config_set(key: &str, value: &str) -> Result<()> {
1036    status(&["config", key, value]).with_context(|| format!("failed to set git config {key}"))
1037}
1038
1039pub fn config_unset(key: &str) -> Result<()> {
1040    // git config --unset exits 5 when the key was not set; either way it is now
1041    // gone, so treat that as success.
1042    output_codes(&["config", "--unset", key], &[5], "git config --unset").map(|_| ())
1043}
1044
1045/// Run a git command and map its exit code: trimmed stdout on success, `None`
1046/// for any code in `ok_empty` (an expected "nothing here" - e.g. `config
1047/// --get`'s 1, or `config --unset`'s 5), and an error otherwise. `label` names
1048/// the command for the error message.
1049fn output_codes(args: &[&str], ok_empty: &[i32], label: &str) -> Result<Option<String>> {
1050    let output = Command::new("git")
1051        .args(args)
1052        .stdout(Stdio::piped())
1053        .stderr(Stdio::piped())
1054        .output()
1055        .context("failed to run git")?;
1056
1057    match output.status.code() {
1058        Some(0) => Ok(Some(
1059            String::from_utf8_lossy(&output.stdout).trim().to_owned(),
1060        )),
1061        Some(code) if ok_empty.contains(&code) => Ok(None),
1062        _ => Err(command_error(label, &output.stderr)),
1063    }
1064}
1065
1066fn output(args: &[&str]) -> Result<String> {
1067    let output = Command::new("git")
1068        .args(args)
1069        .stdout(Stdio::piped())
1070        .stderr(Stdio::piped())
1071        .output()
1072        .context("failed to run git")?;
1073
1074    if output.status.success() {
1075        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
1076    } else {
1077        Err(command_error("git", &output.stderr))
1078    }
1079}
1080
1081/// Like [`output`], but feeds `input` to the command on stdin (for plumbing
1082/// such as `hash-object --stdin` and `mktree`).
1083fn output_with_stdin(args: &[&str], input: &str) -> Result<String> {
1084    let mut child = Command::new("git")
1085        .args(args)
1086        .stdin(Stdio::piped())
1087        .stdout(Stdio::piped())
1088        .stderr(Stdio::piped())
1089        .spawn()
1090        .context("failed to run git")?;
1091    {
1092        let mut stdin = child.stdin.take().context("git has no stdin")?;
1093        stdin
1094            .write_all(input.as_bytes())
1095            .context("failed to write to git")?;
1096    }
1097    let output = child.wait_with_output().context("failed to run git")?;
1098    if output.status.success() {
1099        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
1100    } else {
1101        Err(command_error("git", &output.stderr))
1102    }
1103}
1104
1105/// Run git quietly: progress and advice only matter when something goes
1106/// wrong, so capture them and replay on failure. `--verbose` passes
1107/// everything through.
1108fn status(args: &[&str]) -> Result<()> {
1109    if verbose() {
1110        return status_passthrough(args);
1111    }
1112
1113    let output = Command::new("git")
1114        .args(args)
1115        .output()
1116        .context("failed to run git")?;
1117
1118    if output.status.success() {
1119        Ok(())
1120    } else {
1121        let _ = std::io::stdout().write_all(&output.stdout);
1122        let _ = std::io::stderr().write_all(&output.stderr);
1123        bail!("git exited with status {}", output.status)
1124    }
1125}
1126
1127/// Inherit stdio unconditionally, for git commands that may need the
1128/// terminal (e.g. `rebase --continue` opening the editor).
1129fn status_passthrough(args: &[&str]) -> Result<()> {
1130    let status = Command::new("git")
1131        .args(args)
1132        .status()
1133        .context("failed to run git")?;
1134
1135    if status.success() {
1136        Ok(())
1137    } else {
1138        bail!("git exited with status {status}")
1139    }
1140}
1141
1142fn command_error(command: &str, stderr: &[u8]) -> anyhow::Error {
1143    let stderr = String::from_utf8_lossy(stderr).trim().to_owned();
1144    if stderr.is_empty() {
1145        anyhow!("{command} failed")
1146    } else {
1147        anyhow!("{command} failed: {stderr}")
1148    }
1149}
1150
1151#[cfg(test)]
1152mod tests {
1153    use super::*;
1154
1155    /// The shape `git worktree list --porcelain` prints for a main worktree, a
1156    /// linked one, a detached one, and a bare repo.
1157    const PORCELAIN: &str = "\
1158worktree /repo
1159HEAD f7cff917cf874d0c6ff3108260fda91ac3271baf
1160branch refs/heads/feat/b
1161
1162worktree /repo/../wt-a
1163HEAD 0700673acebfe459d480fa3bd616b2ecf6249fe1
1164branch refs/heads/feat/a
1165
1166worktree /repo/../wt-detached
1167HEAD 25fb6254b4b1cd5cbe2b0d4b1f5b1cf6e7d8a9b0
1168detached
1169";
1170
1171    #[test]
1172    fn worktree_parsing_keeps_branches_and_drops_detached_ones() {
1173        // No current worktree to exclude: every branch-holding record survives,
1174        // and the detached one - which holds no branch and so blocks nothing -
1175        // does not.
1176        let held = parse_worktree_branches(PORCELAIN, None);
1177        assert_eq!(
1178            held,
1179            vec![
1180                ("feat/b".to_owned(), std::path::PathBuf::from("/repo")),
1181                (
1182                    "feat/a".to_owned(),
1183                    std::path::PathBuf::from("/repo/../wt-a")
1184                ),
1185            ]
1186        );
1187    }
1188
1189    #[test]
1190    fn worktree_parsing_excludes_the_worktree_we_are_standing_in() {
1191        // The point of the exclusion: a caller must be able to read a hit as
1192        // "another worktree holds this", never as its own checkout.
1193        let held = parse_worktree_branches(PORCELAIN, Some(std::path::Path::new("/repo")));
1194        assert_eq!(
1195            held,
1196            vec![(
1197                "feat/a".to_owned(),
1198                std::path::PathBuf::from("/repo/../wt-a")
1199            )]
1200        );
1201    }
1202
1203    #[test]
1204    fn a_bare_record_does_not_lend_its_path_to_the_next_branch() {
1205        // A bare repo opens a record with no branch line. The following
1206        // worktree's branch must not be attributed to the bare path.
1207        let porcelain = "\
1208worktree /repo/.bare
1209bare
1210
1211worktree /repo/wt-a
1212HEAD 0700673acebfe459d480fa3bd616b2ecf6249fe1
1213branch refs/heads/feat/a
1214";
1215        assert_eq!(
1216            parse_worktree_branches(porcelain, None),
1217            vec![("feat/a".to_owned(), std::path::PathBuf::from("/repo/wt-a"))]
1218        );
1219    }
1220
1221    #[test]
1222    fn branch_names_containing_slashes_survive_the_refs_heads_strip() {
1223        // Only the refs/heads/ prefix comes off - the rest of the name is the
1224        // branch, slashes and all.
1225        let porcelain = "\
1226worktree /repo/wt
1227HEAD 0700673acebfe459d480fa3bd616b2ecf6249fe1
1228branch refs/heads/feat/deep/nested/name
1229";
1230        assert_eq!(
1231            parse_worktree_branches(porcelain, None)
1232                .first()
1233                .map(|(branch, _)| branch.as_str()),
1234            Some("feat/deep/nested/name")
1235        );
1236    }
1237
1238    #[test]
1239    fn empty_porcelain_holds_nothing() {
1240        assert!(parse_worktree_branches("", None).is_empty());
1241    }
1242
1243    #[test]
1244    fn a_collision_message_quotes_the_path_it_suggests_pasting() {
1245        // A worktree path with a space in it has to survive the round trip into
1246        // the user's shell.
1247        let message = collision_message("feat/a", "../my worktree", false);
1248        assert!(
1249            message.contains(r#"`cd "../my worktree"`"#),
1250            "cd suggestion is not pasteable: {message}"
1251        );
1252        assert!(
1253            message.contains(r#"`git worktree remove "../my worktree"`"#),
1254            "remove suggestion is not pasteable: {message}"
1255        );
1256        assert!(
1257            message.contains(r#"`git -C "../my worktree" checkout --detach`"#),
1258            "detach suggestion is not pasteable: {message}"
1259        );
1260    }
1261
1262    #[test]
1263    fn a_collision_with_the_main_worktree_never_suggests_removing_it() {
1264        // `git worktree remove` refuses on the main worktree, so offering it
1265        // there would be advice the user cannot act on.
1266        let message = collision_message("feat/a", "../product", true);
1267        assert!(
1268            !message.contains("git worktree remove"),
1269            "the main worktree cannot be removed: {message}"
1270        );
1271        assert!(
1272            message.contains(r#"`git -C "../product" checkout --detach`"#),
1273            "no workable way to free the branch: {message}"
1274        );
1275    }
1276
1277    #[test]
1278    fn the_main_worktree_is_the_first_record_listed() {
1279        let porcelain = "\
1280worktree /repo/product
1281HEAD 1111111111111111111111111111111111111111
1282branch refs/heads/feat/b
1283
1284worktree /repo/product-worktrees/feat/a
1285HEAD 2222222222222222222222222222222222222222
1286branch refs/heads/feat/a
1287";
1288        assert_eq!(
1289            parse_main_worktree(porcelain),
1290            Some(std::path::PathBuf::from("/repo/product"))
1291        );
1292    }
1293
1294    #[test]
1295    fn no_listing_names_no_main_worktree() {
1296        assert_eq!(parse_main_worktree(""), None);
1297    }
1298
1299    #[test]
1300    fn one_worktree_holding_three_branches_is_freed_once() {
1301        let held = [
1302            std::path::Path::new("../wt-a"),
1303            std::path::Path::new("../wt-a"),
1304            std::path::Path::new("../wt-b"),
1305        ];
1306        assert_eq!(
1307            distinct_paths(held),
1308            vec![
1309                std::path::PathBuf::from("../wt-a"),
1310                std::path::PathBuf::from("../wt-b")
1311            ]
1312        );
1313    }
1314
1315    #[test]
1316    fn a_collision_message_names_the_branch_and_where_it_lives() {
1317        let message = collision_message("feat/a", "../wt-a", false);
1318        assert!(message.starts_with("feat/a is checked out in the worktree at ../wt-a"));
1319    }
1320
1321    #[test]
1322    fn a_merge_queue_rejection_is_downgraded_to_the_queued_refs() {
1323        // The exact shape git prints when one ref of a multi-ref push is locked
1324        // by a GitHub merge queue while its sibling pushes fine.
1325        let stderr = "\
1326remote: error: GH006: Protected branch update failed for refs/heads/feat/tf-deploy.
1327remote: - A pull request for this branch has been added to a merge queue. Branches that
1328remote:   are queued for merging cannot be updated. To modify this branch, dequeue the
1329remote:   associated pull request.
1330To github.com:higharc/product
1331 + 016bb37...3a94024 feat/spa-env -> feat/spa-env (forced update)
1332 ! [remote rejected]         feat/tf-deploy -> feat/tf-deploy (protected branch hook declined)
1333error: failed to push some refs to 'github.com:higharc/product'";
1334        assert_eq!(
1335            merge_queue_rejection(stderr),
1336            Some(vec!["feat/tf-deploy".to_owned()])
1337        );
1338    }
1339
1340    #[test]
1341    fn a_stale_lease_rejection_is_not_swallowed_even_with_a_queue_mention() {
1342        // A force-with-lease failure is a real problem; the queue wording in the
1343        // dependabot banner must not mask it.
1344        let stderr = "\
1345remote: GitHub found 270 vulnerabilities ... merge queue notes ...
1346 ! [rejected]        feat/tf-deploy -> feat/tf-deploy (stale info)
1347error: failed to push some refs";
1348        assert_eq!(merge_queue_rejection(stderr), None);
1349    }
1350
1351    #[test]
1352    fn no_queue_mention_is_not_a_queue_rejection() {
1353        let stderr = " ! [remote rejected] feat/x -> feat/x (permission denied)";
1354        assert_eq!(merge_queue_rejection(stderr), None);
1355    }
1356
1357    #[test]
1358    fn landed_branches_drops_only_the_held_ones() {
1359        let attempted = [
1360            "feat/a".to_owned(),
1361            "feat/b".to_owned(),
1362            "feat/c".to_owned(),
1363        ];
1364        // A branch held back by the queue is dropped; order is preserved so the
1365        // "pushed ..." line never names a branch warned as held.
1366        assert_eq!(
1367            landed_branches(&attempted, &["feat/b".to_owned()]),
1368            vec!["feat/a".to_owned(), "feat/c".to_owned()]
1369        );
1370        // Nothing held: everything landed.
1371        assert_eq!(landed_branches(&attempted, &[]), attempted.to_vec());
1372        // Every branch held: nothing landed.
1373        assert!(landed_branches(&attempted, &attempted).is_empty());
1374    }
1375
1376    #[test]
1377    fn a_stale_lease_push_names_the_rejected_branch() {
1378        // The exact shape from a submit after a lower branch merged: one ref
1379        // pushes, the stale one is rejected by --force-with-lease.
1380        let stderr = "\
1381To github.com:higharc/product
1382   3a94024..d63a2b2  feat/spa-env -> feat/spa-env
1383 ! [rejected]                feat/tf-deploy -> feat/tf-deploy (stale info)
1384error: failed to push some refs to 'github.com:higharc/product'";
1385        assert_eq!(
1386            stale_rejection(stderr),
1387            Some(vec!["feat/tf-deploy".to_owned()])
1388        );
1389    }
1390
1391    #[test]
1392    fn a_non_fast_forward_push_is_treated_as_stale() {
1393        let stderr = " ! [rejected]  feat/x -> feat/x (non-fast-forward)";
1394        assert_eq!(stale_rejection(stderr), Some(vec!["feat/x".to_owned()]));
1395    }
1396
1397    #[test]
1398    fn an_unrelated_push_failure_is_not_classified_as_stale() {
1399        // Permission/network failures must keep their own error, not "run sync".
1400        let stderr = " ! [remote rejected] feat/x -> feat/x (permission denied)";
1401        assert_eq!(stale_rejection(stderr), None);
1402        assert_eq!(stale_rejection("fatal: could not read from remote"), None);
1403    }
1404
1405    #[test]
1406    fn a_mixed_stale_and_non_stale_rejection_is_not_classified_as_stale() {
1407        // One ref is stale, another was refused for a reason `git stk sync`
1408        // will not fix; the clean message replaces git's output, so it must not
1409        // claim sync resolves the permission failure - fall through to raw git.
1410        let stderr = "\
1411 ! [rejected]                feat/tf-deploy -> feat/tf-deploy (stale info)
1412 ! [remote rejected]         feat/locked -> feat/locked (permission denied)
1413error: failed to push some refs";
1414        assert_eq!(stale_rejection(stderr), None);
1415    }
1416
1417    #[test]
1418    fn help_mentions_update_refs_matches_pre_2_43_spelling() {
1419        assert!(help_mentions_update_refs(
1420            "    --update-refs    update branches that point to commits that are being rebased"
1421        ));
1422    }
1423
1424    #[test]
1425    fn help_mentions_update_refs_matches_negatable_spelling() {
1426        assert!(help_mentions_update_refs(
1427            "    --[no-]update-refs    update branches that point to commits that are being rebased"
1428        ));
1429    }
1430
1431    #[test]
1432    fn help_mentions_update_refs_rejects_help_without_the_option() {
1433        assert!(!help_mentions_update_refs(
1434            "    --[no-]autosquash    move commits that begin with squash!/fixup!"
1435        ));
1436    }
1437
1438    #[test]
1439    fn detection_agrees_with_the_real_git_on_this_machine() {
1440        // Ground truth: `--update-refs -h` fails with "unknown option" on a
1441        // git without the flag and prints help on one that has it.
1442        let probe = Command::new("git")
1443            .args(["rebase", "--update-refs", "-h"])
1444            .stdout(Stdio::piped())
1445            .stderr(Stdio::piped())
1446            .output()
1447            .expect("run git rebase probe");
1448        let probe_text = format!(
1449            "{}{}",
1450            String::from_utf8_lossy(&probe.stdout),
1451            String::from_utf8_lossy(&probe.stderr)
1452        );
1453        let real_support = !probe_text.contains("unknown option");
1454
1455        assert_eq!(
1456            supports_rebase_update_refs().expect("detect support"),
1457            real_support
1458        );
1459    }
1460}