worktrunk 0.43.0

A CLI for Git worktree management, designed for parallel AI agent workflows
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
//! Tests for git repository methods to improve code coverage.

use std::fs;

use worktrunk::git::Repository;

use crate::common::{BareRepoTest, TestRepo};

// =============================================================================
// is_bare() tests
// =============================================================================

/// When `core.bare` is unset (e.g., repos cloned by Eclipse/EGit), `is_bare()`
/// must return `false`. Before the fix for #1939, `git rev-parse
/// --is-bare-repository` was used, which infers `true` from inside `.git/`
/// when `core.bare` is absent.
#[test]
fn test_is_bare_returns_false_when_core_bare_unset() {
    let repo = TestRepo::new();

    // Simulate a repo where core.bare was never written (e.g., Eclipse/EGit)
    repo.run_git(&["config", "--unset", "core.bare"]);

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
    assert!(
        !repository.is_bare().unwrap(),
        "repo with unset core.bare should not be detected as bare"
    );
}

#[test]
fn test_is_bare_returns_false_for_normal_repo() {
    let repo = TestRepo::new();
    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
    assert!(!repository.is_bare().unwrap());
}

#[test]
fn test_is_bare_returns_true_for_bare_repo() {
    let test = BareRepoTest::new();
    let repository = Repository::at(test.bare_repo_path().to_path_buf()).unwrap();
    assert!(repository.is_bare().unwrap());
}

// =============================================================================
// worktree_state() tests - simulate various git operation states
// =============================================================================

#[test]
fn test_worktree_state_normal() {
    let repo = TestRepo::new();
    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();

    // Normal state - no special files
    let state = repository.worktree_state().unwrap();
    assert!(state.is_none());
}

#[test]
fn test_worktree_state_merging() {
    let repo = TestRepo::new();
    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();

    // Simulate merge state by creating MERGE_HEAD
    let git_dir = repo.root_path().join(".git");
    fs::write(git_dir.join("MERGE_HEAD"), "abc123\n").unwrap();

    let state = repository.worktree_state().unwrap();
    assert_eq!(state, Some("MERGING".to_string()));
}

#[test]
fn test_worktree_state_rebasing_with_progress() {
    let repo = TestRepo::new();
    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();

    // Simulate rebase state with progress
    let git_dir = repo.root_path().join(".git");
    let rebase_dir = git_dir.join("rebase-merge");
    fs::create_dir_all(&rebase_dir).unwrap();
    fs::write(rebase_dir.join("msgnum"), "2\n").unwrap();
    fs::write(rebase_dir.join("end"), "5\n").unwrap();

    let state = repository.worktree_state().unwrap();
    assert_eq!(state, Some("REBASING 2/5".to_string()));
}

#[test]
fn test_worktree_state_rebasing_apply() {
    let repo = TestRepo::new();
    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();

    // Simulate rebase-apply state (git am or git rebase without -m)
    let git_dir = repo.root_path().join(".git");
    let rebase_dir = git_dir.join("rebase-apply");
    fs::create_dir_all(&rebase_dir).unwrap();
    fs::write(rebase_dir.join("msgnum"), "3\n").unwrap();
    fs::write(rebase_dir.join("end"), "7\n").unwrap();

    let state = repository.worktree_state().unwrap();
    assert_eq!(state, Some("REBASING 3/7".to_string()));
}

#[test]
fn test_worktree_state_rebasing_no_progress() {
    let repo = TestRepo::new();
    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();

    // Simulate rebase state without progress files
    let git_dir = repo.root_path().join(".git");
    let rebase_dir = git_dir.join("rebase-merge");
    fs::create_dir_all(&rebase_dir).unwrap();
    // No msgnum/end files - just the directory

    let state = repository.worktree_state().unwrap();
    assert_eq!(state, Some("REBASING".to_string()));
}

#[test]
fn test_worktree_state_cherry_picking() {
    let repo = TestRepo::new();
    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();

    // Simulate cherry-pick state
    let git_dir = repo.root_path().join(".git");
    fs::write(git_dir.join("CHERRY_PICK_HEAD"), "def456\n").unwrap();

    let state = repository.worktree_state().unwrap();
    assert_eq!(state, Some("CHERRY-PICKING".to_string()));
}

#[test]
fn test_worktree_state_reverting() {
    let repo = TestRepo::new();
    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();

    // Simulate revert state
    let git_dir = repo.root_path().join(".git");
    fs::write(git_dir.join("REVERT_HEAD"), "789abc\n").unwrap();

    let state = repository.worktree_state().unwrap();
    assert_eq!(state, Some("REVERTING".to_string()));
}

#[test]
fn test_worktree_state_bisecting() {
    let repo = TestRepo::new();
    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();

    // Simulate bisect state
    let git_dir = repo.root_path().join(".git");
    fs::write(git_dir.join("BISECT_LOG"), "# bisect log\n").unwrap();

    let state = repository.worktree_state().unwrap();
    assert_eq!(state, Some("BISECTING".to_string()));
}

// =============================================================================
// available_branches() tests
// =============================================================================

#[test]
fn test_available_branches_all_have_worktrees() {
    let mut repo = TestRepo::new();
    // main branch already has a worktree (the main repo)
    // Create feature branch with worktree
    repo.add_worktree("feature");

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
    let available = repository.available_branches().unwrap();

    // Both main and feature have worktrees, so nothing should be available
    assert!(available.is_empty());
}

#[test]
fn test_available_branches_some_without_worktrees() {
    let repo = TestRepo::with_initial_commit();
    // Create a branch without a worktree
    repo.git_command()
        .args(["branch", "orphan-branch"])
        .run()
        .unwrap();

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
    let available = repository.available_branches().unwrap();

    // orphan-branch has no worktree, so it should be available
    assert!(available.contains(&"orphan-branch".to_string()));
    // main has a worktree, so it should not be available
    assert!(!available.contains(&"main".to_string()));
}

// =============================================================================
// all_branches() tests
// =============================================================================

#[test]
fn test_all_branches() {
    let repo = TestRepo::with_initial_commit();
    // Create some branches
    repo.git_command().args(["branch", "alpha"]).run().unwrap();
    repo.git_command().args(["branch", "beta"]).run().unwrap();

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
    let branches = repository.all_branches().unwrap();

    assert!(branches.contains(&"main".to_string()));
    assert!(branches.contains(&"alpha".to_string()));
    assert!(branches.contains(&"beta".to_string()));
}

// =============================================================================
// project_identifier() URL parsing tests
// =============================================================================

#[test]
fn test_project_identifier_https() {
    let mut repo = TestRepo::with_initial_commit();
    repo.setup_remote("main");
    // Override the remote URL to https format
    repo.git_command()
        .args([
            "remote",
            "set-url",
            "origin",
            "https://github.com/user/repo.git",
        ])
        .run()
        .unwrap();

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
    let id = repository.project_identifier().unwrap();
    assert_eq!(id, "github.com/user/repo");
}

#[test]
fn test_project_identifier_http() {
    let mut repo = TestRepo::with_initial_commit();
    repo.setup_remote("main");
    // Override the remote URL to http format (no SSL)
    repo.git_command()
        .args([
            "remote",
            "set-url",
            "origin",
            "http://gitlab.example.com/team/project.git",
        ])
        .run()
        .unwrap();

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
    let id = repository.project_identifier().unwrap();
    assert_eq!(id, "gitlab.example.com/team/project");
}

#[test]
fn test_project_identifier_ssh_colon() {
    let mut repo = TestRepo::with_initial_commit();
    repo.setup_remote("main");
    // Override the remote URL to SSH format with colon
    repo.git_command()
        .args([
            "remote",
            "set-url",
            "origin",
            "git@github.com:user/repo.git",
        ])
        .run()
        .unwrap();

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
    let id = repository.project_identifier().unwrap();
    assert_eq!(id, "github.com/user/repo");
}

#[test]
fn test_project_identifier_ssh_protocol() {
    let mut repo = TestRepo::with_initial_commit();
    repo.setup_remote("main");
    // Override the remote URL to ssh:// format
    repo.git_command()
        .args([
            "remote",
            "set-url",
            "origin",
            "ssh://git@github.com/user/repo.git",
        ])
        .run()
        .unwrap();

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
    let id = repository.project_identifier().unwrap();
    // ssh://git@github.com/user/repo.git -> github.com/user/repo
    assert_eq!(id, "github.com/user/repo");
}

#[test]
fn test_project_identifier_ssh_protocol_with_port() {
    let mut repo = TestRepo::with_initial_commit();
    repo.setup_remote("main");
    // Override the remote URL to ssh:// format with port
    repo.git_command()
        .args([
            "remote",
            "set-url",
            "origin",
            "ssh://git@gitlab.example.com:2222/team/project.git",
        ])
        .run()
        .unwrap();

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
    let id = repository.project_identifier().unwrap();
    // Port is stripped — irrelevant to project identity
    assert_eq!(id, "gitlab.example.com/team/project");
}

#[test]
fn test_project_identifier_no_remote_fallback() {
    let repo = TestRepo::with_initial_commit();

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
    let id = repository.project_identifier().unwrap();
    // Should be the full canonical path (security: avoids collisions across unrelated repos)
    let expected = dunce::canonicalize(repo.root_path()).unwrap();
    assert_eq!(id, expected.to_str().unwrap());
}

// =============================================================================
// config_value/set_config tests
// =============================================================================

#[test]
fn test_get_config_exists() {
    let repo = TestRepo::new();
    repo.git_command()
        .args(["config", "test.key", "test-value"])
        .run()
        .unwrap();

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
    let value = repository.config_value("test.key").unwrap();
    assert_eq!(value, Some("test-value".to_string()));
}

#[test]
fn test_get_config_not_exists() {
    let repo = TestRepo::new();

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
    let value = repository.config_value("nonexistent.key").unwrap();
    assert!(value.is_none());
}

#[test]
fn test_set_config() {
    let repo = TestRepo::new();

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
    repository.set_config("test.setting", "new-value").unwrap();

    // Verify it was set
    let value = repository.config_value("test.setting").unwrap();
    assert_eq!(value, Some("new-value".to_string()));
}

// =============================================================================
// config_value() error handling: corrupt config propagation
// =============================================================================

#[test]
fn test_config_value_propagates_error_on_corrupt_config() {
    let repo = TestRepo::new();
    let root = repo.root_path().to_path_buf();

    // Create repository before corrupting config
    let repository = Repository::at(root.clone()).unwrap();

    // Corrupt the git config file after repository creation
    let config_path = root.join(".git/config");
    fs::write(&config_path, "[invalid section\n").unwrap();

    let result = repository.config_value("test.key");

    // Should propagate the error, not silently return None
    assert!(
        result.is_err(),
        "config_value() should propagate errors from corrupt config, not return Ok(None)"
    );
}

#[test]
fn test_clear_hint_propagates_error_on_corrupt_config() {
    let repo = TestRepo::new();
    let root = repo.root_path().to_path_buf();

    // Create repository and set a hint before corrupting config
    let repository = Repository::at(root.clone()).unwrap();
    repository.mark_hint_shown("test-hint").unwrap();

    // Corrupt the git config file
    let config_path = root.join(".git/config");
    fs::write(&config_path, "[invalid section\n").unwrap();

    let result = repository.clear_hint("test-hint");

    // Should propagate the error, not silently return Ok(false)
    assert!(
        result.is_err(),
        "clear_hint() should propagate errors from corrupt config, not return Ok(false)"
    );
}

// =============================================================================
// Bulk config cache coverage
// =============================================================================

/// `mark_hint_shown` → `has_shown_hint` → `list_shown_hints` → `clear_hint`
/// exercises the full write-then-read round trip through the bulk config
/// cache, including coherent in-memory updates.
#[test]
fn test_hint_roundtrip_through_bulk_cache() {
    let repo = TestRepo::new();
    let r = Repository::at(repo.root_path().to_path_buf()).unwrap();

    // Populate bulk cache before the write so set/unset hit the in-memory
    // update paths.
    assert!(!r.is_bare().unwrap());

    r.mark_hint_shown("zebra").unwrap();
    r.mark_hint_shown("alpha").unwrap();
    assert!(r.has_shown_hint("zebra"));
    assert!(r.has_shown_hint("alpha"));
    assert!(!r.has_shown_hint("unknown"));

    // Deterministic alphabetical ordering (bulk cache is a HashMap — order
    // must be explicitly sorted for display).
    let hints = r.list_shown_hints();
    assert_eq!(hints, vec!["alpha".to_string(), "zebra".to_string()]);

    // Clear one → coherent in-memory removal.
    assert!(r.clear_hint("alpha").unwrap());
    assert!(!r.has_shown_hint("alpha"));
    assert!(r.has_shown_hint("zebra"));
    assert_eq!(r.list_shown_hints(), vec!["zebra".to_string()]);

    // Clear missing → Ok(false).
    assert!(!r.clear_hint("never-set").unwrap());
}

/// `primary_remote()` honours `checkout.defaultRemote` when it points at
/// a configured remote — covers the early-return branch in the new bulk
/// lookup.
#[test]
fn test_primary_remote_honours_checkout_default_remote() {
    let repo = TestRepo::new();
    repo.run_git(&[
        "remote",
        "add",
        "origin",
        "https://github.com/max-sixty/worktrunk.git",
    ]);
    repo.run_git(&[
        "remote",
        "add",
        "upstream",
        "https://github.com/max-sixty/worktrunk.git",
    ]);
    repo.run_git(&["config", "checkout.defaultRemote", "upstream"]);

    let r = Repository::at(repo.root_path().to_path_buf()).unwrap();
    assert_eq!(r.primary_remote().unwrap(), "upstream");
    // With no `checkout.defaultRemote`, falls back to the first remote
    // with a URL (the filter-out-phantom-entries path).
    repo.run_git(&["config", "--unset", "checkout.defaultRemote"]);
    let r2 = Repository::at(repo.root_path().to_path_buf()).unwrap();
    assert_eq!(r2.primary_remote().unwrap(), "origin");
}

/// `all_remote_urls()` enumerates every configured remote via the bulk
/// map, filtering out phantom entries (keys with `remote.X.*` that have
/// no `.url`).
#[test]
fn test_all_remote_urls_filters_phantom_remotes() {
    let repo = TestRepo::new();
    repo.run_git(&[
        "remote",
        "add",
        "origin",
        "https://github.com/max-sixty/worktrunk.git",
    ]);
    // A phantom entry: remote.X.prunetags set but no URL → should not appear.
    repo.run_git(&["config", "remote.phantom.prunetags", "true"]);

    let r = Repository::at(repo.root_path().to_path_buf()).unwrap();
    let urls = r.all_remote_urls();
    assert_eq!(urls.len(), 1, "expected only origin, got {urls:?}");
    assert_eq!(urls[0].0, "origin");
}

/// `unset_config_value` cleanly removes in-memory state after the bulk
/// cache is populated. Guards against a regression where the in-memory
/// remove used the literal key instead of the canonical form.
#[test]
fn test_unset_config_removes_from_bulk_cache() {
    let repo = TestRepo::new();
    let r = Repository::at(repo.root_path().to_path_buf()).unwrap();

    // Populate cache, then write a mixed-case variable key (canonical
    // variable name is lowercased by git).
    let _ = r.is_bare();
    r.set_config("branch.main.pushRemote", "origin").unwrap();
    assert_eq!(
        r.config_value("branch.main.pushRemote").unwrap(),
        Some("origin".to_string())
    );

    // Unset removes it — subsequent reads return None.
    assert!(r.unset_config("branch.main.pushRemote").unwrap());
    assert_eq!(r.config_value("branch.main.pushRemote").unwrap(), None);

    // Unsetting again → Ok(false).
    assert!(!r.unset_config("branch.main.pushRemote").unwrap());
}

/// `set_default_branch` → `clear_default_branch_cache` round trip,
/// covering the specialized default-branch writers that route through
/// `set_config_value` / `unset_config_value`.
#[test]
fn test_set_and_clear_default_branch() {
    let repo = TestRepo::new();
    let r = Repository::at(repo.root_path().to_path_buf()).unwrap();
    r.set_default_branch("main").unwrap();
    assert_eq!(r.default_branch(), Some("main".to_string()));

    // Clearing an existing cache returns true; a second clear returns false.
    let r2 = Repository::at(repo.root_path().to_path_buf()).unwrap();
    assert!(r2.clear_default_branch_cache().unwrap());
    assert!(!r2.clear_default_branch_cache().unwrap());
}

/// `switch_previous` / `set_switch_previous` round trip. Exercises
/// `worktrunk.history` read + write through the bulk-config helpers.
#[test]
fn test_switch_previous_roundtrip() {
    let repo = TestRepo::new();
    let r = Repository::at(repo.root_path().to_path_buf()).unwrap();
    // Populate the cache first to hit the in-memory update branch.
    let _ = r.is_bare();
    assert_eq!(r.switch_previous(), None);
    r.set_switch_previous(Some("feature-a")).unwrap();
    assert_eq!(r.switch_previous(), Some("feature-a".to_string()));
    // `None` is a no-op — doesn't clear.
    r.set_switch_previous(None).unwrap();
    assert_eq!(r.switch_previous(), Some("feature-a".to_string()));
}

/// `primary_remote_url` composes `primary_remote` + `remote_url`,
/// returning the raw URL for the primary remote. `primary_remote_parsed_url`
/// threads that through `GitRemoteUrl::parse`.
#[test]
fn test_primary_remote_url_composition() {
    let repo = TestRepo::new();
    repo.run_git(&[
        "remote",
        "add",
        "origin",
        "https://github.com/max-sixty/worktrunk.git",
    ]);
    let r = Repository::at(repo.root_path().to_path_buf()).unwrap();
    assert_eq!(
        r.primary_remote_url(),
        Some("https://github.com/max-sixty/worktrunk.git".to_string())
    );
    let parsed = r.primary_remote_parsed_url().expect("parses");
    assert_eq!(parsed.owner(), "max-sixty");
    assert_eq!(parsed.repo(), "worktrunk");

    // Without a remote, both return None.
    let bare = TestRepo::new();
    let r2 = Repository::at(bare.root_path().to_path_buf()).unwrap();
    assert_eq!(r2.primary_remote_url(), None);
    assert!(r2.primary_remote_parsed_url().is_none());
}

/// `remote_url` for a configured remote round-trips; unknown remotes
/// return `None`. Covers the `.filter(|url| !url.is_empty())` branch
/// via the happy-path URL read.
#[test]
fn test_remote_url_known_and_unknown() {
    let repo = TestRepo::new();
    repo.run_git(&[
        "remote",
        "add",
        "origin",
        "git@github.com:max-sixty/worktrunk.git",
    ]);
    let r = Repository::at(repo.root_path().to_path_buf()).unwrap();
    assert_eq!(
        r.remote_url("origin"),
        Some("git@github.com:max-sixty/worktrunk.git".to_string())
    );
    assert_eq!(r.remote_url("nonexistent"), None);
}

/// `primary_remote()` errors when no remotes are configured — covers
/// the `ok_or_else(|| anyhow!("No remotes configured"))` final arm.
#[test]
fn test_primary_remote_errors_with_no_remotes() {
    let repo = TestRepo::new(); // TestRepo::new() ships without a remote.
    let r = Repository::at(repo.root_path().to_path_buf()).unwrap();
    let err = r.primary_remote().unwrap_err();
    assert!(
        err.to_string().contains("No remotes configured"),
        "unexpected error: {err}"
    );
}

/// `require_target_ref(None)` surfaces `StaleDefaultBranch` when the
/// persisted default branch no longer resolves locally. Covers the
/// `target.is_none()` arm added alongside `require_target_branch` for
/// commands like `wt step commit` that accept any commit-ish target.
#[test]
fn test_require_target_ref_surfaces_stale_default_branch() {
    use worktrunk::git::GitError;
    let repo = TestRepo::new();
    let r = Repository::at(repo.root_path().to_path_buf()).unwrap();
    r.set_config("worktrunk.default-branch", "nonexistent-branch")
        .unwrap();

    // Fresh Repository so the OnceCell re-reads the stale value.
    let r2 = Repository::at(repo.root_path().to_path_buf()).unwrap();
    let err = r2.require_target_ref(None).unwrap_err();
    let gerr = err.downcast_ref::<GitError>().expect("GitError");
    assert!(
        matches!(gerr, GitError::StaleDefaultBranch { branch } if branch == "nonexistent-branch"),
        "expected StaleDefaultBranch, got {gerr:?}"
    );
}

/// `unset_config_value` propagates errors from corrupt git config
/// rather than returning `Ok(false)` (the exit-code-5 "key absent" case).
#[test]
fn test_unset_config_propagates_error_on_corrupt_config() {
    let repo = TestRepo::new();
    let root = repo.root_path().to_path_buf();
    let r = Repository::at(root.clone()).unwrap();
    r.set_default_branch("main").unwrap();

    // Corrupt the git config so subsequent writes fail with a real error
    // (not the benign exit-code-5 that maps to Ok(false)).
    fs::write(root.join(".git/config"), "[invalid section\n").unwrap();
    let err = r.unset_config("worktrunk.default-branch");
    assert!(
        err.is_err(),
        "unset_config should propagate corrupt-config errors: {err:?}"
    );
}

// =============================================================================
// Bug #1: Tag/branch name collision tests
// =============================================================================

/// When a tag and branch share the same name, git resolves unqualified refs to
/// the tag by default. This can cause is_ancestor() to return incorrect results
/// if the tag points to a different commit than the branch.
///
/// This test verifies that integration checking uses qualified refs (refs/heads/)
/// to avoid this ambiguity.
#[test]
fn test_tag_branch_name_collision_is_ancestor() {
    let repo = TestRepo::with_initial_commit();

    // Initial commit already exists from with_initial_commit()
    let main_sha = repo.git_output(&["rev-parse", "HEAD"]);

    // Create feature branch with additional commits
    repo.run_git(&["checkout", "-b", "feature"]);
    fs::write(repo.root_path().join("feature.txt"), "feature content").unwrap();
    repo.run_git(&["add", "feature.txt"]);
    repo.run_git(&["commit", "-m", "Feature commit"]);

    // Create a tag named "feature" pointing to the MAIN commit (earlier)
    // This simulates the scenario where someone creates a tag with the same name
    repo.run_git(&["tag", "feature", &main_sha]);

    // Now git has ambiguity: "feature" could be the tag (at main_sha) or the branch (ahead)
    // The branch "feature" is NOT an ancestor of main (it's ahead)
    // But the tag "feature" points to main_sha, which IS an ancestor of main (same commit)

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();

    // Without qualified refs, this would incorrectly return true
    // (checking the tag, which equals main, instead of the branch, which is ahead)
    // With the fix (using refs/heads/), this should correctly return false
    let result = repository.is_ancestor("feature", "main").unwrap();

    // The branch "feature" is ahead of main, so it should NOT be an ancestor
    assert!(
        !result,
        "is_ancestor should check the branch 'feature', not the tag 'feature'"
    );
}

/// Test that same_commit() correctly distinguishes between tag and branch
/// when they share the same name but point to different commits.
#[test]
fn test_tag_branch_name_collision_same_commit() {
    let repo = TestRepo::with_initial_commit();

    // Get main's SHA
    let main_sha = repo.git_output(&["rev-parse", "HEAD"]);

    // Create feature branch with additional commits
    repo.run_git(&["checkout", "-b", "feature"]);
    fs::write(repo.root_path().join("feature.txt"), "feature content").unwrap();
    repo.run_git(&["add", "feature.txt"]);
    repo.run_git(&["commit", "-m", "Feature commit"]);

    // Create a tag named "feature" pointing to main (different from branch)
    repo.run_git(&["tag", "feature", &main_sha]);

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();

    // The branch "feature" is NOT at the same commit as main
    // But the tag "feature" IS at the same commit as main
    // Without qualified refs, this would incorrectly return true
    let result = repository.same_commit("feature", "main").unwrap();

    assert!(
        !result,
        "same_commit should check the branch 'feature', not the tag 'feature'"
    );
}

/// Test that trees_match() correctly distinguishes between tag and branch
/// when they share the same name but point to commits with different trees.
#[test]
fn test_tag_branch_name_collision_trees_match() {
    let repo = TestRepo::with_initial_commit();

    // Get main's SHA
    let main_sha = repo.git_output(&["rev-parse", "HEAD"]);

    // Create feature branch with different content
    repo.run_git(&["checkout", "-b", "feature"]);
    fs::write(repo.root_path().join("feature.txt"), "feature content").unwrap();
    repo.run_git(&["add", "feature.txt"]);
    repo.run_git(&["commit", "-m", "Feature commit"]);

    // Create a tag named "feature" pointing to main (different tree)
    repo.run_git(&["tag", "feature", &main_sha]);

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();

    // The branch "feature" has different tree content than main
    // But the tag "feature" has the same tree as main
    // Without qualified refs, this would incorrectly return true
    let result = repository.trees_match("feature", "main").unwrap();

    assert!(
        !result,
        "trees_match should check the branch 'feature', not the tag 'feature'"
    );
}

/// Test that integration functions correctly handle HEAD (not a branch).
#[test]
fn test_integration_functions_handle_head() {
    let repo = TestRepo::new();

    // Create a commit so HEAD differs from an empty state
    fs::write(repo.root_path().join("file.txt"), "content").unwrap();
    repo.run_git(&["add", "file.txt"]);
    repo.run_git(&["commit", "-m", "Add file"]);

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();

    // HEAD should work in all integration functions
    // (resolve_preferring_branch should pass HEAD through unchanged)
    assert!(repository.same_commit("HEAD", "main").unwrap());
    assert!(repository.is_ancestor("main", "HEAD").unwrap());
    assert!(repository.trees_match("HEAD", "main").unwrap());
}

/// Test that integration functions correctly handle commit SHAs.
#[test]
fn test_integration_functions_handle_shas() {
    let repo = TestRepo::with_initial_commit();

    let main_sha = repo.git_output(&["rev-parse", "HEAD"]);

    // Create feature branch
    repo.run_git(&["checkout", "-b", "feature"]);
    fs::write(repo.root_path().join("feature.txt"), "content").unwrap();
    repo.run_git(&["add", "feature.txt"]);
    repo.run_git(&["commit", "-m", "Feature"]);

    let feature_sha = repo.git_output(&["rev-parse", "HEAD"]);

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();

    // SHAs should work in all integration functions
    // (resolve_preferring_branch should pass SHAs through unchanged)
    assert!(repository.same_commit(&main_sha, "main").unwrap());
    assert!(!repository.same_commit(&feature_sha, &main_sha).unwrap());
    assert!(repository.is_ancestor(&main_sha, &feature_sha).unwrap());
}

/// Test that integration functions correctly handle remote refs.
#[test]
fn test_integration_functions_handle_remote_refs() {
    let mut repo = TestRepo::with_initial_commit();
    repo.setup_remote("main");

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();

    // Remote refs like origin/main should work
    // (resolve_preferring_branch should pass them through unchanged since
    // refs/heads/origin/main doesn't exist)
    assert!(repository.same_commit("origin/main", "main").unwrap());
    assert!(repository.is_ancestor("origin/main", "main").unwrap());
}

// =============================================================================
// merge-tree exit code handling tests
// =============================================================================

/// has_merge_conflicts returns false for clean merges (exit 0)
/// and true for conflicts (exit 1).
#[test]
fn test_has_merge_conflicts_clean_vs_conflicting() {
    let repo = TestRepo::new();
    fs::write(repo.root_path().join("base.txt"), "base\n").unwrap();
    repo.run_git(&["add", "base.txt"]);
    repo.run_git(&["commit", "-m", "Base"]);

    // Clean merge: feature adds a new file (no overlap with main)
    repo.run_git(&["checkout", "-b", "clean-feature"]);
    fs::write(repo.root_path().join("new.txt"), "new\n").unwrap();
    repo.run_git(&["add", "new.txt"]);
    repo.run_git(&["commit", "-m", "Add new file"]);
    repo.run_git(&["checkout", "main"]);

    // Conflicting merge: feature edits the same file differently
    repo.run_git(&["checkout", "-b", "conflict-feature"]);
    fs::write(repo.root_path().join("base.txt"), "conflict\n").unwrap();
    repo.run_git(&["add", "base.txt"]);
    repo.run_git(&["commit", "-m", "Edit base"]);
    repo.run_git(&["checkout", "main"]);
    fs::write(repo.root_path().join("base.txt"), "main-edit\n").unwrap();
    repo.run_git(&["add", "base.txt"]);
    repo.run_git(&["commit", "-m", "Edit base on main"]);

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
    assert!(
        !repository
            .has_merge_conflicts("main", "clean-feature")
            .unwrap()
    );
    assert!(
        repository
            .has_merge_conflicts("main", "conflict-feature")
            .unwrap()
    );
}

/// has_merge_conflicts returns true (not Err) for orphan branches,
/// since unrelated histories can't be cleanly merged.
#[test]
fn test_has_merge_conflicts_orphan_branch() {
    let repo = TestRepo::with_initial_commit();

    repo.run_git(&["checkout", "--orphan", "orphan"]);
    repo.run_git(&["rm", "-rf", "."]);
    fs::write(repo.root_path().join("orphan.txt"), "orphan\n").unwrap();
    repo.run_git(&["add", "orphan.txt"]);
    repo.run_git(&["commit", "-m", "Orphan commit"]);
    repo.run_git(&["checkout", "main"]);

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();

    // Orphan branches have no merge base — treated as conflicting, not as an error
    assert!(repository.has_merge_conflicts("main", "orphan").unwrap());
}

/// merge_integration_probe short-circuits for orphan branches:
/// would_merge_add=true, is_patch_id_match=false.
#[test]
fn test_merge_integration_probe_orphan_branch() {
    let repo = TestRepo::with_initial_commit();

    repo.run_git(&["checkout", "--orphan", "orphan"]);
    repo.run_git(&["rm", "-rf", "."]);
    fs::write(repo.root_path().join("orphan.txt"), "orphan\n").unwrap();
    repo.run_git(&["add", "orphan.txt"]);
    repo.run_git(&["commit", "-m", "Orphan commit"]);
    repo.run_git(&["checkout", "main"]);

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
    let probe = repository
        .merge_integration_probe("orphan", "main")
        .unwrap();

    assert!(probe.would_merge_add, "orphan branch always has changes");
    assert!(
        !probe.is_patch_id_match,
        "no patch-id match possible without merge base"
    );
}

/// merge_integration_probe correctly detects already-integrated branches
/// (clean merge that doesn't change target tree).
#[test]
fn test_merge_integration_probe_already_integrated() {
    let repo = TestRepo::with_initial_commit();

    // Create feature, then merge it into main via fast-forward
    repo.run_git(&["checkout", "-b", "feature"]);
    fs::write(repo.root_path().join("feature.txt"), "content\n").unwrap();
    repo.run_git(&["add", "feature.txt"]);
    repo.run_git(&["commit", "-m", "Feature"]);
    repo.run_git(&["checkout", "main"]);
    repo.run_git(&["merge", "feature"]);

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
    let probe = repository
        .merge_integration_probe("feature", "main")
        .unwrap();

    assert!(!probe.would_merge_add, "already-merged branch adds nothing");
}

// =============================================================================
// Bug: repo_path() inside git submodules
// =============================================================================

/// Test that `repo_path()` returns the correct working directory when run inside
/// a git submodule.
///
/// Previously, `repo_path()` derived the path from `git_common_dir.parent()`, which
/// fails for submodules where git data is stored in `parent/.git/modules/sub`.
/// The fix tries `git rev-parse --show-toplevel` first (works for submodules),
/// falling back to parent of git_common_dir for normal repos.
#[test]
fn test_repo_path_in_submodule() {
    // Create parent and submodule-origin repos
    let parent = TestRepo::new();
    fs::write(parent.path().join("README.md"), "# Parent").unwrap();
    parent.run_git(&["add", "."]);
    parent.run_git(&["commit", "-m", "Initial commit"]);

    let sub_origin = TestRepo::new();
    fs::write(sub_origin.path().join("README.md"), "# Submodule").unwrap();
    sub_origin.run_git(&["add", "."]);
    sub_origin.run_git(&["commit", "-m", "Submodule initial commit"]);

    // Add submodule to parent (using local path directly, with file transport allowed)
    parent
        .repo
        .run_command(&[
            "-c",
            "protocol.file.allow=always",
            "submodule",
            "add",
            sub_origin.path().to_str().unwrap(),
            "sub",
        ])
        .unwrap();
    parent.run_git(&["commit", "-m", "Add submodule"]);

    // Now test: create Repository from inside the submodule
    let submodule_path = parent.path().join("sub");
    assert!(
        submodule_path.exists(),
        "Submodule path should exist: {:?}",
        submodule_path
    );

    let repository = Repository::at(submodule_path.clone()).unwrap();

    // The key assertion: repo_path() should return the submodule's working directory,
    // NOT something like parent/.git/modules/sub
    let repo_path = repository.repo_path().unwrap();

    // Canonicalize both paths for comparison (handles symlinks like /var -> /private/var on macOS)
    let expected = dunce::canonicalize(&submodule_path).unwrap();
    let actual = dunce::canonicalize(repo_path).unwrap();

    assert_eq!(
        actual, expected,
        "repo_path() should return submodule's working directory ({:?}), not git modules path",
        expected
    );

    // Also verify that git_common_dir is in the parent's .git/modules/ (confirming this is a real submodule)
    let git_common_dir = repository.git_common_dir();
    // Use components() to check path structure (works on both Unix and Windows)
    let components: Vec<_> = git_common_dir.components().collect();
    let has_git_modules = components.windows(2).any(|pair| {
        matches!(
            (pair[0].as_os_str().to_str(), pair[1].as_os_str().to_str()),
            (Some(".git"), Some("modules"))
        )
    });
    assert!(
        has_git_modules,
        "git_common_dir should be in parent's .git/modules/ for a submodule, got: {:?}",
        git_common_dir
    );

    // Verify list_worktrees() returns corrected paths for submodule main worktree.
    // Git's `worktree list` reports the main worktree as .git/modules/sub for submodules,
    // which is wrong — it should be the actual working directory.
    let worktrees = repository.list_worktrees().unwrap();
    assert!(
        !worktrees.is_empty(),
        "list_worktrees() should return at least the main worktree"
    );
    let main_wt_path = dunce::canonicalize(&worktrees[0].path).unwrap();
    assert_eq!(
        main_wt_path, expected,
        "list_worktrees()[0].path should be the submodule working directory, not .git/modules/sub"
    );

    // Verify worktree_for_branch() returns the corrected path (this is what `wt switch` uses)
    let main_branch = worktrees[0]
        .branch
        .as_deref()
        .expect("submodule main worktree should have a branch");
    let found_path = repository
        .worktree_for_branch(main_branch)
        .unwrap()
        .unwrap();
    let found_canonical = dunce::canonicalize(&found_path).unwrap();
    assert_eq!(
        found_canonical, expected,
        "worktree_for_branch() should return submodule working directory for default branch"
    );
}

// =============================================================================
// branch() error propagation tests (Bug fix: branch() swallows errors)
// =============================================================================

#[test]
fn test_branch_returns_none_for_detached_head() {
    let repo = TestRepo::with_initial_commit();
    let root = repo.root_path().to_path_buf();

    // Detach HEAD by checking out a specific commit
    let sha = repo.git_output(&["rev-parse", "HEAD"]);

    repo.run_git(&["checkout", "--detach", &sha]);

    // Create a fresh repository instance to avoid cached result
    let repository = Repository::at(&root).unwrap();
    // Use worktree_at with explicit path, not current_worktree() which uses base_path()
    let wt = repository.worktree_at(&root);

    let result = wt.branch();
    assert!(
        result.is_ok(),
        "branch() should succeed even for detached HEAD"
    );
    assert!(
        result.unwrap().is_none(),
        "branch() should return None for detached HEAD"
    );
}

#[test]
fn test_branch_returns_branch_for_unborn_repo() {
    let repo = TestRepo::empty();
    let root = repo.root_path().to_path_buf();
    let repository = Repository::at(&root).unwrap();
    let wt = repository.worktree_at(&root);

    let result = wt.branch();
    assert!(
        result.is_ok(),
        "branch() should succeed for unborn repo (no commits)"
    );
    assert_eq!(
        result.unwrap(),
        Some("main".to_string()),
        "branch() should return the default branch name even without commits"
    );
}

#[test]
fn test_branch_returns_branch_name() {
    let repo = TestRepo::new();
    let root = repo.root_path().to_path_buf();
    let repository = Repository::at(&root).unwrap();
    // Use worktree_at with explicit path, not current_worktree() which uses base_path()
    let wt = repository.worktree_at(&root);

    let result = wt.branch();
    assert!(result.is_ok(), "branch() should succeed");
    assert_eq!(
        result.unwrap(),
        Some("main".to_string()),
        "branch() should return the current branch name"
    );
}

#[test]
fn test_branch_caches_result() {
    let repo = TestRepo::new();
    let root = repo.root_path().to_path_buf();
    let repository = Repository::at(&root).unwrap();
    // Use worktree_at with explicit path, not current_worktree() which uses base_path()
    let wt = repository.worktree_at(&root);

    // First call
    let result1 = wt.branch().unwrap();
    // Second call should return cached result
    let result2 = wt.branch().unwrap();

    assert_eq!(result1, result2);
    assert_eq!(result1, Some("main".to_string()));
}

// =============================================================================
// is_dirty() behavior tests
// =============================================================================

