Skip to content
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,9 @@ For concrete numbers on your own setup, run the included benchmark tool against

### Does it support SSH?

No. `git-sync` supports smart HTTP/HTTPS only.
Yes. `git-sync` supports SSH remotes through the local `ssh` binary, including
`ssh://`, SCP-style `git@host:path.git`, and `git+ssh://` URLs. See
[docs/usage.md](docs/usage.md) for details and current caveats.

### Does it run as a daemon or watch for changes?

Expand Down
3 changes: 2 additions & 1 deletion docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ Out of scope (with pointers):

- The pack format itself (object types, deltas, index format) — see [Git's pack-format docs](https://git-scm.com/docs/pack-format)
- Dumb HTTP — `git-sync` does not support it
- SSH transport — `git-sync` is HTTPS-only
- Full SSH transport details — `git-sync` supports SSH, but this document is
focused on the Smart HTTP wire flow
- Bundle URI, partial clones, and other newer extensions

## Smart HTTP Overview
Expand Down
24 changes: 24 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,30 @@ git-sync sync \
<target-url>
```

### SSH remotes

`git-sync` also supports SSH remotes. Accepted forms include:

- `ssh://git@example.com/org/repo.git`
- `git@example.com:org/repo.git`
- `git+ssh://example.com/org/repo.git`

SSH transport shells out to the local `ssh` binary, so host aliases,
`IdentityFile`, agent-backed keys, and other `~/.ssh/config` behavior come
from your existing SSH setup rather than separate `git-sync` flags.

`git-sync` runs SSH with `BatchMode=yes`, which avoids interactive password or
host-key prompts during syncs. On first contact with a host, add it to
`known_hosts` ahead of time or configure `StrictHostKeyChecking=accept-new`
for that host in your SSH config.

Current limitation: `--progress` and `--show-stats` do not yet include
byte-counted SSH transfer metrics, so `--progress` and `--stats` omit
SSH-side throughput.

If `ssh` is not available on `PATH`, `git-sync` fails early with a clear
`locate ssh binary` error before contacting either remote.

## Sync Behavior

`sync` picks the bootstrap relay path automatically when the target is empty. For non-empty targets, safe fast-forward updates also use a relay path that streams the source pack directly into target `receive-pack` without local materialization. Anything not relay-eligible (force, prune, deletes, tag retargets) falls back to a materialized path bounded by `--materialized-max-objects`.
Expand Down
18 changes: 18 additions & 0 deletions internal/gitproto/conn.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package gitproto

import (
"context"
"io"
"net/url"
)

// Conn represents a connection to a Git remote transport such as Smart HTTP
// or SSH.
type Conn interface {
RequestInfoRefs(ctx context.Context, service string, gitProtocol string) ([]byte, error)
PostRPCStreamBody(ctx context.Context, service string, body io.Reader, v2 bool, phase string) (io.ReadCloser, error)
Endpoint() *url.URL
ProgressWriter() io.Writer
SetProgressWriter(w io.Writer)
Close() error
}
22 changes: 11 additions & 11 deletions internal/gitproto/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func (s *RefService) SupportsBootstrapBatch() bool {
func (s *RefService) FetchToStore(
ctx context.Context,
store storer.Storer,
conn *Conn,
conn Conn,
desired map[plumbing.ReferenceName]DesiredRef,
targetRefs map[plumbing.ReferenceName]plumbing.Hash,
) error {
Expand All @@ -81,7 +81,7 @@ func (s *RefService) FetchToStore(
// Caller must close the returned ReadCloser.
func (s *RefService) FetchPack(
ctx context.Context,
conn *Conn,
conn Conn,
desired map[plumbing.ReferenceName]DesiredRef,
targetRefs map[plumbing.ReferenceName]plumbing.Hash,
) (io.ReadCloser, error) {
Expand All @@ -102,7 +102,7 @@ func (s *RefService) FetchPack(
func (s *RefService) FetchCommitGraph(
ctx context.Context,
store storer.Storer,
conn *Conn,
conn Conn,
ref DesiredRef,
haves []plumbing.Hash,
) error {
Expand Down Expand Up @@ -156,7 +156,7 @@ func (s *RefService) Capabilities() []string {
func fetchToStoreV2(
ctx context.Context,
store storer.Storer,
conn *Conn,
conn Conn,
caps *V2Capabilities,
desired map[plumbing.ReferenceName]DesiredRef,
targetRefs map[plumbing.ReferenceName]plumbing.Hash,
Expand Down Expand Up @@ -194,12 +194,12 @@ func fetchToStoreV2(
return err
}
defer ioutil.CheckClose(reader, &err)
return storeV2FetchPack(store, reader, verbose, conn.ProgressOut)
return storeV2FetchPack(store, reader, verbose, conn.ProgressWriter())
}

func fetchPackV2(
ctx context.Context,
conn *Conn,
conn Conn,
caps *V2Capabilities,
desired map[plumbing.ReferenceName]DesiredRef,
targetRefs map[plumbing.ReferenceName]plumbing.Hash,
Expand Down Expand Up @@ -240,7 +240,7 @@ func fetchPackV2(
if err != nil {
return nil, err
}
packStream, err := openV2PackStream(reader, verbose, conn.ProgressOut)
packStream, err := openV2PackStream(reader, verbose, conn.ProgressWriter())
if err != nil {
_ = reader.Close()
return nil, err
Expand Down Expand Up @@ -431,7 +431,7 @@ func buildV1UploadPackBody(
func fetchToStoreV1(
ctx context.Context,
store storer.Storer,
conn *Conn,
conn Conn,
adv *packp.AdvRefs,
desired map[plumbing.ReferenceName]DesiredRef,
targetRefs map[plumbing.ReferenceName]plumbing.Hash,
Expand All @@ -456,7 +456,7 @@ func fetchToStoreV1(
if drainErr := drainTrailingNAKs(buffered); drainErr != nil {
return fmt.Errorf("drain server response: %w", drainErr)
}
sbReader := buildSidebandReader(caps, buffered, progressSink(verbose, "source: ", conn.ProgressOut))
sbReader := buildSidebandReader(caps, buffered, progressSink(verbose, "source: ", conn.ProgressWriter()))
if err := packfile.UpdateObjectStorage(store, sbReader); err != nil {
return fmt.Errorf("update object storage: %w", err)
}
Expand All @@ -465,7 +465,7 @@ func fetchToStoreV1(

func fetchPackV1(
ctx context.Context,
conn *Conn,
conn Conn,
adv *packp.AdvRefs,
desired map[plumbing.ReferenceName]DesiredRef,
targetRefs map[plumbing.ReferenceName]plumbing.Hash,
Expand All @@ -491,7 +491,7 @@ func fetchPackV1(
return nil, fmt.Errorf("drain server response: %w", drainErr)
}
return &wrappedRC{
Reader: buildSidebandReader(caps, buffered, progressSink(verbose, "source: ", conn.ProgressOut)),
Reader: buildSidebandReader(caps, buffered, progressSink(verbose, "source: ", conn.ProgressWriter())),
Closer: reader,
}, nil
}
Expand Down
28 changes: 14 additions & 14 deletions internal/gitproto/fetch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ func TestFetchPackV1ContextCanceled(t *testing.T) {
if err != nil {
t.Fatalf("parse endpoint: %v", err)
}
conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
started <- struct{}{}
<-req.Context().Done()
return nil, req.Context().Err()
Expand Down Expand Up @@ -411,7 +411,7 @@ func TestFetchPackV2ContextCanceled(t *testing.T) {
if err != nil {
t.Fatalf("parse endpoint: %v", err)
}
conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
started <- struct{}{}
<-req.Context().Done()
return nil, req.Context().Err()
Expand Down Expand Up @@ -463,7 +463,7 @@ func TestFetchToStoreV2ContextCanceled(t *testing.T) {
if err != nil {
t.Fatalf("parse endpoint: %v", err)
}
conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
started <- struct{}{}
<-req.Context().Done()
return nil, req.Context().Err()
Expand Down Expand Up @@ -514,7 +514,7 @@ func TestFetchToStoreV2ClosesBodyOnDecodeError(t *testing.T) {
t.Fatalf("parse endpoint: %v", err)
}
body := &trackingReadCloser{ReadCloser: io.NopCloser(bytes.NewBufferString(FormatPktLine("bogus\n") + "0000"))}
conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Request: req,
Expand Down Expand Up @@ -552,7 +552,7 @@ func TestFetchToStoreV2ContextCanceledMidStream(t *testing.T) {
}

ctx, cancel := context.WithCancel(context.Background())
conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
body := &blockingPacketBody{
ctx: req.Context(),
startedRead: startedRead,
Expand Down Expand Up @@ -609,7 +609,7 @@ func TestFetchPackV1ClosesBodyOnDecodeError(t *testing.T) {
t.Fatalf("parse endpoint: %v", err)
}
body := &trackingReadCloser{ReadCloser: io.NopCloser(bytes.NewBufferString("not-a-valid-server-response"))}
conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Request: req,
Expand Down Expand Up @@ -644,7 +644,7 @@ func TestFetchPackV1ReturnedReaderClosesBodyOnInterruption(t *testing.T) {
data: []byte("0008NAK\nPACK"),
err: io.ErrUnexpectedEOF,
}
conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Request: req,
Expand Down Expand Up @@ -686,7 +686,7 @@ func TestFetchPackV1ReturnedReaderErrorsOnMalformedMidStreamPacket(t *testing.T)
t.Fatalf("parse endpoint: %v", err)
}
body := &trackingReadCloser{ReadCloser: io.NopCloser(bytes.NewBufferString("0008NAK\nzzzz"))}
conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Request: req,
Expand Down Expand Up @@ -733,7 +733,7 @@ func TestFetchPackV1DrainsSecondNAK(t *testing.T) {
t.Fatalf("parse endpoint: %v", err)
}
body := &trackingReadCloser{ReadCloser: io.NopCloser(bytes.NewReader(payload))}
conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Request: req,
Expand Down Expand Up @@ -776,7 +776,7 @@ func TestFetchPackV2ClosesBodyOnDecodeError(t *testing.T) {
t.Fatalf("parse endpoint: %v", err)
}
body := &trackingReadCloser{ReadCloser: io.NopCloser(bytes.NewBufferString("0000"))}
conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Request: req,
Expand Down Expand Up @@ -912,7 +912,7 @@ func TestFetchPackV2ManyHavesSendsDoneAndReadsPack(t *testing.T) {
}

seenRequest := false
conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
seenRequest = true
body, err := io.ReadAll(req.Body)
if err != nil {
Expand Down Expand Up @@ -999,7 +999,7 @@ func TestFetchPackV2ReturnedReaderClosesBodyOnInterruption(t *testing.T) {
data: wire.Bytes(),
err: io.ErrUnexpectedEOF,
}
conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Request: req,
Expand Down Expand Up @@ -1045,7 +1045,7 @@ func TestFetchPackV2ReturnedReaderErrorsOnMalformedMidStreamPacket(t *testing.T)
t.Fatalf("parse endpoint: %v", err)
}
body := &trackingReadCloser{ReadCloser: io.NopCloser(bytes.NewBufferString(FormatPktLine("packfile\n") + "zzzz"))}
conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Request: req,
Expand Down Expand Up @@ -1088,7 +1088,7 @@ func TestFetchToStoreV2ClosesBodyOnMalformedMidStreamPacket(t *testing.T) {
t.Fatalf("parse endpoint: %v", err)
}
body := &trackingReadCloser{ReadCloser: io.NopCloser(bytes.NewBufferString(FormatPktLine("packfile\n") + "zzzz"))}
conn := NewConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
conn := NewHTTPConn(ep, "source", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Request: req,
Expand Down
16 changes: 8 additions & 8 deletions internal/gitproto/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,14 @@ type PushCommand struct {
// construction without worrying about whether downstream strategies have
// already captured a value copy.
type Pusher struct {
Conn *Conn
Conn Conn
Adv *packp.AdvRefs
Verbose bool
OnRejection func(refName plumbing.ReferenceName, status string)
}

// NewPusher builds a target-side push executor.
func NewPusher(conn *Conn, adv *packp.AdvRefs, verbose bool) *Pusher {
func NewPusher(conn Conn, adv *packp.AdvRefs, verbose bool) *Pusher {
return &Pusher{Conn: conn, Adv: adv, Verbose: verbose}
}

Expand Down Expand Up @@ -140,7 +140,7 @@ func annotateLeaseFailure(err error) error {
// sendReceivePack encodes and POSTs a receive-pack request, then decodes the report.
func sendReceivePack(
ctx context.Context,
conn *Conn,
conn Conn,
req *packp.UpdateRequests,
packData io.Reader,
verbose bool,
Expand All @@ -166,11 +166,11 @@ func sendReceivePack(
switch {
case req.Capabilities.Supports(capability.Sideband64k):
dem := sideband.NewDemuxer(sideband.Sideband64k, reader)
dem.Progress = progressSink(verbose, "target: ", conn.ProgressOut)
dem.Progress = progressSink(verbose, "target: ", conn.ProgressWriter())
respReader = dem
case req.Capabilities.Supports(capability.Sideband):
dem := sideband.NewDemuxer(sideband.Sideband, reader)
dem.Progress = progressSink(verbose, "target: ", conn.ProgressOut)
dem.Progress = progressSink(verbose, "target: ", conn.ProgressWriter())
respReader = dem
}

Expand Down Expand Up @@ -201,7 +201,7 @@ func sendReceivePack(
// PushObjects pushes locally-materialized objects to the target.
func PushObjects(
ctx context.Context,
conn *Conn,
conn Conn,
adv *packp.AdvRefs,
commands []PushCommand,
store storer.Storer,
Expand Down Expand Up @@ -242,7 +242,7 @@ func PushObjects(
// PushPack pushes a pack stream (relay) to the target.
func PushPack(
ctx context.Context,
conn *Conn,
conn Conn,
adv *packp.AdvRefs,
commands []PushCommand,
pack io.ReadCloser,
Expand Down Expand Up @@ -276,7 +276,7 @@ func PushPack(
// PushCommands sends ref update commands without a pack (for ref-only changes).
func PushCommands(
ctx context.Context,
conn *Conn,
conn Conn,
adv *packp.AdvRefs,
commands []PushCommand,
verbose bool,
Expand Down
8 changes: 4 additions & 4 deletions internal/gitproto/push_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,13 @@ func fakeReceivePackServer(t *testing.T, reportErr string) *httptest.Server {
}))
}

func connForServer(t *testing.T, srv *httptest.Server) *Conn {
func connForServer(t *testing.T, srv *httptest.Server) *HTTPConn {
t.Helper()
ep, err := transport.ParseURL(srv.URL + "/repo.git")
if err != nil {
t.Fatalf("parse endpoint: %v", err)
}
return NewConn(ep, "test", nil, srv.Client().Transport)
return NewHTTPConn(ep, "test", nil, srv.Client().Transport)
}

func TestPushPackClosesPackOnSuccess(t *testing.T) {
Expand Down Expand Up @@ -189,7 +189,7 @@ func TestPushPackClosesPackOnContextCanceled(t *testing.T) {
if err != nil {
t.Fatalf("parse endpoint: %v", err)
}
conn := NewConn(ep, "target", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
conn := NewHTTPConn(ep, "target", nil, roundTripperFunc(func(req *http.Request) (*http.Response, error) {
started <- struct{}{}
<-req.Context().Done()
return nil, req.Context().Err()
Expand Down Expand Up @@ -322,7 +322,7 @@ func TestPushPackRejectsDeletes(t *testing.T) {
// Use a nil-transport conn -- we should never reach the network.
ep, err := transport.ParseURL("https://example.com/repo.git")
require.NoError(t, err)
conn := &Conn{Endpoint: ep, HTTP: &http.Client{}}
conn := &HTTPConn{EndpointURL: ep, HTTP: &http.Client{}}

err = PushPack(context.Background(), conn, adv, []PushCommand{
{Name: "refs/heads/old", Delete: true},
Expand Down
Loading
Loading