[Home](/content/site-root.html)

Log in

# Merge pull request \#66 from entireio/nodo/convert-sha256

`ffe0aed`→[main](/content/gh/entireio/git-sync/commits/main/index.html)·

nodo·1mo ago·10 files·+4,255 added/-1 removed

convert-sha256: one-off SHA1 → SHA256 repo conversion

## Changes

10

- .entire

- M.gitignore+1

- M.golangci.yaml+2

- MREADME.md+3

- cmd/git-sync

- Aconvert\_sha256.go+134

- Aconvert\_sha256\_test.go+84

- internal/sha256convert

- Asha256convert.go+1865

- Asha256convert\_test.go+1800

- Mroot.go+1

- docs

- Aconvert-sha256.md+364

- internal/syncer

- Msyncer.go+1/-1

```
1 unmodified line

2
3
4
5

1 unmodified line

settings.local.json
metadata/
logs/
redactors/local/
```

M.entire/.gitignore+1

```
97 unmodified lines

98
99
100
101
102
103
104
105

97 unmodified lines

- github.com/go-git/go-git/v6/plumbing/storer.EncodedObjectIter
        - github.com/go-git/go-billy/v6.Filesystem
        - entire.io/entire/git-sync/internal/auth.Method
        - entire.io/entire/git-sync/internal/gitproto.Conn
        - entire.io/entire/git-sync/internal/gitproto.AuthMethod
    nolintlint:
      require-explanation: true
      require-specific: true
```

M.golangci.yaml+2

```
26 unmodified lines

27
28
29
30
31
32
33
34
60 unmodified lines

95
96
97
98
99
100
101

26 unmodified lines

`sync` automatically bootstraps an empty target, so the same command covers initial seeding and ongoing sync. To preview what would happen without pushing, run `git-sync plan` — it takes the same flags as `sync`, and `--mode replicate` previews a `replicate` run.

For one-off SHA1 → SHA256 repo conversion, `git-sync convert-sha256` fetches from an HTTP source and writes a new SHA256 bare repo on disk, with optional commit-message hash rewrites, an origin-notes ref, and a sidecar mapping file. See [docs/convert-sha256.md](docs/convert-sha256.md).

For command examples, JSON output, auth, protocol flags, and advanced command notes, see [docs/usage.md](docs/usage.md).

## Library API
60 unmodified lines

- [docs/usage.md](docs/usage.md) — CLI commands, examples, sync behavior, JSON output, auth, protocol notes
- [docs/architecture.md](docs/architecture.md) — product rationale, package layout, operation modes vs transfer modes, memory model
- [docs/protocol.md](docs/protocol.md) — smart HTTP, pkt-line, capability negotiation, sideband, relay framing
- [docs/convert-sha256.md](docs/convert-sha256.md) — one-off SHA1 → SHA256 repo conversion, mapping outputs, sharp edges
- [docs/testing.md](docs/testing.md) — test suites and integration coverage

## FAQ
```

MREADME.md+3

```
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

package main

import (
	"errors"
	"fmt"

gitsync "entire.io/entire/git-sync"
	"entire.io/entire/git-sync/cmd/git-sync/internal/sha256convert"
	"github.com/spf13/cobra"
)

func newConvertSHA256Cmd() *cobra.Command {
	var (
		req         = sha256convert.Request{}
		jsonOutput  bool
		protocolVal = newProtocolFlag()
	)

cmd := &cobra.Command{
		Use:   "convert-sha256 [flags] <source-url> <target-dir>",
		Short: "One-off SHA1 → SHA256 conversion of a remote repo into a local bare repo",
		Long: `convert-sha256 fetches a pack from a SHA1 HTTP source and writes a new
SHA256 bare repository on disk at <target-dir>. Every reachable object is
re-hashed under SHA256 and tree/commit/tag references are rewritten.

All branches and tags on the source are always converted — partial scope
risks stranding cross-branch references in commit messages. Pass
--all-refs to also include refs/notes/* and other custom namespaces;
pass --exclude-ref-prefix to subtract specific namespaces from --all-refs.
Exclude prefixes that would drop any branch or tag (e.g. refs/heads/feature/,
refs/tags/, refs/) are rejected at run time to preserve the always-convert
invariant.

Server-internal pull/merge-request refs (refs/pull/*, refs/pull-requests/*,
refs/merge-requests/*) are NOT converted even under --all-refs: they hold
unmerged code foreign to the repository, and mirroring the result onward
would republish it. Pass --include-pull-refs to convert them anyway.

The conversion is destructive in two ways the caller should be aware of:
GPG signatures on commits and tags are dropped (they sign over the
original SHA1 content and would be invalid post-rewrite), and any
submodule gitlink fails the run — its .gitmodules upstream still
advertises SHA1 hashes, so a rewritten SHA256 gitlink would point at a
hash the upstream cannot resolve and break ` + "`git submodule update`" + ` in
every clone. Exclude refs that reference submodules, or convert the
submodule repository first and re-point .gitmodules.`,
		Args:          cobra.MaximumNArgs(2),
		SilenceErrors: true,
		SilenceUsage:  true,
		RunE: func(cmd *cobra.Command, args []string) error {
			req.ProtocolMode = gitsync.ProtocolMode(protocolVal)
			if err := resolveConvertSHA256Args(&req, args); err != nil {
				return err
			}

result, err := sha256convert.Run(cmd.Context(), req)
			// Print whatever state Run produced even on error: signed
			// tags landed before signBranchTips failed, --check
			// findings, and the --keep-source-objects temp dir are
			// all things the user needs to see to clean up or debug.
			// Run zero-values fields it never touched, so this is
			// safe to call on a half-populated result.
			if result.SourceURL != "" || result.TargetDir != "" {
				printOutput(jsonOutput, result)
			}
			if err != nil {
				return fmt.Errorf("convert-sha256: %w", err)
			}
			return nil
		},
	}

cmd.Flags().StringVar(&req.SourceURL, "source-url", "", "source repository URL")
	cmd.Flags().BoolVar(&req.SourceFollowInfoRefsRedirect, "source-follow-info-refs-redirect",
		envBool("GITSYNC_SOURCE_FOLLOW_INFO_REFS_REDIRECT"),
		"send follow-up source RPCs to the final /info/refs redirect host")
	cmd.Flags().StringVar(&req.SourceAuth.Token, "source-token",
		envOr("GITSYNC_SOURCE_TOKEN", ""), "source token/password")
	cmd.Flags().StringVar(&req.SourceAuth.Username, "source-username",
		envOr("GITSYNC_SOURCE_USERNAME", "git"), "source basic auth username")
	cmd.Flags().StringVar(&req.SourceAuth.BearerToken, "source-bearer-token",
		envOr("GITSYNC_SOURCE_BEARER_TOKEN", ""), "source bearer token")
	cmd.Flags().BoolVar(&req.SourceAuth.SkipTLSVerify, "source-insecure-skip-tls-verify",
		envBool("GITSYNC_SOURCE_INSECURE_SKIP_TLS_VERIFY"),
		"skip TLS certificate verification for the source")
	cmd.Flags().StringVar(&req.TargetDir, "target-dir", "", "directory to initialize as a SHA256 bare repository")

allRefsFlag(cmd, allRefsUsageScopeOnly, &req.AllRefs)
	excludeRefPrefixFlag(cmd, &req.ExcludeRefPrefixes)
	cmd.Flags().BoolVar(&req.IncludePullRefs, "include-pull-refs", false,
		"with --all-refs, also convert server-internal pull/merge-request refs (refs/pull/*, refs/pull-requests/*, refs/merge-requests/*); off by default because they hold unmerged foreign code")
	addProtocolFlag(cmd, &protocolVal)
	cmd.Flags().BoolVarP(&req.Verbose, "verbose", "v", false, "verbose logging")
	cmd.Flags().BoolVar(&req.Progress, "progress", false,
		"show live per-phase object counts on stderr (TTY only)")
	cmd.Flags().BoolVar(&req.Check, "check", false,
		"verify the output after conversion (config, HEAD, refs, git fsck --full)")
	cmd.Flags().StringVar(&req.SignMode, "sign-mode", sha256convert.SignModeNone,
		"post-conversion signing: `none` (default), or `tips` to sign each branch tip as refs/tags/converted/<branch> via `git tag -s`")
	cmd.Flags().StringVar(&req.SignKey, "sign-key", "",
		"signing key id to pass to `git tag -s -u`; default uses the repo's user.signingkey")
	cmd.Flags().BoolVar(&req.KeepSourceObjects, "keep-source-objects", false,
		"keep the temporary SHA1 store on disk after conversion (for debugging)")
	cmd.Flags().StringVar(&req.MappingFile, "write-mapping", "",
		"write the full SHA1 → SHA256 mapping as a TSV to this path; useful for rewriting external references")
	cmd.Flags().BoolVar(&req.SkipMessageRewrite, "no-rewrite-messages", false,
		"do not rewrite SHA1 hash references found in commit and tag messages")
	cmd.Flags().BoolVar(&req.SkipOriginNotes, "no-origin-notes", false,
		"do not write a refs/notes/sha1-origin ref recording each commit's original SHA1")
	cmd.Flags().BoolVar(&jsonOutput, "json", false, "print JSON output")

return cmd
}

// resolveConvertSHA256Args consumes positional args left-to-right,
// skipping fields the user already supplied via flags. Without that
// rule, `--source-url <url> <dir>` would look like one positional and
// land in SourceURL — leaving TargetDir empty even though the user
// gave both. The two-flags-no-positionals and zero-flags-two-positionals
// shapes also work, as do the symmetric --target-dir + positional URL.
func resolveConvertSHA256Args(req *sha256convert.Request, args []string) error {
	positional := args
	if req.SourceURL == "" && len(positional) > 0 {
		req.SourceURL = positional[0]
		positional = positional[1:]
	}
	if req.TargetDir == "" && len(positional) > 0 {
		req.TargetDir = positional[0]
	}
	if req.SourceURL == "" || req.TargetDir == "" {
		return errors.New("convert-sha256 requires a source URL and a target directory")
	}
	return nil
}
```

Acmd/git-sync/convert\_sha256.go+134

```
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

package main

import (
	"strings"
	"testing"

"entire.io/entire/git-sync/cmd/git-sync/internal/sha256convert"
)

func TestResolveConvertSHA256Args(t *testing.T) {
	const url = "http://example.invalid/repo.git"
	const dir = "/tmp/out"

tests := []struct {
		name    string
		req     sha256convert.Request
		args    []string
		wantURL string
		wantDir string
		wantErr string
	}{
		{
			name:    "both positionals",
			args:    []string{url, dir},
			wantURL: url,
			wantDir: dir,
		},
		{
			name:    "url flag plus positional dir — the reported bug",
			req:     sha256convert.Request{SourceURL: url},
			args:    []string{dir},
			wantURL: url,
			wantDir: dir,
		},
		{
			name:    "dir flag plus positional url",
			req:     sha256convert.Request{TargetDir: dir},
			args:    []string{url},
			wantURL: url,
			wantDir: dir,
		},
		{
			name:    "both flags, no positionals",
			req:     sha256convert.Request{SourceURL: url, TargetDir: dir},
			args:    nil,
			wantURL: url,
			wantDir: dir,
		},
		{
			name:    "missing dir",
			req:     sha256convert.Request{SourceURL: url},
			args:    nil,
			wantErr: "requires a source URL and a target directory",
		},
		{
			name:    "missing both",
			args:    nil,
			wantErr: "requires a source URL and a target directory",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			req := tt.req
			err := resolveConvertSHA256Args(&req, tt.args)
			switch {
			case tt.wantErr == "" && err != nil:
				t.Fatalf("unexpected error: %v", err)
			case tt.wantErr != "" && err == nil:
				t.Fatalf("expected error containing %q, got nil", tt.wantErr)
			case tt.wantErr != "" && !strings.Contains(err.Error(), tt.wantErr):
				t.Fatalf("error %q does not contain %q", err.Error(), tt.wantErr)
			}
			if tt.wantErr != "" {
				return
			}
			if req.SourceURL != tt.wantURL {
				t.Errorf("SourceURL: got %q, want %q", req.SourceURL, tt.wantURL)
			}
			if req.TargetDir != tt.wantDir {
				t.Errorf("TargetDir: got %q, want %q", req.TargetDir, tt.wantDir)
			}
		})
	}
}
```

Acmd/git-sync/convert\_sha256\_test.go+84

```
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
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865

// Package sha256convert implements a one-off SHA1 → SHA256 conversion for a
// single repository. It fetches a pack from a remote SHA1 HTTP endpoint into
// a temporary on-disk SHA1 bare repo, then walks every reachable object and
// re-emits it under SHA256 into a new bare repo at the user-supplied path.
//
// The tool is intentionally scoped: GPG signatures on commits and tags
// are dropped (they sign over the original SHA1 byte stream and would be
// invalid post-rewrite), and any submodule gitlink fails the run so the
// caller chooses which refs to exclude. The linked-to repository's URL
// still points at an upstream SHA1 store, which has no way to resolve a
// SHA256-rewritten gitlink, so rewriting would produce a tree that
// fsck-passes but breaks `git submodule update`.
//
// The SHA1 → SHA256 mapping is preserved, so the original hashes stay
// recoverable: by default as a refs/notes/sha1-origin notes ref in the
// converted repo (disable with --no-origin-notes), and optionally as a
// sidecar TSV via --write-mapping.
package sha256convert

import (
	"bufio"
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"sync/atomic"
	"time"

git "github.com/go-git/go-git/v6"
	"github.com/go-git/go-git/v6/plumbing"
	"github.com/go-git/go-git/v6/plumbing/filemode"
	formatcfg "github.com/go-git/go-git/v6/plumbing/format/config"
	"github.com/go-git/go-git/v6/plumbing/object"
	"github.com/go-git/go-git/v6/plumbing/storer"
	transporthttp "github.com/go-git/go-git/v6/plumbing/transport/http"
	"github.com/go-git/go-git/v6/storage/filesystem"

gitsync "entire.io/entire/git-sync"
	"entire.io/entire/git-sync/internal/auth"
	"entire.io/entire/git-sync/internal/convert"
	"entire.io/entire/git-sync/internal/gitproto"
	"entire.io/entire/git-sync/internal/planner"
)

// Request describes a single SHA1 → SHA256 conversion.
//
// Scope is intentionally fixed: every branch and every annotated/lightweight
// tag on the source is always converted. Partial scope risks stranding
// cross-branch references in commit messages, which defeats the point of a
// one-off cutover. AllRefs additionally pulls in refs/notes and other custom
// namespaces; ExcludeRefPrefixes subtracts from that. Server-internal
// pull/merge-request namespaces are excluded from AllRefs by default (see
// IncludePullRefs) because they carry unmerged foreign code.
type Request struct {
	SourceURL                    string
	SourceAuth                   gitsync.EndpointAuth
	SourceFollowInfoRefsRedirect bool
	TargetDir                    string

AllRefs            bool
	ExcludeRefPrefixes []string

// IncludePullRefs opts back into converting the server-internal
	// pull/merge-request namespaces (refs/pull/*, refs/pull-requests/*,
	// refs/merge-requests/*) that AllRefs would otherwise pull in. They
	// are excluded by default: those refs hold code proposed from forks
	// and other branches — foreign to the repository until merged — and
	// the converted repo is typically mirrored onward with
	// `git push --mirror`, where a destination forge may surface them as
	// ordinary refs and republish unreviewed code as repo content. No
	// effect without AllRefs (the namespaces are out of scope anyway).
	IncludePullRefs bool

ProtocolMode gitsync.ProtocolMode
	Verbose      bool
	Progress     bool
	Check        bool

// SignMode selects the post-conversion attestation strategy. ""
	// and SignModeNone sign nothing; SignModeTips runs
	// `git tag -s converted/<branch> <tip>` for every converted branch,
	// attesting the entire reachable history of each branch via its
	// tip's parent chain. (A future "all" mode could sign every commit
	// and tag.) SignKey is passed to git as `-u <SignKey>`; leave empty
	// to use the repo's default signing identity.
	SignMode string
	SignKey  string

KeepSourceObjects bool

// MappingFile, when non-empty, is a path to which a TSV of every
	// translated object's SHA1 → SHA256 mapping is written. Useful for
	// rewriting external systems that reference old commit hashes.
	MappingFile string

// SkipMessageRewrite disables the inline rewrite of SHA1 hashes found
	// in commit and tag messages. Off by default (rewriting is on).
	SkipMessageRewrite bool

// SkipOriginNotes disables the refs/notes/sha1-origin output that
	// records each translated commit's original SHA1. Off by default
	// (notes are written).
	SkipOriginNotes bool

// Out receives human-readable status lines. Nil means os.Stderr.
	Out io.Writer
}

// Counts tallies converted objects by kind.
type Counts struct {
	Blobs   int `json:"blobs"`
	Trees   int `json:"trees"`
	Commits int `json:"commits"`
	Tags    int `json:"tags"`
}

// Result is the conversion summary, suitable for JSON output.
type Result struct {
	SourceURL            string   `json:"sourceUrl"`
	TargetDir            string   `json:"targetDir"`
	Protocol             string   `json:"protocol"`
	RefsConverted        int      `json:"refsConverted"`
	Counts               Counts   `json:"counts"`
	SignaturesStripped   int      `json:"signaturesStripped"`
	MessageRewrites      int      `json:"messageRewrites"`
	AmbiguousMessageRefs []string `json:"ambiguousMessageRefs,omitempty"`
	SkippedPullRefs      int      `json:"skippedPullRefs,omitempty"`
	OriginNotesRef       string   `json:"originNotesRef,omitempty"`
	MappingFile          string   `json:"mappingFile,omitempty"`
	SignedTags           []string `json:"signedTags,omitempty"`
	Checks               []Check  `json:"checks,omitempty"`
	TempDir              string   `json:"tempDir,omitempty"`
}

// Check is one named verification step from --check, with the result
// and a short detail string suitable for logging/JSON output.
//
// Skipped distinguishes "this check passed" from "this check did not
// run" — e.g. fsck when git is not on PATH, or HEAD on a tags-only
// conversion. Skipped implies OK so callers that only branch on OK
// still treat it as non-fatal; callers that need a stricter signal
// (CI gating, audit logs) should branch on Skipped first.
type Check struct {
	Name    string `json:"name"`
	OK      bool   `json:"ok"`
	Skipped bool   `json:"skipped,omitempty"`
	Detail  string `json:"detail,omitempty"`
}

// previewMax caps how many items from a potentially-long list (ambiguous
// prefixes, signed tags) are inlined into a Lines() summary before
// switching to a "(N more)" suffix.
const previewMax = 5

// previewJoin renders items as a comma-separated list, inlining at most
// previewMax of them and appending ", ... (N more<suffix>)" when the list
// is longer. suffix points at where the full list lives (e.g.
// "; full list in --json"); pass "" for none.
func previewJoin(items []string, suffix string) string {
	if len(items) <= previewMax {
		return strings.Join(items, ", ")
	}
	return fmt.Sprintf("%s, ... (%d more%s)",
		strings.Join(items[:previewMax], ", "), len(items)-previewMax, suffix)
}

// Lines satisfies the human-readable output contract used by other git-sync subcommands.
func (r Result) Lines() []string {
	lines := []string{
		"sha256 bare repo: " + r.TargetDir,
		fmt.Sprintf("source: %s (%s)", r.SourceURL, r.Protocol),
		fmt.Sprintf("converted: %d blobs, %d trees, %d commits, %d tags",
			r.Counts.Blobs, r.Counts.Trees, r.Counts.Commits, r.Counts.Tags),
		fmt.Sprintf("refs written: %d", r.RefsConverted),
	}
	if r.SignaturesStripped > 0 {
		// Mixes commit/tag signatures (GPG/SSH/X.509) and embedded
		// mergetag headers — each counts as one signed artifact whose
		// signature became invalid post-rewrite.
		lines = append(lines, fmt.Sprintf("warning: stripped %d signature(s) / mergetag header(s); they no longer match the rewritten object content", r.SignaturesStripped))
	}
	if r.MessageRewrites > 0 {
		lines = append(lines, fmt.Sprintf("rewrote %d SHA1 hash reference(s) in commit/tag messages", r.MessageRewrites))
	}
	if n := len(r.AmbiguousMessageRefs); n > 0 {
		lines = append(lines, fmt.Sprintf("warning: %d ambiguous SHA1 hex prefix(es) in messages left unrewritten (look up via the mapping file): %s",
			n, previewJoin(r.AmbiguousMessageRefs, "")))
	}
	if r.SkippedPullRefs > 0 {
		lines = append(lines, fmt.Sprintf("excluded %d foreign pull/merge-request ref(s) (refs/pull/*, refs/pull-requests/*, refs/merge-requests/*) from --all-refs; pass --include-pull-refs to convert them", r.SkippedPullRefs))
	}
	if r.OriginNotesRef != "" {
		lines = append(lines, fmt.Sprintf("origin notes ref: %s (use `git notes --ref=%s show <sha256>` to recover old SHA1)",
			r.OriginNotesRef, strings.TrimPrefix(r.OriginNotesRef, "refs/notes/")))
	}
	if r.MappingFile != "" {
		lines = append(lines, "mapping written to: "+r.MappingFile)
	}
	if n := len(r.SignedTags); n > 0 {
		lines = append(lines, fmt.Sprintf("signed %d branch attestation tag(s): %s",
			n, previewJoin(r.SignedTags, "; full list in --json")))
	}
	if r.TempDir != "" {
		lines = append(lines, "kept source objects: "+r.TempDir)
	}
	return lines
}

// Run performs the conversion described by req.
//
//nolint:maintidx // Run is a linear orchestrator over distinct phases (fetch → discover → init → translate → refs → notes → mapping → sign → check); each phase is short and isolated. Splitting into helpers would obscure the pipeline rather than clarify it.
func Run(ctx context.Context, req Request) (Result, error) {
	if req.SourceURL == "" {
		return Result{}, errors.New("convert-sha256 requires --source-url")
	}
	if req.TargetDir == "" {
		return Result{}, errors.New("convert-sha256 requires a target directory")
	}
	// Enforce the documented invariant: every branch and every tag is
	// always converted. Otherwise the partial set could strand
	// cross-branch hash references in commit and tag messages, which
	// the message-rewrite pass is built to keep intact.
	if bad := protectedExcludePrefixes(req.ExcludeRefPrefixes); len(bad) > 0 {
		return Result{}, fmt.Errorf("convert-sha256 refuses --exclude-ref-prefix values that would drop branches or tags: %s (only namespaces outside refs/heads/ and refs/tags/ may be excluded)", strings.Join(bad, ", "))
	}
	switch req.SignMode {
	case "", SignModeNone, SignModeTips:
	default:
		return Result{}, fmt.Errorf("convert-sha256: unknown --sign-mode %q (valid: %s, %s)", req.SignMode, SignModeNone, SignModeTips)
	}
	out := req.Out
	if out == nil {
		out = os.Stderr
	}

targetCreated, err := ensureEmptyTarget(req.TargetDir)
	if err != nil {
		return Result{}, err
	}

tempDir, err := os.MkdirTemp("", "git-sync-sha256-src-")
	if err != nil {
		return Result{}, fmt.Errorf("create temp dir: %w", err)
	}
	cleanupTemp := true
	defer func() {
		if cleanupTemp {
			_ = os.RemoveAll(tempDir)
		}
	}()

// cleanupTarget fires when set, undoing the SHA256 bare repo we
	// initialize below. Without it, any error after PlainInit leaves
	// config/objects/refs/HEAD behind, and the next retry hits
	// ensureEmptyTarget's "not empty" refusal with no indication of how
	// to recover. We restore the exact pre-run state: if this run created
	// the target directory, remove it entirely; if the user pre-created it
	// (empty — a mountpoint, or a dir whose ownership/ACLs they set up),
	// remove only the entries we added and leave the directory in place.
	// Suppressed by --keep-source-objects so users can inspect partial
	// state.
	cleanupTarget := false
	defer func() {
		if cleanupTarget && !req.KeepSourceObjects {
			cleanupConvertedTarget(req.TargetDir, targetCreated)
		}
	}()

// Credentials embedded in the URL (https://user:token@host/...) must
	// never reach status output, the JSON result, or — worst of all — the
	// signed attestation tag message, which is permanent and gets pushed.
	// The fetch path keeps the original req.SourceURL; only these
	// human-facing / persisted copies are redacted.
	redactedSourceURL := redactSourceURL(req.SourceURL)

// Build the result struct early so error paths can surface
	// what little ran successfully. In particular, --keep-source-objects
	// exists to debug failures, so cleanupTemp must flip and TempDir
	// must be in the result *before* any later error return; otherwise
	// the temp store gets wiped on exactly the runs that need it.
	res := Result{SourceURL: redactedSourceURL, TargetDir: req.TargetDir}
	if req.KeepSourceObjects {
		cleanupTemp = false
		res.TempDir = tempDir
	}

srcRepo, err := git.PlainInit(tempDir, true)
	if err != nil {
		return res, fmt.Errorf("init temporary SHA1 store: %w", err)
	}

// Source connection + ref discovery -----------------------------------
	// Scope is fixed: always include every branch and every tag. AllRefs
	// extends to refs/notes/* and other namespaces; ExcludeRefPrefixes
	// can subtract from that under AllRefs. Pull/merge-request namespaces
	// are excluded by default (foreign code) unless --include-pull-refs.
	planCfg := planner.PlanConfig{
		IncludeTags:        true,
		AllRefs:            req.AllRefs,
		ExcludeRefPrefixes: effectiveExcludePrefixes(req.ExcludeRefPrefixes, req.AllRefs, req.IncludePullRefs),
	}
	conn, refService, sourceRefList, err := openSource(ctx, req, planCfg)
	if err != nil {
		return res, err
	}
	defer conn.Close()
	refService.Verbose = req.Verbose

sourceRefs := gitproto.RefHashMap(sourceRefList)
	desired, _, err := planner.BuildDesiredRefs(sourceRefs, planCfg)
	if err != nil {
		return res, fmt.Errorf("build desired refs: %w", err)
	}
	if len(desired) == 0 {
		return res, errors.New("no source refs matched the requested scope")
	}

// Surface how many pull/merge-request refs the default exclusion
	// dropped, so an --all-refs run doesn't silently omit them. Only
	// meaningful when we actually excluded them.
	if req.AllRefs && !req.IncludePullRefs {
		if skipped := countForeignPullRefs(sourceRefs); skipped > 0 {
			res.SkippedPullRefs = skipped
			fmt.Fprintf(out, "excluding %d foreign pull/merge-request ref(s) from --all-refs (pass --include-pull-refs to convert them) ...\n", skipped)
		}
	}

// Refuse before any further I/O if the source carries refs that
	// would collide with our side outputs. writeRefs runs before
	// writeOriginNotes / signBranchTips, so without this check the
	// later side-output write would silently clobber the source ref.
	if err := checkSideOutputCollision(desired, req.SkipOriginNotes, req.SignMode == SignModeTips); err != nil {
		return res, err
	}

// Fetch into temp SHA1 store ------------------------------------------
	fmt.Fprintf(out, "fetching %d ref(s) from %s ...\n", len(desired), redactedSourceURL)
	gpDesired := convert.DesiredRefs(desired)
	if err := refService.FetchToStore(ctx, srcRepo.Storer, conn, gpDesired, nil); err != nil &&
		!errors.Is(err, git.NoErrAlreadyUpToDate) {
		return res, fmt.Errorf("fetch source pack: %w", err)
	}

// Discover reachable set before initing the target. Submodule
	// errors surface here, so a failed run leaves the target dir
	// untouched (it was only ensured-empty so far) rather than half
	// converted.
	rootSHA1s := make([]plumbing.Hash, 0, len(desired))
	for _, d := range desired {
		rootSHA1s = append(rootSHA1s, d.SourceHash)
	}
	fmt.Fprintln(out, "discovering reachable objects ...")
	progressActive := req.Progress && isTTY(out)
	var discCounter *atomic.Int64
	var stopDisc func()
	if progressActive {
		c := new(atomic.Int64)
		discCounter = c
		stopDisc = startProgressTick(out, func() string {
			return fmt.Sprintf("  discovered %d objects", c.Load())
		})
	}
	reachable, err := discoverReachable(ctx, srcRepo.Storer, rootSHA1s, discCounter)
	if stopDisc != nil {
		stopDisc()
	}
	if err != nil {
		return res, fmt.Errorf("discover reachable: %w", err)
	}

// Discovery succeeded — safe to materialize the SHA256 target.
	dstRepo, err := git.PlainInit(req.TargetDir, true, git.WithObjectFormat(formatcfg.SHA256))
	if err != nil {
		return res, fmt.Errorf("init SHA256 target at %s: %w", req.TargetDir, err)
	}
	// Anything that fails past here would leave the target dir
	// non-empty (config + HEAD + maybe objects/refs), blocking a
	// retry on ensureEmptyTarget; arm the deferred cleanup now.
	cleanupTarget = true

tr, err := newTranslator(ctx, srcRepo.Storer, dstRepo.Storer, !req.SkipMessageRewrite, reachable)
	if err != nil {
		return res, err
	}
	fmt.Fprintln(out, "translating objects to sha256 ...")
	var stopTr func()
	if progressActive {
		stopTr = startProgressTick(out, func() string {
			return fmt.Sprintf("  translated %d blobs, %d trees, %d commits, %d tags",
				tr.blobs.Load(), tr.trees.Load(), tr.commitsCount.Load(), tr.tags.Load())
		})
	}
	for _, d := range desired {
		if _, err := tr.translate(d.SourceHash); err != nil {
			if stopTr != nil {
				stopTr()
			}
			return res, fmt.Errorf("translate %s: %w", d.SourceRef, err)
		}
	}
	if stopTr != nil {
		stopTr()
	}

// Write refs ---------------------------------------------------------
	refsWritten, err := writeRefs(dstRepo.Storer, desired, tr.mapping)
	if err != nil {
		return res, fmt.Errorf("write target refs: %w", err)
	}

// Point HEAD at a ref that actually exists in the target. PlainInit
	// defaults HEAD to refs/heads/master, which often doesn't exist
	// (e.g. repos using "main"), and would then fail the --check HEAD
	// step. See pickHEAD for the selection order.
	if headRef := pickHEAD(refService.HeadTarget, desired); headRef != "" {
		if err := dstRepo.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, headRef)); err != nil {
			return res, fmt.Errorf("set HEAD: %w", err)
		}
	}

// The converted repo is now complete: every reachable object is
	// written, all refs point at translated tips, and HEAD resolves.
	// Everything past here — origin notes, the mapping file, signing, and
	// --check — is optional enrichment or post-hoc verification. A failure
	// in any of those must surface the error but must NOT delete a
	// successful conversion: a multi-hour kernel-scale run, a --write-mapping
	// path typo, or a signing-key misconfig should never silently discard
	// the repo the user just built. Disarm the target cleanup here so those
	// steps leave the converted repo on disk for inspection or re-run.
	cleanupTarget = false

res.Protocol = refService.Protocol
	res.RefsConverted = refsWritten
	res.Counts = tr.snapshotCounts()
	res.SignaturesStripped = tr.signaturesStripped
	res.MessageRewrites = tr.messageRewrites
	if len(tr.ambiguousMessageRefs) > 0 {
		amb := make([]string, 0, len(tr.ambiguousMessageRefs))
		for s := range tr.ambiguousMessageRefs {
			amb = append(amb, s)
		}
		sort.Strings(amb)
		res.AmbiguousMessageRefs = amb
	}

if !req.SkipOriginNotes && len(tr.commits) > 0 {
		notesRef, err := tr.writeOriginNotes(originNotesRef)
		if err != nil {
			return res, fmt.Errorf("write origin notes: %w", err)
		}
		if err := dstRepo.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName(notesRef), tr.lastNotesCommit)); err != nil {
			return res, fmt.Errorf("set %s: %w", notesRef, err)
		}
		res.OriginNotesRef = notesRef
	}

if req.MappingFile != "" {
		if err := tr.writeMappingFile(req.MappingFile); err != nil {
			return res, fmt.Errorf("write mapping file: %w", err)
		}
		res.MappingFile = req.MappingFile
	}

if req.SignMode == SignModeTips {
		signed, err := signBranchTips(ctx, out, req.TargetDir, req.SignKey, redactedSourceURL, desired)
		// signBranchTips returns the tags it had already created
		// when it failed mid-iteration. Surface that partial list
		// even on error so the caller can clean up — without it,
		// signed converted/* tags would be left on disk with no
		// indication in either Result or the error.
		res.SignedTags = signed
		if err != nil {
			return res, fmt.Errorf("sign: %w", err)
		}
	}

if req.Check {
		fmt.Fprintln(out, "verifying output ...")
		// Collect the side outputs this run actually wrote so the
		// refs check knows which target refs to ignore. Anything not
		// in here is assumed to be a translated source ref.
		sideOutputs := make(map[plumbing.ReferenceName]struct{}, 1+len(res.SignedTags))
		if res.OriginNotesRef != "" {
			sideOutputs[plumbing.ReferenceName(res.OriginNotesRef)] = struct{}{}
		}
		for _, tag := range res.SignedTags {
			sideOutputs[plumbing.ReferenceName(tag)] = struct{}{}
		}
		hasBranches := false
		for _, d := range desired {
			if d.TargetRef.IsBranch() {
				hasBranches = true
				break
			}
		}
		res.Checks = runChecks(ctx, req.TargetDir, dstRepo, refsWritten, sideOutputs, hasBranches)
		for _, c := range res.Checks {
			mark := "✓"
			switch {
			case !c.OK:
				mark = "✗"
			case c.Skipped:
				mark = "○"
			}
			fmt.Fprintf(out, "  %s %s: %s\n", mark, c.Name, c.Detail)
		}
		for _, c := range res.Checks {
			if !c.OK {
				// The conversion finished; a failed check is a
				// post-hoc verification miss, so the target stays
				// on disk (cleanup was already disarmed once the
				// conversion completed) for the user to inspect
				// exactly what failed.
				return res, fmt.Errorf("check %q failed: %s", c.Name, c.Detail)
			}
		}
	}

// Run completed successfully; the target dir is kept (cleanup was
	// disarmed once the conversion completed above).
	return res, nil
}

// signBranchTips runs `git tag -s converted/<branch> <branch>` for every
// branch in the desired set. The converter's signing identity (whatever
// `user.signingkey` / `gpg.format` is set to in the target repo, or the
// caller-supplied signKey) attests each branch's full reachable history
// via the parent chain encoded in the tip commit's bytes.
//
// stdin/stderr are inherited so gpg/ssh-agent prompts work
// interactively. A failure short-circuits the run; tags signed before
// the failure stay in the target repo.
func signBranchTips(ctx context.Context, out io.Writer, targetDir, signKey, sourceURL string, desired map[plumbing.ReferenceName]planner.DesiredRef) ([]string, error) {
	gitBin, err := exec.LookPath("git")
	if err != nil {
		return nil, fmt.Errorf("git binary required to sign: %w", err)
	}
	// Iterate in a deterministic order so re-runs over the same source
	// produce the same sequence of tags (modulo the signature payload,
	// which carries the signer's timestamp).
	branchNames := make([]string, 0, len(desired))
	for name := range desired {
		if name.IsBranch() {
			branchNames = append(branchNames, string(name))
		}
	}
	sort.Strings(branchNames)

var signed []string
	for _, refName := range branchNames {
		shortName := plumbing.ReferenceName(refName).Short()
		tagName := strings.TrimPrefix(attestationTagPrefix, "refs/tags/") + shortName
		fmt.Fprintf(out, "signing %s ...\n", "refs/tags/"+tagName)

msg := fmt.Sprintf(
			"SHA1 → SHA256 conversion attestation for %s.\n\n"+
				"Source: %s\nProduced by git-sync convert-sha256.\n",
			refName, sourceURL)
		args := []string{"-C", targetDir, "tag", "-s", "-m", msg}
		if signKey != "" {
			args = append(args, "-u", signKey)
		}
		args = append(args, tagName, refName)

cmd := exec.CommandContext(ctx, gitBin, args...)
		// Deliberate departure from the req.Out plumbing the rest of
		// Run uses: gpg/ssh-agent and pinentry need a real TTY for
		// passphrase prompts, so we inherit the parent's stdio
		// directly. The consequence is that callers passing
		// req.Out = io.Discard (e.g. tests) still see subprocess
		// output on real stderr — that's the cost of working
		// authentication.
		cmd.Stdin = os.Stdin
		cmd.Stdout = os.Stderr // git tag -s is usually quiet on success
		cmd.Stderr = os.Stderr
		if err := cmd.Run(); err != nil {
			return signed, fmt.Errorf("git tag -s %s: %w", tagName, err)
		}
		signed = append(signed, "refs/tags/"+tagName)
	}
	return signed, nil
}

// runChecks performs lightweight verification of the converted repo.
// Returns one Check per step. Callers print and/or fail-on-error based
// on these. No early return so users see the full picture even when an
// earlier check fails.
//
// sideOutputs holds the exact refs the run created on top of the
// source set (the origin-notes ref, any --sign-mode tips attestation tags), so
// the refs check can omit them from the resolved/expected fraction
// without false-positive-skipping a same-named source ref.
//
// hasBranches says whether any refs/heads/* landed in the target. If
// false, this is a tags-only conversion and HEAD is left at the
// PlainInit default (refs/heads/master, which won't exist); the HEAD
// check is then a no-op rather than a guaranteed failure.
func runChecks(ctx context.Context, targetDir string, repo *git.Repository, refsExpected int, sideOutputs map[plumbing.ReferenceName]struct{}, hasBranches bool) []Check {
	checks := []Check{}

// 1. Config: extensions.objectformat = sha256. Parse the file
	// section-aware so we don't false-positive on a commented line or
	// a similarly-named key in another section.
	cfgFile, err := os.Open(filepath.Join(targetDir, "config"))
	switch {
	case err != nil:
		checks = append(checks, Check{Name: "config", OK: false, Detail: err.Error()})
	default:
		cfg := formatcfg.New()
		decodeErr := formatcfg.NewDecoder(cfgFile).Decode(cfg)
		_ = cfgFile.Close()
		switch {
		case decodeErr != nil:
			checks = append(checks, Check{Name: "config", OK: false, Detail: fmt.Sprintf("parse config: %v", decodeErr)})
		case !strings.EqualFold(cfg.Section("extensions").Option("objectformat"), "sha256"):
			checks = append(checks, Check{Name: "config", OK: false, Detail: "extensions.objectformat = sha256 not set"})
		default:
			checks = append(checks, Check{Name: "config", OK: true, Detail: "extensions.objectformat = sha256"})
		}
	}

// 2. HEAD resolves to an existing object. Skipped on tags-only
	// conversions, where the target legitimately has no branch for
	// HEAD to symlink to.
	switch {
	case !hasBranches:
		checks = append(checks, Check{Name: "HEAD", OK: true, Skipped: true, Detail: "tags-only conversion; no branch to point at"})
	default:
		head, err := repo.Reference(plumbing.HEAD, true)
		switch {
		case err != nil:
			checks = append(checks, Check{Name: "HEAD", OK: false, Detail: err.Error()})
		case head.Hash().IsZero():
			checks = append(checks, Check{Name: "HEAD", OK: false, Detail: "resolves to zero hash"})
		default:
			if _, err := repo.Storer.EncodedObject(plumbing.AnyObject, head.Hash()); err != nil {
				checks = append(checks, Check{Name: "HEAD", OK: false, Detail: fmt.Sprintf("%s: %v", head.Hash(), err)})
			} else {
				checks = append(checks, Check{Name: "HEAD", OK: true, Detail: head.Hash().String()})
			}
		}
	}

// 3. Every written ref resolves to an existing object. Skip the
	// specific refs this run created as side outputs — they're
	// accounted for in their own Result fields and would otherwise
	// make the displayed fraction misleading. Skipping by exact name
	// (not by prefix) avoids hiding a legitimate source ref that
	// happened to share a namespace.
	resolved := 0
	missing := ""
	refs, err := repo.References()
	if err != nil {
		checks = append(checks, Check{Name: "refs", OK: false, Detail: err.Error()})
	} else {
		walkErr := refs.ForEach(func(r *plumbing.Reference) error {
			if r.Type() != plumbing.HashReference {
				return nil
			}
			if _, skip := sideOutputs[r.Name()]; skip {
				return nil
			}
			if _, err := repo.Storer.EncodedObject(plumbing.AnyObject, r.Hash()); err != nil {
				if missing == "" {
					missing = fmt.Sprintf("%s → %s: %v", r.Name(), r.Hash(), err)
				}
				return nil
			}
			resolved++
			return nil
		})
		switch {
		case walkErr != nil:
			checks = append(checks, Check{Name: "refs", OK: false, Detail: walkErr.Error()})
		case missing != "":
			checks = append(checks, Check{Name: "refs", OK: false, Detail: missing})
		case resolved < refsExpected:
			checks = append(checks, Check{Name: "refs", OK: false, Detail: fmt.Sprintf("only %d / %d refs resolved", resolved, refsExpected)})
		default:
			checks = append(checks, Check{Name: "refs", OK: true, Detail: fmt.Sprintf("%d / %d resolve to objects", resolved, refsExpected)})
		}
	}

// 4. git fsck --full (if git is on PATH).
	gitBin, err := exec.LookPath("git")
	if err != nil {
		checks = append(checks, Check{Name: "git fsck --full", OK: true, Skipped: true, Detail: "git not in PATH"})
		return checks
	}
	cmd := exec.CommandContext(ctx, gitBin, "-C", targetDir, "fsck", "--full")
	fsckOut, err := cmd.CombinedOutput()
	switch {
	case err != nil:
		checks = append(checks, Check{Name: "git fsck --full", OK: false, Detail: fmt.Sprintf("%v\n%s", err, fsckOut)})
	case fsckHasError(fsckOut):
		// Belt-and-braces against a hypothetical git version that prints
		// "error:" / "fatal:" lines but exits zero. Match line prefixes
		// rather than a substring so a branch or path containing "error"
		// in a benign dangling/warning line doesn't trip the check.
		checks = append(checks, Check{Name: "git fsck --full", OK: false, Detail: strings.TrimSpace(string(fsckOut))})
	default:
		checks = append(checks, Check{Name: "git fsck --full", OK: true, Detail: "clean"})
	}
	return checks
}

// fsckHasError reports whether git-fsck output contains a line that
// signals a real problem. We match (case-insensitively) any line whose
// first token starts with "error" or "fatal" — covering "error:",
// "fatal:", and the rare "errorInX:" variants — plus the
// "missing <type> <sha>" / "broken link" / "bad <thing>" object reports
// emitted by older git. Dangling and warning lines are intentionally
// ignored.
//
// Splits on raw newlines rather than using bufio.Scanner so a single
// very long line (some fsck reports include long paths) is not
// silently truncated at the scanner's 64 KiB default.
func fsckHasError(out []byte) bool {
	for _, raw := range bytes.Split(out, []byte("\n")) {
		line := strings.TrimSpace(string(raw))
		if line == "" {
			continue
		}
		lower := strings.ToLower(line)
		if strings.HasPrefix(lower, "error") || strings.HasPrefix(lower, "fatal") {
			return true
		}
		if strings.HasPrefix(lower, "missing ") || strings.HasPrefix(lower, "broken link") || strings.HasPrefix(lower, "bad ") {
			return true
		}
	}
	return false
}

const (
	originNotesRef       = "refs/notes/sha1-origin"
	attestationTagPrefix = "refs/tags/converted/"
)

// --sign-mode values. SignModeNone (and the empty string) sign nothing;
// SignModeTips mints one signed attestation tag per branch tip. The set
// is deliberately small and forward-compatible: an "all" mode that signs
// every commit/tag could be added without changing the flag's shape.
const (
	SignModeNone = "none"
	SignModeTips = "tips"
)

// foreignPullRefPrefixes are the server-internal pull/merge-request
// namespaces that hold code proposed from forks and other branches —
// content foreign to the repository's own history until merged. They are
// excluded from --all-refs by default: the converted repo is usually
// mirrored onward with `git push --mirror`, and a destination forge may
// surface these refs as ordinary refs, republishing unreviewed code as if
// it were part of the repo. --include-pull-refs opts back in.
var foreignPullRefPrefixes = []string{
	"refs/pull/",           // GitHub, Gitea, Forgejo
	"refs/pull-requests/",  // Bitbucket Server / Data Center
	"refs/merge-requests/", // GitLab
}

// effectiveExcludePrefixes combines the user's --exclude-ref-prefix values
// with the default pull/merge-request exclusions. The latter only apply
// under --all-refs (the namespaces are out of scope otherwise) and only
// when the user did not pass --include-pull-refs.
func effectiveExcludePrefixes(userPrefixes []string, allRefs, includePullRefs bool) []string {
	out := append([]string(nil), userPrefixes...)
	if allRefs && !includePullRefs {
		out = append(out, foreignPullRefPrefixes...)
	}
	return out
}

// countForeignPullRefs reports how many source refs fall under a
// pull/merge-request namespace, so the run can tell the user exactly how
// many refs the default exclusion dropped rather than silently omitting
// them from an --all-refs conversion.
func countForeignPullRefs(refs map[plumbing.ReferenceName]plumbing.Hash) int {
	n := 0
	for name := range refs {
		for _, p := range foreignPullRefPrefixes {
			if strings.HasPrefix(string(name), p) {
				n++
				break
			}
		}
	}
	return n
}

// protectedExcludePrefixes returns the subset of prefixes that, under
// planner.IsRefExcluded's string-prefix semantics, would knock out at
// least one branch or tag. A prefix matches a branch if either side
// is a string-prefix of the other against "refs/heads/" (and likewise
// for "refs/tags/"). That covers:
//
//   - bare "" (excludes every ref)
//   - "refs/" or "refs/h", "refs/heads/" (whole branch namespace)
//   - "refs/heads/feature/" (some branches)
//   - "refs/tags/" and any narrower suffix
//
// Returned in input order, with duplicates removed, so the error
// message shows the user exactly which flag values to drop.
func protectedExcludePrefixes(prefixes []string) []string {
	protected := []string{"refs/heads/", "refs/tags/"}
	var bad []string
	seen := map[string]struct{}{}
	for _, raw := range prefixes {
		p := strings.TrimSpace(raw)
		if _, dup := seen[p]; dup {
			continue
		}
		for _, prot := range protected {
			if strings.HasPrefix(p, prot) || strings.HasPrefix(prot, p) {
				bad = append(bad, raw)
				seen[p] = struct{}{}
				break
			}
		}
	}
	return bad
}

// checkSideOutputCollision refuses the conversion when the source set
// already contains a ref name this run would later write as a side
// output. Without this guard, writeRefs would publish the source's
// value first and writeOriginNotes / signBranchTips would silently
// overwrite it — losing the source ref and hiding the conflict.
func checkSideOutputCollision(desired map[plumbing.ReferenceName]planner.DesiredRef, skipOriginNotes, signTips bool) error {
	if !skipOriginNotes {
		if _, conflict := desired[plumbing.ReferenceName(originNotesRef)]; conflict {
			return fmt.Errorf("source already advertises %s; pass --no-origin-notes to keep that source ref, or --exclude-ref-prefix %s to drop it from the conversion", originNotesRef, originNotesRef)
		}
	}
	if signTips {
		var clashes []string
		for name := range desired {
			if strings.HasPrefix(string(name), attestationTagPrefix) {
				clashes = append(clashes, string(name))
			}
		}
		if len(clashes) > 0 {
			sort.Strings(clashes)
			return fmt.Errorf("source has %s under %s, which collides with the attestation tags --sign-mode tips would create; use --sign-mode none or rename the source tag(s)", strings.Join(clashes, ", "), attestationTagPrefix)
		}
	}
	return nil
}

// ensureEmptyTarget refuses to init into a non-empty directory so the user
// doesn't quietly accumulate objects into an existing repo. It reports
// created=true when it had to make the directory, so the caller's failure
// cleanup can tell "remove the whole tree we created" apart from "remove
// only the contents we added to a directory the user pre-created".
func ensureEmptyTarget(path string) (created bool, err error) {
	entries, err := os.ReadDir(path)
	if err != nil {
		if os.IsNotExist(err) {
			if mkErr := os.MkdirAll(path, 0o755); mkErr != nil {
				return false, fmt.Errorf("create target dir: %w", mkErr)
			}
			return true, nil
		}
		return false, fmt.Errorf("read target dir: %w", err)
	}
	if len(entries) > 0 {
		return false, fmt.Errorf("target directory %s is not empty", path)
	}
	return false, nil
}

// cleanupConvertedTarget restores the target directory to the state it had
// before the run, for use on a failure path. When the run created the
// directory (created=true) it is removed outright. When the user
// pre-created it — ensureEmptyTarget accepts an existing empty directory —
// only the entries the run added are removed, leaving the directory and
// any ownership, permissions, ACLs, or mount the user set up intact.
//
// Best-effort, like the temp-dir cleanup defer: a removal error is not
// surfaced because the run is already failing for another reason.
func cleanupConvertedTarget(path string, created bool) {
	if created {
		_ = os.RemoveAll(path)
		return
	}
	removeDirContents(path)
}

// removeDirContents removes every entry inside dir but leaves dir itself
// in place. Best-effort (see cleanupConvertedTarget).
func removeDirContents(dir string) {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return
	}
	for _, e := range entries {
		_ = os.RemoveAll(filepath.Join(dir, e.Name()))
	}
}

// redactSourceURL removes any credentials embedded in a source URL so
// they never reach status output, the JSON result, or the signed
// attestation tag message. The entire userinfo component is stripped,
// not just the password: token auth commonly carries the secret in the
// username position (https://<token>@host/...), which url.URL.Redacted()
// would leave intact. The fetch path keeps the original req.SourceURL,
// so stripping here does not affect authentication.
//
// If the URL cannot be parsed (openSource parses the same string and
// fails the run otherwise), we return a placeholder rather than risk
// echoing credentials we could not locate.
func redactSourceURL(raw string) string {
	u, err := url.Parse(raw)
	if err != nil {
		return "<source url redacted>"
	}
	u.User = nil
	return u.String()
}

func openSource(ctx context.Context, req Request, planCfg planner.PlanConfig) (gitproto.Conn, *gitproto.RefService, []*plumbing.Reference, error) {
	ep, err := url.Parse(req.SourceURL)
	if err != nil {
		// url.Parse wraps the raw URL in its error (`parse "<url>": ...`),
		// which would leak embedded credentials (https://user:token@host)
		// into status output, logs, and CI. Surface only the underlying
		// reason — *url.Error.Err carries the cause without the URL string.
		reason := err
		var ue *url.Error
		if errors.As(err, &ue) {
			reason = ue.Err
		}
		return nil, nil, nil, fmt.Errorf("parse source URL: %w", reason)
	}
	if ep.Scheme != "http" && ep.Scheme != "https" {
		return nil, nil, nil, fmt.Errorf("convert-sha256 currently supports HTTP/HTTPS sources only; got %q", ep.Scheme)
	}
	authMethod, err := auth.Resolve(auth.Endpoint{
		Username:      req.SourceAuth.Username,
		Token:         req.SourceAuth.Token,
		BearerToken:   req.SourceAuth.BearerToken,
		SkipTLSVerify: req.SourceAuth.SkipTLSVerify,
	}, ep)
	if err != nil {
		return nil, nil, nil, fmt.Errorf("resolve source auth: %w", err)
	}
	httpClient := &http.Client{Transport: gitproto.NewHTTPTransport(req.SourceAuth.SkipTLSVerify)}
	conn := gitproto.NewHTTPConnWithClient(ep, "source", normalizeAuth(authMethod), httpClient)
	conn.FollowInfoRefsRedirect = req.SourceFollowInfoRefsRedirect

mode := string(req.ProtocolMode)
	if mode == "" {
		mode = string(gitsync.ProtocolAuto)
	}

refs, svc, err := gitproto.ListSourceRefs(ctx, conn, mode, planner.RefPrefixes(planCfg))
	if err != nil {
		_ = conn.Close()
		return nil, nil, nil, fmt.Errorf("list source refs: %w", err)
	}
	return conn, svc, refs, nil
}

func normalizeAuth(m auth.Method) gitproto.AuthMethod {
	if m == nil {
		return nil
	}
	// auth.Method and gitproto.AuthMethod share the same Authorizer signature.
	// Wrap so we can pass either *transporthttp.BasicAuth or *transporthttp.TokenAuth.
	if a, ok := m.(*transporthttp.BasicAuth); ok {
		return a
	}
	if a, ok := m.(*transporthttp.TokenAuth); ok {
		return a
	}
	return authAdapter{m: m}
}

type authAdapter struct{ m auth.Method }

func (a authAdapter) Authorizer(req *http.Request) error {
	if err := a.m.Authorizer(req); err != nil {
		return fmt.Errorf("authorize request: %w", err)
	}
	return nil
}

// translator walks the SHA1 source store, rewrites object content with
// SHA256-mapped hashes, and writes the result into the target bare repo
// via SetEncodedObject. The target storer is configured for SHA256 (see
// the PlainInit in Run), so go-git hashes and names every loose object
// under SHA256.
type translator struct {
	// ctx is checked at the top of every translate() call so a Ctrl-C
	// during a million-object conversion is responsive. It is the same
	// context passed to Run() and is not stored to outlive its caller.
	ctx context.Context //nolint:containedctx // translate() is recursive and not directly called by Run; threading ctx through every signature is noisier than a single field used for cancellation only.
	src *filesystem.Storage
	// dst is the SHA256-configured target store. Each translated object
	// is built with dst.NewEncodedObject (which binds it to the target's
	// SHA256 hasher) and persisted with dst.SetEncodedObject, so both the
	// returned hash and the on-disk loose path are computed under SHA256.
	dst storer.EncodedObjectStorer
	// reachable holds every in-scope SHA1 with its object type, built up
	// front by discoverReachable, which walks tree/commit/tag dependencies
	// from the desired ref tips. It is the authoritative "what's in
	// scope" set: abbreviated SHA1 prefixes in commit/tag messages are
	// resolved against this set so a unique match is fixed before any
	// encoding starts, and so message-reference edges can be added to
	// the translation DFS in topological order.
	reachable map[plumbing.Hash]plumbing.ObjectType
	mapping   map[plumbing.Hash]plumbing.Hash
	// inProgress detects cycles in the translation DFS. Real Git
	// histories cannot form cycles (the parent/tree/tag-target edges
	// are a DAG by construction, and SHA1 message-reference cycles are
	// cryptographically infeasible), but a defensive guard turns
	// surprising input into a clear error instead of a stack overflow.
	inProgress map[plumbing.Hash]struct{}
	// commits records every translated commit's old SHA1, in DFS order,
	// for use by writeOriginNotes. We track separately rather than walking
	// the full mapping because notes only attach meaningfully to commits.
	commits []plumbing.Hash
	// ambiguousMessageRefs collects every hex prefix in a commit/tag
	// message that matched more than one in-scope SHA1 and was
	// therefore left unrewritten. Surfaced to the user as a warning
	// so they know which references to investigate via the mapping
	// file.
	ambiguousMessageRefs map[string]struct{}
	// resolveCache memoizes resolveMessageRef results. reachable is
	// frozen before translation starts, so the (prefix → matchResult)
	// mapping is stable for the lifetime of the translator. The
	// abbreviated-hash path costs O(len(reachable)) per distinct prefix;
	// caching collapses repeats — the same hash cited across many
	// messages, or more than once in one — to a single scan.
	resolveCache map[string]resolveCacheEntry
	// Live counts updated atomically so the --progress ticker goroutine
	// can sample them without racing against translation. Snapshot into
	// a Counts struct at the end of the run.
	blobs              atomic.Int64
	trees              atomic.Int64
	commitsCount       atomic.Int64
	tags               atomic.Int64
	signaturesStripped int
	messageRewrites    int
	rewriteMessages    bool
	lastNotesCommit    plumbing.Hash
}

func (t *translator) snapshotCounts() Counts {
	return Counts{
		Blobs:   int(t.blobs.Load()),
		Trees:   int(t.trees.Load()),
		Commits: int(t.commitsCount.Load()),
		Tags:    int(t.tags.Load()),
	}
}

func newTranslator(ctx context.Context, src storer.Storer, dst storer.EncodedObjectStorer, rewriteMessages bool, reachable map[plumbing.Hash]plumbing.ObjectType) (*translator, error) {
	srcFS, ok := src.(*filesystem.Storage)
	if !ok {
		return nil, fmt.Errorf("source storage is not filesystem-backed (%T)", src)
	}
	if reachable == nil {
		reachable = make(map[plumbing.Hash]plumbing.ObjectType)
	}
	return &translator{
		ctx:                  ctx,
		src:                  srcFS,
		dst:                  dst,
		reachable:            reachable,
		mapping:              make(map[plumbing.Hash]plumbing.Hash),
		inProgress:           make(map[plumbing.Hash]struct{}),
		ambiguousMessageRefs: make(map[string]struct{}),
		resolveCache:         make(map[string]resolveCacheEntry),
		rewriteMessages:      rewriteMessages,
	}, nil
}

// discoverReachable walks every object reachable from roots (via tree
// entries, commit tree+parent links, and tag targets) and returns a
// (SHA1 → object type) map covering the full in-scope set.
//
// Submodule gitlinks: any submodule entry (mode 160000) fails the run
// here, before the target bare repo is initialized — failing fast
// keeps half-converted state off disk. Rewriting the gitlink to SHA256
// would produce a tree the upstream .gitmodules repo can never
// resolve, since it advertises only SHA1.
//
// Message-reference edges are not part of this pass; those are added
// during translation, where the partial mapping is updated as we go.
//
// If progress is non-nil, it is incremented once per object visited.
// The --progress ticker samples this counter from another goroutine.
func discoverReachable(ctx context.Context, src storer.Storer, roots []plumbing.Hash, progress *atomic.Int64) (map[plumbing.Hash]plumbing.ObjectType, error) {
	srcFS, ok := src.(*filesystem.Storage)
	if !ok {
		return nil, fmt.Errorf("source storage is not filesystem-backed (%T)", src)
	}
	reachable := make(map[plumbing.Hash]plumbing.ObjectType)

// Iterative DFS with an explicit stack. The previous recursive
	// implementation walked deep linear histories (50k–100k commits
	// is not unheard of) one Go stack frame deep per parent edge,
	// growing the goroutine stack by tens of MiB on kernel-scale
	// runs. The explicit stack keeps memory usage proportional to
	// the in-flight frontier, not the longest chain.
	stack := make([]plumbing.Hash, 0, len(roots))
	stack = append(stack, roots...)
	for len(stack) > 0 {
		// Per-object cancellation check. Discovery on a kernel-scale
		// repo runs for several minutes before translate() takes
		// over, so without this Ctrl-C would not interrupt the run
		// until the discovery phase finished on its own.
		if err := ctx.Err(); err != nil {
			return nil, fmt.Errorf("discover: %w", err)
		}
		sha1 := stack[len(stack)-1]
		stack = stack[:len(stack)-1]
		if _, seen := reachable[sha1]; seen {
			continue
		}
		obj, err := srcFS.EncodedObject(plumbing.AnyObject, sha1)
		if err != nil {
			return nil, fmt.Errorf("discover %s: %w", sha1, err)
		}
		reachable[sha1] = obj.Type()
		if progress != nil {
			progress.Add(1)
		}
		switch obj.Type() { //nolint:exhaustive // OFSDelta/REFDelta/AnyObject/InvalidObject cannot reach a resolved storage.
		case plumbing.BlobObject:
			// No outgoing edges.
		case plumbing.TreeObject:
			tree := &object.Tree{}
			if err := tree.Decode(obj); err != nil {
				return nil, fmt.Errorf("discover decode tree %s: %w", sha1, err)
			}
			for _, e := range tree.Entries {
				if e.Mode == filemode.Submodule {
					// A submodule gitlink stores a hash that refers to a
					// commit in a *different* repository — the one named
					// by the matching .gitmodules URL. Even when that
					// commit happens to be in our source store, the URL
					// still points at an upstream SHA1 repo, so rewriting
					// the gitlink to SHA256 produces a tree that fsck-
					// passes but breaks `git submodule update` forever:
					// the upstream advertises only SHA1 hashes. The only
					// safe answer is to refuse and let the caller scope
					// the offending ref out (or convert the submodule
					// upstream first and re-point .gitmodules).
					return nil, fmt.Errorf(
						"tree %s contains a submodule gitlink %q at %s; convert-sha256 cannot rewrite submodule pointers "+
							"because the linked-to repository would still advertise SHA1 hashes — "+
							"exclude refs that reference it or convert the submodule repository first",
						sha1, e.Name, e.Hash)
				}
				stack = append(stack, e.Hash)
			}
		case plumbing.CommitObject:
			c := &object.Commit{}
			if err := c.Decode(obj); err != nil {
				return nil, fmt.Errorf("discover decode commit %s: %w", sha1, err)
			}
			stack = append(stack, c.TreeHash)
			stack = append(stack, c.ParentHashes...)
		case plumbing.TagObject:
			tag := &object.Tag{}
			if err := tag.Decode(obj); err != nil {
				return nil, fmt.Errorf("discover decode tag %s: %w", sha1, err)
			}
			stack = append(stack, tag.Target)
		default:
			return nil, fmt.Errorf("unexpected object type %v for %s during discovery", obj.Type(), sha1)
		}
	}
	return reachable, nil
}

// translate is intentionally recursive. Unlike discoverReachable's
// purely-structural DFS, translate's edges are dynamic: tree entries,
// commit parents, tag targets, *and* message-reference edges resolved
// against the partial mapping built so far. Converting that to an
// explicit work stack would require an "after-children" callback per
// object type and is easy to get subtly wrong (re-encoding before all
// referenced hashes are placed silently corrupts the message rewrite).
//
// Recursion depth is bounded by the longest dependency chain in the
// source DAG — in practice the longest commit-parent chain, since
// trees and tags add at most one frame each. Linux kernel history is
// O(70k) commits along its deepest single-parent path; Go's growable
// stacks comfortably absorb that (~tens of MiB). Cycle detection above
// turns any unexpected graph shape into a clear error rather than a
// stack-overflow crash.
func (t *translator) translate(sha1 plumbing.Hash) (plumbing.Hash, error) {
	// Cheap per-object cancellation check so Ctrl-C during a long
	// conversion (kernel-scale: ~10M objects) returns promptly rather
	// than running the whole DFS to completion.
	if err := t.ctx.Err(); err != nil {
		return plumbing.ZeroHash, fmt.Errorf("translate %s: %w", sha1, err)
	}
	if newH, ok := t.mapping[sha1]; ok {
		return newH, nil
	}
	if _, busy := t.inProgress[sha1]; busy {
		// Real Git histories cannot form cycles via parent, tree, or
		// tag-target edges (those are a DAG by construction), and
		// SHA1 message-reference cycles are cryptographically
		// infeasible (each commit's hash depends on its content,
		// including any hash it embeds). A trip here would mean an
		// unexpected graph shape; surface it instead of overflowing
		// the stack.
		return plumbing.ZeroHash, fmt.Errorf("translation cycle detected at %s", sha1)
	}
	t.inProgress[sha1] = struct{}{}
	defer delete(t.inProgress, sha1)

obj, err := t.src.EncodedObject(plumbing.AnyObject, sha1)
	if err != nil {
		return plumbing.ZeroHash, fmt.Errorf("lookup %s: %w", sha1, err)
	}
	switch obj.Type() { //nolint:exhaustive // OFSDelta/REFDelta/AnyObject/InvalidObject cannot reach a resolved storage.
	case plumbing.BlobObject:
		return t.translateBlob(sha1, obj)
	case plumbing.TreeObject:
		return t.translateTree(sha1, obj)
	case plumbing.CommitObject:
		return t.translateCommit(sha1, obj)
	case plumbing.TagObject:
		return t.translateTag(sha1, obj)
	default:
		return plumbing.ZeroHash, fmt.Errorf("unexpected object type %v for %s", obj.Type(), sha1)
	}
}

func (t *translator) translateBlob(sha1 plumbing.Hash, src plumbing.EncodedObject) (plumbing.Hash, error) {
	r, err := src.Reader()
	if err != nil {
		return plumbing.ZeroHash, fmt.Errorf("blob reader: %w", err)
	}
	defer r.Close()
	body, err := io.ReadAll(r)
	if err != nil {
		return plumbing.ZeroHash, fmt.Errorf("blob read: %w", err)
	}
	newHash, err := t.storeBlob(body)
	if err != nil {
		return plumbing.ZeroHash, fmt.Errorf("blob store: %w", err)
	}
	t.mapping[sha1] = newHash
	t.blobs.Add(1)
	return newHash, nil
}

func (t *translator) translateTree(sha1 plumbing.Hash, src plumbing.EncodedObject) (plumbing.Hash, error) {
	tree := &object.Tree{}
	if err := tree.Decode(src); err != nil {
		return plumbing.ZeroHash, fmt.Errorf("decode tree %s: %w", sha1, err)
	}
	for i, entry := range tree.Entries {
		if entry.Mode == filemode.Submodule {
			// Should not be reachable: discoverReachable refuses any
			// submodule gitlink up-front. Keep this as a defensive
			// guard so the rewrite path never silently produces a
			// SHA256 tree whose gitlink points at a hash the
			// .gitmodules upstream repo cannot resolve.
			return plumbing.ZeroHash, fmt.Errorf(
				"tree %s contains submodule gitlink %q at %s; convert-sha256 refuses to rewrite submodule pointers",
				sha1, entry.Name, entry.Hash)
		}
		newH, err := t.translate(entry.Hash)
		if err != nil {
			return plumbing.ZeroHash, fmt.Errorf("tree %s entry %q: %w", sha1, entry.Name, err)
		}
		tree.Entries[i].Hash = newH
	}
	newHash, err := t.store(tree.Encode)
	if err != nil {
		return plumbing.ZeroHash, fmt.Errorf("store tree %s: %w", sha1, err)
	}
	t.mapping[sha1] = newHash
	t.trees.Add(1)
	return newHash, nil
}

// stripSignatures clears a commit's or tag's signature fields, returning
// true if either was set. Signatures sign over the original SHA1 byte
// stream and cannot survive the rewrite. A transitional dual-hash object
// can carry both the SHA1-form "gpgsig" (Signature) and "gpgsig-sha256"
// (SignatureSHA256) for the same logical signature, so we clear both and
// the caller counts the artifact once.
func stripSignatures(sig, sigSHA256 *string) bool {
	if *sig == "" && *sigSHA256 == "" {
		return false
	}
	*sig = ""
	*sigSHA256 = ""
	return true
}

func (t *translator) translateCommit(sha1 plumbing.Hash, src plumbing.EncodedObject) (plumbing.Hash, error) {
	c := &object.Commit{}
	if err := c.Decode(src); err != nil {
		return plumbing.ZeroHash, fmt.Errorf("decode commit %s: %w", sha1, err)
	}
	newTree, err := t.translate(c.TreeHash)
	if err != nil {
		return plumbing.ZeroHash, fmt.Errorf("commit %s tree: %w", sha1, err)
	}
	c.TreeHash = newTree
	for i, p := range c.ParentHashes {
		newP, err := t.translate(p)
		if err != nil {
			return plumbing.ZeroHash, fmt.Errorf("commit %s parent %s: %w", sha1, p, err)
		}
		c.ParentHashes[i] = newP
	}
	if t.rewriteMessages {
		rewritten, n, err := t.rewriteMessageRefs(c.Message, "commit", sha1)
		if err != nil {
			return plumbing.ZeroHash, err
		}
		if n > 0 {
			c.Message = rewritten
			t.messageRewrites += n
		}
	}
	if stripSignatures(&c.Signature, &c.SignatureSHA256) {
		t.signaturesStripped++
	}
	// "mergetag" extra headers embed a copy of a signed annotated tag with
	// its own signature. Drop them too — they reference the pre-rewrite
	// commit/tag content and cannot be re-signed here.
	if len(c.ExtraHeaders) > 0 {
		filtered := c.ExtraHeaders[:0]
		for _, h := range c.ExtraHeaders {
			if h.Key == "mergetag" {
				t.signaturesStripped++
				continue
			}
			filtered = append(filtered, h)
		}
		c.ExtraHeaders = filtered
	}
	newHash, err := t.store(c.Encode)
	if err != nil {
		return plumbing.ZeroHash, fmt.Errorf("store commit %s: %w", sha1, err)
	}
	t.mapping[sha1] = newHash
	t.commits = append(t.commits, sha1)
	t.commitsCount.Add(1)
	return newHash, nil
}

func (t *translator) translateTag(sha1 plumbing.Hash, src plumbing.EncodedObject) (plumbing.Hash, error) {
	tag := &object.Tag{}
	if err := tag.Decode(src); err != nil {
		return plumbing.ZeroHash, fmt.Errorf("decode tag %s: %w", sha1, err)
	}
	newTarget, err := t.translate(tag.Target)
	if err != nil {
		return plumbing.ZeroHash, fmt.Errorf("tag %s target: %w", sha1, err)
	}
	tag.Target = newTarget
	if t.rewriteMessages {
		rewritten, n, err := t.rewriteMessageRefs(tag.Message, "tag", sha1)
		if err != nil {
			return plumbing.ZeroHash, err
		}
		if n > 0 {
			tag.Message = rewritten
			t.messageRewrites += n
		}
	}
	if stripSignatures(&tag.Signature, &tag.SignatureSHA256) {
		t.signaturesStripped++
	}
	newHash, err := t.store(tag.Encode)
	if err != nil {
		return plumbing.ZeroHash, fmt.Errorf("store tag %s: %w", sha1, err)
	}
	t.mapping[sha1] = newHash
	t.tags.Add(1)
	return newHash, nil
}

// store encodes an object into a fresh EncodedObject bound to the target
// store's SHA256 hasher and persists it as a loose object, returning the
// new SHA256 hash. Building the object via dst.NewEncodedObject is what
// guarantees the returned hash and the on-disk filename are both computed
// under SHA256: NewEncodedObject binds the object to the store's object
// format, and SetEncodedObject writes (and returns) it under that format.
//
// This path could not be used on go-git v6 alpha.3 — its objfile.Writer
// hardcoded SHA1, so SetEncodedObject placed every translated object at a
// SHA1-derived path even on a SHA256 store. alpha.4 derives the hash
// format from the store config (go-git commit 5cab3a7), so the manual
// loose-object writer this code used to carry is no longer needed.
//
// go-git's loose writer is atomic (tempfile + rename) and idempotent on
// duplicate hashes (it lstats the destination and drops the temp file if
// it already exists); duplicate source objects never reach here anyway,
// since translate() memoizes through t.mapping before encoding.
func (t *translator) store(encode func(plumbing.EncodedObject) error) (plumbing.Hash, error) {
	obj := t.dst.NewEncodedObject()
	if err := encode(obj); err != nil {
		return plumbing.ZeroHash, err
	}
	h, err := t.dst.SetEncodedObject(obj)
	if err != nil {
		return plumbing.ZeroHash, fmt.Errorf("set encoded object: %w", err)
	}
	return h, nil
}

// storeBlob persists raw bytes as a blob. Blob content is never rewritten
// during conversion (blobs carry no hash references), so the bytes are
// copied verbatim.
func (t *translator) storeBlob(content []byte) (plumbing.Hash, error) {
	return t.store(func(o plumbing.EncodedObject) error {
		o.SetType(plumbing.BlobObject)
		o.SetSize(int64(len(content)))
		w, err := o.Writer()
		if err != nil {
			return fmt.Errorf("blob writer: %w", err)
		}
		if _, err := w.Write(content); err != nil {
			_ = w.Close()
			return fmt.Errorf("write blob content: %w", err)
		}
		if err := w.Close(); err != nil {
			return fmt.Errorf("close blob writer: %w", err)
		}
		return nil
	})
}

// hashPattern matches hex runs that could be a git object hash. Git's
// default abbreviation is 7 chars; 40 is a full SHA1. Case-insensitive
// so messages that paste an uppercase or mixed-case hash (e.g. from
// some commit graph viewers) still resolve — the lookup canonicalizes
// to lowercase before checking the reachable set. We only rewrite a
// match if the prefix uniquely identifies a commit or tag in the
// reachable set, so false positives on incidental hex strings are
// essentially impossible (a random hex would have to collide with a
// real source SHA1).
var hashPattern = regexp.MustCompile(`(?i)\b[0-9a-f]{7,40}\b`)

// matchResult is the 3-state outcome of resolving a hex prefix in a
// commit/tag message against the reachable set. We distinguish
// "ambiguous" from "no match" so the caller can warn the user about
// prefixes that *could* be rewritten if they were a couple of chars
// longer.
type matchResult int

const (
	matchNone matchResult = iota
	matchUnique
	matchAmbiguous
)

// rewriteMessageRefs resolves the SHA1 hash references in a commit or tag
// message and rewrites the unique in-scope ones to their full SHA256 hex,
// in a single regex pass over msg. It first translates every referenced
// object — adding it as an edge in the translation DFS so t.mapping holds
// the object by the time we substitute. That ordering is what lets a
// cross-branch reference (a cherry-pick, or a revert of a sibling branch)
// resolve regardless of which branch ref iteration processed first; it is
// the subtlest invariant in this file, which is why both translateCommit
// and translateTag funnel through here instead of copying it.
//
// kind ("commit"/"tag") and sha1 only frame a translate error. Returns the
// rewritten message and the number of substitutions made. Ambiguous
// prefixes are left in place and recorded in t.ambiguousMessageRefs so the
// caller can surface a warning at the end of the run.
//
// Uniqueness is decided against t.reachable rather than t.mapping so that
// abbreviated prefixes get the same verdict during translation as they
// would after every object has been translated — the answer cannot flip
// depending on what has been processed so far.
//
// Performance: the abbreviated-hash path scans the reachable set linearly
// for each distinct prefix (memoized by resolveCache). Fine for repos up
// to ~100k commits; slower past that. If this ever matters, build a
// sorted-prefix index over reachable SHA1 hex strings once and binary
// search.
func (t *translator) rewriteMessageRefs(msg, kind string, sha1 plumbing.Hash) (string, int, error) {
	spans := hashPattern.FindAllStringIndex(msg, -1)
	if len(spans) == 0 {
		return msg, 0, nil
	}
	// Resolve every match once (resolveMessageRef is memoized), recording
	// the unique in-scope refs to translate before any substitution.
	type resolvedMatch struct {
		lo, hi int
		hash   plumbing.Hash
		result matchResult
	}
	matches := make([]resolvedMatch, 0, len(spans))
	seen := make(map[plumbing.Hash]struct{})
	var refs []plumbing.Hash
	for _, s := range spans {
		hash, result := t.resolveMessageRef(msg[s[0]:s[1]])
		matches = append(matches, resolvedMatch{lo: s[0], hi: s[1], hash: hash, result: result})
		if result == matchUnique {
			if _, dup := seen[hash]; !dup {
				seen[hash] = struct{}{}
				refs = append(refs, hash)
			}
		}
	}
	for _, ref := range refs {
		if _, err := t.translate(ref); err != nil {
			return "", 0, fmt.Errorf("%s %s message ref %s: %w", kind, sha1, ref, err)
		}
	}
	// Rebuild msg, substituting each unique ref now present in the mapping.
	var b strings.Builder
	b.Grow(len(msg))
	prev, count := 0, 0
	for _, m := range matches {
		b.WriteString(msg[prev:m.lo])
		tok := msg[m.lo:m.hi]
		switch m.result {
		case matchUnique:
			if newHash, ok := t.mapping[m.hash]; ok {
				b.WriteString(newHash.String())
				count++
			} else {
				// reachable says this SHA1 is in scope but the DFS hasn't
				// placed it. Shouldn't happen — we translated every ref
				// above — so leave the original hex if it somehow does.
				b.WriteString(tok)
			}
		case matchAmbiguous:
			t.ambiguousMessageRefs[tok] = struct{}{}
			b.WriteString(tok)
		case matchNone:
			b.WriteString(tok)
		}
		prev = m.hi
	}
	b.WriteString(msg[prev:])
	return b.String(), count, nil
}

// resolveMessageRef classifies a hex prefix against the reachable set.
// Returns matchUnique with the resolved SHA1 when exactly one commit
// or tag in scope matches; matchAmbiguous when more than one does;
// matchNone otherwise (no match, or the match is a blob/tree — those
// are filtered so incidental hex collisions on content hashes aren't
// rewritten).
// resolveCacheEntry holds a memoized (Hash, matchResult) pair from
// resolveMessageRef. Stored in t.resolveCache keyed by lowercased prefix.
type resolveCacheEntry struct {
	hash   plumbing.Hash
	result matchResult
}

func (t *translator) resolveMessageRef(prefix string) (plumbing.Hash, matchResult) {
	// Canonicalize to lowercase: hashPattern is case-insensitive so
	// the caller can match `ABCD1234` in a message, but reachable
	// keys and plumbing.Hash.String() are always lowercase hex.
	prefix = strings.ToLower(prefix)
	if cached, ok := t.resolveCache[prefix]; ok {
		return cached.hash, cached.result
	}
	hash, result := t.resolveMessageRefUncached(prefix)
	t.resolveCache[prefix] = resolveCacheEntry{hash: hash, result: result}
	return hash, result
}

func (t *translator) resolveMessageRefUncached(prefix string) (plumbing.Hash, matchResult) {
	if len(prefix) == 40 {
		sha1, ok := plumbing.FromHex(prefix)
		if !ok {
			return plumbing.ZeroHash, matchNone
		}
		typ, in := t.reachable[sha1]
		if !in {
			return plumbing.ZeroHash, matchNone
		}
		if typ != plumbing.CommitObject && typ != plumbing.TagObject {
			return plumbing.ZeroHash, matchNone
		}
		return sha1, matchUnique
	}
	var match plumbing.Hash
	matches := 0
	for sha1, typ := range t.reachable {
		if typ != plumbing.CommitObject && typ != plumbing.TagObject {
			continue
		}
		if strings.HasPrefix(sha1.String(), prefix) {
			matches++
			if matches > 1 {
				return plumbing.ZeroHash, matchAmbiguous
			}
			match = sha1
		}
	}
	if matches == 1 {
		return match, matchUnique
	}
	return plumbing.ZeroHash, matchNone
}

// notesCommitTime returns the committer/author timestamp for the
// synthetic notes wrapper commit. Reads SOURCE_DATE_EPOCH (the
// reproducible-builds convention) when set, falling back to the Unix
// epoch so two runs over identical source state always produce the
// same notes-ref hash.
func notesCommitTime() time.Time {
	if raw := os.Getenv("SOURCE_DATE_EPOCH"); raw != "" {
		if secs, err := strconv.ParseInt(raw, 10, 64); err == nil {
			return time.Unix(secs, 0).UTC()
		}
	}
	return time.Unix(0, 0).UTC()
}

// writeOriginNotes writes a `git notes` ref to dst that records each
// translated commit's original SHA1, keyed by its new SHA256. Standard
// git tooling (`git log --notes=<ref>`, `git notes --ref=<ref> show
// <commit>`) can then surface the old hash to anyone with the repo.
//
// The notes tree is flat (no fanout). Git supports either layout, and a
// flat layout keeps this code small; on repos with millions of commits
// lookups slow down to a linear tree scan, but the data is preserved.
func (t *translator) writeOriginNotes(refName string) (string, error) {
	if len(t.commits) == 0 {
		return "", nil
	}
	// Note for each commit: a blob containing the original SHA1 hex + newline.
	// We collect (sha256-of-new-commit → blob hash) pairs so the tree entry
	// path is the commit's new hash.
	type entry struct {
		key  plumbing.Hash
		blob plumbing.Hash
	}
	entries := make([]entry, 0, len(t.commits))
	for _, oldSHA1 := range t.commits {
		newCommit, ok := t.mapping[oldSHA1]
		if !ok {
			continue
		}
		blobHash, err := t.storeBlob([]byte(oldSHA1.String() + "\n"))
		if err != nil {
			return "", fmt.Errorf("note blob for %s: %w", oldSHA1, err)
		}
		entries = append(entries, entry{key: newCommit, blob: blobHash})
	}
	if len(entries) == 0 {
		return "", nil
	}

treeEntries := make([]object.TreeEntry, 0, len(entries))
	for _, e := range entries {
		treeEntries = append(treeEntries, object.TreeEntry{
			Name: e.key.String(),
			Mode: filemode.Regular,
			Hash: e.blob,
		})
	}
	sort.Slice(treeEntries, func(i, j int) bool {
		return treeEntries[i].Name < treeEntries[j].Name
	})
	tree := &object.Tree{Entries: treeEntries}
	treeHash, err := t.store(tree.Encode)
	if err != nil {
		return "", fmt.Errorf("store notes tree: %w", err)
	}

// Honor SOURCE_DATE_EPOCH for reproducible builds; otherwise pin to
	// the Unix epoch so the notes-ref hash is identical across runs over
	// the same source state. The notes commit is bookkeeping — its
	// timestamp carries no meaningful information about when the
	// underlying SHA1 history was created.
	sig := object.Signature{Name: "git-sync", Email: "noreply@entire.io", When: notesCommitTime()}
	commit := &object.Commit{
		Author:    sig,
		Committer: sig,
		Message:   "git-sync convert-sha256: SHA1 origin notes\n",
		TreeHash:  treeHash,
	}
	commitHash, err := t.store(commit.Encode)
	if err != nil {
		return "", fmt.Errorf("store notes commit: %w", err)
	}
	t.lastNotesCommit = commitHash
	return refName, nil
}

// startProgressTick spawns a goroutine that, every 500 ms, rewrites a
// single line in place on out with the string returned by render. The
// returned stop function halts the goroutine and emits a trailing
// newline so subsequent prints start on a fresh row.
//
// Only intended for TTY output: the rendered line uses '\r\x1b[K' to\
// overwrite itself, which looks fine on a terminal and ugly anywhere\
// else. Callers gate on isTTY before calling.\
func startProgressTick(out io.Writer, render func() string) func() {\
	stop := make(chan struct{})\
	done := make(chan struct{})\
	go func() {\
		defer close(done)\
		t := time.NewTicker(500 * time.Millisecond)\
		defer t.Stop()\
		for {\
			select {\
			case <-stop:\
				return\
			case <-t.C:\
				fmt.Fprintf(out, "\r\x1b[K%s", render())\
			}\
		}\
	}()\
	stopOnce := false\
	return func() {\
		if stopOnce {\
			return\
		}\
		stopOnce = true\
		close(stop)\
		<-done\
		// Last frame + newline so subsequent output is on a clean row.\
		fmt.Fprintf(out, "\r\x1b[K%s\n", render())\
	}\
}\
\
// isTTY reports whether w is a writable terminal. The --progress\
// ticker is suppressed on non-TTY destinations because the '\r'-style\
// in-place updates would otherwise show up as literal control\
// characters in log files and pipes.\
func isTTY(w io.Writer) bool {\
	f, ok := w.(*os.File)\
	if !ok {\
		return false\
	}\
	fi, err := f.Stat()\
	if err != nil {\
		return false\
	}\
	return (fi.Mode() & os.ModeCharDevice) != 0\
}\
\
// writeMappingFile dumps the SHA1 → SHA256 mapping as a TSV. Lines are\
// sorted by SHA1 so diffs across runs are stable. Includes every\
// translated object (blob/tree/commit/tag), so external tooling can use\
// it for content-addressed lookups regardless of object kind.\
func (t *translator) writeMappingFile(path string) error {\
	type pair struct{ sha1, sha256 string }\
	pairs := make([]pair, 0, len(t.mapping))\
	for old, newH := range t.mapping {\
		pairs = append(pairs, pair{sha1: old.String(), sha256: newH.String()})\
	}\
	sort.Slice(pairs, func(i, j int) bool { return pairs[i].sha1 < pairs[j].sha1 })\
\
	if dir := filepath.Dir(path); dir != "" {\
		if err := os.MkdirAll(dir, 0o755); err != nil {\
			return fmt.Errorf("mkdir %s: %w", dir, err)\
		}\
	}\
	f, err := os.Create(path)\
	if err != nil {\
		return fmt.Errorf("create %s: %w", path, err)\
	}\
	// Close is best-effort on the failure path (the underlying issue\
	// will already have surfaced via Flush). On the success path the\
	// explicit Close below propagates its error — networked / quota'd\
	// filesystems can defer write failures until close.\
	closed := false\
	defer func() {\
		if !closed {\
			_ = f.Close()\
		}\
	}()\
	w := bufio.NewWriter(f)\
	if _, err := fmt.Fprintln(w, "# sha1\tsha256"); err != nil {\
		return fmt.Errorf("write mapping header: %w", err)\
	}\
	for _, p := range pairs {\
		if _, err := fmt.Fprintf(w, "%s\t%s\n", p.sha1, p.sha256); err != nil {\
			return fmt.Errorf("write mapping line: %w", err)\
		}\
	}\
	if err := w.Flush(); err != nil {\
		return fmt.Errorf("flush mapping file: %w", err)\
	}\
	if err := f.Close(); err != nil {\
		return fmt.Errorf("close mapping file: %w", err)\
	}\
	closed = true\
	return nil\
}\
\
// pickHEAD chooses which target-side ref the bare repo's HEAD should\
// symlink to. It returns "" when no suitable branch exists (e.g. a\
// tags-only conversion), in which case the caller leaves HEAD at the\
// PlainInit default.\
//\
// Selection order:\
//  1. The source's advertised HEAD, if it landed in the converted set.\
//     Resolved via the desired entry's TargetRef so a user-supplied ref\
//     mapping is honored.\
//  2. refs/heads/main, then refs/heads/master, if either is present in\
//     the converted target refs. Some HTTP v1 servers do not advertise\
//     HEAD, so we pattern-match on conventional defaults.\
//  3. The lexicographically first refs/heads/* in the target set, for\
//     a deterministic fallback when neither convention is present.\
func pickHEAD(advertised plumbing.ReferenceName, desired map[plumbing.ReferenceName]planner.DesiredRef) plumbing.ReferenceName {\
	if advertised != "" {\
		if d, ok := desired[advertised]; ok {\
			return d.TargetRef\
		}\
	}\
	branches := make(map[plumbing.ReferenceName]struct{}, len(desired))\
	for _, d := range desired {\
		if d.TargetRef.IsBranch() {\
			branches[d.TargetRef] = struct{}{}\
		}\
	}\
	for _, candidate := range []plumbing.ReferenceName{"refs/heads/main", "refs/heads/master"} {\
		if _, ok := branches[candidate]; ok {\
			return candidate\
		}\
	}\
	if len(branches) == 0 {\
		return ""\
	}\
	names := make([]string, 0, len(branches))\
	for name := range branches {\
		names = append(names, string(name))\
	}\
	sort.Strings(names)\
	return plumbing.ReferenceName(names[0])\
}\
\
func writeRefs(\
	dst storer.Storer,\
	desired map[plumbing.ReferenceName]planner.DesiredRef,\
	mapping map[plumbing.Hash]plumbing.Hash,\
) (int, error) {\
	written := 0\
	for _, d := range desired {\
		newHash, ok := mapping[d.SourceHash]\
		if !ok {\
			return written, fmt.Errorf("ref %s tip %s missing from translation map", d.TargetRef, d.SourceHash)\
		}\
		if err := dst.SetReference(plumbing.NewHashReference(d.TargetRef, newHash)); err != nil {\
			return written, fmt.Errorf("set ref %s: %w", d.TargetRef, err)\
		}\
		written++\
	}\
	return written, nil\
}\
```\
\
Acmd/git-sync/internal/sha256convert/sha256convert.go+1865\
\
```\
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\
1498\
1499\
1500\
1501\
1502\
1503\
1504\
1505\
1506\
1507\
1508\
1509\
1510\
1511\
1512\
1513\
1514\
1515\
1516\
1517\
1518\
1519\
1520\
1521\
1522\
1523\
1524\
1525\
1526\
1527\
1528\
1529\
1530\
1531\
1532\
1533\
1534\
1535\
1536\
1537\
1538\
1539\
1540\
1541\
1542\
1543\
1544\
1545\
1546\
1547\
1548\
1549\
1550\
1551\
1552\
1553\
1554\
1555\
1556\
1557\
1558\
1559\
1560\
1561\
1562\
1563\
1564\
1565\
1566\
1567\
1568\
1569\
1570\
1571\
1572\
1573\
1574\
1575\
1576\
1577\
1578\
1579\
1580\
1581\
1582\
1583\
1584\
1585\
1586\
1587\
1588\
1589\
1590\
1591\
1592\
1593\
1594\
1595\
1596\
1597\
1598\
1599\
1600\
1601\
1602\
1603\
1604\
1605\
1606\
1607\
1608\
1609\
1610\
1611\
1612\
1613\
1614\
1615\
1616\
1617\
1618\
1619\
1620\
1621\
1622\
1623\
1624\
1625\
1626\
1627\
1628\
1629\
1630\
1631\
1632\
1633\
1634\
1635\
1636\
1637\
1638\
1639\
1640\
1641\
1642\
1643\
1644\
1645\
1646\
1647\
1648\
1649\
1650\
1651\
1652\
1653\
1654\
1655\
1656\
1657\
1658\
1659\
1660\
1661\
1662\
1663\
1664\
1665\
1666\
1667\
1668\
1669\
1670\
1671\
1672\
1673\
1674\
1675\
1676\
1677\
1678\
1679\
1680\
1681\
1682\
1683\
1684\
1685\
1686\
1687\
1688\
1689\
1690\
1691\
1692\
1693\
1694\
1695\
1696\
1697\
1698\
1699\
1700\
1701\
1702\
1703\
1704\
1705\
1706\
1707\
1708\
1709\
1710\
1711\
1712\
1713\
1714\
1715\
1716\
1717\
1718\
1719\
1720\
1721\
1722\
1723\
1724\
1725\
1726\
1727\
1728\
1729\
1730\
1731\
1732\
1733\
1734\
1735\
1736\
1737\
1738\
1739\
1740\
1741\
1742\
1743\
1744\
1745\
1746\
1747\
1748\
1749\
1750\
1751\
1752\
1753\
1754\
1755\
1756\
1757\
1758\
1759\
1760\
1761\
1762\
1763\
1764\
1765\
1766\
1767\
1768\
1769\
1770\
1771\
1772\
1773\
1774\
1775\
1776\
1777\
1778\
1779\
1780\
1781\
1782\
1783\
1784\
1785\
1786\
1787\
1788\
1789\
1790\
1791\
1792\
1793\
1794\
1795\
1796\
1797\
1798\
1799\
1800\
\
package sha256convert\
\
import (\
	"bytes"\
	"compress/zlib"\
	"context"\
	"crypto/sha256"\
	"encoding/hex"\
	"errors"\
	"fmt"\
	"io"\
	"net/http"\
	"net/http/cgi"\
	"net/http/httptest"\
	"os"\
	"os/exec"\
	"path/filepath"\
	"strings"\
	"testing"\
	"time"\
\
	git "github.com/go-git/go-git/v6"\
	"github.com/go-git/go-git/v6/plumbing"\
	"github.com/go-git/go-git/v6/plumbing/filemode"\
	formatcfg "github.com/go-git/go-git/v6/plumbing/format/config"\
	"github.com/go-git/go-git/v6/plumbing/object"\
	gogitstorer "github.com/go-git/go-git/v6/plumbing/storer"\
	"github.com/go-git/go-git/v6/storage/filesystem"\
\
	"entire.io/entire/git-sync/internal/planner"\
)\
\
// TestTranslator builds a small SHA1 source repo with blobs, trees, commits,\
// and an annotated tag — including signed commit/tag — then runs the\
// translator and asserts both the bookkeeping counts and the on-disk\
// invariant: every loose object's filename equals sha256(headered content).\
// That invariant is the one go-git v6 alpha 3 gets wrong via its\
// SetEncodedObject path; verifying it directly prevents regressing back\
// onto the broken loose-object writer.\
func TestTranslator(t *testing.T) {\
	root := t.TempDir()\
	srcDir := filepath.Join(root, "src.git")\
	dstDir := filepath.Join(root, "dst.git")\
\
	srcRepo, err := git.PlainInit(srcDir, true)\
	if err != nil {\
		t.Fatalf("init SHA1 source: %v", err)\
	}\
	dstRepo, err := git.PlainInit(dstDir, true, git.WithObjectFormat(formatcfg.SHA256))\
	if err != nil {\
		t.Fatalf("init SHA256 target: %v", err)\
	}\
\
	blobHash := writeBlob(t, srcRepo.Storer, []byte("hello world\n"))\
	treeHash := writeTree(t, srcRepo.Storer, []object.TreeEntry{\
		{Name: "README", Mode: filemode.Regular, Hash: blobHash},\
	})\
\
	sig := object.Signature{Name: "Test", Email: "test@example.com", When: time.Unix(1700000000, 0).UTC()}\
	commit1 := &object.Commit{\
		Author:    sig,\
		Committer: sig,\
		Message:   "initial\n",\
		TreeHash:  treeHash,\
		Signature: "-----BEGIN PGP SIGNATURE-----\nfake sig data\n-----END PGP SIGNATURE-----",\
	}\
	c1Hash := writeObject(t, srcRepo.Storer, commit1.Encode)\
\
	commit2 := &object.Commit{\
		Author:       sig,\
		Committer:    sig,\
		Message:      "second\n",\
		TreeHash:     treeHash,\
		ParentHashes: []plumbing.Hash{c1Hash},\
	}\
	c2Hash := writeObject(t, srcRepo.Storer, commit2.Encode)\
\
	tag := &object.Tag{\
		Name:       "v1",\
		Tagger:     sig,\
		Message:    "annotated tag\n",\
		TargetType: plumbing.CommitObject,\
		Target:     c2Hash,\
		Signature:  "-----BEGIN PGP SIGNATURE-----\nfake tag sig\n-----END PGP SIGNATURE-----",\
	}\
	tagHash := writeObject(t, srcRepo.Storer, tag.Encode)\
\
	reachable, err := discoverReachable(t.Context(), srcRepo.Storer, []plumbing.Hash{tagHash}, nil)\
	if err != nil {\
		t.Fatalf("discoverReachable: %v", err)\
	}\
	tr, err := newTranslator(t.Context(), srcRepo.Storer, dstRepo.Storer, false, reachable)\
	if err != nil {\
		t.Fatalf("newTranslator: %v", err)\
	}\
	newTagHash, err := tr.translate(tagHash)\
	if err != nil {\
		t.Fatalf("translate tag: %v", err)\
	}\
\
	wantCounts := Counts{Blobs: 1, Trees: 1, Commits: 2, Tags: 1}\
	if got := tr.snapshotCounts(); got != wantCounts {\
		t.Errorf("counts: got %+v, want %+v", got, wantCounts)\
	}\
	if tr.signaturesStripped != 2 {\
		t.Errorf("signatures stripped: got %d, want 2 (commit + tag)", tr.signaturesStripped)\
	}\
\
	// Idempotency: translating the same hash again must reuse the mapping\
	// without writing more objects or bumping counters.\
	startBlobs := tr.blobs.Load()\
	if _, err := tr.translate(tagHash); err != nil {\
		t.Fatalf("re-translate tag: %v", err)\
	}\
	if tr.blobs.Load() != startBlobs {\
		t.Errorf("re-translate increased blob count; memoization broken")\
	}\
\
	// Every translated hash must point at a loose object whose filename\
	// equals sha256(headered content). This is the precise invariant the\
	// go-git bug violates — keep it as a test.\
	objectsDir := filepath.Join(dstDir, "objects")\
	verified := 0\
	for _, h := range tr.mapping {\
		assertLooseObjectHashMatches(t, objectsDir, h)\
		verified++\
	}\
	if verified == 0 {\
		t.Fatal("no objects in mapping; nothing was verified")\
	}\
\
	// The translated tag must decode under the SHA256 target and point at\
	// a SHA256 commit whose tree resolves to a SHA256 tree.\
	tagObj, err := object.GetTag(dstRepo.Storer, newTagHash)\
	if err != nil {\
		t.Fatalf("read translated tag: %v", err)\
	}\
	if tagObj.Signature != "" {\
		t.Errorf("translated tag still carries a signature: %q", tagObj.Signature)\
	}\
	if tagObj.Target != tr.mapping[c2Hash] {\
		t.Errorf("translated tag target: got %s, want %s", tagObj.Target, tr.mapping[c2Hash])\
	}\
\
	commit, err := object.GetCommit(dstRepo.Storer, tagObj.Target)\
	if err != nil {\
		t.Fatalf("read translated commit: %v", err)\
	}\
	if commit.Signature != "" {\
		t.Errorf("translated commit still carries a signature: %q", commit.Signature)\
	}\
	if len(commit.ParentHashes) != 1 || commit.ParentHashes[0] != tr.mapping[c1Hash] {\
		t.Errorf("translated commit parents: got %v, want [%s]", commit.ParentHashes, tr.mapping[c1Hash])\
	}\
	if commit.TreeHash != tr.mapping[treeHash] {\
		t.Errorf("translated commit tree: got %s, want %s", commit.TreeHash, tr.mapping[treeHash])\
	}\
}\
\
// TestTranslator_RewritesMessageHashes confirms that SHA1 hash references\
// in commit and tag messages — both full 40-char and short forms — are\
// rewritten to the corresponding SHA256 when those SHA1s are translated\
// objects in the same conversion, and that ambiguous/unknown short\
// prefixes are left alone.\
func TestTranslator_RewritesMessageHashes(t *testing.T) {\
	root := t.TempDir()\
	srcDir := filepath.Join(root, "src.git")\
	dstDir := filepath.Join(root, "dst.git")\
\
	srcRepo, err := git.PlainInit(srcDir, true)\
	if err != nil {\
		t.Fatalf("init SHA1 source: %v", err)\
	}\
	dstRepo, err := git.PlainInit(dstDir, true, git.WithObjectFormat(formatcfg.SHA256))\
	if err != nil {\
		t.Fatalf("init SHA256 target: %v", err)\
	}\
\
	blobHash := writeBlob(t, srcRepo.Storer, []byte("x\n"))\
	treeHash := writeTree(t, srcRepo.Storer, []object.TreeEntry{\
		{Name: "f", Mode: filemode.Regular, Hash: blobHash},\
	})\
	sig := object.Signature{Name: "Test", Email: "t@example.com", When: time.Unix(1700000000, 0).UTC()}\
	parent := &object.Commit{Author: sig, Committer: sig, Message: "first\n", TreeHash: treeHash}\
	parentSHA1 := writeObject(t, srcRepo.Storer, parent.Encode)\
\
	// Child commit's message references the parent by full hash, by 7-char\
	// short prefix, and includes an unrelated 7-char hex string that should\
	// not match anything in the mapping.\
	parentHex := parentSHA1.String()\
	childMsg := fmt.Sprintf(\
		"reverts %s\nsee short %s for context\nunrelated hex 1234567 follows\n",\
		parentHex, parentHex[:7])\
	child := &object.Commit{\
		Author:       sig,\
		Committer:    sig,\
		Message:      childMsg,\
		TreeHash:     treeHash,\
		ParentHashes: []plumbing.Hash{parentSHA1},\
	}\
	childSHA1 := writeObject(t, srcRepo.Storer, child.Encode)\
\
	reachable, err := discoverReachable(t.Context(), srcRepo.Storer, []plumbing.Hash{childSHA1}, nil)\
	if err != nil {\
		t.Fatalf("discoverReachable: %v", err)\
	}\
	tr, err := newTranslator(t.Context(), srcRepo.Storer, dstRepo.Storer, true, reachable)\
	if err != nil {\
		t.Fatalf("newTranslator: %v", err)\
	}\
	if _, err := tr.translate(childSHA1); err != nil {\
		t.Fatalf("translate child: %v", err)\
	}\
\
	// 2 references should have been rewritten (full + short). The unrelated\
	// 7-char hex string is not in the mapping, so it stays.\
	if tr.messageRewrites != 2 {\
		t.Errorf("message rewrites: got %d, want 2", tr.messageRewrites)\
	}\
\
	childNew := tr.mapping[childSHA1]\
	parentNew := tr.mapping[parentSHA1]\
	gotChild, err := object.GetCommit(dstRepo.Storer, childNew)\
	if err != nil {\
		t.Fatalf("read translated child: %v", err)\
	}\
	if !strings.Contains(gotChild.Message, parentNew.String()) {\
		t.Errorf("child message missing full SHA256 of parent:\n%s", gotChild.Message)\
	}\
	if strings.Contains(gotChild.Message, parentHex) {\
		t.Errorf("child message still contains original parent SHA1:\n%s", gotChild.Message)\
	}\
	if !strings.Contains(gotChild.Message, "1234567") {\
		t.Errorf("unrelated short hex was wrongly substituted:\n%s", gotChild.Message)\
	}\
}\
\
// TestTranslator_RewritesCrossBranchReferences is the test that proves the\
// discovery-plus-topological-DFS design fixes the cross-branch limitation\
// the older inline-only rewriter had. Two unrelated branches share no\
// ancestry. Branch A has a single commit cA. Branch B has commit cB whose\
// message references cA by both full and abbreviated SHA1. We translate B\
// first, *then* A — the order under which the older code would have left\
// cB's message un-rewritten because cA was not yet in the mapping when cB\
// was encoded. With message-reference edges in the DFS, translating cB\
// pulls cA in via t.translate, so the mapping is populated and the\
// rewrite succeeds.\
func TestTranslator_RewritesCrossBranchReferences(t *testing.T) {\
	root := t.TempDir()\
	srcDir := filepath.Join(root, "src.git")\
	dstDir := filepath.Join(root, "dst.git")\
	srcRepo := initSHA1(t, srcDir)\
	dstRepo := initSHA256(t, dstDir)\
\
	blobA := writeBlob(t, srcRepo.Storer, []byte("a\n"))\
	treeA := writeTree(t, srcRepo.Storer, []object.TreeEntry{\
		{Name: "a", Mode: filemode.Regular, Hash: blobA},\
	})\
	blobB := writeBlob(t, srcRepo.Storer, []byte("b\n"))\
	treeB := writeTree(t, srcRepo.Storer, []object.TreeEntry{\
		{Name: "b", Mode: filemode.Regular, Hash: blobB},\
	})\
\
	sig := object.Signature{Name: "Test", Email: "t@example.com", When: time.Unix(1700000000, 0).UTC()}\
	cA := writeObject(t, srcRepo.Storer, (&object.Commit{\
		Author: sig, Committer: sig, Message: "branch A tip\n", TreeHash: treeA,\
	}).Encode)\
	// cB has no parent in common with cA — they are siblings under\
	// no ancestor, exactly the case where ancestor-only inline\
	// rewriting would have failed.\
	cAHex := cA.String()\
	cB := writeObject(t, srcRepo.Storer, (&object.Commit{\
		Author:    sig,\
		Committer: sig,\
		Message: fmt.Sprintf("branch B tip\n\nCherry-picked from %s\nsee short %s\n",\
			cAHex, cAHex[:8]),\
		TreeHash: treeB,\
	}).Encode)\
\
	// Discovery must see both branches so the reachable set covers cA\
	// before cB is encoded.\
	reachable, err := discoverReachable(t.Context(), srcRepo.Storer, []plumbing.Hash{cB, cA}, nil)\
	if err != nil {\
		t.Fatalf("discoverReachable: %v", err)\
	}\
	tr := mustTranslator(t, srcRepo.Storer, dstRepo.Storer, true, reachable)\
	// Translate B first — the order that would have left the rewrite\
	// stranded under the old design.\
	if _, err := tr.translate(cB); err != nil {\
		t.Fatalf("translate cB: %v", err)\
	}\
	if _, err := tr.translate(cA); err != nil {\
		t.Fatalf("translate cA: %v", err)\
	}\
\
	if tr.messageRewrites != 2 {\
		t.Errorf("expected 2 rewrites (full + short SHA1 of cA), got %d", tr.messageRewrites)\
	}\
	cBNew := tr.mapping[cB]\
	cANew := tr.mapping[cA]\
	if cBNew.IsZero() || cANew.IsZero() {\
		t.Fatalf("missing mapping entries: cB=%s cA=%s", cBNew, cANew)\
	}\
	gotB, err := object.GetCommit(dstRepo.Storer, cBNew)\
	if err != nil {\
		t.Fatalf("read cB: %v", err)\
	}\
	if !strings.Contains(gotB.Message, cANew.String()) {\
		t.Errorf("cB's message missing cA's SHA256:\n%s", gotB.Message)\
	}\
	if strings.Contains(gotB.Message, cAHex) {\
		t.Errorf("cB's message still contains cA's original SHA1:\n%s", gotB.Message)\
	}\
}\
\
// TestTranslator_SkipMessageRewrite confirms that with rewriteMessages\
// false, the translator leaves message content (including SHA1 hashes)\
// untouched.\
func TestTranslator_SkipMessageRewrite(t *testing.T) {\
	root := t.TempDir()\
	srcRepo := initSHA1(t, filepath.Join(root, "src.git"))\
	dstRepo := initSHA256(t, filepath.Join(root, "dst.git"))\
\
	blob := writeBlob(t, srcRepo.Storer, []byte("x\n"))\
	tree := writeTree(t, srcRepo.Storer, []object.TreeEntry{{Name: "f", Mode: filemode.Regular, Hash: blob}})\
	sig := object.Signature{Name: "Test", Email: "t@example.com", When: time.Unix(1, 0).UTC()}\
	parent := writeObject(t, srcRepo.Storer, (&object.Commit{Author: sig, Committer: sig, Message: "p\n", TreeHash: tree}).Encode)\
	parentHex := parent.String()\
\
	child := &object.Commit{\
		Author: sig, Committer: sig, TreeHash: tree, ParentHashes: []plumbing.Hash{parent},\
		Message: "reverts " + parentHex + "\n",\
	}\
	childSHA1 := writeObject(t, srcRepo.Storer, child.Encode)\
\
	reachable, err := discoverReachable(t.Context(), srcRepo.Storer, []plumbing.Hash{childSHA1}, nil)\
	if err != nil {\
		t.Fatalf("discoverReachable: %v", err)\
	}\
	tr := mustTranslator(t, srcRepo.Storer, dstRepo.Storer, false, reachable)\
	if _, err := tr.translate(childSHA1); err != nil {\
		t.Fatalf("translate: %v", err)\
	}\
	if tr.messageRewrites != 0 {\
		t.Errorf("expected no rewrites when disabled; got %d", tr.messageRewrites)\
	}\
	got, err := object.GetCommit(dstRepo.Storer, tr.mapping[childSHA1])\
	if err != nil {\
		t.Fatalf("read translated child: %v", err)\
	}\
	if !strings.Contains(got.Message, parentHex) {\
		t.Errorf("rewrite-disabled run still mutated the message: %q", got.Message)\
	}\
}\
\
// TestTranslator_WriteOriginNotes builds a small history and verifies that\
// the notes tree contains one entry per translated commit and that each\
// entry resolves to a blob whose content is the commit's original SHA1.\
func TestTranslator_WriteOriginNotes(t *testing.T) {\
	root := t.TempDir()\
	srcRepo := initSHA1(t, filepath.Join(root, "src.git"))\
	dstRepo := initSHA256(t, filepath.Join(root, "dst.git"))\
\
	blob := writeBlob(t, srcRepo.Storer, []byte("hi\n"))\
	tree := writeTree(t, srcRepo.Storer, []object.TreeEntry{{Name: "f", Mode: filemode.Regular, Hash: blob}})\
	sig := object.Signature{Name: "Test", Email: "t@example.com", When: time.Unix(1700000000, 0).UTC()}\
	c1 := writeObject(t, srcRepo.Storer, (&object.Commit{Author: sig, Committer: sig, Message: "c1\n", TreeHash: tree}).Encode)\
	c2 := writeObject(t, srcRepo.Storer, (&object.Commit{Author: sig, Committer: sig, Message: "c2\n", TreeHash: tree, ParentHashes: []plumbing.Hash{c1}}).Encode)\
\
	reachable, err := discoverReachable(t.Context(), srcRepo.Storer, []plumbing.Hash{c2}, nil)\
	if err != nil {\
		t.Fatalf("discoverReachable: %v", err)\
	}\
	tr := mustTranslator(t, srcRepo.Storer, dstRepo.Storer, false, reachable)\
	if _, err := tr.translate(c2); err != nil {\
		t.Fatalf("translate: %v", err)\
	}\
\
	refName, err := tr.writeOriginNotes(originNotesRef)\
	if err != nil {\
		t.Fatalf("writeOriginNotes: %v", err)\
	}\
	if refName != originNotesRef {\
		t.Errorf("ref name: got %q, want %q", refName, originNotesRef)\
	}\
	notesCommit, err := object.GetCommit(dstRepo.Storer, tr.lastNotesCommit)\
	if err != nil {\
		t.Fatalf("read notes commit: %v", err)\
	}\
	notesTree, err := notesCommit.Tree()\
	if err != nil {\
		t.Fatalf("read notes tree: %v", err)\
	}\
	if len(notesTree.Entries) != 2 {\
		t.Fatalf("notes entries: got %d, want 2", len(notesTree.Entries))\
	}\
	for _, mapped := range []plumbing.Hash{tr.mapping[c1], tr.mapping[c2]} {\
		entry, err := notesTree.FindEntry(mapped.String())\
		if err != nil {\
			t.Fatalf("no notes entry for %s: %v", mapped, err)\
		}\
		blob, err := object.GetBlob(dstRepo.Storer, entry.Hash)\
		if err != nil {\
			t.Fatalf("read note blob: %v", err)\
		}\
		reader, err := blob.Reader()\
		if err != nil {\
			t.Fatalf("open note blob: %v", err)\
		}\
		buf, err := io.ReadAll(reader)\
		if err != nil {\
			_ = reader.Close()\
			t.Fatalf("read note blob: %v", err)\
		}\
		_ = reader.Close()\
		got := strings.TrimSpace(string(buf))\
		var origSHA1 plumbing.Hash\
		for s, n := range tr.mapping {\
			if n == mapped {\
				origSHA1 = s\
				break\
			}\
		}\
		if got != origSHA1.String() {\
			t.Errorf("note for %s: got %q, want %q", mapped, got, origSHA1.String())\
		}\
	}\
}\
\
// TestTranslator_WriteMappingFile checks the sidecar TSV format: header\
// line, sorted by SHA1, one entry per translated object.\
func TestTranslator_WriteMappingFile(t *testing.T) {\
	root := t.TempDir()\
	srcRepo := initSHA1(t, filepath.Join(root, "src.git"))\
	dstRepo := initSHA256(t, filepath.Join(root, "dst.git"))\
\
	blob := writeBlob(t, srcRepo.Storer, []byte("hi\n"))\
	tree := writeTree(t, srcRepo.Storer, []object.TreeEntry{{Name: "f", Mode: filemode.Regular, Hash: blob}})\
	sig := object.Signature{Name: "Test", Email: "t@example.com", When: time.Unix(1700000000, 0).UTC()}\
	commit := writeObject(t, srcRepo.Storer, (&object.Commit{Author: sig, Committer: sig, Message: "c\n", TreeHash: tree}).Encode)\
\
	reachable, err := discoverReachable(t.Context(), srcRepo.Storer, []plumbing.Hash{commit}, nil)\
	if err != nil {\
		t.Fatalf("discoverReachable: %v", err)\
	}\
	tr := mustTranslator(t, srcRepo.Storer, dstRepo.Storer, false, reachable)\
	if _, err := tr.translate(commit); err != nil {\
		t.Fatalf("translate: %v", err)\
	}\
\
	path := filepath.Join(root, "mapping.tsv")\
	if err := tr.writeMappingFile(path); err != nil {\
		t.Fatalf("writeMappingFile: %v", err)\
	}\
	raw, err := os.ReadFile(path)\
	if err != nil {\
		t.Fatalf("read mapping: %v", err)\
	}\
	lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")\
	if !strings.HasPrefix(lines[0], "#") {\
		t.Errorf("first line should be a header comment, got %q", lines[0])\
	}\
	data := lines[1:]\
	if len(data) != len(tr.mapping) {\
		t.Errorf("mapping line count: got %d, want %d", len(data), len(tr.mapping))\
	}\
	// Sorted by SHA1.\
	for i := 1; i < len(data); i++ {\
		prev := strings.Split(data[i-1], "\t")[0]\
		cur := strings.Split(data[i], "\t")[0]\
		if prev >= cur {\
			t.Errorf("mapping not sorted: %q >= %q", prev, cur)\
		}\
	}\
	// Every translated hash present.\
	mapped := map[string]string{}\
	for _, line := range data {\
		parts := strings.Split(line, "\t")\
		if len(parts) != 2 {\
			t.Errorf("malformed line %q", line)\
			continue\
		}\
		mapped[parts[0]] = parts[1]\
	}\
	for old, newH := range tr.mapping {\
		if mapped[old.String()] != newH.String() {\
			t.Errorf("missing or wrong mapping for %s: got %q, want %s", old, mapped[old.String()], newH)\
		}\
	}\
}\
\
// TestTranslator_AmbiguousMessageRefWarning verifies that when an\
// abbreviated SHA1 prefix in a commit message matches more than one\
// in-scope commit, the prefix is left unrewritten and recorded in\
// t.ambiguousMessageRefs so the caller can surface a warning.\
//\
// We can't easily force a real SHA1 prefix collision in a test, so\
// we install two synthetic entries in the reachable map after the\
// translator is constructed and then run rewriteHashesInMessage\
// directly. This exercises the same code path the production\
// pipeline takes.\
func TestTranslator_AmbiguousMessageRefWarning(t *testing.T) {\
	root := t.TempDir()\
	srcRepo := initSHA1(t, filepath.Join(root, "src.git"))\
	dstRepo := initSHA256(t, filepath.Join(root, "dst.git"))\
	tr := mustTranslator(t, srcRepo.Storer, dstRepo.Storer, true, nil)\
\
	// Two real-looking SHA1 hashes that share the prefix "deadbee".\
	one := plumbing.NewHash("deadbee100000000000000000000000000000001")\
	two := plumbing.NewHash("deadbee200000000000000000000000000000002")\
	tr.reachable[one] = plumbing.CommitObject\
	tr.reachable[two] = plumbing.CommitObject\
\
	out, count, err := tr.rewriteMessageRefs("see commit deadbee for details\n", "commit", plumbing.ZeroHash)\
	if err != nil {\
		t.Fatalf("rewriteMessageRefs: %v", err)\
	}\
	if count != 0 {\
		t.Errorf("ambiguous prefix should not be rewritten; got count=%d", count)\
	}\
	if !strings.Contains(out, "deadbee") {\
		t.Errorf("ambiguous prefix should be left in message; got %q", out)\
	}\
	if _, recorded := tr.ambiguousMessageRefs["deadbee"]; !recorded {\
		t.Errorf("expected %q to be recorded in ambiguousMessageRefs, got %v",\
			"deadbee", tr.ambiguousMessageRefs)\
	}\
}\
\
// TestTranslator_UnresolvableSubmodule confirms that a tree entry with\
// Submodule mode pointing at a commit not in the source repo is\
// rejected during discovery (fail-fast), before any object is written\
// to the target.\
func TestTranslator_UnresolvableSubmodule(t *testing.T) {\
	root := t.TempDir()\
	srcDir := filepath.Join(root, "src.git")\
\
	srcRepo, err := git.PlainInit(srcDir, true)\
	if err != nil {\
		t.Fatalf("init SHA1 source: %v", err)\
	}\
\
	blobHash := writeBlob(t, srcRepo.Storer, []byte("contents\n"))\
	// External-looking SHA1 — not in source.\
	external := plumbing.NewHash("0123456789abcdef0123456789abcdef01234567")\
	treeHash := writeTree(t, srcRepo.Storer, []object.TreeEntry{\
		{Name: "file", Mode: filemode.Regular, Hash: blobHash},\
		{Name: "sub", Mode: filemode.Submodule, Hash: external},\
	})\
\
	_, err = discoverReachable(t.Context(), srcRepo.Storer, []plumbing.Hash{treeHash}, nil)\
	if err == nil {\
		t.Fatal("expected discoverReachable to fail on unresolvable submodule, got nil")\
	}\
	if !strings.Contains(err.Error(), "submodule") {\
		t.Errorf("error should mention submodule; got: %v", err)\
	}\
}\
\
// TestTranslator_VendoredSubmoduleStillRefused locks in the rule that\
// even a submodule whose commit happens to live in the source store is\
// rejected. The earlier "vendored" carve-out rewrote such gitlinks to\
// SHA256, but .gitmodules still points at an upstream SHA1 repo, so\
// `git submodule update` would fail in clones of the converted repo.\
func TestTranslator_VendoredSubmoduleStillRefused(t *testing.T) {\
	root := t.TempDir()\
	srcDir := filepath.Join(root, "src.git")\
\
	srcRepo, err := git.PlainInit(srcDir, true)\
	if err != nil {\
		t.Fatalf("init SHA1 source: %v", err)\
	}\
\
	// Create a commit that lives in this source store, then point a\
	// tree's submodule gitlink at it. discoverReachable used to recurse\
	// into that commit ("vendored") and translate the gitlink; now it\
	// refuses regardless.\
	blobHash := writeBlob(t, srcRepo.Storer, []byte("inner\n"))\
	innerTree := writeTree(t, srcRepo.Storer, []object.TreeEntry{\
		{Name: "f", Mode: filemode.Regular, Hash: blobHash},\
	})\
	sig := object.Signature{Name: "Test", Email: "t@example.com", When: time.Unix(1700000000, 0).UTC()}\
	innerCommit := &object.Commit{Author: sig, Committer: sig, Message: "inner\n", TreeHash: innerTree}\
	innerSHA1 := writeObject(t, srcRepo.Storer, innerCommit.Encode)\
\
	outerTree := writeTree(t, srcRepo.Storer, []object.TreeEntry{\
		{Name: "sub", Mode: filemode.Submodule, Hash: innerSHA1},\
	})\
\
	_, err = discoverReachable(t.Context(), srcRepo.Storer, []plumbing.Hash{outerTree}, nil)\
	if err == nil {\
		t.Fatal("expected discoverReachable to refuse vendored submodule, got nil")\
	}\
	if !strings.Contains(err.Error(), "submodule") {\
		t.Errorf("error should mention submodule; got: %v", err)\
	}\
}\
\
// --- helpers ---\
\
// initSHA1 and initSHA256 are t.Fatalf-wrapping `git.PlainInit` shortcuts\
// used to keep test bodies focused on the translator logic rather than\
// error-handling boilerplate.\
func initSHA1(t *testing.T, path string) *git.Repository {\
	t.Helper()\
	r, err := git.PlainInit(path, true)\
	if err != nil {\
		t.Fatalf("init SHA1 source at %s: %v", path, err)\
	}\
	return r\
}\
\
func initSHA256(t *testing.T, path string) *git.Repository {\
	t.Helper()\
	r, err := git.PlainInit(path, true, git.WithObjectFormat(formatcfg.SHA256))\
	if err != nil {\
		t.Fatalf("init SHA256 target at %s: %v", path, err)\
	}\
	return r\
}\
\
func mustTranslator(t *testing.T, src, dst gogitstorer.Storer, rewrite bool, reachable map[plumbing.Hash]plumbing.ObjectType) *translator {\
	t.Helper()\
	tr, err := newTranslator(t.Context(), src, dst, rewrite, reachable)\
	if err != nil {\
		t.Fatalf("newTranslator: %v", err)\
	}\
	return tr\
}\
\
func writeBlob(t *testing.T, storer interface {\
	NewEncodedObject() plumbing.EncodedObject\
	SetEncodedObject(obj plumbing.EncodedObject) (plumbing.Hash, error)\
}, content []byte) plumbing.Hash {\
	t.Helper()\
	obj := storer.NewEncodedObject()\
	obj.SetType(plumbing.BlobObject)\
	obj.SetSize(int64(len(content)))\
	w, err := obj.Writer()\
	if err != nil {\
		t.Fatalf("blob writer: %v", err)\
	}\
	if _, err := w.Write(content); err != nil {\
		t.Fatalf("blob write: %v", err)\
	}\
	if err := w.Close(); err != nil {\
		t.Fatalf("blob close: %v", err)\
	}\
	h, err := storer.SetEncodedObject(obj)\
	if err != nil {\
		t.Fatalf("blob store: %v", err)\
	}\
	return h\
}\
\
func writeTree(t *testing.T, storer interface {\
	NewEncodedObject() plumbing.EncodedObject\
	SetEncodedObject(obj plumbing.EncodedObject) (plumbing.Hash, error)\
}, entries []object.TreeEntry) plumbing.Hash {\
	t.Helper()\
	tree := &object.Tree{Entries: entries}\
	// object.Tree.Encode requires the slice to be sorted by name; tests\
	// pre-sort their entries, but be safe.\
	return writeObject(t, storer, tree.Encode)\
}\
\
func writeObject(t *testing.T, storer interface {\
	NewEncodedObject() plumbing.EncodedObject\
	SetEncodedObject(obj plumbing.EncodedObject) (plumbing.Hash, error)\
}, encode func(plumbing.EncodedObject) error) plumbing.Hash {\
	t.Helper()\
	obj := storer.NewEncodedObject()\
	if err := encode(obj); err != nil {\
		t.Fatalf("encode: %v", err)\
	}\
	h, err := storer.SetEncodedObject(obj)\
	if err != nil {\
		t.Fatalf("store: %v", err)\
	}\
	return h\
}\
\
// assertLooseObjectHashMatches reads the on-disk loose object for h, zlib-\
// decompresses it, and confirms sha256(decompressed bytes) == h. The\
// decompressed bytes include the "<type> <size>\x00" header, which is what\
// git hashes — so this is a direct check on the loose writer's correctness.\
func assertLooseObjectHashMatches(t *testing.T, objectsDir string, h plumbing.Hash) {\
	t.Helper()\
	hex := h.String()\
	if len(hex) != 64 {\
		t.Errorf("hash %s is not 64 hex chars (sha256)", hex)\
		return\
	}\
	path := filepath.Join(objectsDir, hex[:2], hex[2:])\
	raw, err := os.ReadFile(path)\
	if err != nil {\
		t.Fatalf("read %s: %v", path, err)\
	}\
	zr, err := zlib.NewReader(bytes.NewReader(raw))\
	if err != nil {\
		t.Fatalf("zlib %s: %v", path, err)\
	}\
	defer zr.Close()\
	plain, err := io.ReadAll(zr)\
	if err != nil {\
		t.Fatalf("decompress %s: %v", path, err)\
	}\
	sum := sha256.Sum256(plain)\
	got := makeHex(sum[:])\
	if got != hex {\
		t.Errorf("loose object %s: sha256(content) = %s; filename and content disagree", hex, got)\
	}\
}\
\
func makeHex(b []byte) string {\
	return hex.EncodeToString(b)\
}\
\
// --- Integration test (gated) ---\
\
const gitHTTPBackendEnv = "GITSYNC_E2E_SHA256_HTTP_BACKEND"\
\
// TestRun_GitHTTPBackend exercises the full convert-sha256 pipeline against\
// a local git http-backend serving a real SHA1 source repo. Gated like the\
// other end-to-end git-http-backend tests to keep the default test runs\
// hermetic (no external binaries required).\
func TestRun_GitHTTPBackend(t *testing.T) {\
	if os.Getenv(gitHTTPBackendEnv) == "" {\
		t.Skipf("set %s=1 to run the convert-sha256 git-http-backend integration test", gitHTTPBackendEnv)\
	}\
	gitBin, err := exec.LookPath("git")\
	if err != nil {\
		t.Skipf("git binary not available: %v", err)\
	}\
\
	root := t.TempDir()\
	srcBare := filepath.Join(root, "source.git")\
	worktree := filepath.Join(root, "work")\
	dstDir := filepath.Join(root, "target.git")\
\
	mustGit(t, root, "init", "--bare", srcBare)\
	mustGit(t, root, "init", "-b", "main", worktree)\
	mustGit(t, worktree, "config", "user.name", "convert-sha256 test")\
	mustGit(t, worktree, "config", "user.email", "test@example.com")\
	mustWrite(t, filepath.Join(worktree, "README"), "hello\n")\
	mustGit(t, worktree, "add", "README")\
	mustGit(t, worktree, "commit", "-m", "initial")\
	// Capture the first commit's SHA1 so the second commit's message can\
	// reference it (both full and abbreviated). The conversion should\
	// rewrite both to the new SHA256 hash.\
	firstSHA1 := strings.TrimSpace(mustGitOutput(t, worktree, "rev-parse", "HEAD"))\
	mustWrite(t, filepath.Join(worktree, "second.txt"), "world\n")\
	mustGit(t, worktree, "add", "second.txt")\
	mustGit(t, worktree, "commit", "-m",\
		fmt.Sprintf("second\n\nreverts %s\nsee short %s", firstSHA1, firstSHA1[:7]))\
	mustGit(t, worktree, "tag", "-a", "v1", "-m", "first tag")\
	mustGit(t, worktree, "remote", "add", "origin", srcBare)\
	mustGit(t, worktree, "push", "origin", "HEAD:refs/heads/main")\
	mustGit(t, worktree, "push", "origin", "v1")\
\
	srv := newCGIBackend(t, gitBin, root)\
	defer srv.Close()\
\
	mappingPath := filepath.Join(root, "mapping.tsv")\
	res, err := Run(context.Background(), Request{\
		SourceURL:   srv.URL + "/source.git",\
		TargetDir:   dstDir,\
		MappingFile: mappingPath,\
		Check:       true,\
		Out:         io.Discard,\
	})\
	if err != nil {\
		t.Fatalf("convert-sha256 run: %v", err)\
	}\
	if res.Counts.Commits < 2 {\
		t.Errorf("expected at least 2 commits converted, got %+v", res.Counts)\
	}\
	if res.Counts.Tags != 1 {\
		t.Errorf("expected 1 tag converted, got %d", res.Counts.Tags)\
	}\
	if res.RefsConverted < 2 {\
		t.Errorf("expected at least 2 refs (main + v1), got %d", res.RefsConverted)\
	}\
\
	// The converted repo must be self-consistent under SHA256.\
	fsckOut, err := exec.CommandContext(t.Context(), gitBin, "-C", dstDir, "fsck", "--full").CombinedOutput()\
	if err != nil {\
		t.Fatalf("git fsck failed: %v\n%s", err, fsckOut)\
	}\
	if strings.Contains(string(fsckOut), "error") || strings.Contains(string(fsckOut), "bad sha") {\
		t.Fatalf("git fsck reported errors:\n%s", fsckOut)\
	}\
\
	// Sanity: extensions.objectformat is set, and git can walk the history.\
	format := mustGitOutput(t, dstDir, "config", "extensions.objectformat")\
	if strings.TrimSpace(format) != "sha256" {\
		t.Errorf("extensions.objectformat: got %q, want %q", strings.TrimSpace(format), "sha256")\
	}\
	log := mustGitOutput(t, dstDir, "log", "--oneline", "refs/heads/main")\
	if !strings.Contains(log, "initial") || !strings.Contains(log, "second") {\
		t.Errorf("git log missing expected commit subjects:\n%s", log)\
	}\
	tagShow := mustGitOutput(t, dstDir, "cat-file", "-p", "refs/tags/v1")\
	if !strings.Contains(tagShow, "first tag") {\
		t.Errorf("annotated tag did not round-trip:\n%s", tagShow)\
	}\
\
	// Message rewriting: the second commit's body referenced firstSHA1\
	// twice (full + 7-char short). Both should now be SHA256 hashes.\
	if res.MessageRewrites != 2 {\
		t.Errorf("message rewrites: got %d, want 2", res.MessageRewrites)\
	}\
	secondMsg := mustGitOutput(t, dstDir, "log", "-1", "--format=%B", "refs/heads/main")\
	if strings.Contains(secondMsg, firstSHA1) {\
		t.Errorf("second commit message still contains the original SHA1:\n%s", secondMsg)\
	}\
\
	// Origin notes: the ref exists, and the head commit's note resolves\
	// to the original SHA1 it was rewritten from.\
	if res.OriginNotesRef != "refs/notes/sha1-origin" {\
		t.Errorf("OriginNotesRef: got %q, want refs/notes/sha1-origin", res.OriginNotesRef)\
	}\
	headSHA256 := strings.TrimSpace(mustGitOutput(t, dstDir, "rev-parse", "refs/heads/main"))\
	note := strings.TrimSpace(mustGitOutput(t, dstDir, "notes", "--ref=sha1-origin", "show", headSHA256))\
	// The note for the second (head) commit holds its pre-conversion SHA1.\
	headSHA1 := strings.TrimSpace(mustGitOutput(t, srcBare, "rev-parse", "refs/heads/main"))\
	if note != headSHA1 {\
		t.Errorf("origin note for head: got %q, want %q", note, headSHA1)\
	}\
\
	// Mapping file: present, sorted, has at least one entry per\
	// translated commit/tree/blob/tag.\
	if res.MappingFile != mappingPath {\
		t.Errorf("MappingFile: got %q, want %q", res.MappingFile, mappingPath)\
	}\
	mapping, err := os.ReadFile(mappingPath)\
	if err != nil {\
		t.Fatalf("read mapping file: %v", err)\
	}\
	if !strings.Contains(string(mapping), headSHA1) {\
		t.Errorf("mapping file missing head SHA1 %s:\n%s", headSHA1, mapping)\
	}\
\
	// --check: every step should pass against a freshly-converted repo,\
	// including git fsck --full (available since we already need the\
	// git binary to drive the source side of this test).\
	if len(res.Checks) == 0 {\
		t.Fatal("expected Checks to be populated when --check is enabled")\
	}\
	for _, c := range res.Checks {\
		if !c.OK {\
			t.Errorf("check %q failed: %s", c.Name, c.Detail)\
		}\
	}\
	expected := map[string]bool{"config": false, "HEAD": false, "refs": false, "git fsck --full": false}\
	for _, c := range res.Checks {\
		expected[c.Name] = true\
	}\
	for name, present := range expected {\
		if !present {\
			t.Errorf("--check did not run %q step", name)\
		}\
	}\
}\
\
// TestRun_GitHTTPBackend_KeepsTargetOnPostConversionFailure guards the\
// invariant that a failure in an optional post-conversion step (here, an\
// unwritable --write-mapping path) leaves the fully-converted repo on disk\
// rather than deleting it. The target is complete and valid once refs and\
// HEAD are written; only the mapping side output failed. Regression test for\
// the cleanup-target lifecycle: cleanup must be disarmed once the conversion\
// completes, so a path typo or a signing misconfig can't silently discard a\
// (potentially multi-hour) conversion.\
func TestRun_GitHTTPBackend_KeepsTargetOnPostConversionFailure(t *testing.T) {\
	if os.Getenv(gitHTTPBackendEnv) == "" {\
		t.Skipf("set %s=1 to run the convert-sha256 git-http-backend integration test", gitHTTPBackendEnv)\
	}\
	gitBin, err := exec.LookPath("git")\
	if err != nil {\
		t.Skipf("git binary not available: %v", err)\
	}\
\
	root := t.TempDir()\
	srcBare := filepath.Join(root, "source.git")\
	worktree := filepath.Join(root, "work")\
	dstDir := filepath.Join(root, "target.git")\
\
	mustGit(t, root, "init", "--bare", srcBare)\
	mustGit(t, root, "init", "-b", "main", worktree)\
	mustGit(t, worktree, "config", "user.name", "convert-sha256 test")\
	mustGit(t, worktree, "config", "user.email", "test@example.com")\
	mustWrite(t, filepath.Join(worktree, "README"), "hello\n")\
	mustGit(t, worktree, "add", "README")\
	mustGit(t, worktree, "commit", "-m", "initial")\
	mustGit(t, worktree, "remote", "add", "origin", srcBare)\
	mustGit(t, worktree, "push", "origin", "HEAD:refs/heads/main")\
\
	srv := newCGIBackend(t, gitBin, root)\
	defer srv.Close()\
\
	// Force the mapping-file write to fail: a regular file standing in for a\
	// directory component makes os.MkdirAll return ENOTDIR. This fires after\
	// the conversion is otherwise complete (refs + HEAD written).\
	blocker := filepath.Join(root, "blocker")\
	mustWrite(t, blocker, "not a dir\n")\
	mappingPath := filepath.Join(blocker, "mapping.tsv")\
\
	_, err = Run(context.Background(), Request{\
		SourceURL:   srv.URL + "/source.git",\
		TargetDir:   dstDir,\
		MappingFile: mappingPath,\
		Out:         io.Discard,\
	})\
	if err == nil {\
		t.Fatal("expected an error from the unwritable --write-mapping path, got nil")\
	}\
	if !strings.Contains(err.Error(), "mapping file") {\
		t.Errorf("error should name the failing step; got: %v", err)\
	}\
\
	// The fix: the converted repo must still be on disk and self-consistent,\
	// not deleted by the post-conversion failure.\
	if _, statErr := os.Stat(dstDir); statErr != nil {\
		t.Fatalf("converted target was deleted after a post-conversion failure: %v", statErr)\
	}\
	format := strings.TrimSpace(mustGitOutput(t, dstDir, "config", "extensions.objectformat"))\
	if format != "sha256" {\
		t.Errorf("extensions.objectformat: got %q, want sha256", format)\
	}\
	fsckOut, fsckErr := exec.CommandContext(t.Context(), gitBin, "-C", dstDir, "fsck", "--full").CombinedOutput()\
	if fsckErr != nil {\
		t.Fatalf("git fsck on kept target failed: %v\n%s", fsckErr, fsckOut)\
	}\
	if strings.Contains(string(fsckOut), "error") || strings.Contains(string(fsckOut), "bad sha") {\
		t.Fatalf("git fsck reported errors on kept target:\n%s", fsckOut)\
	}\
}\
\
// TestRun_GitHTTPBackend_Sign verifies the --sign-mode tips path end-to-end. SSH\
// signing is used (not GPG) because it can be set up from scratch in the\
// test with just ssh-keygen, no agent required.\
func TestRun_GitHTTPBackend_Sign(t *testing.T) {\
	if os.Getenv(gitHTTPBackendEnv) == "" {\
		t.Skipf("set %s=1 to run the convert-sha256 git-http-backend integration test", gitHTTPBackendEnv)\
	}\
	gitBin, err := exec.LookPath("git")\
	if err != nil {\
		t.Skipf("git binary not available: %v", err)\
	}\
	sshKeygenBin, err := exec.LookPath("ssh-keygen")\
	if err != nil {\
		t.Skipf("ssh-keygen not available: %v", err)\
	}\
\
	root := t.TempDir()\
	srcBare := filepath.Join(root, "source.git")\
	worktree := filepath.Join(root, "work")\
	dstDir := filepath.Join(root, "target.git")\
\
	mustGit(t, root, "init", "--bare", srcBare)\
	mustGit(t, root, "init", "-b", "main", worktree)\
	mustGit(t, worktree, "config", "user.name", "convert-sha256 test")\
	mustGit(t, worktree, "config", "user.email", "test@example.com")\
	mustWrite(t, filepath.Join(worktree, "README"), "hello\n")\
	mustGit(t, worktree, "add", "README")\
	mustGit(t, worktree, "commit", "-m", "initial")\
	mustGit(t, worktree, "remote", "add", "origin", srcBare)\
	mustGit(t, worktree, "push", "origin", "HEAD:refs/heads/main")\
\
	// Generate an ephemeral ed25519 SSH key for signing.\
	keyPath := filepath.Join(root, "signkey")\
	keygen := exec.CommandContext(t.Context(), sshKeygenBin, "-q", "-t", "ed25519", "-N", "", "-f", keyPath, "-C", "test@example.com")\
	if out, err := keygen.CombinedOutput(); err != nil {\
		t.Fatalf("ssh-keygen: %v\n%s", err, out)\
	}\
\
	// Write a global gitconfig that points git at SSH signing using the\
	// ephemeral key, and route GIT_CONFIG_GLOBAL at it so signBranchTips'\
	// subprocess inherits the config.\
	globalCfg := filepath.Join(root, "global.gitconfig")\
	if err := os.WriteFile(globalCfg, []byte(fmt.Sprintf(`\
[user]\
	name = Conversion Test\
	email = test@example.com\
	signingkey = %s\
[gpg]\
	format = ssh\
`, keyPath)), 0o600); err != nil {\
		t.Fatalf("write global gitconfig: %v", err)\
	}\
	t.Setenv("GIT_CONFIG_GLOBAL", globalCfg)\
	// Disable any system gitconfig so the test isn't influenced by host\
	// signing config.\
	t.Setenv("GIT_CONFIG_SYSTEM", "/dev/null")\
\
	srv := newCGIBackend(t, gitBin, root)\
	defer srv.Close()\
\
	res, err := Run(context.Background(), Request{\
		SourceURL: srv.URL + "/source.git",\
		TargetDir: dstDir,\
		SignMode:  SignModeTips,\
		Out:       io.Discard,\
	})\
	if err != nil {\
		t.Fatalf("convert-sha256 run: %v", err)\
	}\
\
	wantTag := "refs/tags/converted/main"\
	if len(res.SignedTags) != 1 || res.SignedTags[0] != wantTag {\
		t.Errorf("SignedTags: got %v, want [%s]", res.SignedTags, wantTag)\
	}\
\
	// The tag exists in the target and is an annotated, signed tag (the\
	// body contains a SSH SIGNATURE block; cat-file -p shows the tag\
	// object including the signature).\
	tagShow := mustGitOutput(t, dstDir, "cat-file", "-p", wantTag)\
	if !strings.Contains(tagShow, "BEGIN SSH SIGNATURE") {\
		t.Errorf("expected signed tag to contain an SSH SIGNATURE block:\n%s", tagShow)\
	}\
	if !strings.Contains(tagShow, "SHA1 → SHA256 conversion attestation") {\
		t.Errorf("expected signed tag message to contain attestation text:\n%s", tagShow)\
	}\
\
	// Tag's target should be the branch tip (the SHA256 hash of the\
	// converted main).\
	mainTip := strings.TrimSpace(mustGitOutput(t, dstDir, "rev-parse", "refs/heads/main"))\
	tagTarget := strings.TrimSpace(mustGitOutput(t, dstDir, "rev-list", "-n", "1", wantTag))\
	if tagTarget != mainTip {\
		t.Errorf("signed tag target: got %s, want %s (main tip)", tagTarget, mainTip)\
	}\
}\
\
// TestRun_GitHTTPBackend_PullRefs verifies that --all-refs excludes\
// pull/merge-request namespaces by default — including the foreign commit\
// that only a pull ref makes reachable — and that --include-pull-refs opts\
// back in.\
func TestRun_GitHTTPBackend_PullRefs(t *testing.T) {\
	if os.Getenv(gitHTTPBackendEnv) == "" {\
		t.Skipf("set %s=1 to run the convert-sha256 git-http-backend integration test", gitHTTPBackendEnv)\
	}\
	gitBin, err := exec.LookPath("git")\
	if err != nil {\
		t.Skipf("git binary not available: %v", err)\
	}\
\
	root := t.TempDir()\
	srcBare := filepath.Join(root, "source.git")\
	worktree := filepath.Join(root, "work")\
\
	mustGit(t, root, "init", "--bare", srcBare)\
	mustGit(t, root, "init", "-b", "main", worktree)\
	mustGit(t, worktree, "config", "user.name", "convert-sha256 test")\
	mustGit(t, worktree, "config", "user.email", "test@example.com")\
	mustWrite(t, filepath.Join(worktree, "README"), "hello\n")\
	mustGit(t, worktree, "add", "README")\
	mustGit(t, worktree, "commit", "-m", "initial")\
	mustGit(t, worktree, "remote", "add", "origin", srcBare)\
	mustGit(t, worktree, "push", "origin", "HEAD:refs/heads/main")\
	// A server-internal PR ref whose tip is foreign: the commit is on no\
	// branch, so only refs/pull/1/head makes it reachable.\
	mustWrite(t, filepath.Join(worktree, "evil.txt"), "foreign\n")\
	mustGit(t, worktree, "add", "evil.txt")\
	mustGit(t, worktree, "commit", "-m", "foreign PR commit")\
	mustGit(t, worktree, "push", "origin", "HEAD:refs/pull/1/head")\
\
	srv := newCGIBackend(t, gitBin, root)\
	defer srv.Close()\
\
	// Default: --all-refs must drop refs/pull/* and report the count.\
	defaultDir := filepath.Join(root, "default.git")\
	res, err := Run(context.Background(), Request{\
		SourceURL: srv.URL + "/source.git",\
		TargetDir: defaultDir,\
		AllRefs:   true,\
		Out:       io.Discard,\
	})\
	if err != nil {\
		t.Fatalf("convert (default): %v", err)\
	}\
	if res.SkippedPullRefs != 1 {\
		t.Errorf("SkippedPullRefs (default): got %d, want 1", res.SkippedPullRefs)\
	}\
	if refs := mustGitOutput(t, defaultDir, "for-each-ref", "--format=%(refname)"); strings.Contains(refs, "refs/pull/") {\
		t.Errorf("default --all-refs conversion leaked a pull ref:\n%s", refs)\
	}\
\
	// Opt-in: --include-pull-refs converts refs/pull/* and the foreign tip.\
	inclDir := filepath.Join(root, "incl.git")\
	res2, err := Run(context.Background(), Request{\
		SourceURL:       srv.URL + "/source.git",\
		TargetDir:       inclDir,\
		AllRefs:         true,\
		IncludePullRefs: true,\
		Out:             io.Discard,\
	})\
	if err != nil {\
		t.Fatalf("convert (include): %v", err)\
	}\
	if res2.SkippedPullRefs != 0 {\
		t.Errorf("SkippedPullRefs (--include-pull-refs): got %d, want 0", res2.SkippedPullRefs)\
	}\
	if refs := mustGitOutput(t, inclDir, "for-each-ref", "--format=%(refname)"); !strings.Contains(refs, "refs/pull/1/head") {\
		t.Errorf("--include-pull-refs did not convert the pull ref:\n%s", refs)\
	}\
}\
\
func mustGit(t *testing.T, dir string, args ...string) {\
	t.Helper()\
	cmd := exec.CommandContext(t.Context(), "git", args...)\
	cmd.Dir = dir\
	cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")\
	if out, err := cmd.CombinedOutput(); err != nil {\
		t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)\
	}\
}\
\
func mustGitOutput(t *testing.T, dir string, args ...string) string {\
	t.Helper()\
	cmd := exec.CommandContext(t.Context(), "git", args...)\
	cmd.Dir = dir\
	cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")\
	out, err := cmd.CombinedOutput()\
	if err != nil {\
		t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)\
	}\
	return string(out)\
}\
\
func mustWrite(t *testing.T, path, content string) {\
	t.Helper()\
	if err := os.WriteFile(path, []byte(content), 0o644); err != nil {\
		t.Fatalf("write %s: %v", path, err)\
	}\
}\
\
type cgiBackend struct {\
	*httptest.Server\
}\
\
func newCGIBackend(t *testing.T, gitBin, root string) *cgiBackend {\
	t.Helper()\
	handler := &cgi.Handler{\
		Path: gitBin,\
		Args: []string{"http-backend"},\
		Env: []string{\
			"GIT_PROJECT_ROOT=" + root,\
			"GIT_HTTP_EXPORT_ALL=1",\
		},\
	}\
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\
		handler.ServeHTTP(w, r)\
	}))\
	return &cgiBackend{Server: srv}\
}\
\
// Compile-time sanity: confirm the storers the translator expects are still\
// the filesystem-backed type that PlainInit returns. If a future go-git\
// release changes the concrete storer, the type assertion in newTranslator\
// will start failing in this package's tests rather than only at runtime\
// against a real repo.\
var _ = (*filesystem.Storage)(nil)\
\
func TestProtectedExcludePrefixes(t *testing.T) {\
	tests := []struct {\
		name     string\
		prefixes []string\
		want     []string\
	}{\
		{"nil input", nil, nil},\
		{"single benign namespace", []string{"refs/pull/"}, nil},\
		{"multiple benign namespaces", []string{"refs/pull/", "refs/notes/", "refs/changes/"}, nil},\
		{"whole branches namespace banned", []string{"refs/heads/"}, []string{"refs/heads/"}},\
		{"whole tags namespace banned", []string{"refs/tags/"}, []string{"refs/tags/"}},\
		{"branch sub-namespace banned", []string{"refs/heads/feature/"}, []string{"refs/heads/feature/"}},\
		{"tag sub-namespace banned", []string{"refs/tags/v1/"}, []string{"refs/tags/v1/"}},\
		{"refs/ banned because it would drop everything", []string{"refs/"}, []string{"refs/"}},\
		{"empty string banned (would drop every ref)", []string{""}, []string{""}},\
		{"partial refs/h banned (covers refs/heads/)", []string{"refs/h"}, []string{"refs/h"}},\
		{"mixed input reports only the bad ones, in order", []string{"refs/pull/", "refs/heads/", "refs/notes/", "refs/tags/v1.0"}, []string{"refs/heads/", "refs/tags/v1.0"}},\
		{"duplicates collapsed", []string{"refs/heads/", "refs/heads/"}, []string{"refs/heads/"}},\
		{"trims whitespace before matching", []string{"  refs/heads/  "}, []string{"  refs/heads/  "}},\
	}\
	for _, tt := range tests {\
		t.Run(tt.name, func(t *testing.T) {\
			got := protectedExcludePrefixes(tt.prefixes)\
			if len(got) != len(tt.want) {\
				t.Fatalf("protectedExcludePrefixes(%v) = %v, want %v", tt.prefixes, got, tt.want)\
			}\
			for i := range got {\
				if got[i] != tt.want[i] {\
					t.Fatalf("protectedExcludePrefixes(%v)[%d] = %q, want %q", tt.prefixes, i, got[i], tt.want[i])\
				}\
			}\
		})\
	}\
}\
\
func TestRedactSourceURL(t *testing.T) {\
	tests := []struct {\
		name string\
		in   string\
		want string\
	}{\
		{"no credentials", "https://github.com/o/r.git", "https://github.com/o/r.git"},\
		{"user and password", "https://user:secret@github.com/o/r.git", "https://github.com/o/r.git"},\
		// Token-as-username: url.URL.Redacted() would leave this intact\
		// (no password component), so we must strip the whole userinfo.\
		{"token as username", "https://ghp_abc123@github.com/o/r.git", "https://github.com/o/r.git"},\
		{"x-access-token form", "https://x-access-token:ghp_abc@github.com/o/r.git", "https://github.com/o/r.git"},\
		// Unparseable input must not echo whatever credentials it carried.\
		{"unparseable", "https://tok@ho%zz/r.git", "<source url redacted>"},\
	}\
	for _, tt := range tests {\
		t.Run(tt.name, func(t *testing.T) {\
			if got := redactSourceURL(tt.in); got != tt.want {\
				t.Errorf("redactSourceURL(%q) = %q, want %q", tt.in, got, tt.want)\
			}\
		})\
	}\
}\
\
func TestEffectiveExcludePrefixes(t *testing.T) {\
	user := []string{"refs/changes/"}\
	tests := []struct {\
		name             string\
		allRefs          bool\
		includePullRefs  bool\
		wantPullExcluded bool\
	}{\
		{"without --all-refs the pull namespaces are not added", false, false, false},\
		{"--all-refs excludes pull namespaces by default", true, false, true},\
		{"--all-refs --include-pull-refs keeps them in scope", true, true, false},\
	}\
	for _, tt := range tests {\
		t.Run(tt.name, func(t *testing.T) {\
			got := effectiveExcludePrefixes(user, tt.allRefs, tt.includePullRefs)\
			// The user's own prefixes are always preserved.\
			if len(got) == 0 || got[0] != "refs/changes/" {\
				t.Fatalf("user prefixes not preserved: %v", got)\
			}\
			hasPull := false\
			for _, p := range got {\
				if p == "refs/pull/" {\
					hasPull = true\
				}\
			}\
			if hasPull != tt.wantPullExcluded {\
				t.Errorf("refs/pull/ excluded = %v, want %v (got %v)", hasPull, tt.wantPullExcluded, got)\
			}\
		})\
	}\
}\
\
func TestCountForeignPullRefs(t *testing.T) {\
	refs := map[plumbing.ReferenceName]plumbing.Hash{\
		"refs/heads/main":            plumbing.NewHash("aaaa000000000000000000000000000000000001"),\
		"refs/pull/1/head":           plumbing.NewHash("aaaa000000000000000000000000000000000002"),\
		"refs/pull/2/head":           plumbing.NewHash("aaaa000000000000000000000000000000000003"),\
		"refs/merge-requests/9/head": plumbing.NewHash("aaaa000000000000000000000000000000000004"),\
		"refs/pull-requests/5/from":  plumbing.NewHash("aaaa000000000000000000000000000000000005"),\
		"refs/tags/v1":               plumbing.NewHash("aaaa000000000000000000000000000000000006"),\
	}\
	if got, want := countForeignPullRefs(refs), 4; got != want {\
		t.Errorf("countForeignPullRefs = %d, want %d", got, want)\
	}\
}\
\
// TestEnsureEmptyTarget covers the three cases the failure-cleanup logic\
// depends on: an absent directory is created (created=true), a\
// pre-existing empty directory is accepted without claiming creation\
// (created=false), and a non-empty directory is refused.\
func TestEnsureEmptyTarget(t *testing.T) {\
	t.Run("absent dir is created", func(t *testing.T) {\
		path := filepath.Join(t.TempDir(), "out")\
		created, err := ensureEmptyTarget(path)\
		if err != nil {\
			t.Fatalf("ensureEmptyTarget: %v", err)\
		}\
		if !created {\
			t.Errorf("created = false, want true for an absent directory")\
		}\
		if info, statErr := os.Stat(path); statErr != nil || !info.IsDir() {\
			t.Errorf("expected %s to be created as a directory; stat err=%v", path, statErr)\
		}\
	})\
\
	t.Run("pre-existing empty dir is accepted, not claimed as created", func(t *testing.T) {\
		path := t.TempDir() // already exists and is empty\
		created, err := ensureEmptyTarget(path)\
		if err != nil {\
			t.Fatalf("ensureEmptyTarget: %v", err)\
		}\
		if created {\
			t.Errorf("created = true, want false for a pre-existing directory")\
		}\
	})\
\
	t.Run("non-empty dir is refused", func(t *testing.T) {\
		path := t.TempDir()\
		if err := os.WriteFile(filepath.Join(path, "f"), []byte("x"), 0o644); err != nil {\
			t.Fatalf("seed file: %v", err)\
		}\
		if _, err := ensureEmptyTarget(path); err == nil {\
			t.Errorf("expected refusal of a non-empty directory, got nil")\
		}\
	})\
}\
\
// TestCleanupConvertedTarget locks in the invariant the failure path\
// claims: a directory the run created is removed outright, but a\
// directory the user pre-created is restored to empty — never deleted —\
// so a mountpoint or a dir with user-set ownership/ACLs survives a failed\
// conversion.\
func TestCleanupConvertedTarget(t *testing.T) {\
	t.Run("run-created dir is removed outright", func(t *testing.T) {\
		path := filepath.Join(t.TempDir(), "out")\
		if err := os.MkdirAll(filepath.Join(path, "objects"), 0o755); err != nil {\
			t.Fatalf("seed: %v", err)\
		}\
		cleanupConvertedTarget(path, true)\
		if _, err := os.Stat(path); !os.IsNotExist(err) {\
			t.Errorf("expected %s to be removed; stat err=%v", path, err)\
		}\
	})\
\
	t.Run("pre-existing dir is emptied but kept", func(t *testing.T) {\
		path := t.TempDir()\
		// Mimic a half-written bare repo: nested dirs plus a top-level file.\
		if err := os.MkdirAll(filepath.Join(path, "objects", "pack"), 0o755); err != nil {\
			t.Fatalf("seed dirs: %v", err)\
		}\
		if err := os.WriteFile(filepath.Join(path, "HEAD"), []byte("ref: x\n"), 0o644); err != nil {\
			t.Fatalf("seed file: %v", err)\
		}\
		cleanupConvertedTarget(path, false)\
		info, err := os.Stat(path)\
		if err != nil || !info.IsDir() {\
			t.Fatalf("pre-existing directory must survive cleanup; stat err=%v", err)\
		}\
		entries, err := os.ReadDir(path)\
		if err != nil {\
			t.Fatalf("read dir: %v", err)\
		}\
		if len(entries) != 0 {\
			t.Errorf("directory should be empty after cleanup, got %d entries", len(entries))\
		}\
	})\
}\
\
// TestPreviewJoin covers the inline-vs-truncate boundary the two Lines()\
// summaries share, including the optional suffix that points at the full\
// list — the bit that had drifted between the two copy-pasted blocks.\
func TestPreviewJoin(t *testing.T) {\
	over := []string{"a", "b", "c", "d", "e", "f", "g"} // 7 > previewMax (5)\
	tests := []struct {\
		name   string\
		items  []string\
		suffix string\
		want   string\
	}{\
		{"empty", nil, "", ""},\
		{"under the cap joins all", []string{"a", "b"}, "", "a, b"},\
		{"exactly at the cap joins all", []string{"a", "b", "c", "d", "e"}, "", "a, b, c, d, e"},\
		{"over the cap truncates with count", over, "", "a, b, c, d, e, ... (2 more)"},\
		{"suffix lands inside the parens", over, "; full list in --json", "a, b, c, d, e, ... (2 more; full list in --json)"},\
	}\
	for _, tt := range tests {\
		t.Run(tt.name, func(t *testing.T) {\
			if got := previewJoin(tt.items, tt.suffix); got != tt.want {\
				t.Errorf("previewJoin(%v, %q) = %q, want %q", tt.items, tt.suffix, got, tt.want)\
			}\
		})\
	}\
}\
\
// The default pull-ref exclusions must never trip the branch/tag\
// protection guard — they live outside refs/heads/ and refs/tags/.\
func TestForeignPullRefPrefixes_NotProtected(t *testing.T) {\
	if bad := protectedExcludePrefixes(foreignPullRefPrefixes); len(bad) != 0 {\
		t.Errorf("foreign pull-ref prefixes rejected by protectedExcludePrefixes: %v", bad)\
	}\
}\
\
func TestRun_RejectsExcludePrefixesThatDropBranchesOrTags(t *testing.T) {\
	// We never reach the network here — the validation fires before\
	// any I/O — so a non-empty target dir is the only thing the early\
	// path needs.\
	dst := t.TempDir()\
	req := Request{\
		SourceURL:          "http://example.invalid/repo.git",\
		TargetDir:          filepath.Join(dst, "out"),\
		ExcludeRefPrefixes: []string{"refs/pull/", "refs/heads/feature/"},\
	}\
	_, err := Run(t.Context(), req)\
	if err == nil {\
		t.Fatalf("Run accepted --exclude-ref-prefix refs/heads/feature/, expected refusal")\
	}\
	msg := err.Error()\
	if !strings.Contains(msg, "refs/heads/feature/") {\
		t.Fatalf("error did not name the offending prefix: %v", err)\
	}\
	if !strings.Contains(msg, "exclude-ref-prefix") {\
		t.Fatalf("error did not mention the flag: %v", err)\
	}\
}\
\
func TestCheckSideOutputCollision(t *testing.T) {\
	mk := func(name string) planner.DesiredRef {\
		ref := plumbing.ReferenceName(name)\
		return planner.DesiredRef{SourceRef: ref, TargetRef: ref}\
	}\
	tests := []struct {\
		name             string\
		desired          map[plumbing.ReferenceName]planner.DesiredRef\
		skipOriginNotes  bool\
		sign             bool\
		wantErrSubstring string\
	}{\
		{\
			name: "no collisions accepted",\
			desired: map[plumbing.ReferenceName]planner.DesiredRef{\
				"refs/heads/main": mk("refs/heads/main"),\
				"refs/tags/v1":    mk("refs/tags/v1"),\
			},\
			wantErrSubstring: "",\
		},\
		{\
			name: "origin-notes collision refused by default",\
			desired: map[plumbing.ReferenceName]planner.DesiredRef{\
				"refs/heads/main":        mk("refs/heads/main"),\
				"refs/notes/sha1-origin": mk("refs/notes/sha1-origin"),\
			},\
			wantErrSubstring: "refs/notes/sha1-origin",\
		},\
		{\
			name: "origin-notes collision allowed when --no-origin-notes set",\
			desired: map[plumbing.ReferenceName]planner.DesiredRef{\
				"refs/notes/sha1-origin": mk("refs/notes/sha1-origin"),\
			},\
			skipOriginNotes:  true,\
			wantErrSubstring: "",\
		},\
		{\
			name: "converted-tag collision refused only when --sign-mode tips",\
			desired: map[plumbing.ReferenceName]planner.DesiredRef{\
				"refs/heads/main":          mk("refs/heads/main"),\
				"refs/tags/converted/main": mk("refs/tags/converted/main"),\
			},\
			sign:             true,\
			wantErrSubstring: "refs/tags/converted/main",\
		},\
		{\
			name: "converted-tag without --sign-mode tips passes through",\
			desired: map[plumbing.ReferenceName]planner.DesiredRef{\
				"refs/tags/converted/main": mk("refs/tags/converted/main"),\
			},\
			sign:             false,\
			wantErrSubstring: "",\
		},\
		{\
			name: "multiple converted-tag collisions listed in sorted order",\
			desired: map[plumbing.ReferenceName]planner.DesiredRef{\
				"refs/tags/converted/zeta":  mk("refs/tags/converted/zeta"),\
				"refs/tags/converted/alpha": mk("refs/tags/converted/alpha"),\
			},\
			sign:             true,\
			wantErrSubstring: "refs/tags/converted/alpha, refs/tags/converted/zeta",\
		},\
	}\
	for _, tt := range tests {\
		t.Run(tt.name, func(t *testing.T) {\
			err := checkSideOutputCollision(tt.desired, tt.skipOriginNotes, tt.sign)\
			switch {\
			case tt.wantErrSubstring == "" && err != nil:\
				t.Fatalf("unexpected error: %v", err)\
			case tt.wantErrSubstring != "" && err == nil:\
				t.Fatalf("expected error containing %q, got nil", tt.wantErrSubstring)\
			case tt.wantErrSubstring != "" && !strings.Contains(err.Error(), tt.wantErrSubstring):\
				t.Fatalf("error %q does not contain %q", err.Error(), tt.wantErrSubstring)\
			}\
		})\
	}\
}\
\
// TestDiscoverReachable_HonorsCtxCancellation confirms discovery\
// returns promptly when its context is canceled before it starts,\
// matching the per-object check translate() already does.\
func TestDiscoverReachable_HonorsCtxCancellation(t *testing.T) {\
	root := t.TempDir()\
	srcDir := filepath.Join(root, "src.git")\
	srcRepo, err := git.PlainInit(srcDir, true)\
	if err != nil {\
		t.Fatalf("init source: %v", err)\
	}\
	blob := writeBlob(t, srcRepo.Storer, []byte("x\n"))\
	tree := writeTree(t, srcRepo.Storer, []object.TreeEntry{{Name: "f", Mode: filemode.Regular, Hash: blob}})\
\
	ctx, cancel := context.WithCancel(t.Context())\
	cancel()\
	_, err = discoverReachable(ctx, srcRepo.Storer, []plumbing.Hash{tree}, nil)\
	if err == nil {\
		t.Fatal("expected canceled ctx to surface as error")\
	}\
	if !errors.Is(err, context.Canceled) {\
		t.Errorf("error should wrap context.Canceled; got %v", err)\
	}\
}\
\
// TestRunChecks_FsckSkippedWhenGitMissing locks in the Skipped flag\
// for the fsck check. Callers that gate on Check.OK alone now can't\
// tell a real fsck pass from a skip; Skipped resolves the ambiguity.\
func TestRunChecks_FsckSkippedWhenGitMissing(t *testing.T) {\
	if _, err := exec.LookPath("git"); err != nil {\
		t.Skip("git binary available; this test exercises the missing-git path via PATH override")\
	}\
	// Force LookPath("git") to fail by overriding PATH.\
	t.Setenv("PATH", "")\
	dir := t.TempDir()\
	repo, err := git.PlainInit(dir, true, git.WithObjectFormat(formatcfg.SHA256))\
	if err != nil {\
		t.Fatalf("init: %v", err)\
	}\
	checks := runChecks(t.Context(), dir, repo, 0, nil, false)\
	var fsck Check\
	for _, c := range checks {\
		if c.Name == "git fsck --full" {\
			fsck = c\
			break\
		}\
	}\
	if fsck.Name == "" {\
		t.Fatalf("fsck check missing from output")\
	}\
	if !fsck.Skipped {\
		t.Errorf("fsck should be Skipped when git is missing, got %+v", fsck)\
	}\
	if !fsck.OK {\
		t.Errorf("Skipped implies OK; got %+v", fsck)\
	}\
}\
\
// TestHashPattern_CaseInsensitive locks in the (?i) on hashPattern —\
// uppercase or mixed-case SHA1 references in messages must resolve\
// against the (lowercase-canonical) reachable set.\
func TestHashPattern_CaseInsensitive(t *testing.T) {\
	root := t.TempDir()\
	srcDir := filepath.Join(root, "src.git")\
	dstDir := filepath.Join(root, "dst.git")\
\
	srcRepo, err := git.PlainInit(srcDir, true)\
	if err != nil {\
		t.Fatalf("init src: %v", err)\
	}\
	dstRepo, err := git.PlainInit(dstDir, true, git.WithObjectFormat(formatcfg.SHA256))\
	if err != nil {\
		t.Fatalf("init dst: %v", err)\
	}\
	blob := writeBlob(t, srcRepo.Storer, []byte("x\n"))\
	tree := writeTree(t, srcRepo.Storer, []object.TreeEntry{{Name: "f", Mode: filemode.Regular, Hash: blob}})\
	sig := object.Signature{Name: "T", Email: "t@example.com", When: time.Unix(1700000000, 0).UTC()}\
	parent := &object.Commit{Author: sig, Committer: sig, Message: "first\n", TreeHash: tree}\
	parentHash := writeObject(t, srcRepo.Storer, parent.Encode)\
\
	// Reference the parent with an UPPERCASE full hash.\
	upper := strings.ToUpper(parentHash.String())\
	child := &object.Commit{\
		Author: sig, Committer: sig,\
		Message:      "see " + upper + " for context\n",\
		TreeHash:     tree,\
		ParentHashes: []plumbing.Hash{parentHash},\
	}\
	childHash := writeObject(t, srcRepo.Storer, child.Encode)\
\
	reachable, err := discoverReachable(t.Context(), srcRepo.Storer, []plumbing.Hash{childHash}, nil)\
	if err != nil {\
		t.Fatalf("discover: %v", err)\
	}\
	tr, err := newTranslator(t.Context(), srcRepo.Storer, dstRepo.Storer, true, reachable)\
	if err != nil {\
		t.Fatalf("newTranslator: %v", err)\
	}\
	newChild, err := tr.translate(childHash)\
	if err != nil {\
		t.Fatalf("translate: %v", err)\
	}\
	if tr.messageRewrites != 1 {\
		t.Errorf("expected 1 rewrite (case-insensitive match), got %d", tr.messageRewrites)\
	}\
	c, err := object.GetCommit(dstRepo.Storer, newChild)\
	if err != nil {\
		t.Fatalf("read translated child: %v", err)\
	}\
	if strings.Contains(c.Message, upper) {\
		t.Errorf("uppercase SHA1 should have been rewritten; message: %q", c.Message)\
	}\
}\
\
// TestFsckHasError_HandlesLongLinesAndCase covers two fragility\
// fixes: lines longer than bufio.Scanner's 64 KiB default must not be\
// silently truncated (we use bytes.Split now), and the "error" /\
// "fatal" prefix match must be case-insensitive so e.g. older or\
// custom git builds emitting "ERROR:" still trip the check.\
func TestFsckHasError_HandlesLongLinesAndCase(t *testing.T) {\
	t.Run("long line still scanned", func(t *testing.T) {\
		// 100 KiB of dangling-blob filler followed by an error line.\
		out := append(bytes.Repeat([]byte("a"), 100*1024), []byte("\nerror: bad ref\n")...)\
		if !fsckHasError(out) {\
			t.Errorf("fsckHasError should detect error line after a long preceding line")\
		}\
	})\
	t.Run("uppercase ERROR matches", func(t *testing.T) {\
		if !fsckHasError([]byte("ERROR: corruption\n")) {\
			t.Errorf("fsckHasError should match uppercase ERROR")\
		}\
	})\
	t.Run("fatal without colon matches", func(t *testing.T) {\
		if !fsckHasError([]byte("Fatal failure in pack\n")) {\
			t.Errorf("fsckHasError should match Fatal prefix even without colon")\
		}\
	})\
	t.Run("dangling warnings are not errors", func(t *testing.T) {\
		if fsckHasError([]byte("dangling commit abc123\n")) {\
			t.Errorf("dangling lines should not trip fsckHasError")\
		}\
	})\
}\
\
// TestResolveMessageRef_Memoizes confirms a second call for the same\
// prefix doesn't re-scan reachable. We do not have a counter on the\
// scan, so we test the cache by mutating reachable between calls and\
// verifying the second call returns the original result. (In real\
// usage, reachable is frozen — this is just a behavioral observation\
// to lock in cache effectiveness.)\
func TestResolveMessageRef_Memoizes(t *testing.T) {\
	root := t.TempDir()\
	srcDir := filepath.Join(root, "src.git")\
	dstDir := filepath.Join(root, "dst.git")\
	srcRepo, err := git.PlainInit(srcDir, true)\
	if err != nil {\
		t.Fatalf("init src: %v", err)\
	}\
	dstRepo, err := git.PlainInit(dstDir, true, git.WithObjectFormat(formatcfg.SHA256))\
	if err != nil {\
		t.Fatalf("init dst: %v", err)\
	}\
	reachable := map[plumbing.Hash]plumbing.ObjectType{\
		plumbing.NewHash("abc1234567890abcdef1234567890abcdef12345"): plumbing.CommitObject,\
	}\
	tr, err := newTranslator(t.Context(), srcRepo.Storer, dstRepo.Storer, true, reachable)\
	if err != nil {\
		t.Fatalf("newTranslator: %v", err)\
	}\
\
	prefix := "abc12345"\
	h1, r1 := tr.resolveMessageRef(prefix)\
	// Mutate reachable; if the cache works, the next call must return\
	// the same answer as the first.\
	for k := range tr.reachable {\
		delete(tr.reachable, k)\
	}\
	h2, r2 := tr.resolveMessageRef(prefix)\
	if h1 != h2 || r1 != r2 {\
		t.Errorf("resolveMessageRef should return cached value; got first (%s, %v) vs second (%s, %v)", h1, r1, h2, r2)\
	}\
	if _, cached := tr.resolveCache[strings.ToLower(prefix)]; !cached {\
		t.Errorf("resolveCache should contain entry for %q", prefix)\
	}\
}\
\
// TestRunChecks_TagOnlyConversionSkipsHEAD locks in the rule that a\
// tags-only conversion does not fail --check on HEAD. PlainInit leaves\
// HEAD pointing at refs/heads/master (which won't exist), and pickHEAD\
// returns "" because the desired set has no branches; runChecks must\
// detect that and mark HEAD as "skipped" rather than "missing".\
func TestRunChecks_TagOnlyConversionSkipsHEAD(t *testing.T) {\
	dir := t.TempDir()\
	repo, err := git.PlainInit(dir, true, git.WithObjectFormat(formatcfg.SHA256))\
	if err != nil {\
		t.Fatalf("init SHA256 target: %v", err)\
	}\
\
	checks := runChecks(t.Context(), dir, repo, 0, nil, false)\
	var head Check\
	for _, c := range checks {\
		if c.Name == "HEAD" {\
			head = c\
			break\
		}\
	}\
	if head.Name == "" {\
		t.Fatalf("HEAD check missing from runChecks output")\
	}\
	if !head.OK {\
		t.Errorf("HEAD should be OK for tags-only conversion, got %+v", head)\
	}\
	if !head.Skipped {\
		t.Errorf("HEAD should be marked Skipped on tags-only conversion, got %+v", head)\
	}\
	if !strings.Contains(head.Detail, "tags-only") {\
		t.Errorf("HEAD detail should explain the skip reason, got %q", head.Detail)\
	}\
}\
\
func TestPickHEAD(t *testing.T) {\
	branch := func(name string) planner.DesiredRef {\
		ref := plumbing.ReferenceName("refs/heads/" + name)\
		return planner.DesiredRef{Kind: planner.RefKindBranch, SourceRef: ref, TargetRef: ref}\
	}\
	tag := func(name string) planner.DesiredRef {\
		ref := plumbing.ReferenceName("refs/tags/" + name)\
		return planner.DesiredRef{Kind: planner.RefKindTag, SourceRef: ref, TargetRef: ref}\
	}\
	tests := []struct {\
		name       string\
		advertised plumbing.ReferenceName\
		desired    map[plumbing.ReferenceName]planner.DesiredRef\
		want       plumbing.ReferenceName\
	}{\
		{\
			name:       "advertised HEAD wins when present in desired",\
			advertised: "refs/heads/develop",\
			desired: map[plumbing.ReferenceName]planner.DesiredRef{\
				"refs/heads/main":    branch("main"),\
				"refs/heads/develop": branch("develop"),\
			},\
			want: "refs/heads/develop",\
		},\
		{\
			name:       "advertised HEAD respects ref mapping (target side)",\
			advertised: "refs/heads/source-name",\
			desired: map[plumbing.ReferenceName]planner.DesiredRef{\
				"refs/heads/source-name": {\
					Kind:      planner.RefKindBranch,\
					SourceRef: "refs/heads/source-name",\
					TargetRef: "refs/heads/target-name",\
				},\
			},\
			want: "refs/heads/target-name",\
		},\
		{\
			name:       "falls back to main when advertised HEAD missing",\
			advertised: "",\
			desired: map[plumbing.ReferenceName]planner.DesiredRef{\
				"refs/heads/main":   branch("main"),\
				"refs/heads/master": branch("master"),\
			},\
			want: "refs/heads/main",\
		},\
		{\
			name:       "falls back to master when no main",\
			advertised: "",\
			desired: map[plumbing.ReferenceName]planner.DesiredRef{\
				"refs/heads/master":  branch("master"),\
				"refs/heads/feature": branch("feature"),\
			},\
			want: "refs/heads/master",\
		},\
		{\
			name:       "falls back to first sorted branch when neither main nor master",\
			advertised: "",\
			desired: map[plumbing.ReferenceName]planner.DesiredRef{\
				"refs/heads/zeta":  branch("zeta"),\
				"refs/heads/alpha": branch("alpha"),\
				"refs/heads/beta":  branch("beta"),\
			},\
			want: "refs/heads/alpha",\
		},\
		{\
			name:       "advertised HEAD pointing outside desired falls back to convention",\
			advertised: "refs/heads/dropped",\
			desired: map[plumbing.ReferenceName]planner.DesiredRef{\
				"refs/heads/main": branch("main"),\
			},\
			want: "refs/heads/main",\
		},\
		{\
			name:       "tags-only conversion returns empty so HEAD stays at PlainInit default",\
			advertised: "",\
			desired: map[plumbing.ReferenceName]planner.DesiredRef{\
				"refs/tags/v1.0": tag("v1.0"),\
			},\
			want: "",\
		},\
		{\
			name:       "empty desired returns empty",\
			advertised: "",\
			desired:    map[plumbing.ReferenceName]planner.DesiredRef{},\
			want:       "",\
		},\
	}\
	for _, tt := range tests {\
		t.Run(tt.name, func(t *testing.T) {\
			got := pickHEAD(tt.advertised, tt.desired)\
			if got != tt.want {\
				t.Fatalf("pickHEAD = %q, want %q", got, tt.want)\
			}\
		})\
	}\
}\
```\
\
Acmd/git-sync/internal/sha256convert/sha256convert\_test.go+1800\
\
```\
35 unmodified lines\
\
36\
37\
38\
39\
40\
41\
42\
\
35 unmodified lines\
\
	cmd.AddCommand(newBootstrapCmd())\
	cmd.AddCommand(newProbeCmd())\
	cmd.AddCommand(newFetchCmd())\
	cmd.AddCommand(newConvertSHA256Cmd())\
	cmd.AddCommand(newVersionCmd())\
\
	return cmd\
```\
\
Mcmd/git-sync/root.go+1\
\
````\
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\
\
# SHA1 → SHA256 Conversion\
\
`git-sync convert-sha256` is a one-off migration command that fetches a pack\
from a SHA1 HTTP source and writes a new SHA256 bare repository on disk.\
Every reachable object is re-hashed under SHA256 and tree, commit, and tag\
references are rewritten accordingly. The command does not push to a\
remote, does not modify the source, and is meant to run once per repo.\
SHA256 hashes have no relation to the original SHA1 hashes beyond a\
mapping the command can optionally emit.\
\
## Quick Start\
\
```bash\
git-sync convert-sha256 \\
https://github.com/source-org/source-repo.git \\
/path/to/out.git\
```\
\
The target directory must not exist or must be empty. The result is a bare\
repository with `extensions.objectformat = sha256` and a\
`refs/notes/sha1-origin` ref recording each commit's pre-conversion SHA1.\
\
Scope is fixed: every branch and every tag on the source is always\
converted. Pass `--all-refs` to also include `refs/notes/*` and other\
custom namespaces; pair with `--exclude-ref-prefix` to subtract specific\
namespaces. Server-internal pull/merge-request refs (`refs/pull/*`,\
`refs/pull-requests/*`, `refs/merge-requests/*`) are excluded even under\
`--all-refs` — see [Sharp Edges](#sharp-edges). Pass `--include-pull-refs`\
to convert them anyway.\
\
For a private source, pass the token via the environment so it isn't\
exposed in `ps`:\
\
```bash\
GITSYNC_SOURCE_TOKEN=ghp_xxx git-sync convert-sha256 \\
https://github.com/source-org/private-repo.git \\
/path/to/out.git\
```\
\
## What It Does\
\
1. Probes the source via smart HTTP and lists every in-scope ref.\
2. Fetches a single self-contained pack via `upload-pack` into a\
   temporary on-disk SHA1 bare repo (cleaned up at the end unless\
   `--keep-source-objects` is passed).\
3. Discovers every reachable object — walking trees, commits, and tags\
   — and records each one's SHA1 and object type. Submodule gitlinks\
   are checked here; unresolvable ones fail-fast before any output is\
   written.\
4. Initializes the target as a bare SHA256 repository\
   (`git init --object-format=sha256` equivalent).\
5. Translates every reachable object in topological order via memoized\
   DFS:\
   - **Blobs**: re-hashed under SHA256; content unchanged.\
   - **Trees**: each entry's hash translated.\
   - **Commits**: `tree` and `parent` hashes translated; GPG signatures\
     and `mergetag` headers dropped; in-scope SHA1 references in the\
     message are translated first and then substituted.\
   - **Tags**: target hash translated; signatures dropped; message\
     hashes rewritten the same way.\
6. Writes refs at the translated tip hashes; points HEAD at the\
   source's advertised HEAD when it was converted, else `main`/`master`,\
   else the first branch alphabetically (a tags-only conversion leaves\
   HEAD at the init default); builds `refs/notes/sha1-origin` (unless\
   `--no-origin-notes`); emits the `--write-mapping` TSV (if requested).\
\
## Side Outputs\
\
The conversion deliberately decouples SHA1 from SHA256 — two runs of\
this tool against the same source produce SHA256 hashes that share\
nothing with the originals. Three on-ramps help bridge the gap.\
\
### Inline message rewriting (default on)\
\
Commit and tag messages are scanned for 7-to-40-character hex runs.\
When a run uniquely matches a commit or tag SHA1 in the reachable set,\
it is replaced with the full SHA256 hex:\
\
```\
Reverts: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0    →    full SHA256\
Cherry-picked from a1b2c3d                            →    full SHA256\
```\
\
Two properties make this robust:\
\
- **Uniqueness is decided against the reachable set, not the in-flight\
  mapping.** The discovery pass enumerates every reachable SHA1 before\
  any encoding starts, so abbreviated prefixes get the same verdict\
  regardless of how far the translation has progressed. Ambiguous\
  prefixes are left unrewritten and reported (warning on stderr +\
  `--json`'s `ambiguousMessageRefs`); look them up in the mapping file.\
- **Cross-branch references resolve.** Each in-scope SHA1 mentioned in\
  a message is added as a dependency edge in the translation DFS, so\
  the referenced commit is translated before the referencing commit is\
  encoded. A cherry-pick from a sibling branch resolves just as\
  reliably as a revert of an ancestor.\
\
False positives are essentially impossible: a run is substituted only\
if its prefix uniquely matches a commit or tag in scope. Blob and tree\
hashes are excluded from the match set. Disable with\
`--no-rewrite-messages` if you prefer untouched messages.\
\
### Origin notes ref (default on)\
\
`refs/notes/sha1-origin` holds, for each translated commit, the\
pre-conversion SHA1 keyed by the new SHA256:\
\
```bash\
git -C /path/to/out.git notes --ref=sha1-origin show <sha256>\
# prints the original SHA1\
\
git -C /path/to/out.git log --notes=sha1-origin\
# shows the original SHA1 below each commit's body\
```\
\
Notes attach meaningfully only to commits; blobs, trees, and tags are\
not represented. Disable with `--no-origin-notes`.\
\
### Sidecar mapping file (opt in via `--write-mapping`)\
\
`--write-mapping <path>` emits a TSV with one line per translated\
object, sorted by SHA1:\
\
```\
# sha1   sha256\
00027b675386b21c4ca05316145671fb7034d251   d80415fa21bebb...\
000bb155604d06f1c48fc7feb4b025d991ef3366   a23cf98db5abfa...\
...\
```\
\
Useful for bulk rewriting external systems: feed the file to a script\
that walks Jira tickets, PR bodies, deploy manifests, or any other\
system that holds frozen SHA1 references.\
\
### Branch-tip attestation tags (opt in via `--sign-mode tips`)\
\
`--sign-mode` defaults to `none` (sign nothing). `--sign-mode tips`\
shells out to `git tag -s converted/<branch> <tip>` for every\
converted branch after the conversion completes. Each resulting\
signed annotated tag is a cryptographic attestation by the converter\
that the entire reachable history of that branch — every parent, tree,\
and blob — is what the converter saw at conversion time. Anyone can\
verify the chain afterwards with `git verify-tag refs/tags/converted/<branch>`.\
\
The mechanism is the standard one: parent hashes are part of each\
commit's bytes, so the tip's hash transitively commits to the whole\
history. Signing the tip attests every ancestor.\
\
Important nuance: the signature is by the *converter*, not by the\
original authors (whose own signatures are necessarily lost — see\
"GPG signatures are stripped" under Sharp Edges). The attestation\
chain becomes "*X attests this is the conversion they produced*"\
rather than "*the original authors wrote this commit*". For internal\
mirrors or single-identity repos that's a strict improvement over\
unsigned-everywhere; for broad public repos it is weaker than the\
pre-conversion chain.\
\
Signing uses the target repo's git signing config (`user.signingkey`,\
`gpg.format`) by default — same as `git commit -S` or a normal\
`git tag -s`. Override with `--sign-key <id>`, which is passed to\
`git tag -s -u <id>`. SSH signing (`gpg.format = ssh`) and OpenPGP\
both work because we shell out to `git`.\
\
Requires the `git` binary on `PATH`. Signing failures (no key\
configured, gpg/ssh-agent unavailable, etc.) abort the run after the\
conversion has already completed — the target repo is left in a\
valid converted state, just without the attestation tags. Re-run\
`git tag -s converted/<branch> <tip>` manually once the signing\
identity is set up.\
\
## Flags\
\
```\
--source-url                       source repository URL\
--source-token                     source password/token (prefer env)\
--source-username                  source basic auth username (default git)\
--source-bearer-token              source bearer token\
--source-insecure-skip-tls-verify  skip TLS verification (testing only)\
--source-follow-info-refs-redirect follow /info/refs cross-host redirects\
--target-dir                       SHA256 bare repo directory (must be empty)\
\
--all-refs                         also include refs/* outside heads/tags\
                                   (notes, custom namespaces; excludes\
                                   pull/merge-request refs by default)\
--exclude-ref-prefix               subtract refs by prefix; repeatable\
--include-pull-refs                with --all-refs, also convert\
                                   refs/pull/*, refs/pull-requests/*,\
                                   refs/merge-requests/* (off by default)\
\
--protocol                         protocol mode (auto, v1, v2)\
--write-mapping                    write SHA1 → SHA256 TSV to this path\
--no-rewrite-messages              skip inline hash rewrites in messages\
--no-origin-notes                  skip refs/notes/sha1-origin\
--check                            verify the output (config, HEAD, refs, git fsck)\
--sign-mode                        signing mode: none (default) or tips\
                                   (sign each branch tip as\
                                   refs/tags/converted/<branch> via `git tag -s`)\
--sign-key                         signing key id passed to `git tag -s -u <key>`\
--keep-source-objects              leave the temp SHA1 store on disk\
--progress                         live per-phase object counts (TTY only)\
--json                             machine-readable output\
--verbose, -v                      verbose logging\
```\
\
There are no `--branch`, `--tags`, or `--map` flags: scope is fixed to\
every branch and every tag on the source.\
\
Environment fallbacks: `GITSYNC_SOURCE_TOKEN`, `GITSYNC_SOURCE_USERNAME`,\
`GITSYNC_SOURCE_BEARER_TOKEN`, `GITSYNC_SOURCE_INSECURE_SKIP_TLS_VERIFY`,\
`GITSYNC_SOURCE_FOLLOW_INFO_REFS_REDIRECT`, `GITSYNC_PROTOCOL`.\
\
## Sharp Edges\
\
**GPG signatures are stripped.** A signature is bytes signed over the\
commit's pre-conversion content (including the SHA1 hashes in `tree`\
and `parent` lines). After rewriting, the bytes no longer match the\
signature, so verification would always fail; the command drops them\
and prints a count. Signed annotated tags lose their signature the\
same way. `mergetag` headers on merge commits — which embed a signed\
tag with its own signature — are removed entirely, since the embedded\
tag references original SHA1s and the signature was computed over\
those original bytes.\
\
**Submodule gitlinks must resolve in-repo.** Tree entries with mode\
`160000` reference a commit in another repository, but a SHA1 hash\
cannot be embedded in a SHA256 tree. The command fails-fast in the\
discovery pass — before the target bare repo is initialized — naming\
the offending tree, entry, and hash. Convert the submodule repository\
first so its commit hashes are available in SHA256.\
\
**Replace refs and source notes refs become detached.**\
`refs/replace/<sha1>` encodes a SHA1 in the ref name, so the name\
doesn't match under SHA256 and the replacement never triggers.\
`refs/notes/*` trees from the source (copied under `--all-refs`)\
encode the target object's hash as the entry name, so notes survive\
as data but no longer attach to their original commits. Use the\
tool's own `refs/notes/sha1-origin` for the inverse lookup.\
\
**Foreign pull/merge-request refs are excluded by default.** Even under\
`--all-refs`, the command skips `refs/pull/*` (GitHub/Gitea/Forgejo),\
`refs/pull-requests/*` (Bitbucket), and `refs/merge-requests/*` (GitLab).\
These server-internal namespaces hold code proposed from forks and other\
branches — content foreign to the repository's own history until it is\
reviewed and merged. The converted repo is typically mirrored onward with\
`git push --mirror`, and a destination forge may not treat those\
namespaces as read-only PR refs; it can surface them as ordinary refs and\
thereby republish unreviewed code as if it were part of the repository.\
The run prints how many such refs it dropped. Pass `--include-pull-refs`\
to convert them anyway (e.g. for a faithful archival mirror you control).\
\
## Operational Notes\
\
**One-off, not incremental.** Each run produces a fresh SHA256 repo\
from scratch — there is no "fetch the new SHA1 commits and append to\
the existing SHA256 repo" mode. Realistic use: convert once, then\
make the converted repo the new canonical store. The conversion is\
fully deterministic: branch hashes, tag hashes, **and** the\
`refs/notes/sha1-origin` ref are all identical across runs against the\
same source state. The notes wrapper commit's timestamp is pinned to\
the Unix epoch — or to `SOURCE_DATE_EPOCH` when that environment\
variable is set — rather than `time.Now()`, so even the notes ref\
reproduces byte-for-byte.\
\
**Loose-object storage.** Every translated object is written as a\
loose file under `objects/<aa>/<rest>` — no pack file is produced.\
Correct, but slow on filesystems that dislike millions of small files.\
Run `git -C <target> gc --aggressive` afterwards to pack the converted\
repo down to a single packfile.\
\
**Memory linear in reachable object count.** Two `map[Hash]…`\
structures stay live for the whole run: `reachable` (SHA1 → object\
type, built by discovery) and `mapping` (SHA1 → SHA256, built by\
translation). At cobra scale (~5k objects), kilobytes; at Linux kernel\
scale (~16M objects), roughly 2 GB peak.\
\
**Discovery adds a ~1.5× decode pass.** Every reachable object is\
decoded twice: once in discovery (no encoding) and once in translation\
(decode + encode). The cost buys consistent uniqueness verdicts for\
message rewriting and submodule fail-fast.\
\
**Abbreviated-prefix lookup is a linear scan.** Each abbreviated SHA1\
in a message triggers an O(reachable) scan to check uniqueness. Fine\
to ~100k commits; slower past that. A sorted-prefix index would make\
it O(log N), an easy optimization if someone hits the wall.\
\
## Verifying the Output\
\
Pass `--check` and the command runs four sanity checks against the\
converted repo at the end of the run, printing one line each:\
\
```\
verifying output ...\
✓ config: extensions.objectformat = sha256\
✓ HEAD: ffe9fff421b77f2dcc049a95b3b8ba7b9da8976dd61bcf35e9fe2d993babc470\
✓ refs: 37 / 37 resolve to objects\
✓ git fsck --full: clean\
```\
\
The checks are:\
\
1. **config** — `extensions.objectformat = sha256` is present in\
   `<target>/config`.\
2. **HEAD** — resolves to a non-zero hash and that object exists in\
   the store.\
3. **refs** — every written ref resolves to an object in the store.\
   Side outputs this run created — `refs/notes/sha1-origin` and any\
   `--sign-mode tips` attestation tags — are counted separately, so the\
   reported total matches `RefsConverted`.\
4. **git fsck --full** — the external `git` binary runs a full\
   integrity check. Skipped (and reported as such) when `git` isn't\
   on `PATH`; the conversion still succeeds.\
\
If any check fails the command exits non-zero. The full per-check\
results are also in `--json`'s `checks` array. Without `--check` no\
verification runs and the run completes as soon as the conversion\
itself finishes.\
\
You can also run the checks by hand on a converted repo, with or\
without `--check`:\
\
```bash\
git -C /path/to/out.git fsck --full                     # zero errors expected\
git -C /path/to/out.git config extensions.objectformat  # prints sha256\
git -C /path/to/out.git log --oneline -5                # SHA256 hashes\
git -C /path/to/out.git log --notes=sha1-origin -5      # with original SHA1\
```\
\
To use the result as a working repo:\
\
```bash\
git clone /path/to/out.git /path/to/checkout\
```\
\
To serve it from a host that accepts SHA256:\
\
```bash\
git -C /path/to/out.git push --mirror <new-remote-url>\
```\
\
## Implementation Notes\
\
The pipeline runs in four phases (pack fetch → discovery → target init →\
translation), with refs and side outputs written at the end. Submodule\
errors surface in discovery, before the target repo is materialized.\
\
Translation is a memoized recursive DFS. Tree, parent, tag-target, and\
message-reference edges are all part of the DFS, so the mapping is\
populated by the time any object's bytes are encoded. A defensive\
`inProgress` set guards against cycles; real Git histories can't form\
them (parent/tree/tag-target edges are a DAG, and SHA1 message-\
reference cycles are cryptographically infeasible), but a trip into\
the guard becomes a hard error rather than a stack overflow.\
\
Translated objects are written with go-git's `SetEncodedObject`. Each\
one is built through the target store's `NewEncodedObject`, which binds\
it to the store's SHA256 hasher, so both the returned hash and the\
on-disk loose path are computed under SHA256. (Earlier revisions wrote\
loose objects by hand: `go-git/v6@v6.0.0-alpha.3`'s\
`plumbing/format/objfile.Writer` hardcoded SHA1 in its hasher and would\
have placed every translated object at a SHA1-derived path. That was\
fixed upstream in `v6.0.0-alpha.4`, which derives the hash format from\
the store config.) A unit test recomputes `sha256` of every loose\
object's decompressed content and compares it against the filename to\
guard against a regression.\
````\
\
Adocs/convert-sha256.md+364\
\
```\
348 unmodified lines\
\
349\
350\
351\
352\
352\
353\
354\
355\
\
348 unmodified lines\
\
// --- Session setup ---\
\
func newConn(raw Endpoint, label string, stats *statsCollector, httpClient *http.Client) (gitproto.Conn, error) { //nolint:ireturn // transport selection intentionally returns the shared connection interface\
func newConn(raw Endpoint, label string, stats *statsCollector, httpClient *http.Client) (gitproto.Conn, error) {\
	ep, err := transport.ParseURL(raw.URL)\
	if err != nil {\
		return nil, fmt.Errorf("parse endpoint: %w", err)\
```\
\
Minternal/syncer/syncer.go+1/-1