#[test]
fn test_is_dirty_does_not_detect_skip_worktree_changes() {
    // This test documents a known limitation: is_dirty() uses `git status --porcelain`
    // which doesn't show files hidden via --skip-worktree or --assume-unchanged.
    //
    // We intentionally don't check for these because:
    // 1. Detecting them requires `git ls-files -v` which lists ALL tracked files
    // 2. On large repos (70k+ files), this adds noticeable latency to every clean check
    // 3. Users who use skip-worktree are power users who understand the implications
    let repo = TestRepo::new();
    let root = repo.root_path().to_path_buf();

    // Create and commit a file
    let file_path = root.join("local.env");
    fs::write(&file_path, "original").unwrap();
    repo.run_git(&["add", "local.env"]);
    repo.run_git(&["commit", "-m", "add local.env"]);

    // Mark with skip-worktree and modify
    repo.run_git(&["update-index", "--skip-worktree", "local.env"]);
    fs::write(&file_path, "modified but hidden").unwrap();

    let repository = Repository::at(&root).unwrap();
    let wt = repository.worktree_at(&root);

    // is_dirty() returns false — this is documented behavior, not a bug
    assert!(
        !wt.is_dirty().unwrap(),
        "is_dirty() does not detect skip-worktree changes by design"
    );
}

// =============================================================================
// sparse_checkout_paths() tests
// =============================================================================

#[test]
fn test_sparse_checkout_paths_empty_for_normal_repo() {
    let repo = TestRepo::new();
    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();

    let paths = repository.sparse_checkout_paths();
    assert!(
        paths.is_empty(),
        "normal repo should have no sparse checkout paths"
    );
}

#[test]
fn test_sparse_checkout_paths_returns_cone_paths() {
    let repo = TestRepo::new();

    // Create directories with files and commit them
    let dir1 = repo.root_path().join("dir1");
    let dir2 = repo.root_path().join("dir2");
    fs::create_dir_all(&dir1).unwrap();
    fs::create_dir_all(&dir2).unwrap();
    fs::write(dir1.join("file.txt"), "content1").unwrap();
    fs::write(dir2.join("file.txt"), "content2").unwrap();
    repo.run_git(&["add", "."]);
    repo.run_git(&["commit", "-m", "add directories"]);

    // Set up sparse checkout in cone mode
    repo.run_git(&["sparse-checkout", "init", "--cone"]);
    repo.run_git(&["sparse-checkout", "set", "dir1", "dir2"]);

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
    let paths = repository.sparse_checkout_paths();

    assert_eq!(paths, &["dir1".to_string(), "dir2".to_string()]);
}

#[test]
fn test_sparse_checkout_paths_cached() {
    let repo = TestRepo::new();

    let dir1 = repo.root_path().join("dir1");
    fs::create_dir_all(&dir1).unwrap();
    fs::write(dir1.join("file.txt"), "content").unwrap();
    repo.run_git(&["add", "."]);
    repo.run_git(&["commit", "-m", "add dir1"]);

    repo.run_git(&["sparse-checkout", "init", "--cone"]);
    repo.run_git(&["sparse-checkout", "set", "dir1"]);

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();

    let first = repository.sparse_checkout_paths();
    let second = repository.sparse_checkout_paths();

    assert_eq!(first, second);
    assert_eq!(first, &["dir1".to_string()]);
}

#[test]
fn test_branch_diff_stats_scoped_to_sparse_checkout() {
    let repo = TestRepo::new();

    // Create two directories with files on main
    let inside = repo.root_path().join("inside");
    let outside = repo.root_path().join("outside");
    fs::create_dir_all(&inside).unwrap();
    fs::create_dir_all(&outside).unwrap();
    fs::write(inside.join("file.txt"), "base content\n").unwrap();
    fs::write(outside.join("file.txt"), "base content\n").unwrap();
    repo.run_git(&["add", "."]);
    repo.run_git(&["commit", "-m", "add directories"]);

    // Create feature branch and modify files in both directories
    repo.run_git(&["checkout", "-b", "feature"]);
    fs::write(inside.join("file.txt"), "modified inside\nadded line\n").unwrap();
    fs::write(outside.join("file.txt"), "modified outside\nadded line\n").unwrap();
    repo.run_git(&["add", "."]);
    repo.run_git(&["commit", "-m", "modify both dirs"]);

    // Go back to main and set up sparse checkout
    repo.run_git(&["checkout", "main"]);
    repo.run_git(&["sparse-checkout", "init", "--cone"]);
    repo.run_git(&["sparse-checkout", "set", "inside"]);

    let repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
    let stats = repository.branch_diff_stats("main", "feature").unwrap();

    // Only changes in inside/ should be counted
    // inside/file.txt: "base content\n" → "modified inside\nadded line\n" = 2 added, 1 deleted
    assert_eq!(stats.added, 2, "sparse: only inside/ additions");
    assert_eq!(stats.deleted, 1, "sparse: only inside/ deletions");

    // Disable sparse checkout — full stats include both inside/ and outside/
    repo.run_git(&["sparse-checkout", "disable"]);
    let full_repository = Repository::at(repo.root_path().to_path_buf()).unwrap();
    let full_stats = full_repository
        .branch_diff_stats("main", "feature")
        .unwrap();

    // Both files have identical diffs, so full = 2x sparse
    assert_eq!(full_stats.added, 4, "full: inside/ + outside/ additions");
    assert_eq!(full_stats.deleted, 2, "full: inside/ + outside/ deletions");
}