git-stk 0.12.2

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
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
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);
}

/// Public so a command can hold back detail that would otherwise repeat on
/// every run - see the stacked-field notice in `providers::github`.
pub 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())
}

/// Branches checked out in linked worktrees *other than this one*, paired with
/// the directory holding each. Git refuses to switch to, rebase, or delete a
/// branch another worktree holds, so callers check this before those.
pub fn worktree_branches() -> Result<Vec<(String, std::path::PathBuf)>> {
    let porcelain = output(&["worktree", "list", "--porcelain"])?;
    Ok(parse_worktree_branches(
        &porcelain,
        repo_root().ok().as_deref(),
    ))
}

/// Add a detached worktree at `path`, parked on `commit`. Detached on purpose:
/// it holds no branch, so it cannot collide with the user's checkout or any
/// other worktree.
///
/// `--force` because `path` is a scratch directory git-stk owns outright: a
/// killed run can leave the directory gone but still registered, and git then
/// refuses the path as "a missing but already registered worktree". Forcing is
/// scoped to that one path - `git worktree prune` would also clear entries for
/// the user's own worktrees that happen to be on unmounted volumes.
pub fn worktree_add_detached(path: &std::path::Path, commit: &str) -> Result<()> {
    let path = path.to_string_lossy().into_owned();
    status(&[
        "worktree", "add", "--detach", "--force", "--quiet", &path, commit,
    ])
    .with_context(|| format!("failed to create a worktree at {path}"))
}

/// Add a worktree at `path` holding a new branch created off `start`.
pub fn worktree_add_new_branch(path: &std::path::Path, branch: &str, start: &str) -> Result<()> {
    let path = path.to_string_lossy().into_owned();
    status(&["worktree", "add", "--quiet", "-b", branch, &path, start])
        .with_context(|| format!("failed to create a worktree for {branch} at {path}"))
}

/// Whether a worktree has uncommitted changes. Used before removing one git-stk
/// created, so work in it is never silently discarded.
pub fn worktree_has_changes(path: &std::path::Path) -> bool {
    let dir = path.to_string_lossy().into_owned();
    // Fails safe: if the state cannot be read at all, assume there is work to
    // lose. The caller removes with --force, so guessing "clean" here would
    // discard exactly what this guard exists to protect.
    output(&["-C", &dir, "status", "--porcelain"]).map_or(true, |out| !out.is_empty())
}

/// Remove a worktree, discarding anything in it. Only for worktrees git-stk
/// created and owns.
pub fn worktree_remove(path: &std::path::Path) -> Result<()> {
    let path = path.to_string_lossy().into_owned();
    status(&["worktree", "remove", "--force", &path])
        .with_context(|| format!("failed to remove the worktree at {path}"))
}

/// Move an existing worktree's detached HEAD to `commit`, without touching any
/// branch.
pub fn checkout_detached_in(worktree: &std::path::Path, commit: &str) -> Result<()> {
    let dir = worktree.to_string_lossy().into_owned();
    status(&["-C", &dir, "checkout", "--detach", "--quiet", commit])
        .with_context(|| format!("failed to check out {commit} in {dir}"))
}

/// An absolute path under the repo's common git dir. Callers that hand a path to
/// another process (a worktree location, a command's working directory) need it
/// absolute, since a relative one would be read against the wrong directory.
pub fn git_common_path_absolute(path: &str) -> Result<std::path::PathBuf> {
    let joined = git_common_path(path)?;
    std::path::absolute(&joined).with_context(|| format!("failed to resolve {joined}"))
}

/// The worktree holding `branch`, if one other than this one does.
pub fn worktree_holding(branch: &str) -> Result<Option<std::path::PathBuf>> {
    Ok(worktree_branches()?
        .into_iter()
        .find(|(name, _)| name == branch)
        .map(|(_, path)| path))
}

/// Whether this command is running inside a linked worktree rather than the
/// main checkout. Best effort: an unreadable root reads as the main worktree,
/// which is where most runs happen.
pub fn in_linked_worktree() -> bool {
    repo_root().is_ok_and(|root| !is_main_worktree(&root))
}

/// Whether `path` is the repo's main worktree. Worth telling apart because
/// `git worktree remove` refuses on it, so any advice that would free a branch
/// by removing its worktree is a dead end there.
pub fn is_main_worktree(path: &std::path::Path) -> bool {
    // Best effort: an unreadable listing just means the path goes undistinguished
    // and the advice stays the one that works everywhere.
    main_worktree().is_some_and(|main| same_path(&main, path))
}

/// The main worktree - the first record `git worktree list` reports.
fn main_worktree() -> Option<std::path::PathBuf> {
    parse_main_worktree(&output(&["worktree", "list", "--porcelain"]).ok()?)
}

