git-stk 0.10.7

Git-native stacked branch workflow helper
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
use std::io::Write;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};

use anyhow::{Context, Result, anyhow, bail};

static VERBOSE: AtomicBool = AtomicBool::new(false);

/// Pass raw git output through instead of capturing it.
pub fn set_verbose(verbose: bool) {
    VERBOSE.store(verbose, Ordering::Relaxed);
}

fn verbose() -> bool {
    VERBOSE.load(Ordering::Relaxed)
}

pub fn current_branch() -> Result<String> {
    output(&["symbolic-ref", "--quiet", "--short", "HEAD"])
        .context("failed to determine current branch")
}

/// Whether the working directory is inside a git work tree. Used for a clean
/// "not a git repository" message instead of letting git's raw error surface
/// from the first command that needs the repo.
pub fn is_in_repo() -> bool {
    Command::new("git")
        .args(["rev-parse", "--is-inside-work-tree"])
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .is_ok_and(|out| out.status.success() && out.stdout.starts_with(b"true"))
}

pub fn local_branches() -> Result<Vec<String>> {
    let output = output(&["for-each-ref", "--format=%(refname:short)", "refs/heads"])?;
    Ok(output.lines().map(str::to_owned).collect())
}

pub fn git_path(path: &str) -> Result<String> {
    output(&["rev-parse", "--git-path", path])
}

/// The repository's top-level working-tree directory.
pub fn repo_root() -> Result<std::path::PathBuf> {
    Ok(std::path::PathBuf::from(output(&[
        "rev-parse",
        "--show-toplevel",
    ])?))
}

/// Resolve `path` under the repo's *common* git dir, which all linked
/// worktrees share, rather than the per-worktree dir `git_path` returns. Use
/// this for state that guards or mirrors the shared config (`branch.*`), so
/// every worktree of a repo agrees on one file.
pub fn git_common_path(path: &str) -> Result<String> {
    let common_dir = output(&["rev-parse", "--git-common-dir"])?;
    Ok(std::path::Path::new(&common_dir)
        .join(path)
        .to_string_lossy()
        .into_owned())
}

pub fn remote_url(remote: &str) -> Result<Option<String>> {
    // git remote get-url exits 2 when the remote does not exist.
    output_codes(&["remote", "get-url", remote], &[2], "git remote get-url")
}

pub fn checkout(branch: &str) -> Result<()> {
    status(&["switch", branch]).with_context(|| format!("failed to check out {branch}"))?;
    anstream::println!(
        "switched to {}",
        crate::style::paint(crate::style::BRANCH, branch)
    );
    Ok(())
}

pub fn create_branch(branch: &str) -> Result<()> {
    status(&["switch", "-c", branch]).with_context(|| format!("failed to create branch {branch}"))
}

/// Create a branch pointing at `sha` without checking it out or touching the
/// working tree - used by `split` to point new branches at existing commits.
pub fn create_branch_at(branch: &str, sha: &str) -> Result<()> {
    status(&["branch", branch, sha])
        .with_context(|| format!("failed to create branch {branch} at {sha}"))
}

/// Force-delete a branch. Use only once review state confirms it landed: a
/// squash merge leaves the commits non-ancestry-merged, so `git branch -d`
/// would refuse even though the work is in.
pub fn delete_branch(branch: &str) -> Result<()> {
    status(&["branch", "-D", branch]).with_context(|| format!("failed to delete branch {branch}"))
}

/// Rename a branch; git moves its `branch.<name>.*` config along with it.
pub fn rename_branch(old: &str, new: &str) -> Result<()> {
    status(&["branch", "-m", old, new]).with_context(|| format!("failed to rename {old} to {new}"))
}

/// Fast-forward a local branch from its remote without checking it out.
pub fn fetch_branch(remote: &str, branch: &str) -> Result<()> {
    let refspec = format!("{branch}:{branch}");
    status(&["fetch", remote, &refspec])
        .with_context(|| format!("failed to fetch {branch} from {remote}"))
}

pub fn pull_ff_only() -> Result<()> {
    status(&["pull", "--ff-only"]).context("failed to fast-forward from the remote")
}

/// Force-push `branches` (with lease), returning the branches that actually
/// landed. Normally that is all of them; the exception is the merge-queue
/// backstop below, which drops a held-back branch from the returned set so the
/// caller never reports a branch as both held and pushed.
pub fn push_force_with_lease(remote: &str, branches: &[String]) -> Result<Vec<String>> {
    let mut args = vec!["push", "--force-with-lease", remote];
    args.extend(branches.iter().map(String::as_str));

    run_lease_push(&args, remote, branches)
}

