Skip to content

Commit fd75c07

Browse files
committed
refactor(changeprovider): make the git provider pure logic over a contract
Move the git transport plumbing — the bare local copy, fetch, commit resolution, merge base, the git command environment — and the Auth contract out of the change provider extension into platform/git/repo, built on platform/git/exec. The provider now depends on a small Repository interface it defines and holds no os/exec and no credential handling: it parses the change URI, picks the stack baseline, reads the diff and author, and shapes the result, nothing more. Auth moves with the copy it configures, resolving the review point that authentication did not belong in the change provider. The wiring's tokenAuth now implements gitrepo.Auth and SetConfig is gitrepo.SetConfig. Repository-plumbing tests move to platform/git/repo; the provider's behavior tests stay and drive a real gitrepo.Repo through the interface.
1 parent d89eb45 commit fd75c07

10 files changed

Lines changed: 270 additions & 200 deletions

File tree

platform/git/repo/BUILD.bazel

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = [
6+
"auth.go",
7+
"repo.go",
8+
],
9+
importpath = "github.com/uber/submitqueue/platform/git/repo",
10+
visibility = ["//visibility:public"],
11+
deps = ["//platform/git/exec:go_default_library"],
12+
)
13+
14+
go_test(
15+
name = "go_default_test",
16+
srcs = ["repo_test.go"],
17+
# The pinned git, so these assertions describe the build the services run
18+
# rather than whatever the host happens to have.
19+
data = ["@git"],
20+
embed = [":go_default_library"],
21+
env = {
22+
"SUBMITQUEUE_TEST_GIT": "$(location @git//:git)",
23+
},
24+
deps = [
25+
"//platform/git/exec:go_default_library",
26+
"//platform/git/exectest:go_default_library",
27+
"@com_github_stretchr_testify//assert:go_default_library",
28+
"@com_github_stretchr_testify//require:go_default_library",
29+
],
30+
)

submitqueue/extension/changeprovider/git/auth.go renamed to platform/git/repo/auth.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
package git
15+
package gitrepo
1616

1717
import "context"
1818

1919
// Auth prepares a local repository to authenticate to its remote.
2020
//
21-
// This provider never decides what a credential is, where it comes from, or how
21+
// This package never decides what a credential is, where it comes from, or how
2222
// long it lives. An integrator wires an implementation in — reading an
2323
// environment variable, calling a secrets manager, minting a short-lived token —
2424
// and only that implementation changes when the answer does.

submitqueue/extension/changeprovider/git/repo.go renamed to platform/git/repo/repo.go

Lines changed: 73 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,21 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
package git
15+
// Package gitrepo keeps a local, bare copy of a git remote and answers
16+
// questions about its commits.
17+
//
18+
// It is transport plumbing, not domain logic: it fetches, resolves commits,
19+
// and computes merge bases, but never decides what those facts mean. A reader
20+
// that derives change metadata — files, line counts, author — drives a copy
21+
// through this package and interprets the raw git output itself.
22+
//
23+
// The copy is bare because nothing here checks anything out, so there is no
24+
// working tree to leave dirty and no index to corrupt. Git commands against one
25+
// copy cannot safely interleave, so a Repo carries a lock (it embeds
26+
// sync.Mutex) that every reader sharing the copy holds across a sequence of
27+
// commands. Command environment and git-binary resolution come from
28+
// platform/git/exec, the one source of truth every git caller shares.
29+
package gitrepo
1630

