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