/// Run a force-with-lease push, returning the branches that actually landed,
/// and classifying the two rejections git-stk can explain better than raw git
/// output:
///
/// - **Merge queue** (GitHub locks a queued branch): the ref is rejected with
///   GH006 while its siblings push fine. `restack`/`sync` already freeze
///   branches they know are queued, so this is the backstop for one enqueued
///   mid-run - the held ref is reported and dropped from the returned set, the
///   successful refs stand, and the push is not failed.
/// - **Stale lease** (the remote moved on, usually because a branch in the
///   stack merged): the lease no longer matches, so git rejects with `stale
///   info`/`non-fast-forward`. `git stk sync` reconciles it, so say so instead
///   of leaving the user with git's plumbing error.
///
/// Anything else surfaces with git's own output, unchanged.
fn run_lease_push(args: &[&str], remote: &str, branches: &[String]) -> Result<Vec<String>> {
    // Verbose mode streams straight through, so there is no captured stderr to
    // classify; fall back to the plain path. (A rejection there still shows
    // git's own message, just without the friendlier translation.)
    if verbose() {
        status_passthrough(args).with_context(|| format!("failed to push branches to {remote}"))?;
        return Ok(branches.to_vec());
    }

    let output = Command::new("git")
        .args(args)
        .output()
        .context("failed to run git")?;
    if output.status.success() {
        return Ok(branches.to_vec());
    }

    // A GitHub branch sitting in a merge queue is locked, so its ref is rejected
    // with GH006 while its siblings push fine; git then exits non-zero even
    // though the rest landed. `restack`/`sync` already freeze branches they know
    // are queued, so this is the backstop for one enqueued mid-run: report the
    // held ref, drop it from the landed set, and let the successful refs stand.
    // Any rejection that is not purely the merge queue (a stale lease, a
    // non-fast-forward) still surfaces as an error.
    let stderr = String::from_utf8_lossy(&output.stderr);
    if let Some(queued) = merge_queue_rejection(&stderr) {
        anstream::eprintln!(
            "{}",
            crate::style::warn(&format!(
                "{} {} in a merge queue and was not updated (dequeue its review to push it)",
                queued.join(", "),
                if queued.len() == 1 { "is" } else { "are" },
            ))
        );
        return Ok(landed_branches(branches, &queued));
    }

    if let Some(stale) = stale_rejection(&stderr) {
        // The user asked for a clean message, not raw git/GitHub noise, so the
        // captured output is dropped in favor of the actionable guidance.
        bail!(
            "could not push {} to {remote}: the remote has moved on \
             (a branch in the stack was likely merged or updated upstream)\n\
             run `git stk sync` to reconcile your local stack with the remote, then try again",
            stale.join(", "),
        );
    }

    let _ = std::io::stdout().write_all(&output.stdout);
    let _ = std::io::stderr().write_all(&output.stderr);
    bail!(
        "failed to push branches to {remote}: git exited with status {}",
        output.status
    )
}

/// The branches that landed: everything attempted except those held back by
/// the merge queue, preserving the attempted order.
fn landed_branches(attempted: &[String], held: &[String]) -> Vec<String> {
    attempted
        .iter()
        .filter(|branch| !held.iter().any(|name| name == *branch))
        .cloned()
        .collect()
}

/// The rejected refs when a push failed *only* because they are in a merge
/// queue, or None when any other failure is mixed in. A genuine lease/
/// fast-forward rejection (`stale info`, `non-fast-forward`, `fetch first`)
/// returns None so it is classified as stale instead; a queue rejection with
/// no such marker returns the branch names so the caller can report them and
/// carry on.
fn merge_queue_rejection(stderr: &str) -> Option<Vec<String>> {
    let lower = stderr.to_lowercase();
    let mentions_queue = lower.contains("merge queue") || lower.contains("queued for merging");
    if !mentions_queue {
        return None;
    }
    // A lease or fast-forward failure is a real problem, not a queue lock - do
    // not swallow a push that failed for those reasons too.
    if ["stale info", "non-fast-forward", "fetch first"]
        .iter()
        .any(|marker| lower.contains(marker))
    {
        return None;
    }
    let rejected = rejected_refs(stderr);
    if rejected.is_empty() {
        None
    } else {
        Some(rejected)
    }
}