1731
import (
1832
"context"
@@ -22,15 +36,17 @@ import (
2236
"path/filepath"
2337
"strings"
2438
"sync"
39+
40+
gitexec "github.com/uber/submitqueue/platform/git/exec"
2541
)
2642

2743
// RepoConfig describes one local copy of a remote.
2844
type RepoConfig struct {
2945
// Git is the path to the git binary. Empty resolves through GIT_EXECUTABLE
3046
// and then PATH.
3147
Git string
32-
// Path is where this service keeps its own copy. It belongs to this service
33-
// alone: another service reading the same remote keeps its own.
48+
// Path is where this copy lives on disk. It belongs to one owner: another
49+
// reader of the same remote keeps its own.
3450
Path string
3551
// RemoteURL is where the copy fetches from — a URL or a local path.
3652
RemoteURL string
@@ -42,52 +58,54 @@ type RepoConfig struct {
4258
Auth Auth
4359
}
4460

45-
// Repo is one local copy of a remote, shared by every provider built over it.
46-
//
47-
// Bare, because nothing here checks anything out: the copy answers questions
48-
// about commits and never produces one. That also means no index and no working
49-
// tree to leave dirty between operations.
61+
// Repo is one local, bare copy of a remote, shared by every reader built over
62+
// it. The embedded mutex serializes git commands against the copy; a reader
63+
// holds it across any sequence that must see a consistent object set.
5064
type Repo struct {
51-
// mu serializes access. Git commands against one repository cannot safely
52-
// interleave, and every provider sharing this copy shares this lock.
53-
mu sync.Mutex
65+
sync.Mutex
5466
cfg RepoConfig
5567
}
5668

5769
// NewRepo returns a Repo for cfg, resolving the git binary. It touches no disk;
5870
// Provision does that.
5971
func NewRepo(cfg RepoConfig) (*Repo, error) {
6072
if cfg.Path == "" {
61-
return nil, fmt.Errorf("git change provider: a repository path is required")
73+
return nil, fmt.Errorf("gitrepo: a repository path is required")
6274
}
6375
if cfg.RemoteURL == "" {
64-
return nil, fmt.Errorf("git change provider: a remote URL is required")
76+
return nil, fmt.Errorf("gitrepo: a remote URL is required")
6577
}
6678
if cfg.Target == "" {
67-
return nil, fmt.Errorf("git change provider: a target branch is required")
79+
return nil, fmt.Errorf("gitrepo: a target branch is required")
6880
}
6981
if cfg.Remote == "" {
7082
cfg.Remote = "origin"
7183
}
7284

73-
git, err := resolveGit(cfg.Git)
85+
git, err := gitexec.Resolve(cfg.Git)
7486
if err != nil {
7587
return nil, err
7688
}
7789
cfg.Git = git
7890
return &Repo{cfg: cfg}, nil
7991
}
8092

93+
// Remote is the name the copy records its remote URL under.
94+
func (r *Repo) Remote() string { return r.cfg.Remote }
95+
96+
// Target is the branch a change's diff is measured against.
97+
func (r *Repo) Target() string { return r.cfg.Target }
98+
8199
// Provision creates the copy if it is not already there and points it at the
82100
// remote, leaving an existing copy's objects alone.
83101
//
84-
// Callers run this at wiring time rather than on first use: resolving a
85-
// provider happens once per message on the validate path, so a copy created
86-
// there would put a clone inside a retry loop and hide a bad remote behind
87-
// queue processing rather than failing the service that owns it.
102+
// Callers run this at wiring time rather than on first use: a reader is often
103+
// resolved once per message on a retry-driven path, so a copy created there
104+
// would put a clone inside a retry loop and hide a bad remote behind queue
105+
// processing rather than failing the service that owns it.
88106
func (r *Repo) Provision(ctx context.Context) error {
89-
r.mu.Lock()
90-
defer r.mu.Unlock()
107+
r.Lock()
108+
defer r.Unlock()
91109

92110
if err := os.MkdirAll(r.cfg.Path, 0o755); err != nil {
93111
return fmt.Errorf("could not create repository directory %q: %w", r.cfg.Path, err)
@@ -110,7 +128,7 @@ func (r *Repo) Provision(ctx context.Context) error {
110128
// at all: an unreachable remote, a wrong URL, or a credential that does not
111129
// work fails the service that is misconfigured. Initializing a directory and
112130
// recording a remote would succeed against a remote that does not exist.
113-
return r.fetchTarget(ctx)
131+
return r.FetchTarget(ctx)
114132
}
115133

116134
// configureRemote records the remote, correcting it if the configuration
@@ -130,24 +148,24 @@ func (r *Repo) configureRemote(ctx context.Context) error {
130148
return err
131149
}
132150

133-
// ensureCommit guarantees sha is present locally, fetching if it is not.
151+
// EnsureCommit guarantees sha is present locally, fetching if it is not.
134152
//
135153
// By SHA first, which needs the server to allow a want for an object it does
136154
// not advertise (github.com does); the change's own ref is the fallback for a
137155
// server that does not. Neither is shallow — a merge base needs ancestry.
138-
func (r *Repo) ensureCommit(ctx context.Context, sha, ref string) error {
139-
if r.hasCommit(ctx, sha) {
156+
func (r *Repo) EnsureCommit(ctx context.Context, sha, ref string) error {
157+
if r.HasCommit(ctx, sha) {
140158
return nil
141159
}
142160
if err := r.applyAuth(ctx); err != nil {
143161
return err
144162
}
145163

146-
if _, err := r.run(ctx, "fetch", r.cfg.Remote, sha); err == nil && r.hasCommit(ctx, sha) {
164+
if _, err := r.run(ctx, "fetch", r.cfg.Remote, sha); err == nil && r.HasCommit(ctx, sha) {
147165
return nil
148166
}
149167
if ref != "" {
150-
if _, err := r.run(ctx, "fetch", r.cfg.Remote, ref); err == nil && r.hasCommit(ctx, sha) {
168+
if _, err := r.run(ctx, "fetch", r.cfg.Remote, ref); err == nil && r.HasCommit(ctx, sha) {
151169
return nil
152170
}
153171
}
@@ -160,9 +178,9 @@ func (r *Repo) ensureCommit(ctx context.Context, sha, ref string) error {
160178
return fmt.Errorf("commit %s is not available from remote %s (tried by SHA and via %q)", sha, r.cfg.Remote, ref)
161179
}
162180

163-
// fetchTarget updates the target branch, which is the baseline a change's first
181+
// FetchTarget updates the target branch, which is the baseline a change's first
164182
// commit is measured from and moves as other changes land.
165-
func (r *Repo) fetchTarget(ctx context.Context) error {
183+
func (r *Repo) FetchTarget(ctx context.Context) error {
166184
if err := r.applyAuth(ctx); err != nil {
167185
return err
168186
}
@@ -177,55 +195,50 @@ func (r *Repo) applyAuth(ctx context.Context) error {
177195
return r.cfg.Auth.Apply(ctx, r.cfg.Path, r.cfg.RemoteURL)
178196
}
179197

180-
func (r *Repo) hasCommit(ctx context.Context, sha string) bool {
198+
// HasCommit reports whether sha is present in the copy.
199+
func (r *Repo) HasCommit(ctx context.Context, sha string) bool {
181200
_, err := r.run(ctx, "cat-file", "-e", sha+"^{commit}")
182201
return err == nil
183202
}
184203

185-
// mergeBase returns the commit two revisions diverged from. Absence of one is
204+
// MergeBase returns the commit two revisions diverged from. Absence of one is
186205
// reported as an error rather than an empty diff: a change sharing no history
187206
// with what it claims to land on is a fact worth surfacing, not a change that
188207
// touches nothing.
189-
func (r *Repo) mergeBase(ctx context.Context, a, b string) (string, error) {
208+
func (r *Repo) MergeBase(ctx context.Context, a, b string) (string, error) {
190209
base, err := r.run(ctx, "merge-base", a, b)
191210
if err != nil {
192211
return "", fmt.Errorf("%s and %s share no history: %w", a, b, err)
193212
}
194213
return base, nil
195214
}
196215

197-
// run executes git inside the copy.
198-
//
199-
// The environment is replaced rather than inherited, for the reason the merger
200-
// records: ambient configuration — a hooks path, a commit template, a signing
201-
// requirement — is exactly what makes a scripted git behave differently on two
202-
// machines. What survives is what reaching a remote needs and what cannot
203-
// change an answer: the SSH agent, TLS roots, and proxy settings.
204-
func (r *Repo) run(ctx context.Context, args ...string) (string, error) {
216+
// command builds a git invocation inside the copy, carrying the shared scrub set
217+
// plus the transport variables a fetch needs. HOME is passed through so git can
218+
// find the user's SSH known_hosts and credential store; it is not in the shared
219+
// transport set, so this package asks for it explicitly.
220+
func (r *Repo) command(ctx context.Context, args ...string) *exec.Cmd {
205221
cmd := exec.CommandContext(ctx, r.cfg.Git, args...)
206222
cmd.Dir = r.cfg.Path
207-
cmd.Env = commandEnv()
223+
cmd.Env = gitexec.Env(gitexec.EnvOptions{Transport: true, Passthrough: []string{"HOME"}})
224+
return cmd
225+
}
208226

209-
var stderr strings.Builder
210-
cmd.Stderr = &stderr
211-
out, err := cmd.Output()
212-
if err != nil {
213-
message := strings.TrimSpace(stderr.String())
214-
if message == "" {
215-
message = err.Error()
216-
}
217-
return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), message)
218-
}
219-
return strings.TrimSpace(string(out)), nil
227+
// run executes git inside the copy and returns trimmed stdout.
228+
func (r *Repo) run(ctx context.Context, args ...string) (string, error) {
229+
out, err := r.outputOf(ctx, args...)
230+
return strings.TrimSpace(out), err
220231
}
221232

222-
// output runs git and returns stdout untrimmed, for commands whose output is
223-
// NUL-delimited and whose trailing separator is part of the format.
224-
func (r *Repo) output(ctx context.Context, args ...string) (string, error) {
225-
cmd := exec.CommandContext(ctx, r.cfg.Git, args...)
226-
cmd.Dir = r.cfg.Path
227-
cmd.Env = commandEnv()
233+
// RunRaw executes git inside the copy and returns stdout untrimmed, for commands
234+
// whose output is NUL-delimited and whose trailing separator is part of the
235+
// format.
236+
func (r *Repo) RunRaw(ctx context.Context, args ...string) (string, error) {
237+
return r.outputOf(ctx, args...)
238+
}
228239

240+
func (r *Repo) outputOf(ctx context.Context, args ...string) (string, error) {
241+
cmd := r.command(ctx, args...)
229242
var stderr strings.Builder
230243
cmd.Stderr = &stderr
231244
out, err := cmd.Output()
@@ -239,58 +252,16 @@ func (r *Repo) output(ctx context.Context, args ...string) (string, error) {
239252
return string(out), nil
240253
}
241254

242-
// scrubbedEnv is the configuration-denying half of a git invocation.
243-
var scrubbedEnv = []string{
244-
"GIT_CONFIG_NOSYSTEM=1",
245-
"GIT_CONFIG_GLOBAL=" + os.DevNull,
246-
"GIT_ATTR_NOSYSTEM=1",
247-
"GIT_TERMINAL_PROMPT=0",
248-
"GIT_PAGER=cat",
249-
"GIT_EDITOR=:",
250-
}
251-
252-
// transportEnvNames are inherited when set. None can change what a diff says;
253-
// all of them decide whether a remote can be reached at all.
254-
var transportEnvNames = []string{
255-
"SSH_AUTH_SOCK",
256-
"SSH_AGENT_PID",
257-
"PATH",
258-
"HOME",
259-
"GIT_SSH",
260-
"GIT_SSH_COMMAND",
261-
"GIT_SSH_VARIANT",
262-
"GIT_SSL_CAINFO",
263-
"GIT_SSL_CAPATH",
264-
"SSL_CERT_DIR",
265-
"SSL_CERT_FILE",
266-
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
267-
"http_proxy", "https_proxy", "no_proxy",
268-
}
269-
270-
func commandEnv() []string {
271-
env := make([]string, 0, len(scrubbedEnv)+len(transportEnvNames))
272-
env = append(env, scrubbedEnv...)
273-
for _, name := range transportEnvNames {
274-
if value, ok := os.LookupEnv(name); ok {
275-
env = append(env, name+"="+value)
276-
}
277-
}
278-
return env
279-
}
280-
281255
// SetConfig writes one local configuration value into the repository at path.
282256
//
283257
// Exported for an Auth implementation, which configures a repository from
284258
// outside this package and would otherwise have to find and run git itself.
285259
func SetConfig(ctx context.Context, path, key, value string) error {
286-
git, err := resolveGit("")
260+
git, err := gitexec.Resolve("")
287261
if err != nil {
288262
return err
289263
}
290-
cmd := exec.CommandContext(ctx, git, "config", key, value)
291-
cmd.Dir = path
292-
cmd.Env = commandEnv()
293-
264+
cmd := gitexec.Command(ctx, git, path, "config", key, value)
294265
var stderr strings.Builder
295266
cmd.Stderr = &stderr
296267
if err := cmd.Run(); err != nil {
@@ -302,27 +273,3 @@ func SetConfig(ctx context.Context, path, key, value string) error {
302273
}
303274
return nil
304275
}
305-
306-
// resolveGit locates the git binary, preferring an explicit path, then
307-
// GIT_EXECUTABLE, then PATH — the convention the rest of the repository uses.
308-
func resolveGit(path string) (string, error) {
309-
candidate := strings.TrimSpace(path)
310-
if candidate == "" {
311-
candidate = strings.TrimSpace(os.Getenv("GIT_EXECUTABLE"))
312-
}
313-
if candidate == "" {
314-
found, err := exec.LookPath("git")
315-
if err != nil {
316-
return "", fmt.Errorf("git change provider: no git binary found: %w", err)
317-
}
318-
candidate = found
319-
}
320-
absolute, err := filepath.Abs(candidate)
321-
if err != nil {
322-
return "", fmt.Errorf("git change provider: %q is not a usable path: %w", candidate, err)
323-
}
324-
if info, err := os.Stat(absolute); err != nil || info.IsDir() {
325-
return "", fmt.Errorf("git change provider: %q is not an executable file", absolute)
326-
}
327-
return absolute, nil
328-
}

0 commit comments

Comments
 (0)