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

Log in

# initial version

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

Soph·3mo ago·7 files·+2,003 added/-0 removed

## Sessions

20848a8dd015View transcript

[?\\
Can you take a look at the go code (wasm) in /Users/soph/Work/entire/devenv/entire-io-worktree1 based a bit on that I wonder if something like this can be build:Codex·GPT-5.4·6 steps](/content/gh/entireio/git-sync/session/019d6d29-8cf7-7fe3-adc9-8c3e4d9d5603#timeline-20848a8dd015/index.html)

## Changes

7

- M.gitignore+1

- AREADME.md+101

- Ago.mod+27

- Ago.sum+102

- internal/syncer

- Aintegration\_test.go+618

- Asyncer.go+1042

- Asyncer\_test.go+112

```
35 unmodified lines

36
37
38
39
40
41
42

35 unmodified lines

# .vscode/

# Binary output (only in root)
git-sync

# worktrees
.worktrees/
```

M.gitignore+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

# git-sync

`git-sync` mirrors refs from a source remote to a target remote without creating a local checkout. It uses an in-memory `go-git` object store and talks smart HTTP directly:

- `info/refs` ref advertisement for source and target
- `upload-pack` fetch from source with target tip hashes advertised as `have`
- `receive-pack` push to target with explicit ref update commands and a streamed packfile

That keeps the target side incremental without fetching target objects into the local process first.

## Current scope

- Smart HTTP only
- No local working tree
- Branch mirroring by default
- Optional tag mirroring with `--tags`
- Optional exact ref mapping with `--map`
- Fast-forward safety by default
- Optional forced retargeting with `--force`
- Optional managed-ref deletion with `--prune`
- Optional transfer stats output with `--stats`

## Limits

- Protocol v2 is not implemented yet. `--protocol auto` currently resolves to v1.
- Ref mapping is explicit, not wildcard-based.
- Only smart HTTP remotes are supported.
- Objects are kept in memory for the duration of the run.

## Usage

```bash
go run ./cmd/git-sync sync \
  --source-token "$GITSYNC_SOURCE_TOKEN" \
  --target-token "$GITSYNC_TARGET_TOKEN" \
https://github.com/source-org/source-repo.git \
https://github.com/target-org/target-repo.git
```

Sync specific branches:

```bash
go run ./cmd/git-sync sync \
  --branch main,release \
  --source-token "$GITSYNC_SOURCE_TOKEN" \
  --target-token "$GITSYNC_TARGET_TOKEN" \
<source-url> \
<target-url>
```

Map a source branch to a different target branch:

```bash
go run ./cmd/git-sync sync \
  --map main:stable \
<source-url> \
<target-url>
```

Mirror tags and prune managed target refs that disappeared from source:

```bash
go run ./cmd/git-sync sync \
  --tags \
  --prune \
<source-url> \
<target-url>
```

Dry run:

```bash
go run ./cmd/git-sync sync --dry-run --stats <source-url> <target-url>
```

## Auth

For GitHub and similar providers, use basic auth with a token as the password.

- `GITSYNC_SOURCE_TOKEN`
- `GITSYNC_TARGET_TOKEN`
- `GITSYNC_SOURCE_USERNAME` default: `git`
- `GITSYNC_TARGET_USERNAME` default: `git`

Bearer auth is also available:

- `GITSYNC_SOURCE_BEARER_TOKEN`
- `GITSYNC_TARGET_BEARER_TOKEN`

## Behavior

- Source refs are listed with `GET /info/refs?service=git-upload-pack`.
- Target refs are listed with `GET /info/refs?service=git-receive-pack`.
- The source fetch advertises current target tip hashes as `have`, so reruns download less when source and target already share history.
- If a target ref does not exist, it is created.
- If a target ref already matches the source, it is skipped.
- Branches are updated only when the target tip is an ancestor of the source tip, unless `--force` is set.
- Tags are immutable by default. Retargeting an existing tag requires `--force`.
- If `--prune` is set, managed target refs that are absent on source are deleted.
- If any ref would be blocked and `--dry-run` is not set, the command exits non-zero before pushing anything.
- `--stats` adds per-service request, byte, want, have, and command counters to the output.
````

AREADME.md+101

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

module github.com/soph/git-sync

go 1.25.2

require github.com/go-git/go-git/v5 v5.17.0

require (
	dario.cat/mergo v1.0.0 // indirect
	github.com/Microsoft/go-winio v0.6.2 // indirect
	github.com/ProtonMail/go-crypto v1.1.6 // indirect
	github.com/cloudflare/circl v1.6.1 // indirect
	github.com/cyphar/filepath-securejoin v0.4.1 // indirect
	github.com/emirpasic/gods v1.18.1 // indirect
	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
	github.com/go-git/go-billy/v5 v5.8.0 // indirect
	github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
	github.com/kevinburke/ssh_config v1.2.0 // indirect
	github.com/pjbgf/sha1cd v0.3.2 // indirect
	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
	github.com/skeema/knownhosts v1.3.1 // indirect
	github.com/xanzy/ssh-agent v0.3.3 // indirect
	golang.org/x/crypto v0.45.0 // indirect
	golang.org/x/net v0.47.0 // indirect
	golang.org/x/sys v0.38.0 // indirect
	gopkg.in/warnings.v0 v0.1.2 // indirect
)
```

Ago.mod+27

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

dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0=
github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM=
github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
```

Ago.sum+102

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

package syncer

import (
	"bytes"
	"context"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"os"
	"strings"
	"sync"
	"testing"
	"time"

billy "github.com/go-git/go-billy/v5"
	"github.com/go-git/go-billy/v5/memfs"
	git "github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing"
	"github.com/go-git/go-git/v5/plumbing/format/pktline"
	"github.com/go-git/go-git/v5/plumbing/object"
	"github.com/go-git/go-git/v5/plumbing/protocol/packp"
	"github.com/go-git/go-git/v5/plumbing/transport"
	transportclient "github.com/go-git/go-git/v5/plumbing/transport/client"
	transporthttp "github.com/go-git/go-git/v5/plumbing/transport/http"
	transportserver "github.com/go-git/go-git/v5/plumbing/transport/server"
	"github.com/go-git/go-git/v5/storage/memory"
)

const testBranch = "master"

func TestRun_IntegrationInitialSyncToEmptyTarget(t *testing.T) {
	sourceRepo, sourceFS := newSourceRepo(t)
	makeCommits(t, sourceRepo, sourceFS, 6)

targetRepo, err := git.Init(memory.NewStorage(), nil)
	if err != nil {
		t.Fatalf("init target repo: %v", err)
	}

sourceServer := newSmartHTTPRepoServer(t, sourceRepo)
	targetServer := newSmartHTTPRepoServer(t, targetRepo)
	defer sourceServer.Close()
	defer targetServer.Close()

result, err := Run(context.Background(), Config{
		Source: Endpoint{URL: sourceServer.RepoURL()},
		Target: Endpoint{URL: targetServer.RepoURL()},
	})
	if err != nil {
		t.Fatalf("initial sync failed: %v", err)
	}
	if result.Pushed != 1 || result.Blocked != 0 {
		t.Fatalf("unexpected result: %+v", result)
	}

assertHeadsMatch(t, sourceRepo, targetRepo, testBranch)

if sourceServer.BytesOut(serviceUploadPack, metricPack) == 0 {
		t.Fatalf("expected source upload-pack response bytes")
	}
	if targetServer.Count(serviceReceivePack, metricPack) != 1 {
		t.Fatalf("expected one receive-pack POST, got %d", targetServer.Count(serviceReceivePack, metricPack))
	}
	if targetServer.BytesIn(serviceReceivePack, metricPack) == 0 {
		t.Fatalf("expected receive-pack request bytes")
	}
	if targetServer.Count(serviceUploadPack, metricPack) != 0 {
		t.Fatalf("expected no target upload-pack POSTs, got %d", targetServer.Count(serviceUploadPack, metricPack))
	}
}

func TestRun_IntegrationResyncFetchesLessFromSource(t *testing.T) {
	sourceRepo, sourceFS := newSourceRepo(t)
	makeCommits(t, sourceRepo, sourceFS, 10)

targetRepo, err := git.Init(memory.NewStorage(), nil)
	if err != nil {
		t.Fatalf("init target repo: %v", err)
	}

if _, err := Run(context.Background(), Config{
		Source: Endpoint{URL: sourceServer.RepoURL()},
		Target: Endpoint{URL: targetServer.RepoURL()},
	}); err != nil {
		t.Fatalf("seed sync failed: %v", err)
	}

fullSourcePackBytes := sourceServer.BytesOut(serviceUploadPack, metricPack)
	if fullSourcePackBytes == 0 {
		t.Fatalf("expected initial source upload-pack bytes")
	}
	if sourceServer.Haves(serviceUploadPack, metricPack) != 0 {
		t.Fatalf("expected no source haves on initial sync, got %d", sourceServer.Haves(serviceUploadPack, metricPack))
	}

sourceServer.ResetMetrics()
	targetServer.ResetMetrics()

makeCommits(t, sourceRepo, sourceFS, 1)

result, err := Run(context.Background(), Config{
		Source: Endpoint{URL: sourceServer.RepoURL()},
		Target: Endpoint{URL: targetServer.RepoURL()},
	})
	if err != nil {
		t.Fatalf("resync failed: %v", err)
	}
	if result.Pushed != 1 || result.Blocked != 0 {
		t.Fatalf("unexpected resync result: %+v", result)
	}

assertHeadsMatch(t, sourceRepo, targetRepo, testBranch)

deltaSourcePackBytes := sourceServer.BytesOut(serviceUploadPack, metricPack)
	if deltaSourcePackBytes == 0 {
		t.Fatalf("expected delta source upload-pack bytes")
	}
	if sourceServer.Wants(serviceUploadPack, metricPack) == 0 {
		t.Fatalf("expected source wants on resync")
	}
	if sourceServer.Haves(serviceUploadPack, metricPack) == 0 {
		t.Fatalf("expected source fetch to advertise haves on resync")
	}

if targetServer.Count(serviceReceivePack, metricPack) != 1 {
		t.Fatalf("expected one receive-pack POST, got %d", targetServer.Count(serviceReceivePack, metricPack))
	}
	if targetServer.Count(serviceUploadPack, metricPack) != 0 {
		t.Fatalf("expected no target upload-pack POSTs, got %d", targetServer.Count(serviceUploadPack, metricPack))
	}
}

func TestRun_IntegrationBranchMappingAndStats(t *testing.T) {
	sourceRepo, sourceFS := newSourceRepo(t)
	makeCommits(t, sourceRepo, sourceFS, 3)

targetRepo, _ := newSourceRepo(t)

result, err := Run(context.Background(), Config{
		Source:    Endpoint{URL: sourceServer.RepoURL()},
		Target:    Endpoint{URL: targetServer.RepoURL()},
		Mappings:  []RefMapping{{Source: "master", Target: "stable"}},
		ShowStats: true,
	})
	if err != nil {
		t.Fatalf("mapped sync failed: %v", err)
	}

sourceRef, err := sourceRepo.Reference(plumbing.NewBranchReferenceName("master"), true)
	if err != nil {
		t.Fatalf("resolve source ref: %v", err)
	}
	targetRef, err := targetRepo.Reference(plumbing.NewBranchReferenceName("stable"), true)
	if err != nil {
		t.Fatalf("resolve target ref: %v", err)
	}
	if sourceRef.Hash() != targetRef.Hash() {
		t.Fatalf("mapped target mismatch: source=%s target=%s", sourceRef.Hash(), targetRef.Hash())
	}
	if !result.Stats.Enabled || len(result.Stats.Items) == 0 {
		t.Fatalf("expected stats to be populated")
	}
}

func TestRun_IntegrationTagsPruneAndForce(t *testing.T) {
	sourceRepo, sourceFS := newSourceRepo(t)
	makeCommits(t, sourceRepo, sourceFS, 2)
	targetRepo, targetFS := newSourceRepo(t)

head, err := sourceRepo.Reference(plumbing.NewBranchReferenceName(testBranch), true)
	if err != nil {
		t.Fatalf("source head: %v", err)
	}
	if err := sourceRepo.Storer.SetReference(plumbing.NewHashReference(plumbing.NewTagReferenceName("v1"), head.Hash())); err != nil {
		t.Fatalf("set source tag: %v", err)
	}
	if err := sourceRepo.Storer.SetReference(plumbing.NewHashReference(plumbing.NewTagReferenceName("old"), head.Hash())); err != nil {
		t.Fatalf("set source old tag: %v", err)
	}

if err := targetRepo.Storer.SetReference(plumbing.NewHashReference(plumbing.NewTagReferenceName("stale"), head.Hash())); err != nil {
		t.Fatalf("set stale target tag: %v", err)
	}

if _, err := Run(context.Background(), Config{
		Source:      Endpoint{URL: sourceServer.RepoURL()},
		Target:      Endpoint{URL: targetServer.RepoURL()},
		IncludeTags: true,
		Prune:       true,
	}); err != nil {
		t.Fatalf("tag sync failed: %v", err)
	}

if _, err := targetRepo.Reference(plumbing.NewTagReferenceName("v1"), true); err != nil {
		t.Fatalf("expected v1 tag on target: %v", err)
	}
	if _, err := targetRepo.Reference(plumbing.NewTagReferenceName("stale"), true); err != plumbing.ErrReferenceNotFound {
		t.Fatalf("expected stale tag to be pruned, got %v", err)
	}

makeCommits(t, sourceRepo, sourceFS, 1)
	makeCommits(t, targetRepo, targetFS, 1)

if _, err := Run(context.Background(), Config{
		Source: Endpoint{URL: sourceServer.RepoURL()},
		Target: Endpoint{URL: targetServer.RepoURL()},
	}); err == nil {
		t.Fatalf("expected divergent sync without force to fail")
	}

if _, err := Run(context.Background(), Config{
		Source: Endpoint{URL: sourceServer.RepoURL()},
		Target: Endpoint{URL: targetServer.RepoURL()},
		Force:  true,
	}); err != nil {
		t.Fatalf("expected forced sync to succeed: %v", err)
	}

assertHeadsMatch(t, sourceRepo, targetRepo, testBranch)
}

func newSourceRepo(t *testing.T) (*git.Repository, billy.Filesystem) {
	t.Helper()

fs := memfs.New()
	repo, err := git.Init(memory.NewStorage(), fs)
	if err != nil {
		t.Fatalf("init source repo: %v", err)
	}

return repo, fs
}

func makeCommits(t *testing.T, repo *git.Repository, fs billy.Filesystem, count int) {
	t.Helper()

wt, err := repo.Worktree()
	if err != nil {
		t.Fatalf("open worktree: %v", err)
	}

for i := 0; i < count; i++ {
		content := strings.Repeat(fmt.Sprintf("line %d %d\n", i, time.Now().UnixNano()), 24)
		file, err := fs.Create("tracked.txt")
		if err != nil {
			t.Fatalf("create file: %v", err)
		}
		if _, err := io.WriteString(file, content); err != nil {
			t.Fatalf("write file: %v", err)
		}
		if err := file.Close(); err != nil {
			t.Fatalf("close file: %v", err)
		}

if _, err := wt.Add("tracked.txt"); err != nil {
			t.Fatalf("add file: %v", err)
		}

_, err = wt.Commit(fmt.Sprintf("commit %d", i), &git.CommitOptions{
			Author:    &objectSignature,
			Committer: &objectSignature,
		})
		if err != nil {
			t.Fatalf("commit: %v", err)
		}
	}
}

var objectSignature = signature()

func signature() object.Signature {
	return object.Signature{
		Name:  "test",
		Email: "test@example.com",
		When:  time.Unix(1, 0).UTC(),
	}
}

func assertHeadsMatch(t *testing.T, sourceRepo, targetRepo *git.Repository, branch string) {
	t.Helper()

sourceRef, err := sourceRepo.Reference(plumbing.NewBranchReferenceName(branch), true)
	if err != nil {
		t.Fatalf("resolve source ref: %v", err)
	}
	targetRef, err := targetRepo.Reference(plumbing.NewBranchReferenceName(branch), true)
	if err != nil {
		t.Fatalf("resolve target ref: %v", err)
	}
	if sourceRef.Hash() != targetRef.Hash() {
		t.Fatalf("branch %s mismatch: source=%s target=%s", branch, sourceRef.Hash(), targetRef.Hash())
	}
}

type metricKind string

const (
	serviceUploadPack  = transport.UploadPackServiceName
	serviceReceivePack = transport.ReceivePackServiceName

metricInfoRefs metricKind = "info_refs"
	metricPack     metricKind = "pack"
)

type exchangeMetric struct {
	service string
	kind    metricKind
	in      int64
	out     int64
	wants   int
	haves   int
}

type smartHTTPRepoServer struct {
	t        *testing.T
	server   *httptest.Server
	repo     *git.Repository
	repoPath string

mu      sync.Mutex
	metrics []exchangeMetric
}

func newSmartHTTPRepoServer(t *testing.T, repo *git.Repository) *smartHTTPRepoServer {
	t.Helper()

s := &smartHTTPRepoServer{
		t:        t,
		repo:     repo,
		repoPath: "/repo.git",
	}
	s.server = httptest.NewServer(http.HandlerFunc(s.handle))
	return s
}

func (s *smartHTTPRepoServer) Close() {
	s.server.Close()
}

func (s *smartHTTPRepoServer) RepoURL() string {
	return s.server.URL + s.repoPath
}

func (s *smartHTTPRepoServer) ResetMetrics() {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.metrics = nil
}

func (s *smartHTTPRepoServer) Count(service string, kind metricKind) int {
	s.mu.Lock()
	defer s.mu.Unlock()
	count := 0
	for _, metric := range s.metrics {
		if metric.service == service && metric.kind == kind {
			count++
		}
	}
	return count
}

func (s *smartHTTPRepoServer) BytesIn(service string, kind metricKind) int64 {
	s.mu.Lock()
	defer s.mu.Unlock()
	var total int64
	for _, metric := range s.metrics {
		if metric.service == service && metric.kind == kind {
			total += metric.in
		}
	}
	return total
}

func (s *smartHTTPRepoServer) BytesOut(service string, kind metricKind) int64 {
	s.mu.Lock()
	defer s.mu.Unlock()
	var total int64
	for _, metric := range s.metrics {
		if metric.service == service && metric.kind == kind {
			total += metric.out
		}
	}
	return total
}

func (s *smartHTTPRepoServer) Wants(service string, kind metricKind) int {
	s.mu.Lock()
	defer s.mu.Unlock()
	total := 0
	for _, metric := range s.metrics {
		if metric.service == service && metric.kind == kind {
			total += metric.wants
		}
	}
	return total
}

func (s *smartHTTPRepoServer) Haves(service string, kind metricKind) int {
	s.mu.Lock()
	defer s.mu.Unlock()
	total := 0
	for _, metric := range s.metrics {
		if metric.service == service && metric.kind == kind {
			total += metric.haves
		}
	}
	return total
}

func (s *smartHTTPRepoServer) handle(w http.ResponseWriter, r *http.Request) {
	switch {
	case r.Method == http.MethodGet && r.URL.Path == s.repoPath+"/info/refs":
		s.handleInfoRefs(w, r)
	case r.Method == http.MethodPost && r.URL.Path == s.repoPath+"/"+serviceUploadPack:
		s.handleUploadPack(w, r)
	case r.Method == http.MethodPost && r.URL.Path == s.repoPath+"/"+serviceReceivePack:
		s.handleReceivePack(w, r)
	default:
		http.NotFound(w, r)
	}
}

func (s *smartHTTPRepoServer) handleInfoRefs(w http.ResponseWriter, r *http.Request) {
	service := r.URL.Query().Get("service")
	if service != serviceUploadPack && service != serviceReceivePack {
		http.Error(w, "missing service", http.StatusBadRequest)
		return
	}

session, err := s.newSession(service)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

var ar *packp.AdvRefs
	switch service {
	case serviceUploadPack:
		ar, err = session.(transport.UploadPackSession).AdvertisedReferencesContext(r.Context())
	case serviceReceivePack:
		ar, err = session.(transport.ReceivePackSession).AdvertisedReferencesContext(r.Context())
	}
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

ar.Prefix = [][]byte{
		[]byte("# service=" + service),
		pktline.Flush,
	}

var buf bytes.Buffer
	if err := ar.Encode(&buf); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-advertisement", service))
	if _, err := w.Write(buf.Bytes()); err != nil {
		s.t.Fatalf("write advertised refs: %v", err)
	}

s.recordMetric(service, metricInfoRefs, 0, int64(buf.Len()), 0, 0)
}

func (s *smartHTTPRepoServer) handleUploadPack(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	defer r.Body.Close()

session, err := s.newSession(serviceUploadPack)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

req := packp.NewUploadPackRequest()
	if err := req.Decode(bytes.NewReader(body)); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

resp, err := session.(transport.UploadPackSession).UploadPack(r.Context(), req)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

var buf bytes.Buffer
	if err := resp.Encode(&buf); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-result", serviceUploadPack))
	if _, err := w.Write(buf.Bytes()); err != nil {
		s.t.Fatalf("write upload-pack response: %v", err)
	}

s.recordMetric(serviceUploadPack, metricPack, int64(len(body)), int64(buf.Len()), len(req.Wants), strings.Count(string(body), "have "))
}

func (s *smartHTTPRepoServer) handleReceivePack(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	defer r.Body.Close()

session, err := s.newSession(serviceReceivePack)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

req := packp.NewReferenceUpdateRequest()
	if err := req.Decode(bytes.NewReader(body)); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

report, err := session.(transport.ReceivePackSession).ReceivePack(r.Context(), req)

var buf bytes.Buffer
	if report != nil {
		if err := report.Encode(&buf); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
	}

w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-result", serviceReceivePack))
	if _, err := w.Write(buf.Bytes()); err != nil {
		s.t.Fatalf("write receive-pack response: %v", err)
	}
	if err != nil {
		return
	}

s.recordMetric(serviceReceivePack, metricPack, int64(len(body)), int64(buf.Len()), 0, 0)
}

func (s *smartHTTPRepoServer) newSession(service string) (interface{}, error) {
	loader := transportserver.MapLoader{}

endpoint, err := transport.NewEndpoint(s.RepoURL())
	if err != nil {
		return nil, err
	}
	loader[endpoint.String()] = s.repo.Storer

srv := transportserver.NewServer(loader)
	switch service {
	case serviceUploadPack:
		return srv.NewUploadPackSession(endpoint, nil)
	case serviceReceivePack:
		return srv.NewReceivePackSession(endpoint, nil)
	default:
		return nil, fmt.Errorf("unknown service %q", service)
	}
}

func (s *smartHTTPRepoServer) recordMetric(service string, kind metricKind, in, out int64, wants, haves int) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.metrics = append(s.metrics, exchangeMetric{
		service: service,
		kind:    kind,
		in:      in,
		out:     out,
		wants:   wants,
		haves:   haves,
	})
}

func TestMain(m *testing.M) {
	originalHTTP := transportclient.Protocols["http"]
	originalHTTPS := transportclient.Protocols["https"]

customHTTP := transporthttp.NewClient(&http.Client{})
	transportclient.InstallProtocol("http", customHTTP)
	transportclient.InstallProtocol("https", customHTTP)

code := m.Run()

transportclient.InstallProtocol("http", originalHTTP)
	transportclient.InstallProtocol("https", originalHTTPS)

os.Exit(code)
}
```

Ainternal/syncer/integration\_test.go+618

package syncer

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"sort"
	"strings"

git "github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing"
	"github.com/go-git/go-git/v5/plumbing/format/packfile"
	"github.com/go-git/go-git/v5/plumbing/object"
	"github.com/go-git/go-git/v5/plumbing/protocol/packp"
	"github.com/go-git/go-git/v5/plumbing/protocol/packp/capability"
	"github.com/go-git/go-git/v5/plumbing/protocol/packp/sideband"
	"github.com/go-git/go-git/v5/plumbing/revlist"
	"github.com/go-git/go-git/v5/plumbing/storer"
	"github.com/go-git/go-git/v5/plumbing/transport"
	transporthttp "github.com/go-git/go-git/v5/plumbing/transport/http"
	"github.com/go-git/go-git/v5/storage/memory"
	"github.com/go-git/go-git/v5/utils/ioutil"
)

const (
	sourceRemoteName = "source"
	protocolModeAuto = "auto"
	protocolModeV1   = "v1"
)

type Endpoint struct {
	URL         string
	Username    string
	Token       string
	BearerToken string
}

type RefMapping struct {
	Source string
	Target string
}

type Config struct {
	Source       Endpoint
	Target       Endpoint
	Branches     []string
	Mappings     []RefMapping
	IncludeTags  bool
	DryRun       bool
	Verbose      bool
	ShowStats    bool
	Force        bool
	Prune        bool
	ProtocolMode string
}

type RefKind string

const (
	RefKindBranch RefKind = "branch"
	RefKindTag    RefKind = "tag"
)

type BranchPlan struct {
	Branch     string
	SourceRef  plumbing.ReferenceName
	TargetRef  plumbing.ReferenceName
	SourceHash plumbing.Hash
	TargetHash plumbing.Hash
	Kind       RefKind
	Action     Action
	Reason     string
}

type Action string

const (
	ActionCreate Action = "create"
	ActionUpdate Action = "update"
	ActionDelete Action = "delete"
	ActionSkip   Action = "skip"
	ActionBlock  Action = "block"
)

type Result struct {
	Plans    []BranchPlan
	Pushed   int
	Skipped  int
	Blocked  int
	Deleted  int
	DryRun   bool
	Stats    Stats
	Protocol string
}

type Stats struct {
	Enabled bool
	Items   map[string]*ServiceStats
}

type ServiceStats struct {
	Name          string
	Requests      int
	RequestBytes  int64
	ResponseBytes int64
	Wants         int
	Haves         int
	Commands      int
}

func (r Result) Lines() []string {
	lines := make([]string, 0, len(r.Plans)+8)
	for _, plan := range r.Plans {
		label := plan.Branch
		if plan.TargetRef != "" {
			label = plan.TargetRef.String()
		}
		line := fmt.Sprintf("%s %s", strings.ToUpper(string(plan.Action)), label)
		if plan.Reason != "" {
			line += " - " + plan.Reason
		}
		lines = append(lines, line)
	}

summary := fmt.Sprintf(
		"summary: pushed=%d deleted=%d skipped=%d blocked=%d protocol=%s",
		r.Pushed, r.Deleted, r.Skipped, r.Blocked, r.Protocol,
	)
	if r.DryRun {
		summary += " dry-run=true"
	}
	lines = append(lines, summary)

if r.Stats.Enabled {
		keys := make([]string, 0, len(r.Stats.Items))
		for key := range r.Stats.Items {
			keys = append(keys, key)
		}
		sort.Strings(keys)
		for _, key := range keys {
			item := r.Stats.Items[key]
			lines = append(lines, fmt.Sprintf(
				"stats: %s requests=%d request-bytes=%d response-bytes=%d wants=%d haves=%d commands=%d",
				item.Name, item.Requests, item.RequestBytes, item.ResponseBytes, item.Wants, item.Haves, item.Commands,
			))
		}
	}

return lines
}

func Run(ctx context.Context, cfg Config) (Result, error) {
	if cfg.ProtocolMode == "" {
		cfg.ProtocolMode = protocolModeAuto
	}
	if cfg.ProtocolMode != protocolModeAuto && cfg.ProtocolMode != protocolModeV1 {
		return Result{}, fmt.Errorf("unsupported protocol mode %q", cfg.ProtocolMode)
	}

repo, err := git.Init(memory.NewStorage(), nil)
	if err != nil {
		return Result{}, fmt.Errorf("init in-memory repository: %w", err)
	}

stats := newStats(cfg.ShowStats)
	sourceConn, err := newTransportConn(cfg.Source, "source", stats)
	if err != nil {
		return Result{}, fmt.Errorf("create source transport: %w", err)
	}
	targetConn, err := newTransportConn(cfg.Target, "target", stats)
	if err != nil {
		return Result{}, fmt.Errorf("create target transport: %w", err)
	}

sourceAdv, err := advertisedRefs(ctx, sourceConn, transport.UploadPackServiceName)
	if err != nil {
		return Result{}, fmt.Errorf("list source refs: %w", err)
	}
	targetAdv, err := advertisedRefs(ctx, targetConn, transport.ReceivePackServiceName)
	if err != nil {
		return Result{}, fmt.Errorf("list target refs: %w", err)
	}

sourceRefs, err := advertisedReferences(sourceAdv)
	if err != nil {
		return Result{}, fmt.Errorf("decode source refs: %w", err)
	}
	targetRefs, err := advertisedReferences(targetAdv)
	if err != nil {
		return Result{}, fmt.Errorf("decode target refs: %w", err)
	}

sourceRefMap := refHashMap(sourceRefs)
	targetRefMap := refHashMap(targetRefs)

desiredRefs, managedTargets, err := buildDesiredRefs(sourceRefMap, cfg)
	if err != nil {
		return Result{}, err
	}
	if len(desiredRefs) == 0 {
		return Result{}, fmt.Errorf("no source refs matched")
	}

if err := fetchSourceRefsWithHaves(ctx, repo, sourceConn, sourceAdv, desiredRefs, targetRefMap); err != nil {
		if !errors.Is(err, git.NoErrAlreadyUpToDate) {
			return Result{}, err
		}
	}

plans, err := buildPlans(repo, desiredRefs, targetRefMap, managedTargets, cfg)
	if err != nil {
		return Result{}, err
	}

result := Result{
		Plans:    plans,
		DryRun:   cfg.DryRun,
		Stats:    stats.snapshot(),
		Protocol: protocolModeV1,
	}

pushPlans := make([]BranchPlan, 0, len(plans))
	for _, plan := range plans {
		switch plan.Action {
		case ActionCreate, ActionUpdate:
			if cfg.DryRun {
				result.Skipped++
				continue
			}
			pushPlans = append(pushPlans, plan)
		case ActionDelete:
			if cfg.DryRun {
				result.Skipped++
				continue
			}
			pushPlans = append(pushPlans, plan)
		case ActionSkip:
			result.Skipped++
		case ActionBlock:
			result.Blocked++
		}
	}

if !cfg.DryRun && result.Blocked > 0 {
		return result, fmt.Errorf("blocked %d ref update(s); rerun with --force where appropriate", result.Blocked)
	}

if !cfg.DryRun && len(pushPlans) > 0 {
		if err := pushToTarget(ctx, repo, targetConn, targetAdv, pushPlans, targetRefMap, cfg.Verbose); err != nil {
			return result, fmt.Errorf("push target refs: %w", err)
		}
	}

for _, plan := range pushPlans {
		switch plan.Action {
		case ActionCreate, ActionUpdate:
			result.Pushed++
		case ActionDelete:
			result.Deleted++
		}
	}

result.Stats = stats.snapshot()
	return result, nil
}

func selectBranches(source map[string]plumbing.Hash, requested []string) map[string]plumbing.Hash {
	if len(requested) == 0 {
		return source
	}

selected := make(map[string]plumbing.Hash, len(requested))
	for _, branch := range requested {
		if hash, ok := source[branch]; ok {
			selected[branch] = hash
		}
	}
	return selected
}

func buildDesiredRefs(sourceRefs map[plumbing.ReferenceName]plumbing.Hash, cfg Config) (map[plumbing.ReferenceName]desiredRef, map[plumbing.ReferenceName]managedTarget, error) {
	desired := make(map[plumbing.ReferenceName]desiredRef)
	managed := make(map[plumbing.ReferenceName]managedTarget)

addManaged := func(sourceRef, targetRef plumbing.ReferenceName, kind RefKind, hash plumbing.Hash) error {
		if hash.IsZero() {
			return fmt.Errorf("source ref %s not found", sourceRef)
		}
		short := targetRef.Short()
		desired[targetRef] = desiredRef{
			Kind:       kind,
			Label:      short,
			SourceRef:  sourceRef,
			TargetRef:  targetRef,
			SourceHash: hash,
		}
		managed[targetRef] = managedTarget{Kind: kind, Label: short}
		return nil
	}

if len(cfg.Mappings) > 0 {
		for _, mapping := range cfg.Mappings {
			sourceRef, targetRef, kind, err := normalizeMapping(mapping)
			if err != nil {
				return nil, nil, err
			}
			if err := addManaged(sourceRef, targetRef, kind, sourceRefs[sourceRef]); err != nil {
				return nil, nil, err
			}
		}
	} else {
		branches := branchMapFromRefHashMap(sourceRefs)
		selected := selectBranches(branches, cfg.Branches)
		for branch, hash := range selected {
			refName := plumbing.NewBranchReferenceName(branch)
			if err := addManaged(refName, refName, RefKindBranch, hash); err != nil {
				return nil, nil, err
			}
		}
	}

if cfg.IncludeTags {
		for refName, hash := range sourceRefs {
			if !refName.IsTag() {
				continue
			}
			if err := addManaged(refName, refName, RefKindTag, hash); err != nil {
				return nil, nil, err
			}
		}
	}

return desired, managed, nil
}

func normalizeMapping(mapping RefMapping) (plumbing.ReferenceName, plumbing.ReferenceName, RefKind, error) {
	src := strings.TrimSpace(mapping.Source)
	dst := strings.TrimSpace(mapping.Target)
	if src == "" || dst == "" {
		return "", "", "", fmt.Errorf("invalid mapping %q:%q", mapping.Source, mapping.Target)
	}

if strings.HasPrefix(src, "refs/") || strings.HasPrefix(dst, "refs/") {
		sourceRef := plumbing.ReferenceName(src)
		targetRef := plumbing.ReferenceName(dst)
		kind := refKindFromName(targetRef)
		if kind == "" {
			return "", "", "", fmt.Errorf("unsupported mapped ref kind: %s -> %s", src, dst)
		}
		return sourceRef, targetRef, kind, nil
	}

return plumbing.NewBranchReferenceName(src), plumbing.NewBranchReferenceName(dst), RefKindBranch, nil
}

func refKindFromName(name plumbing.ReferenceName) RefKind {
	switch {
	case name.IsBranch():
		return RefKindBranch
	case name.IsTag():
		return RefKindTag
	default:
		return ""
	}
}

type desiredRef struct {
	Kind       RefKind
	Label      string
	SourceRef  plumbing.ReferenceName
	TargetRef  plumbing.ReferenceName
	SourceHash plumbing.Hash
}

type managedTarget struct {
	Kind  RefKind
	Label string
}

func buildPlans(
	repo *git.Repository,
	desired map[plumbing.ReferenceName]desiredRef,
	targetRefs map[plumbing.ReferenceName]plumbing.Hash,
	managed map[plumbing.ReferenceName]managedTarget,
	cfg Config,
) ([]BranchPlan, error) {
	if cfg.Prune {
		for targetRef := range targetRefs {
			if _, ok := managed[targetRef]; ok {
				continue
			}
			switch {
			case targetRef.IsTag() && cfg.IncludeTags:
				managed[targetRef] = managedTarget{Kind: RefKindTag, Label: targetRef.Short()}
			case targetRef.IsBranch() && len(cfg.Mappings) == 0 && len(cfg.Branches) == 0:
				managed[targetRef] = managedTarget{Kind: RefKindBranch, Label: targetRef.Short()}
			}
		}
	}

targetNames := make([]plumbing.ReferenceName, 0, len(managed))
	for name := range managed {
		targetNames = append(targetNames, name)
	}
	sort.Slice(targetNames, func(i, j int) bool { return targetNames[i] < targetNames[j] })

plans := make([]BranchPlan, 0, len(targetNames)+8)
	for _, targetRef := range targetNames {
		info := managed[targetRef]
		want, existsInDesired := desired[targetRef]
		targetHash, existsOnTarget := targetRefs[targetRef]

if !existsInDesired {
			if cfg.Prune && existsOnTarget {
				plans = append(plans, BranchPlan{
					Branch:     info.Label,
					TargetRef:  targetRef,
					TargetHash: targetHash,
					Kind:       info.Kind,
					Action:     ActionDelete,
					Reason:     fmt.Sprintf("%s -> <deleted>", shortHash(targetHash)),
				})
			}
			continue
		}

if !existsOnTarget {
			plans = append(plans, BranchPlan{
				Branch:     want.Label,
				SourceRef:  want.SourceRef,
				TargetRef:  want.TargetRef,
				SourceHash: want.SourceHash,
				Kind:       want.Kind,
				Action:     ActionCreate,
				Reason:     fmt.Sprintf("%s -> <new>", shortHash(want.SourceHash)),
			})
			continue
		}

plan, err := planRef(repo, want, targetHash, cfg.Force)
		if err != nil {
			return nil, err
		}
		plans = append(plans, plan)
	}

sort.Slice(plans, func(i, j int) bool {
		return plans[i].TargetRef.String() < plans[j].TargetRef.String()
	})
	return plans, nil
}

func planRef(repo *git.Repository, want desiredRef, targetHash plumbing.Hash, force bool) (BranchPlan, error) {
	plan := BranchPlan{
		Branch:     want.Label,
		SourceRef:  want.SourceRef,
		TargetRef:  want.TargetRef,
		SourceHash: want.SourceHash,
		TargetHash: targetHash,
		Kind:       want.Kind,
	}

if want.SourceHash == targetHash {
		plan.Action = ActionSkip
		plan.Reason = fmt.Sprintf("%s already current", shortHash(want.SourceHash))
		return plan, nil
	}

if want.Kind == RefKindTag {
		if force {
			plan.Action = ActionUpdate
			plan.Reason = fmt.Sprintf("%s -> %s (force tag update)", shortHash(targetHash), shortHash(want.SourceHash))
			return plan, nil
		}
		plan.Action = ActionBlock
		plan.Reason = fmt.Sprintf("%s differs from %s; use --force to retarget tag", shortHash(targetHash), shortHash(want.SourceHash))
		return plan, nil
	}

sourceCommit, err := repo.CommitObject(want.SourceHash)
	if err != nil {
		return plan, fmt.Errorf("load source commit for %s: %w", want.TargetRef, err)
	}

isFF, err := reachesCommitHash(repo.Storer, sourceCommit, targetHash)
	if err != nil {
		return plan, fmt.Errorf("check fast-forward for %s: %w", want.TargetRef, err)
	}
	if isFF {
		plan.Action = ActionUpdate
		plan.Reason = fmt.Sprintf("%s -> %s", shortHash(targetHash), shortHash(want.SourceHash))
		return plan, nil
	}

if force {
		plan.Action = ActionUpdate
		plan.Reason = fmt.Sprintf("%s -> %s (force)", shortHash(targetHash), shortHash(want.SourceHash))
		return plan, nil
	}

plan.Action = ActionBlock
	plan.Reason = fmt.Sprintf("%s is not an ancestor of %s", shortHash(targetHash), shortHash(want.SourceHash))
	return plan, nil
}

func planBranch(repo *git.Repository, branch string, sourceHash, targetHash plumbing.Hash) (BranchPlan, error) {
	return planRef(repo, desiredRef{
		Kind:       RefKindBranch,
		Label:      branch,
		SourceRef:  plumbing.NewBranchReferenceName(branch),
		TargetRef:  plumbing.NewBranchReferenceName(branch),
		SourceHash: sourceHash,
	}, targetHash, false)
}

func fetchSourceRefsWithHaves(
	ctx context.Context,
	repo *git.Repository,
	conn *transportConn,
	sourceAdv *packp.AdvRefs,
	desired map[plumbing.ReferenceName]desiredRef,
	targetRefs map[plumbing.ReferenceName]plumbing.Hash,
) error {
	session, err := conn.transport.NewUploadPackSession(conn.endpoint, conn.authMethod())
	if err != nil {
		return fmt.Errorf("open source upload-pack session: %w", err)
	}
	defer session.Close()

req := packp.NewUploadPackRequestFromCapabilities(sourceAdv.Capabilities)
	for _, ref := range desired {
		req.Wants = append(req.Wants, ref.SourceHash)
	}
	req.Wants = sortedUniqueHashes(req.Wants)
	req.Haves = sortedUniqueHashes(mapsRefValues(targetRefs))
	if len(req.Wants) == 0 {
		return git.NoErrAlreadyUpToDate
	}
	if sourceAdv.Capabilities.Supports(capability.NoProgress) {
		_ = req.Capabilities.Set(capability.NoProgress)
	}
	conn.stats.addWantsHaves("source upload-pack", len(req.Wants), len(req.Haves))

reader, err := session.UploadPack(ctx, req)
	if err != nil {
		if errors.Is(err, transport.ErrEmptyUploadPackRequest) {
			return git.NoErrAlreadyUpToDate
		}
		return fmt.Errorf("source upload-pack: %w", err)
	}
	defer ioutil.CheckClose(reader, &err)

if err := packfile.UpdateObjectStorage(repo.Storer, buildSidebandIfSupported(req.Capabilities, reader, nil)); err != nil {
		return fmt.Errorf("store source packfile: %w", err)
	}

for _, ref := range desired {
		localRef := plumbing.ReferenceName(localBranchRef(sourceRemoteName, ref.TargetRef.Short()))
		if ref.Kind == RefKindTag {
			localRef = plumbing.ReferenceName("refs/remotes/" + sourceRemoteName + "/tags/" + ref.TargetRef.Short())
		}
		if err := repo.Storer.SetReference(plumbing.NewHashReference(localRef, ref.SourceHash)); err != nil {
			return fmt.Errorf("set local source ref %s: %w", ref.SourceRef, err)
		}
	}

return nil
}

func pushToTarget(
	ctx context.Context,
	repo *git.Repository,
	conn *transportConn,
	targetAdv *packp.AdvRefs,
	plans []BranchPlan,
	targetRefs map[plumbing.ReferenceName]plumbing.Hash,
	verbose bool,
) error {
	session, err := conn.transport.NewReceivePackSession(conn.endpoint, conn.authMethod())
	if err != nil {
		return fmt.Errorf("open target receive-pack session: %w", err)
	}
	defer session.Close()

req := packp.NewReferenceUpdateRequestFromCapabilities(targetAdv.Capabilities)
	req.Progress = progressWriter(verbose)
	if targetAdv.Capabilities.Supports(capability.Sideband64k) {
		_ = req.Capabilities.Set(capability.Sideband64k)
	} else if targetAdv.Capabilities.Supports(capability.Sideband) {
		_ = req.Capabilities.Set(capability.Sideband)
	}

commands := make([]*packp.Command, 0, len(plans))
	objects := make([]plumbing.Hash, 0, len(plans))
	hasDelete := false
	hasUpdates := false
	for _, plan := range plans {
		cmd := &packp.Command{
			Name: plan.TargetRef,
			Old:  targetRefs[plan.TargetRef],
		}
		switch plan.Action {
		case ActionCreate, ActionUpdate:
			cmd.New = plan.SourceHash
			objects = append(objects, plan.SourceHash)
			hasUpdates = true
		case ActionDelete:
			cmd.New = plumbing.ZeroHash
			hasDelete = true
		}
		commands = append(commands, cmd)
	}
	req.Commands = commands
	conn.stats.addCommands("target receive-pack", len(commands))
	if hasDelete {
		if !targetAdv.Capabilities.Supports(capability.DeleteRefs) {
			return fmt.Errorf("target does not support delete-refs")
		}
		_ = req.Capabilities.Set(capability.DeleteRefs)
	}

hashesToPush, err := objectsToPush(repo.Storer, objects, targetRefs)
	if err != nil {
		return fmt.Errorf("compute objects to push: %w", err)
	}

report, err := receivePack(ctx, session, repo.Storer, req, hashesToPush, hasUpdates, !targetAdv.Capabilities.Supports(capability.OFSDelta))
	if err != nil {
		return err
	}
	if report != nil {
		if err := report.Error(); err != nil {
			return err
		}
	}
	return nil
}

func receivePack(
	ctx context.Context,
	session transport.ReceivePackSession,
	store storer.Storer,
	req *packp.ReferenceUpdateRequest,
	hashes []plumbing.Hash,
	sendPack bool,
	useRefDeltas bool,
) (*packp.ReportStatus, error) {
	if !sendPack {
		return session.ReceivePack(ctx, req)
	}

rd, wr := io.Pipe()
	req.Packfile = rd
	done := make(chan error, 1)

go func() {
		enc := packfile.NewEncoder(wr, store, useRefDeltas)
		if _, err := enc.Encode(hashes, 10); err != nil {
			done <- wr.CloseWithError(err)
			return
		}
		done <- wr.Close()
	}()

report, err := session.ReceivePack(ctx, req)
	if err != nil {
		_ = rd.Close()
		return nil, err
	}
	if err := <-done; err != nil {
		return nil, err
	}
	return report, nil
}

func objectsToPush(store storer.Storer, wants []plumbing.Hash, targetRefs map[plumbing.ReferenceName]plumbing.Hash) ([]plumbing.Hash, error) {
	haves := sortedUniqueHashes(mapsRefValues(targetRefs))
	if len(wants) == 0 {
		return nil, nil
	}

haveSet := make(map[plumbing.Hash]struct{}, len(haves))
	for _, hash := range haves {
		haveSet[hash] = struct{}{}
	}

filteredWants := make([]plumbing.Hash, 0, len(wants))
	for _, hash := range sortedUniqueHashes(wants) {
		if _, ok := haveSet[hash]; ok {
			continue
		}
		filteredWants = append(filteredWants, hash)
	}
	if len(filteredWants) == 0 {
		return nil, nil
	}

return revlist.Objects(store, filteredWants, haves)
}

func branchMapFromRefHashMap(refs map[plumbing.ReferenceName]plumbing.Hash) map[string]plumbing.Hash {
	branches := make(map[string]plumbing.Hash)
	for name, hash := range refs {
		if name.IsBranch() {
			branches[name.Short()] = hash
		}
	}
	return branches
}

func refHashMap(refs []*plumbing.Reference) map[plumbing.ReferenceName]plumbing.Hash {
	out := make(map[plumbing.ReferenceName]plumbing.Hash)
	for _, ref := range refs {
		if ref.Type() == plumbing.HashReference {
			out[ref.Name()] = ref.Hash()
		}
	}
	return out
}

func advertisedReferences(ar *packp.AdvRefs) ([]*plumbing.Reference, error) {
	refs, err := ar.AllReferences()
	if err != nil {
		return nil, err
	}

iter, err := refs.IterReferences()
	if err != nil {
		return nil, err
	}
	defer iter.Close()

var out []*plumbing.Reference
	err = iter.ForEach(func(ref *plumbing.Reference) error {
		out = append(out, ref)
		return nil
	})
	return out, err
}

type transportConn struct {
	label     string
	endpoint  *transport.Endpoint
	transport transport.Transport
	http      *http.Client
	raw       Endpoint
	stats     *statsCollector
}

func newTransportConn(raw Endpoint, label string, stats *statsCollector) (*transportConn, error) {
	ep, err := transport.NewEndpoint(raw.URL)
	if err != nil {
		return nil, err
	}

httpClient := &http.Client{
		Transport: &countingRoundTripper{
			base:  http.DefaultTransport,
			label: label,
			stats: stats,
		},
	}

return &transportConn{
		label:     label,
		endpoint:  ep,
		transport: transporthttp.NewClient(httpClient),
		http:      httpClient,
		raw:       raw,
		stats:     stats,
	}, nil
}

func (c *transportConn) authMethod() transport.AuthMethod {
	return c.raw.authMethod()
}

func advertisedRefs(ctx context.Context, conn *transportConn, service string) (*packp.AdvRefs, error) {
	url := fmt.Sprintf("%s/info/refs?service=%s", conn.endpoint.String(), service)
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Accept", "*/*")
	req.Header.Set("User-Agent", capability.DefaultAgent())
	applyAuth(req, conn.raw)

res, err := conn.http.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	if err := transporthttp.NewErr(res); err != nil {
		return nil, err
	}

data, err := io.ReadAll(res.Body)
	if err != nil {
		return nil, err
	}

ar := packp.NewAdvRefs()
	if err := ar.Decode(bytes.NewReader(data)); err != nil {
		if err == packp.ErrEmptyAdvRefs {
			return nil, transport.ErrEmptyRemoteRepository
		}
		return nil, err
	}
	return ar, nil
}

func applyAuth(req *http.Request, endpoint Endpoint) {
	switch auth := endpoint.authMethod().(type) {
	case *transporthttp.BasicAuth:
		auth.SetAuth(req)
	case *transporthttp.TokenAuth:
		auth.SetAuth(req)
	}
}

func reachesCommitHash(store storer.EncodedObjectStorer, start *object.Commit, target plumbing.Hash) (bool, error) {
	if start.Hash == target {
		return true, nil
	}

seen := map[plumbing.Hash]bool{}
	stack := []*object.Commit{start}

for len(stack) > 0 {
		current := stack[len(stack)-1]
		stack = stack[:len(stack)-1]
		if seen[current.Hash] {
			continue
		}
		seen[current.Hash] = true

for _, parentHash := range current.ParentHashes {
			if parentHash == target {
				return true, nil
			}
			if seen[parentHash] {
				continue
			}
			parent, err := object.GetCommit(store, parentHash)
			if err != nil {
				if errors.Is(err, plumbing.ErrObjectNotFound) {
					continue
				}
				return false, err
			}
			stack = append(stack, parent)
		}
	}

return false, nil
}

func mapsRefValues(input map[plumbing.ReferenceName]plumbing.Hash) []plumbing.Hash {
	out := make([]plumbing.Hash, 0, len(input))
	for _, hash := range input {
		if !hash.IsZero() {
			out = append(out, hash)
		}
	}
	return out
}

func sortedUniqueHashes(input []plumbing.Hash) []plumbing.Hash {
	seen := make(map[plumbing.Hash]bool, len(input))
	out := make([]plumbing.Hash, 0, len(input))
	for _, hash := range input {
		if seen[hash] {
			continue
		}
		seen[hash] = true
		out = append(out, hash)
	}
	plumbing.HashesSort(out)
	return out
}

func localBranchRef(remoteName, branch string) string {
	return plumbing.NewRemoteReferenceName(remoteName, branch).String()
}

func shortHash(hash plumbing.Hash) string {
	if hash.IsZero() {
		return "<zero>"
	}
	value := hash.String()
	if len(value) > 8 {
		return value[:8]
	}
	return value
}

func progressWriter(verbose bool) io.Writer {
	if !verbose {
		return nil
	}
	return os.Stderr
}

func (e Endpoint) authMethod() transport.AuthMethod {
	if e.BearerToken != "" {
		return &transporthttp.TokenAuth{Token: e.BearerToken}
	}
	if e.Token != "" {
		username := e.Username
		if username == "" {
			username = "git"
		}
		return &transporthttp.BasicAuth{Username: username, Password: e.Token}
	}
	return nil
}

func buildSidebandIfSupported(l *capability.List, reader io.Reader, p sideband.Progress) io.Reader {
	var t sideband.Type
	switch {
	case l.Supports(capability.Sideband):
		t = sideband.Sideband
	case l.Supports(capability.Sideband64k):
		t = sideband.Sideband64k
	default:
		return reader
	}

d := sideband.NewDemuxer(t, reader)
	d.Progress = p
	return d
}

type statsCollector struct {
	enabled bool
	items   map[string]*ServiceStats
}

func newStats(enabled bool) *statsCollector {
	return &statsCollector{enabled: enabled, items: map[string]*ServiceStats{}}
}

func (s *statsCollector) ensure(name string) *ServiceStats {
	item, ok := s.items[name]
	if !ok {
		item = &ServiceStats{Name: name}
		s.items[name] = item
	}
	return item
}

func (s *statsCollector) addWantsHaves(name string, wants, haves int) {
	if !s.enabled {
		return
	}
	item := s.ensure(name)
	item.Wants += wants
	item.Haves += haves
}

func (s *statsCollector) addCommands(name string, commands int) {
	if !s.enabled {
		return
	}
	item := s.ensure(name)
	item.Commands += commands
}

func (s *statsCollector) recordRoundTrip(name string, requestBytes, responseBytes int64) {
	if !s.enabled {
		return
	}
	item := s.ensure(name)
	item.Requests++
	item.RequestBytes += requestBytes
	item.ResponseBytes += responseBytes
}

func (s *statsCollector) snapshot() Stats {
	out := Stats{Enabled: s.enabled, Items: map[string]*ServiceStats{}}
	for key, item := range s.items {
		copyItem := *item
		out.Items[key] = &copyItem
	}
	return out
}

type countingRoundTripper struct {
	base  http.RoundTripper
	label string
	stats *statsCollector
}

func (rt *countingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
	res, err := rt.base.RoundTrip(req)
	if err != nil {
		return nil, err
	}

serviceName := req.URL.Query().Get("service")
	if serviceName == "" {
		serviceName = strings.TrimPrefix(req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:], "/")
	}
	name := strings.TrimSpace(rt.label + " " + serviceName)
	requestBytes := req.ContentLength
	if requestBytes < 0 {
		requestBytes = 0
	}

res.Body = &countingReadCloser{
		ReadCloser: res.Body,
		onClose: func(n int64) {
			rt.stats.recordRoundTrip(name, requestBytes, n)
		},
	}
	return res, nil
}

type countingReadCloser struct {
	io.ReadCloser
	n       int64
	onClose func(int64)
}

func (c *countingReadCloser) Read(p []byte) (int, error) {
	n, err := c.ReadCloser.Read(p)
	c.n += int64(n)
	return n, err
}

func (c *countingReadCloser) Close() error {
	err := c.ReadCloser.Close()
	if c.onClose != nil {
		c.onClose(c.n)
		c.onClose = nil
	}
	return err
}
```

Ainternal/syncer/syncer.go+1042

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

package syncer

import (
	"fmt"
	"testing"
	"time"

git "github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing"
	"github.com/go-git/go-git/v5/plumbing/object"
	"github.com/go-git/go-git/v5/storage/memory"
)

func TestSelectBranches(t *testing.T) {
	source := map[string]plumbing.Hash{
		"main": plumbing.NewHash("1111111111111111111111111111111111111111"),
		"dev":  plumbing.NewHash("2222222222222222222222222222222222222222"),
	}

got := selectBranches(source, []string{"dev", "missing"})
	if len(got) != 1 || got["dev"] != source["dev"] {
		t.Fatalf("unexpected branch selection: %#v", got)
	}
}

func TestPlanBranchSkip(t *testing.T) {
	hash := plumbing.NewHash("1111111111111111111111111111111111111111")
	plan, err := planBranch(nil, "main", hash, hash)
	if err != nil {
		t.Fatalf("planBranch returned error: %v", err)
	}
	if plan.Action != ActionSkip {
		t.Fatalf("expected skip, got %s", plan.Action)
	}
}

func TestPlanBranchCreate(t *testing.T) {
	repo, err := git.Init(memory.NewStorage(), nil)
	if err != nil {
		t.Fatalf("init repo: %v", err)
	}
	sourceHash := seedCommit(t, repo, nil)

plan := BranchPlan{
		Branch:     "main",
		SourceHash: sourceHash,
		Action:     ActionCreate,
	}

if plan.Action != ActionCreate {
		t.Fatalf("expected create")
	}
}

func TestPlanBranchFastForwardAndBlock(t *testing.T) {
	repo, err := git.Init(memory.NewStorage(), nil)
	if err != nil {
		t.Fatalf("init repo: %v", err)
	}

root := seedCommit(t, repo, nil)
	next := seedCommit(t, repo, []plumbing.Hash{root})
	side := seedCommit(t, repo, []plumbing.Hash{root})

ffPlan, err := planBranch(repo, "main", next, root)
	if err != nil {
		t.Fatalf("planBranch fast-forward: %v", err)
	}
	if ffPlan.Action != ActionUpdate {
		t.Fatalf("expected update, got %s", ffPlan.Action)
	}

blockPlan, err := planBranch(repo, "main", side, next)
	if err != nil {
		t.Fatalf("planBranch block: %v", err)
	}
	if blockPlan.Action != ActionBlock {
		t.Fatalf("expected block, got %s", blockPlan.Action)
	}
}

func seedCommit(t *testing.T, repo *git.Repository, parents []plumbing.Hash) plumbing.Hash {
	t.Helper()

now := time.Now().UTC()

obj := repo.Storer.NewEncodedObject()
	commit := &object.Commit{
		Author: object.Signature{
			Name:  "test",
			Email: "test@example.com",
			When:  now,
		},
		Committer: object.Signature{
			Name:  "test",
			Email: "test@example.com",
			When:  now,
		},
		Message:      fmt.Sprintf("test-%d-%d", len(parents), now.UnixNano()),
		TreeHash:     plumbing.ZeroHash,
		ParentHashes: parents,
	}

if err := commit.Encode(obj); err != nil {
		t.Fatalf("encode commit: %v", err)
	}
	hash, err := repo.Storer.SetEncodedObject(obj)
	if err != nil {
		t.Fatalf("store commit: %v", err)
	}
	return hash
}
```

Ainternal/syncer/syncer\_test.go+112