/// The anchor for repo-wide paths that must resolve the same from every
/// worktree. [`repo_root`] answers "where am I", which inside a linked worktree
/// is that worktree - so a default derived from it would nest a new worktree
/// under the one it was created from. Falls back to the root when the listing
/// cannot be read, which is where a repo with no linked worktrees lands anyway.
pub fn main_worktree_root() -> Result<std::path::PathBuf> {
    match main_worktree() {
        Some(path) => Ok(path),
        None => repo_root(),
    }
}

fn parse_main_worktree(porcelain: &str) -> Option<std::path::PathBuf> {
    porcelain
        .lines()
        .find_map(|line| line.strip_prefix("worktree "))
        .map(std::path::PathBuf::from)
}

/// How to hand a branch back, as a command the user can paste. Detaching is what
/// the guards lead with because it works on every worktree: `git worktree remove`
/// refuses on the main one, and moving the operation into the holding worktree
/// only helps when that worktree is the only one in the way.
pub fn detach_command(path: &std::path::Path) -> String {
    // Quoted: a worktree path containing a space would otherwise be pasted back
    // as two arguments.
    format!("git -C \"{}\" checkout --detach", display_path(path))
}

/// A worktree path for a message, tagged when it is the main one so the reader
/// knows why removing it is not among the options.
pub fn describe_worktree(path: &std::path::Path) -> String {
    let shown = display_path(path);
    if is_main_worktree(path) {
        format!("{shown} (the main worktree)")
    } else {
        shown
    }
}

/// Collapse worktree paths to the distinct places involved, so a message about
/// three branches held by one worktree suggests freeing it once, not three times.
pub fn distinct_paths<'a>(
    paths: impl IntoIterator<Item = &'a std::path::Path>,
) -> Vec<std::path::PathBuf> {
    let mut distinct: Vec<std::path::PathBuf> = Vec::new();
    for path in paths {
        if !distinct.iter().any(|seen| same_path(seen, path)) {
            distinct.push(path.to_path_buf());
        }
    }
    distinct
}

/// Parse `git worktree list --porcelain` into (branch, path) pairs. Records are
/// blank-line separated, each opening with `worktree <path>`; only those with a
/// `branch` line hold a branch, so bare and detached ones drop out. The record
/// rooted at `current` is excluded, letting callers read a hit as "someone else
/// holds this".
fn parse_worktree_branches(
    porcelain: &str,
    current: Option<&std::path::Path>,
) -> Vec<(String, std::path::PathBuf)> {
    let current = current.map(canonical);
    let mut held = Vec::new();
    let mut path: Option<std::path::PathBuf> = None;

    for line in porcelain.lines() {
        if let Some(rest) = line.strip_prefix("worktree ") {
            path = Some(std::path::PathBuf::from(rest));
        } else if let Some(branch) = line.strip_prefix("branch refs/heads/") {
            // take() so a record without a branch line cannot borrow the next
            // record's path.
            if let Some(path) = path.take()
                && current.as_deref() != Some(canonical(&path).as_path())
            {
                held.push((branch.to_owned(), path));
            }
        }
    }

    held
}

/// Resolve a worktree path for comparison. Symlinked or `/tmp`-style paths
/// otherwise read as a different worktree than the one we are standing in.
fn canonical(path: &std::path::Path) -> std::path::PathBuf {
    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}

/// Whether two paths name the same place. Never compare worktree paths with
/// `==`: git reports its own resolved form, which differs from anything we
/// build ourselves - `/var` against `/private/var` on macOS, forward against
/// back slashes on Windows - so exact equality quietly reports "different".
pub fn same_path(a: &std::path::Path, b: &std::path::Path) -> bool {
    canonical(a) == canonical(b)
}

/// Render a worktree path for a message the user may paste back as a command.
/// Sibling worktrees are the common layout and `../wt-a` reads better than a
/// long absolute path. Only exact prefix matches are shortened, so the result is
/// always a usable path - never a guess.
pub fn display_path(path: &std::path::Path) -> String {
    let Ok(cwd) = std::env::current_dir() else {
        return path.display().to_string();
    };

    if let Ok(rest) = path.strip_prefix(&cwd)
        && rest.components().next().is_some()
    {
        return format!("./{}", rest.display());
    }
    if let Some(up) = cwd.parent()
        && let Ok(rest) = path.strip_prefix(up)
        && rest.components().next().is_some()
    {
        return format!("../{}", rest.display());
    }

    path.display().to_string()
}

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")
}

/// The explanation for an operation git refuses because another worktree holds
/// `branch` - checkout and rebase both hit this, and should say the same thing.
/// Asked structurally rather than by matching git's wording, which varies across
/// versions and locales. An unanswerable query (very old git, an odd setup)
/// yields None and the caller falls through to git's own error, as before.
fn worktree_collision(branch: &str) -> Option<String> {
    let path = worktree_holding(branch).ok().flatten()?;
    Some(collision_message(
        branch,
        &display_path(&path),
        is_main_worktree(&path),
    ))
}

/// The wording, split out so it can be checked directly. The suggested commands
/// are quoted: a worktree path containing a space would otherwise be pasted back
/// as two arguments. Removal is only offered for a linked worktree - git refuses
/// to remove the main one, so suggesting it there sends the user nowhere.
fn collision_message(branch: &str, shown: &str, is_main: bool) -> String {
    let mut free = format!("free it with `git -C \"{shown}\" checkout --detach`");
    if !is_main {
        free.push_str(&format!(
            ", or drop that worktree with `git worktree remove \"{shown}\"`"
        ));
    }
    format!(
        "{branch} is checked out in the worktree at {shown}\n\
         work on it there with `cd \"{shown}\"`, or {free}"
    )
}

pub fn checkout(branch: &str) -> Result<()> {
    checkout_silently(branch)?;
    anstream::println!("switched to {}", switched_to(branch));
    Ok(())
}

/// Switch without announcing it on stdout, for callers whose stdout carries a
/// value a shell will consume. They report the switch themselves, on stderr.
pub fn checkout_silently(branch: &str) -> Result<()> {
    if let Some(message) = worktree_collision(branch) {
        bail!(message);
    }

    status(&["switch", branch]).with_context(|| format!("failed to check out {branch}"))
}

/// The "switched to <branch>" wording, so stdout and stderr callers agree.
pub fn switched_to(branch: &str) -> String {
    crate::style::paint(crate::style::BRANCH, branch)
}

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<()> {
    if let Some(message) = worktree_collision(branch) {
        bail!(message);
    }
    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<()> {
    if let Some(message) = worktree_collision(branch) {
        bail!(message);
    }
    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")
}

/// Whether a rebase is actually paused in this worktree. Distinguishes a real
/// conflict from git-stk merely having left state on file - git refuses to
/// rebase a branch another worktree holds, which fails the run without ever
/// starting a rebase to continue or abort.
pub fn rebase_in_progress() -> bool {
    ["rebase-merge", "rebase-apply"].iter().any(|dir| {
        git_path(dir)
            .map(|path| std::path::Path::new(&path).exists())
            .unwrap_or(false)
    })
}

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}"))
}

/// Whether `remote` has a head for `branch`. Checks a stack base git-stk does
/// not push itself, before a review is opened against it.
pub(crate) fn remote_has_branch(remote: &str, branch: &str) -> Result<bool> {
    Ok(!remote_branches_present(remote, std::slice::from_ref(&branch.to_owned()))?.is_empty())
}

/// 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)
}

/// Whether merging `other` into `one` would bring nothing new - every change
/// it carries is already there, whatever the commit graphs look like.
///
/// A squash merge rewrites a branch's commits into one whose patch id matches
/// none of the originals, so comparing commits (even with `--cherry-pick`)
/// reports them as missing long after the work has landed. Comparing trees
/// directly is not enough either: any unrelated commit alongside the squash
/// makes the trees differ again while the remote still adds nothing. A
/// three-way merge asks the question the caller actually has, and needs no
/// knowledge of how the merge upstream was done.
///
/// A merge that conflicts is a real divergence, and reads as `false`.
pub fn merge_adds_nothing(one: &str, other: &str) -> Result<bool> {
    // `--write-tree` writes the merged tree and prints its oid; a conflict
    // exits 1, which is an answer rather than a failure. Needs git 2.38, the
    // same floor `rebase --update-refs` already sets.
    let Some(merged) = output_codes(
        &["merge-tree", "--write-tree", one, other],
        &[1],
        "git merge-tree --write-tree",
    )?
    else {
        return Ok(false);
    };
    let ours = output(&["rev-parse", &format!("{one}^{{tree}}")])
        .with_context(|| format!("failed to read the tree of {one}"))?;
    Ok(merged.lines().next().unwrap_or_default().trim() == ours.trim())
}

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::*;

    /// The shape `git worktree list --porcelain` prints for a main worktree, a
    /// linked one, a detached one, and a bare repo.
    const PORCELAIN: &str = "\
worktree /repo
HEAD f7cff917cf874d0c6ff3108260fda91ac3271baf
branch refs/heads/feat/b

worktree /repo/../wt-a
HEAD 0700673acebfe459d480fa3bd616b2ecf6249fe1
branch refs/heads/feat/a