/// The rejected refs when a push was refused because the local side is behind
/// the remote: a `--force-with-lease` lease mismatch (`stale info`), or a plain
/// `non-fast-forward`/`fetch first`. This is the remote having moved on - in a
/// stack, almost always a lower branch that merged - which `git stk sync`
/// reconciles.
///
/// Returns Some only when *every* rejected ref is stale: the friendly "run
/// sync" message replaces git's raw output, so a non-stale rejection mixed in
/// (a permission denial, a declined hook) - which sync would not fix - must
/// fall through to git's own error instead of being hidden behind sync advice.
/// None when nothing was rejected, or any rejection was for another reason.
fn stale_rejection(stderr: &str) -> Option<Vec<String>> {
    let rejected: Vec<&str> = stderr
        .lines()
        .filter(|line| line.contains("[remote rejected]") || line.contains("[rejected]"))
        .collect();
    if rejected.is_empty() || !rejected.iter().all(|line| line_is_stale(line)) {
        return None;
    }
    let names: Vec<String> = rejected
        .iter()
        .filter_map(|line| rejected_ref_name(line))
        .collect();
    if names.is_empty() { None } else { Some(names) }
}

/// Whether a rejected-ref line was refused because the local side is behind the
/// remote (a `--force-with-lease` lease mismatch or a non-fast-forward), rather
/// than a permission/hook refusal. The reason is in the line's trailing `(…)`.
fn line_is_stale(line: &str) -> bool {
    let lower = line.to_lowercase();
    ["stale info", "non-fast-forward", "fetch first"]
        .iter()
        .any(|marker| lower.contains(marker))
}

/// The remote-side ref name from a single `! [remote rejected] <local> ->
/// <remote> (reason)` line.
fn rejected_ref_name(line: &str) -> Option<String> {
    let after = line.split("-> ").nth(1)?;
    Some(after.split_whitespace().next()?.to_owned())
}

/// The remote-side ref names from a push's `! [remote rejected]`/`! [rejected]`
/// lines, regardless of reason.
fn rejected_refs(stderr: &str) -> Vec<String> {
    stderr
        .lines()
        .filter(|line| line.contains("[remote rejected]") || line.contains("[rejected]"))
        .filter_map(rejected_ref_name)
        .collect()
}

/// Push branches and set upstream tracking; used before submitting so new
/// branches exist remotely and rebased ones are safely updated.
pub fn push_set_upstream_force_with_lease(remote: &str, branches: &[String]) -> Result<()> {
    let mut args = vec!["push", "--set-upstream", "--force-with-lease", remote];
    args.extend(branches.iter().map(String::as_str));

    // submit does not need the landed set; a held-back branch is still warned
    // about inside run_lease_push.
    run_lease_push(&args, remote, branches)?;
    Ok(())
}

/// Store `content` as a single-file commit and point `reference` at it, so the
/// data rides along a normal ref push. Orphan each time: the ref just moves to
/// the new commit (callers force-push it, as it is regenerable).
pub fn write_blob_ref(reference: &str, file: &str, content: &str) -> Result<()> {
    let blob = output_with_stdin(&["hash-object", "-w", "--stdin"], content)
        .context("failed to hash stack metadata")?;
    let tree = output_with_stdin(&["mktree"], &format!("100644 blob {blob}\t{file}\n"))
        .context("failed to write stack metadata tree")?;
    let commit = output(&["commit-tree", &tree, "-m", "git-stk stack metadata"])
        .context("failed to commit stack metadata")?;
    status(&["update-ref", reference, &commit])
        .with_context(|| format!("failed to update {reference}"))
}

/// Force-push a single ref to `remote` (the value is regenerable, so
/// last-writer-wins is fine).
pub fn push_ref(remote: &str, reference: &str) -> Result<()> {
    status(&[
        "push",
        "--force",
        remote,
        &format!("{reference}:{reference}"),
    ])
    .with_context(|| format!("failed to push {reference} to {remote}"))
}

/// Force-fetch a single ref from `remote` into the same local ref.
pub fn fetch_ref(remote: &str, reference: &str) -> Result<()> {
    status(&["fetch", remote, &format!("+{reference}:{reference}")])
        .with_context(|| format!("failed to fetch {reference} from {remote}"))
}

/// The contents of `file` in the commit `reference` points at, or None when
/// the ref or file is absent.
pub fn read_ref_file(reference: &str, file: &str) -> Result<Option<String>> {
    let output = Command::new("git")
        .args(["cat-file", "blob", &format!("{reference}:{file}")])
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .context("failed to run git cat-file")?;
    if output.status.success() {
        Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()))
    } else {
        Ok(None)
    }
}

pub fn rebase(parent: &str, branch: &str, update_refs: bool) -> Result<()> {
    let mut args = vec!["rebase"];
    if update_refs {
        args.push("--update-refs");
    }
    args.extend([parent, branch]);

    status(&args).with_context(|| format!("failed to rebase {branch} onto {parent}"))
}

