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
1731import (
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.
2844type 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.
5064type 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.
5971func 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.
88106func (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.
285259func 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