worktree /repo/../wt-detached
HEAD 25fb6254b4b1cd5cbe2b0d4b1f5b1cf6e7d8a9b0
detached
";

    #[test]
    fn worktree_parsing_keeps_branches_and_drops_detached_ones() {
        // No current worktree to exclude: every branch-holding record survives,
        // and the detached one - which holds no branch and so blocks nothing -
        // does not.
        let held = parse_worktree_branches(PORCELAIN, None);
        assert_eq!(
            held,
            vec![
                ("feat/b".to_owned(), std::path::PathBuf::from("/repo")),
                (
                    "feat/a".to_owned(),
                    std::path::PathBuf::from("/repo/../wt-a")
                ),
            ]
        );
    }

    #[test]
    fn worktree_parsing_excludes_the_worktree_we_are_standing_in() {
        // The point of the exclusion: a caller must be able to read a hit as
        // "another worktree holds this", never as its own checkout.
        let held = parse_worktree_branches(PORCELAIN, Some(std::path::Path::new("/repo")));
        assert_eq!(
            held,
            vec![(
                "feat/a".to_owned(),
                std::path::PathBuf::from("/repo/../wt-a")
            )]
        );
    }

    #[test]
    fn a_bare_record_does_not_lend_its_path_to_the_next_branch() {
        // A bare repo opens a record with no branch line. The following
        // worktree's branch must not be attributed to the bare path.
        let porcelain = "\
worktree /repo/.bare
bare

worktree /repo/wt-a
HEAD 0700673acebfe459d480fa3bd616b2ecf6249fe1
branch refs/heads/feat/a
";
        assert_eq!(
            parse_worktree_branches(porcelain, None),
            vec![("feat/a".to_owned(), std::path::PathBuf::from("/repo/wt-a"))]
        );
    }

    #[test]
    fn branch_names_containing_slashes_survive_the_refs_heads_strip() {
        // Only the refs/heads/ prefix comes off - the rest of the name is the
        // branch, slashes and all.
        let porcelain = "\
worktree /repo/wt
HEAD 0700673acebfe459d480fa3bd616b2ecf6249fe1
branch refs/heads/feat/deep/nested/name
";
        assert_eq!(
            parse_worktree_branches(porcelain, None)
                .first()
                .map(|(branch, _)| branch.as_str()),
            Some("feat/deep/nested/name")
        );
    }

    #[test]
    fn empty_porcelain_holds_nothing() {
        assert!(parse_worktree_branches("", None).is_empty());
    }

    #[test]
    fn a_collision_message_quotes_the_path_it_suggests_pasting() {
        // A worktree path with a space in it has to survive the round trip into
        // the user's shell.
        let message = collision_message("feat/a", "../my worktree", false);
        assert!(
            message.contains(r#"`cd "../my worktree"`"#),
            "cd suggestion is not pasteable: {message}"
        );
        assert!(
            message.contains(r#"`git worktree remove "../my worktree"`"#),
            "remove suggestion is not pasteable: {message}"
        );
        assert!(
            message.contains(r#"`git -C "../my worktree" checkout --detach`"#),
            "detach suggestion is not pasteable: {message}"
        );
    }

    #[test]
    fn a_collision_with_the_main_worktree_never_suggests_removing_it() {
        // `git worktree remove` refuses on the main worktree, so offering it
        // there would be advice the user cannot act on.
        let message = collision_message("feat/a", "../product", true);
        assert!(
            !message.contains("git worktree remove"),
            "the main worktree cannot be removed: {message}"
        );
        assert!(
            message.contains(r#"`git -C "../product" checkout --detach`"#),
            "no workable way to free the branch: {message}"
        );
    }

    #[test]
    fn the_main_worktree_is_the_first_record_listed() {
        let porcelain = "\
worktree /repo/product
HEAD 1111111111111111111111111111111111111111
branch refs/heads/feat/b

worktree /repo/product-worktrees/feat/a
HEAD 2222222222222222222222222222222222222222
branch refs/heads/feat/a
";
        assert_eq!(
            parse_main_worktree(porcelain),
            Some(std::path::PathBuf::from("/repo/product"))
        );
    }

    #[test]
    fn no_listing_names_no_main_worktree() {
        assert_eq!(parse_main_worktree(""), None);
    }

    #[test]
    fn one_worktree_holding_three_branches_is_freed_once() {
        let held = [
            std::path::Path::new("../wt-a"),
            std::path::Path::new("../wt-a"),
            std::path::Path::new("../wt-b"),
        ];
        assert_eq!(
            distinct_paths(held),
            vec![
                std::path::PathBuf::from("../wt-a"),
                std::path::PathBuf::from("../wt-b")
            ]
        );
    }

    #[test]
    fn a_collision_message_names_the_branch_and_where_it_lives() {
        let message = collision_message("feat/a", "../wt-a", false);
        assert!(message.starts_with("feat/a is checked out in the worktree at ../wt-a"));
    }

    #[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
        );
    }
}