Skip to content

transport/remotehelper: Add transport for Git remote helpers - #14

Open
pjbgf wants to merge 1 commit into
mainfrom
remote-helper
Open

pjbgf wants to merge 1 commit into
mainfrom
remote-helper

Conversation

@pjbgf

@pjbgf pjbgf commented Oct 31, 2026

Copy link
Copy Markdown
Member

Add a go-git transport that runs a Git remote helper program (git-remote-) and talks to it through the connect capability. The helper only carries the pack protocol, which go-git still speaks itself, so v0, v1 and v2 all work.

Helpers are opt-in per name and per operation, registered with client.WithTransport. The ext helper is refused. The program is resolved to an absolute path once, at construction. Requests are limited to the Git services and addresses starting with "-" are rejected. Git's repository environment variables are stripped from the helper's environment. Handshake lines and stderr are bounded. When the context is done the helper is killed and its pipes are closed, so a process it started cannot keep a read blocked. A helper that does not exit after Close is killed.

Add a go-git transport that runs a Git remote helper program
(git-remote-<name>) and talks to it through the connect capability.
The helper only carries the pack protocol, which go-git still speaks
itself, so v0, v1 and v2 all work.

Helpers are opt-in per name and per operation, registered with
client.WithTransport. The ext helper is refused. The program is
resolved to an absolute path once, at construction. Requests are
limited to the Git services and addresses starting with "-" are
rejected. Git's repository environment variables are stripped from
the helper's environment. Handshake lines and stderr are bounded. When
the context is done the helper is killed and its pipes are closed, so a
process it started cannot keep a read blocked. A helper that does not
exit after Close is killed.

Assisted-by: Claude Opus 5.5 <[email protected]>
Signed-off-by: Paulo Gomes <[email protected]>
Copilot AI balanced review requested due to automatic review settings October 31, 2026 08:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Forced-helper URLs without :// bypass the registered transport, and failed connections return a non-nil interface that can panic during cleanup.

2 open issues
What changed in this PR

Adds an opt-in remote-helper transport that lets go-git exchange Git pack protocols through external git-remote-<name> programs.

Changes:

  • Implements capability negotiation and raw connections through the helper’s connect capability.
  • Adds request validation, environment filtering, bounded diagnostics, and process cleanup.
  • Adds integration tests for protocol versions, clone/push, error handling, and cancellation.
File Description
transport/​remotehelper/​remotehelper.go Implements the transport and helper lifecycle safeguards.
transport/​remotehelper/​remotehelper_test.go Tests transport behavior and end-to-end operations.
transport/​remotehelper/​helper_test.go Provides a subprocess helper with service and failure modes.
transport/​remotehelper/​go.sum Records dependency checksums.
transport/​remotehelper/​go.mod Defines the module and its dependencies.

Review effort: Balanced

go 1.26.0

