initial version · Entire
Log in
initial version
4c34017→main·
Soph·3mo ago·7 files·+2,003 added/-0 removed
Sessions
20848a8dd015View transcript
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 ->
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 ->
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 "
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] = ©Item } 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