/// Rebase only the commits after `base`, replaying `base..branch` onto
/// `parent`. Used when the recorded fork point is known so commits that
/// landed upstream by squash or rebase are not replayed.
pub fn rebase_onto(parent: &str, base: &str, branch: &str, update_refs: bool) -> Result<()> {
    let mut args = vec!["rebase"];
    if update_refs {
        args.push("--update-refs");
    }
    args.extend(["--onto", parent, base, branch]);

    status(&args).with_context(|| format!("failed to rebase {branch} onto {parent} from {base}"))
}

pub fn rev_parse(rev: &str) -> Result<String> {
    let spec = format!("{rev}^{{commit}}");
    output(&["rev-parse", "--verify", &spec]).with_context(|| format!("failed to resolve {rev}"))
}

/// The commit a branch points at, or None when the branch does not exist.
pub fn branch_sha(branch: &str) -> Option<String> {
    rev_parse(branch).ok()
}

/// Point a branch at a commit, creating it if absent. Does not touch the
/// worktree.
pub fn update_ref(branch: &str, sha: &str) -> Result<()> {
    status(&["update-ref", &format!("refs/heads/{branch}"), sha])
        .with_context(|| format!("failed to update {branch} to {sha}"))
}

/// Reset the worktree and index to HEAD. Safe to lose nothing only on a
/// clean tree; callers must check [`worktree_is_clean`] first.
pub fn reset_hard() -> Result<()> {
    status(&["reset", "--hard"]).context("failed to reset the worktree")
}

/// Whether the worktree and index have no uncommitted changes.
pub fn worktree_is_clean() -> Result<bool> {
    Ok(output(&["status", "--porcelain"])?.is_empty())
}

/// Default branch of `remote` (from its locally-known HEAD symref), if any.
pub fn remote_default_branch(remote: &str) -> Option<String> {
    let reference = format!("refs/remotes/{remote}/HEAD");
    let full = output(&["symbolic-ref", "--short", &reference]).ok()?;
    full.strip_prefix(&format!("{remote}/")).map(str::to_owned)
}

/// How many commits `parent` has that `branch` does not: nonzero means the
/// branch needs a restack.
pub fn commits_behind(branch: &str, parent: &str) -> Result<usize> {
    let range = format!("{branch}..{parent}");
    let count = output(&["rev-list", "--count", &range])
        .with_context(|| format!("failed to count commits in {range}"))?;
    count
        .trim()
        .parse()
        .context("failed to parse rev-list count")
}

pub fn merge_base(a: &str, b: &str) -> Result<String> {
    output(&["merge-base", a, b])
        .with_context(|| format!("failed to find merge base of {a} and {b}"))
}

/// A unified-0 diff against HEAD: just the staged changes when `cached`,
/// otherwise all tracked changes (staged and unstaged). Zero context lines
/// so each hunk's pre-image range pinpoints exactly the lines it touches.
pub fn diff_against_head(cached: bool) -> Result<String> {
    // Pin a/ b/ prefixes: diff.mnemonicPrefix / diff.noprefix would otherwise
    // emit headers absorb's parser and `git apply` cannot read.
    let mut args = vec!["diff", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"];
    if cached {
        args.push("--cached");
    }
    args.push("HEAD");
    output(&args).context("failed to diff against HEAD")
}

/// The distinct commits that last touched lines `start..start+len` of `file`
/// in HEAD, newest blame wins per line. An empty range yields nothing.
pub fn blame_line_shas(file: &str, start: usize, len: usize) -> Result<Vec<String>> {
    if len == 0 {
        return Ok(Vec::new());
    }
    let range = format!("{start},{}", start + len - 1);
    let out = output(&[
        "blame",
        "HEAD",
        "-L",
        &range,
        "--line-porcelain",
        "--",
        file,
    ])
    .with_context(|| format!("failed to blame {file}"))?;

    let mut shas = Vec::new();
    for line in out.lines() {
        // Each porcelain block opens with "<40-hex sha> <orig> <final> ...";
        // other fields (author, summary, "previous", the tab-led content) do
        // not start with a bare 40-hex token.
        let token = line.split(' ').next().unwrap_or_default();
        if token.len() == 40
            && token.bytes().all(|byte| byte.is_ascii_hexdigit())
            && !shas.iter().any(|seen| seen == token)
        {
            shas.push(token.to_owned());
        }
    }
    Ok(shas)
}

/// The commits in `range` (e.g. "main..HEAD"), newest first.
pub fn rev_list(range: &str) -> Result<Vec<String>> {
    Ok(output(&["rev-list", range])
        .with_context(|| format!("failed to list commits in {range}"))?
        .lines()
        .map(str::to_owned)
        .collect())
}

/// `(short-sha, subject)` for each commit in `range` (e.g. "main..HEAD"),
/// newest first - one git call, for listing a branch's own commits.
pub fn log_oneline(range: &str) -> Result<Vec<(String, String)>> {
    Ok(output(&["log", "--format=%h%x09%s", range])
        .with_context(|| format!("failed to log {range}"))?
        .lines()
        .filter_map(|line| {
            line.split_once('\t')
                .map(|(sha, subject)| (sha.to_owned(), subject.to_owned()))
        })
        .collect())
}

/// A commit's subject line.
pub fn commit_subject(sha: &str) -> Result<String> {
    output(&["show", "--no-patch", "--format=%s", sha])
        .with_context(|| format!("failed to read subject of {sha}"))
}

/// A commit's body - everything after the subject line; empty when there is none.
pub fn commit_body(sha: &str) -> Result<String> {
    output(&["show", "--no-patch", "--format=%b", sha])
        .with_context(|| format!("failed to read body of {sha}"))
}

/// Stage a unified-0 patch into the index. `--unidiff-zero` is required for
/// git to accept the zero-context hunks absorb works with.
pub fn apply_cached(patch: &str) -> Result<()> {
    let mut child = Command::new("git")
        .args(["apply", "--cached", "--unidiff-zero"])
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .context("failed to run git apply")?;
    {
        let mut stdin = child.stdin.take().context("git apply has no stdin")?;
        stdin
            .write_all(patch.as_bytes())
            .context("failed to write patch to git apply")?;
    }
    let output = child
        .wait_with_output()
        .context("failed to run git apply")?;
    if output.status.success() {
        Ok(())
    } else {
        Err(command_error("git apply", &output.stderr))
    }
}

/// Commit the staged index as a `fixup!` of `sha`, for a later autosquash
/// rebase to fold in. Skips hooks: these are internal, transient commits.
pub fn commit_fixup(sha: &str) -> Result<()> {
    status(&["commit", "--no-verify", &format!("--fixup={sha}")])
        .with_context(|| format!("failed to create fixup commit for {sha}"))
}

/// Unstage everything, leaving the worktree contents untouched.
pub fn reset_index() -> Result<()> {
    status(&["reset", "--quiet"]).context("failed to reset the index")
}

/// Move HEAD to `sha`, returning any commits after it to the index.
pub fn reset_soft(sha: &str) -> Result<()> {
    status(&["reset", "--soft", sha]).with_context(|| format!("failed to reset to {sha}"))
}

/// Stash tracked worktree changes; pair with [`stash_pop`].
pub fn stash_push() -> Result<()> {
    status(&["stash", "push", "--quiet"]).context("failed to stash changes")
}

/// Restore the most recently stashed changes.
pub fn stash_pop() -> Result<()> {
    status(&["stash", "pop", "--quiet"]).context("failed to restore stashed changes")
}

/// Rebase `base..HEAD`, folding `fixup!` commits into their targets. The
/// generated todo is accepted unedited, so it needs no terminal.
pub fn rebase_autosquash(base: &str, update_refs: bool) -> Result<()> {
    let mut args = vec!["rebase", "--interactive", "--autosquash"];
    if update_refs {
        args.push("--update-refs");
    }
    args.push(base);

    let output = Command::new("git")
        .args(&args)
        .env("GIT_SEQUENCE_EDITOR", "true")
        .env("GIT_EDITOR", "true")
        .output()
        .context("failed to run git rebase")?;
    if output.status.success() {
        Ok(())
    } else {
        Err(command_error("git rebase --autosquash", &output.stderr))
    }
}

pub fn is_ancestor(ancestor: &str, descendant: &str) -> Result<bool> {
    // merge-base --is-ancestor exits 0 when it is, 1 when it is not.
    Ok(output_codes(
        &["merge-base", "--is-ancestor", ancestor, descendant],
        &[1],
        "git merge-base --is-ancestor",
    )?
    .is_some())
}

/// Lines added and deleted in `branch` relative to `base`, over the symmetric
/// `base...branch` range a forge uses for a review diff (the branch's own work
/// since it diverged). Binary files, which `--numstat` marks with `-`, count
/// as zero.
pub fn diff_numstat(base: &str, branch: &str) -> Result<(usize, usize)> {
    let output = output(&["diff", "--numstat", &format!("{base}...{branch}")])?;
    let mut added = 0;
    let mut deleted = 0;
    for line in output.lines() {
        let mut columns = line.split('\t');
        added += column_count(columns.next());
        deleted += column_count(columns.next());
    }
    Ok((added, deleted))
}

/// A `--numstat` count column: a number, or 0 for `-` (binary) or anything
/// unparseable.
fn column_count(column: Option<&str>) -> usize {
    column
        .and_then(|value| value.parse::<usize>().ok())
        .unwrap_or(0)
}

pub fn supports_rebase_update_refs() -> Result<bool> {
    let output = Command::new("git")
        .args(["rebase", "-h"])
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .context("failed to inspect git rebase help")?;

    let help = format!(
        "{}{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    Ok(help_mentions_update_refs(&help))
}

/// Whether the short help advertises --update-refs. Match the option name:
/// git renders it as `--update-refs` or `--[no-]update-refs` by version.
fn help_mentions_update_refs(help: &str) -> bool {
    help.contains("update-refs")
}

pub fn rebase_continue() -> Result<()> {
    // Passthrough: continuing a rebase can open the user's editor.
    status_passthrough(&["rebase", "--continue"]).context("failed to continue rebase")
}

pub fn rebase_abort() -> Result<()> {
    status(&["rebase", "--abort"]).context("failed to abort rebase")
}

/// Cherry-pick a commit onto the current branch. On conflict git leaves the
/// cherry-pick in progress, so the error surfaces for the caller to tell the
/// user to resolve and `git cherry-pick --continue`.
pub fn cherry_pick(commit: &str) -> Result<()> {
    status(&["cherry-pick", commit]).with_context(|| format!("failed to cherry-pick {commit}"))
}

/// Refresh the remote-tracking refs (`<remote>/<branch>`) for `branches` that
/// exist on `remote`, in a single fetch. Branches absent from the remote (a
/// freshly created top of stack that was never pushed) are dropped rather than
/// failing the whole fetch. A no-op when none of them are on the remote.
pub fn fetch_tracking(remote: &str, branches: &[String]) -> Result<()> {
    let present = remote_branches_present(remote, branches)?;
    if present.is_empty() {
        return Ok(());
    }
    let mut args = vec!["fetch", remote];
    args.extend(present.iter().map(String::as_str));
    status(&args).with_context(|| format!("failed to fetch branches from {remote}"))
}

/// The subset of `branches` that exist as heads on `remote`, learned in one
/// `ls-remote` so a targeted fetch does not abort on a branch the remote has
/// never seen.
fn remote_branches_present(remote: &str, branches: &[String]) -> Result<Vec<String>> {
    if branches.is_empty() {
        return Ok(Vec::new());
    }
    let mut args = vec!["ls-remote", "--heads", remote];
    args.extend(branches.iter().map(String::as_str));
    let listing =
        output(&args).with_context(|| format!("failed to query {remote} for branch heads"))?;
    let present: Vec<&str> = listing
        .lines()
        .filter_map(|line| line.split_once('\t'))
        .filter_map(|(_, name)| name.strip_prefix("refs/heads/"))
        .collect();
    Ok(branches
        .iter()
        .filter(|branch| present.contains(&branch.as_str()))
        .cloned()
        .collect())
}

/// The commits `tracking` (a `<remote>/<branch>` ref) has that `branch` lacks
/// *and* that have no patch-equivalent already on `branch` - the commits a
/// force-push would silently drop, e.g. one committed straight on the host's
/// web UI. `(short-sha, subject)` oldest-first, the order to cherry-pick them.
/// Empty in the normal post-rebase case, where every remote commit is
/// reproduced locally under a new hash.
pub fn remote_only_commits(branch: &str, tracking: &str) -> Result<Vec<(String, String)>> {
    let range = format!("{branch}...{tracking}");
    let mut commits: Vec<(String, String)> = output(&[
        "log",
        "--cherry-pick",
        "--right-only",
        "--no-merges",
        "--format=%h%x09%s",
        &range,
    ])
    .with_context(|| format!("failed to list remote-only commits in {range}"))?
    .lines()
    .filter_map(|line| {
        line.split_once('\t')
            .map(|(sha, subject)| (sha.to_owned(), subject.to_owned()))
    })
    .collect();
    // log is newest-first; cherry-pick wants oldest-first.
    commits.reverse();
    Ok(commits)
}

pub fn config_get(key: &str) -> Result<Option<String>> {
    // git config --get exits 1 when the key is unset.
    output_codes(&["config", "--get", key], &[1], "git config --get")
}

pub fn config_get_bool(key: &str) -> Result<Option<bool>> {
    let Some(value) = output_codes(
        &["config", "--type=bool", "--get", key],
        &[1],
        "git config --type=bool --get",
    )?
    else {
        return Ok(None);
    };
    match value.as_str() {
        "true" => Ok(Some(true)),
        "false" => Ok(Some(false)),
        _ => bail!("git config {key} is not a boolean: {value}"),
    }
}

pub fn config_get_regexp(pattern: &str) -> Result<Vec<(String, String)>> {
    // git config --get-regexp exits 1 when nothing matches.
    let Some(text) = output_codes(
        &["config", "--get-regexp", pattern],
        &[1],
        "git config --get-regexp",
    )?
    else {
        return Ok(Vec::new());
    };
    Ok(text
        .lines()
        .filter_map(|line| {
            line.split_once(' ')
                .map(|(key, value)| (key.to_owned(), value.to_owned()))
        })
        .collect())
}

pub fn config_set(key: &str, value: &str) -> Result<()> {
    status(&["config", key, value]).with_context(|| format!("failed to set git config {key}"))
}

pub fn config_unset(key: &str) -> Result<()> {
    // git config --unset exits 5 when the key was not set; either way it is now
    // gone, so treat that as success.
    output_codes(&["config", "--unset", key], &[5], "git config --unset").map(|_| ())
}

/// Run a git command and map its exit code: trimmed stdout on success, `None`
/// for any code in `ok_empty` (an expected "nothing here" - e.g. `config
/// --get`'s 1, or `config --unset`'s 5), and an error otherwise. `label` names
/// the command for the error message.
fn output_codes(args: &[&str], ok_empty: &[i32], label: &str) -> Result<Option<String>> {
    let output = Command::new("git")
        .args(args)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .context("failed to run git")?;

    match output.status.code() {
        Some(0) => Ok(Some(
            String::from_utf8_lossy(&output.stdout).trim().to_owned(),
        )),
        Some(code) if ok_empty.contains(&code) => Ok(None),
        _ => Err(command_error(label, &output.stderr)),
    }
}

fn output(args: &[&str]) -> Result<String> {
    let output = Command::new("git")
        .args(args)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .context("failed to run git")?;

    if output.status.success() {
        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
    } else {
        Err(command_error("git", &output.stderr))
    }
}

/// Like [`output`], but feeds `input` to the command on stdin (for plumbing
/// such as `hash-object --stdin` and `mktree`).
fn output_with_stdin(args: &[&str], input: &str) -> Result<String> {
    let mut child = Command::new("git")
        .args(args)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .context("failed to run git")?;
    {
        let mut stdin = child.stdin.take().context("git has no stdin")?;
        stdin
            .write_all(input.as_bytes())
            .context("failed to write to git")?;
    }
    let output = child.wait_with_output().context("failed to run git")?;
    if output.status.success() {
        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
    } else {
        Err(command_error("git", &output.stderr))
    }
}

/// Run git quietly: progress and advice only matter when something goes
/// wrong, so capture them and replay on failure. `--verbose` passes
/// everything through.
fn status(args: &[&str]) -> Result<()> {
    if verbose() {
        return status_passthrough(args);
    }

    let output = Command::new("git")
        .args(args)
        .output()
        .context("failed to run git")?;

    if output.status.success() {
        Ok(())
    } else {
        let _ = std::io::stdout().write_all(&output.stdout);
        let _ = std::io::stderr().write_all(&output.stderr);
        bail!("git exited with status {}", output.status)
    }
}

/// Inherit stdio unconditionally, for git commands that may need the
/// terminal (e.g. `rebase --continue` opening the editor).
fn status_passthrough(args: &[&str]) -> Result<()> {
    let status = Command::new("git")
        .args(args)
        .status()
        .context("failed to run git")?;

    if status.success() {
        Ok(())
    } else {
        bail!("git exited with status {status}")
    }
}

fn command_error(command: &str, stderr: &[u8]) -> anyhow::Error {
    let stderr = String::from_utf8_lossy(stderr).trim().to_owned();
    if stderr.is_empty() {
        anyhow!("{command} failed")
    } else {
        anyhow!("{command} failed: {stderr}")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_merge_queue_rejection_is_downgraded_to_the_queued_refs() {
        // The exact shape git prints when one ref of a multi-ref push is locked
        // by a GitHub merge queue while its sibling pushes fine.
        let stderr = "\
remote: error: GH006: Protected branch update failed for refs/heads/feat/tf-deploy.
remote: - A pull request for this branch has been added to a merge queue. Branches that
remote:   are queued for merging cannot be updated. To modify this branch, dequeue the
remote:   associated pull request.
To github.com:higharc/product
 + 016bb37...3a94024 feat/spa-env -> feat/spa-env (forced update)
 ! [remote rejected]         feat/tf-deploy -> feat/tf-deploy (protected branch hook declined)
error: failed to push some refs to 'github.com:higharc/product'";
        assert_eq!(
            merge_queue_rejection(stderr),
            Some(vec!["feat/tf-deploy".to_owned()])
        );
    }

    #[test]
    fn a_stale_lease_rejection_is_not_swallowed_even_with_a_queue_mention() {
        // A force-with-lease failure is a real problem; the queue wording in the
        // dependabot banner must not mask it.
        let stderr = "\
remote: GitHub found 270 vulnerabilities ... merge queue notes ...
 ! [rejected]        feat/tf-deploy -> feat/tf-deploy (stale info)
error: failed to push some refs";
        assert_eq!(merge_queue_rejection(stderr), None);
    }

    #[test]
    fn no_queue_mention_is_not_a_queue_rejection() {
        let stderr = " ! [remote rejected] feat/x -> feat/x (permission denied)";
        assert_eq!(merge_queue_rejection(stderr), None);
    }

    #[test]
    fn landed_branches_drops_only_the_held_ones() {
        let attempted = [
            "feat/a".to_owned(),
            "feat/b".to_owned(),
            "feat/c".to_owned(),
        ];
        // A branch held back by the queue is dropped; order is preserved so the
        // "pushed ..." line never names a branch warned as held.
        assert_eq!(
            landed_branches(&attempted, &["feat/b".to_owned()]),
            vec!["feat/a".to_owned(), "feat/c".to_owned()]
        );
        // Nothing held: everything landed.
        assert_eq!(landed_branches(&attempted, &[]), attempted.to_vec());
        // Every branch held: nothing landed.
        assert!(landed_branches(&attempted, &attempted).is_empty());
    }

    #[test]
    fn a_stale_lease_push_names_the_rejected_branch() {
        // The exact shape from a submit after a lower branch merged: one ref
        // pushes, the stale one is rejected by --force-with-lease.
        let stderr = "\
To github.com:higharc/product
   3a94024..d63a2b2  feat/spa-env -> feat/spa-env
 ! [rejected]                feat/tf-deploy -> feat/tf-deploy (stale info)
error: failed to push some refs to 'github.com:higharc/product'";
        assert_eq!(
            stale_rejection(stderr),
            Some(vec!["feat/tf-deploy".to_owned()])
        );
    }

    #[test]
    fn a_non_fast_forward_push_is_treated_as_stale() {
        let stderr = " ! [rejected]  feat/x -> feat/x (non-fast-forward)";
        assert_eq!(stale_rejection(stderr), Some(vec!["feat/x".to_owned()]));
    }

    #[test]
    fn an_unrelated_push_failure_is_not_classified_as_stale() {
        // Permission/network failures must keep their own error, not "run sync".
        let stderr = " ! [remote rejected] feat/x -> feat/x (permission denied)";
        assert_eq!(stale_rejection(stderr), None);
        assert_eq!(stale_rejection("fatal: could not read from remote"), None);
    }

    #[test]
    fn a_mixed_stale_and_non_stale_rejection_is_not_classified_as_stale() {
        // One ref is stale, another was refused for a reason `git stk sync`
        // will not fix; the clean message replaces git's output, so it must not
        // claim sync resolves the permission failure - fall through to raw git.
        let stderr = "\
 ! [rejected]                feat/tf-deploy -> feat/tf-deploy (stale info)
 ! [remote rejected]         feat/locked -> feat/locked (permission denied)
error: failed to push some refs";
        assert_eq!(stale_rejection(stderr), None);
    }

    #[test]
    fn help_mentions_update_refs_matches_pre_2_43_spelling() {
        assert!(help_mentions_update_refs(
            "    --update-refs    update branches that point to commits that are being rebased"
        ));
    }

    #[test]
    fn help_mentions_update_refs_matches_negatable_spelling() {
        assert!(help_mentions_update_refs(
            "    --[no-]update-refs    update branches that point to commits that are being rebased"
        ));
    }

    #[test]
    fn help_mentions_update_refs_rejects_help_without_the_option() {
        assert!(!help_mentions_update_refs(
            "    --[no-]autosquash    move commits that begin with squash!/fixup!"
        ));
    }

    #[test]
    fn detection_agrees_with_the_real_git_on_this_machine() {
        // Ground truth: `--update-refs -h` fails with "unknown option" on a
        // git without the flag and prints help on one that has it.
        let probe = Command::new("git")
            .args(["rebase", "--update-refs", "-h"])
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .output()
            .expect("run git rebase probe");
        let probe_text = format!(
            "{}{}",
            String::from_utf8_lossy(&probe.stdout),
            String::from_utf8_lossy(&probe.stderr)
        );
        let real_support = !probe_text.contains("unknown option");

        assert_eq!(
            supports_rebase_update_refs().expect("detect support"),
            real_support
        );
    }
}