require (
github.com/go-git/go-git/v6 v6.0.0-beta.1.0.20261030085734-db474d339548
//
//nolint:ireturn // transport.Connector requires returning the Conn interface.
func (t *Transport) Connect(ctx context.Context, req *transport.Request) (transport.Conn, error) {
return t.connect(ctx, req)

@hiddeco hiddeco left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a few things I think need addressing (see the inline comments), most importantly Close failing for helpers that leave a process behind, and the version of go-git this module requires.

Note that I also left two design questions on go-git/go-git#2546 (submodules, and a separate registry for helpers), which may change how this transport is registered.

go 1.26.0

require (
github.com/go-git/go-git/v6 v6.0.0-beta.1.0.20261030085734-db474d339548

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This version predates go-git/go-git#2546. With it, foo::<address> only reaches the helper when the address happens to contain ://, while foo::/path or foo::host/repo parses as SCP-like SSH and dials host foo. As Go selects the minimum required version, a user who only adds this module gets that behaviour.

The tests do not catch this, as they build the helper URL by hand (helperURL) and clone through test://. I would like to see #2546 merged first, this bumped to a version that includes it, and a clone (and push) through git.CloneContext with a test::<address> URL that asserts the arguments of the helper. This to have at least one test that exercises both repositories together.

}

stderr := &boundedBuffer{buf: nil, limit: maxStderrSize, mu: sync.Mutex{}}
cmd.Stderr = stderr

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As stderr is not an *os.File, os/exec creates a pipe and Wait waits for the copy to finish. When the helper leaves a process behind that holds stderr (e.g. ssh with ControlPersist), Wait returns exec.ErrWaitDelay after 5s. Close then returns an error and the clone fails, while the refs and objects have already been written. A probe with a helper that starts a sleep child failed after ~6s with "exec: WaitDelay expired before I/O complete".

The smallest fix is to treat exec.ErrWaitDelay as success in wait() when c.cmd.ProcessState.Success(). Reading stderr through an os.Pipe with a goroutine Wait does not wait for would avoid the delay as well. Given this was not caught, a test with a child that holds stderr would be good to have.


// Stderr returns what the helper has written to stderr so far.
// StreamSession reports it in place of a failed operation's error.
func (c *conn) Stderr() io.Reader {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because conn implements Stderr(), StreamSession.wrapStderr replaces every Fetch, Push and Command error with what the helper wrote to stderr. Helpers write warnings and progress to stderr routinely, and a probe with a helper that writes a single warning got that warning back in place of context.DeadlineExceeded. As Close and the handshake already append stderr through withStderr, I think this can return nil (or only return stderr once the helper has exited unsuccessfully).

return nil, err
}

sess, err := transport.NewStreamSession(helper, req.Command)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the context is done while NewStreamSession reads the advertisement, this returns a bare "read |0: file already closed". connect wraps ctx.Err() for this case, I think the same should happen here so callers can tell it was a cancellation.

return nil, fmt.Errorf("%w: %q", ErrInvalidName, name)
}

if name == "ext" {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The package documentation says NewTransport always refuses ext, but this only checks the name. NewTransport("foo", Options{Program: "<exec-path>/git-remote-ext"}) is accepted, and runs its address as a command. I would either refuse when the base name of the resolved program is git-remote-ext (case-insensitive, without .exe), or narrow the documentation to "refuses the name ext".

// GitDir is exported to the helper as GIT_DIR, as Git does for the
// repository it runs in. A relative path is made absolute. When
// empty, GIT_DIR is not set.
GitDir string

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One Transport serves every repository the ClientOptions are used for, including submodules, so their helpers get the GIT_DIR of the superproject. Git sets it per repository (transport-helper.c:151-153). This can not be fixed from here without the repository being on transport.Request, so for now I would document that it is wrong for submodules.

assert.NotContains(t, dump.Env, "GIT_PROTOCOL")
}

func TestConnectContextCancelKillsHelper(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This covers cancellation during the capabilities handshake only. Replacing exec.CommandContext with exec.Command keeps the whole suite green, as does deregistering the AfterFunc once connect succeeds. I would like to see a test that cancels the context after a successful Connect (also with a child holding stdout), and asserts that reads end and the helper is gone.

return dump
}

func TestConnectEnvironment(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The appended GIT_DIR= and GIT_PROTOCOL= values win over the inherited ones here, so the stripping of those two is never observed. Removing them from isLocalRepoEnv keeps the test green. Setting both in the parent environment, and making a V0 request with an empty GitDir, would cover it.

// ClientOptions: []client.Option{client.WithTransport("foo", tr)},
// })
//
// The helper is invoked as Git invokes it for a URL given on the command

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the helper is invoked mostly as Git invokes it, but there are deliberate differences. Git passes GIT_CONFIG_PARAMETERS and the other repository variables through and never sets GIT_PROTOCOL for a helper, and for foo:// URLs the helper gets the re-encoded url.URL form rather than the string the user wrote. I would mention these here, so the comment does not suggest parity.

}

// isURLSchemeChar mirrors Git's is_urlschemechar. See
// https://github.com/git/git/blob/v2.56.0/url.c#L5-L19.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: at v2.56.0, is_urlschemechar is at L19-L32.

Suggested change
// https://github.com/git/git/blob/v2.56.0/url.c#L5-L19.
// https://github.com/git/git/blob/v2.56.0/url.c#L19-L32.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants