@@ -11,11 +11,18 @@ import (
1111 "strconv"
1212 "strings"
1313 "sync"
14+ "time"
1415
1516 "github.com/entireio/cli/cmd/entire/cli/logging"
1617 "github.com/entireio/cli/cmd/entire/cli/settings"
1718)
1819
20+ // stampConfigTimeout bounds the local git-config reads/writes that mark a newly
21+ // created checkpoint remote as skipped. They run detached from the fetch's
22+ // context (see stampNewlyCreatedRemote), so a bound guards against a stuck
23+ // config lock hanging the caller.
24+ const stampConfigTimeout = 10 * time .Second
25+
1926// CheckpointTokenEnvVar is the environment variable for providing an access token
2027// used to authenticate git push/fetch operations for checkpoint branches.
2128// The token is injected as an HTTP Basic Authorization header per RFC 7617:
@@ -85,80 +92,120 @@ func Fetch(ctx context.Context, opts FetchOptions) ([]byte, error) {
8592 args = append (args , opts .Remote )
8693 args = append (args , opts .RefSpecs ... )
8794
95+ // A filtered fetch from a URL makes git record a URL-keyed remote section
96+ // (remote.<url>.*) so it can lazy-fetch filtered-out objects later. That
97+ // section also turns the URL into a phantom remote that `git fetch --all`
98+ // and `git remote update` keep dialing. When this fetch is the one creating
99+ // the section, stamp skipFetchAll so bulk fetches skip our adhoc remote.
100+ // Remotes that already existed are left untouched so we never rewrite the
101+ // user's config.
102+ var stampURL string
103+ var stampCandidate , existedBefore bool
104+ if filtered && IsURL (opts .Remote ) {
105+ stampCandidate = true
106+ stampURL = opts .Remote
107+ if token := strings .TrimSpace (os .Getenv (CheckpointTokenEnvVar )); token != "" && isValidToken (token ) {
108+ // With a checkpoint token, newCommand rewrites SSH targets to HTTPS
109+ // and git records the section under the rewritten URL.
110+ stampURL , _ = resolveTargetForTokenAuth (ctx , stampURL )
111+ }
112+ existedBefore = gitRemoteSectionExists (ctx , opts .Dir , stampURL )
113+ }
114+
88115 cmd := newCommand (ctx , args ... )
89116 if opts .Dir != "" {
90117 cmd .Dir = opts .Dir
91118 }
92119 disableTerminalPrompt (cmd )
93120 out , err := cmd .CombinedOutput ()
121+
122+ if stampCandidate && ! existedBefore {
123+ stampNewlyCreatedRemote (ctx , opts .Dir , stampURL )
124+ }
125+
94126 if err != nil {
95127 return out , fmt .Errorf ("git fetch: %w" , err )
96128 }
97- if filtered && IsURL (opts .Remote ) {
98- // Stamp the URL git actually fetched from: with a checkpoint token set,
99- // newCommand rewrites SSH targets to HTTPS, and git records the
100- // promisor entry under the rewritten URL.
101- target := opts .Remote
102- if token := strings .TrimSpace (os .Getenv (CheckpointTokenEnvVar )); token != "" && isValidToken (token ) {
103- target , _ = resolveTargetForTokenAuth (ctx , target )
104- }
105- markPromisorEntrySkipped (ctx , opts .Dir , target )
106- }
107129 return out , nil
108130}
109131
110- // markPromisorEntrySkipped excludes the URL-keyed config section that git
111- // creates for a filtered URL fetch (remote.<url>.promisor=true) from
112- // `git fetch --all` and `git remote update`. Git needs the promisor entry to
113- // lazy-fetch filtered-out objects later, but the entry also makes the URL show
114- // up as a fetchable remote, so without this every checkpoint URL ever fetched
115- // from lingers as a phantom remote that bulk fetches keep dialing.
116- // Best-effort: the fetch already succeeded, so failures only log.
117- func markPromisorEntrySkipped (ctx context.Context , dir , url string ) {
118- if ! gitConfigBool (ctx , dir , "remote." + url + ".promisor" ) {
119- // Git didn't record a promisor entry for this URL; don't invent a
120- // config section that wouldn't otherwise exist.
121- return
122- }
123- for _ , key := range []string {"skipFetchAll" , "skipDefaultUpdate" } {
124- fullKey := "remote." + url + "." + key
125- if gitConfigBool (ctx , dir , fullKey ) {
126- // Checked per key so a partially-stamped entry (e.g. an earlier
127- // run failing between the two writes) still gets completed.
128- continue
129- }
130- cmd := exec .CommandContext (ctx , "git" , "config" , "--local" , fullKey , "true" )
131- if dir != "" {
132- cmd .Dir = dir
133- }
134- if out , cfgErr := cmd .CombinedOutput (); cfgErr != nil {
135- redactedURL := RedactURL (url )
136- // The output can echo the key, which embeds the URL — and a URL
137- // can carry credentials. Redact before logging.
138- msg := strings .TrimSpace (strings .ReplaceAll (string (out ), url , redactedURL ))
139- logging .Warn (ctx , "failed to mark promisor config entry as skipped for bulk fetches" ,
140- slog .String ("url" , redactedURL ),
141- slog .String ("key" , key ),
142- slog .String ("output" , msg ),
143- slog .String ("error" , cfgErr .Error ()),
144- )
145- return
146- }
132+ // stampNewlyCreatedRemote stamps a URL-keyed remote section that this fetch just
133+ // created. Git writes remote.<url>.promisor eagerly during connection setup, so
134+ // a filtered fetch that later fails still leaves the phantom remote behind;
135+ // stamping here — rather than only on fetch success — keeps it from lingering
136+ // unstamped forever (the section then exists on the next attempt, so it never
137+ // looks "new" again). Re-checking existence keeps us from inventing a section
138+ // when the fetch died before git wrote anything.
139+ //
140+ // The git-config commands run on a context detached from the fetch's deadline:
141+ // a filtered fetch that timed out leaves ctx already past its deadline, and
142+ // inheriting it would make these local commands fail immediately and leave the
143+ // phantom unstamped — the very miss this stamping exists to prevent.
144+ func stampNewlyCreatedRemote (ctx context.Context , dir , url string ) {
145+ ctx , cancel := context .WithTimeout (context .WithoutCancel (ctx ), stampConfigTimeout )
146+ defer cancel ()
147+ if gitRemoteSectionExists (ctx , dir , url ) {
148+ markRemoteSkipped (ctx , dir , url )
149+ }
150+ }
151+
152+ // markRemoteSkipped stamps skipFetchAll on a URL-keyed remote section so
153+ // `git fetch --all` and `git remote update` skip it. Called only for remotes
154+ // this fetch just created, so an adhoc checkpoint URL never lingers as a phantom
155+ // remote that bulk fetches keep dialing.
156+ // Best-effort: the git config write is not worth failing the fetch over, so
157+ // failures only log.
158+ func markRemoteSkipped (ctx context.Context , dir , url string ) {
159+ fullKey := "remote." + url + ".skipFetchAll"
160+ cmd := exec .CommandContext (ctx , "git" , "config" , "--local" , fullKey , "true" )
161+ if dir != "" {
162+ cmd .Dir = dir
163+ }
164+ if out , cfgErr := cmd .CombinedOutput (); cfgErr != nil {
165+ redactedURL := RedactURL (url )
166+ // The output can echo the key, which embeds the URL — and a URL can
167+ // carry credentials. Redact before logging.
168+ msg := strings .TrimSpace (strings .ReplaceAll (string (out ), url , redactedURL ))
169+ logging .Warn (ctx , "failed to mark remote config entry as skipped for bulk fetches" ,
170+ slog .String ("url" , redactedURL ),
171+ slog .String ("output" , msg ),
172+ slog .String ("error" , cfgErr .Error ()),
173+ )
147174 }
148175}
149176
150- // gitConfigBool reads a local git config key and reports whether it is set to
151- // a true value. Missing keys and read errors report false.
152- func gitConfigBool (ctx context.Context , dir , key string ) bool {
153- cmd := exec .CommandContext (ctx , "git" , "config" , "--local" , "--get" , "--type=bool" , key )
177+ // gitRemoteSectionExists reports whether a remote.<url>.* config section already
178+ // exists in the local git config. Used to tell whether a filtered URL fetch is
179+ // about to create a new URL-keyed remote, so we only stamp remotes we create and
180+ // never rewrite ones the user already has.
181+ func gitRemoteSectionExists (ctx context.Context , dir , url string ) bool {
182+ cmd := exec .CommandContext (ctx , "git" , "config" , "--local" , "--list" , "--name-only" )
154183 if dir != "" {
155184 cmd .Dir = dir
156185 }
157186 out , err := cmd .Output ()
158187 if err != nil {
159188 return false
160189 }
161- return strings .TrimSpace (string (out )) == "true"
190+ // Each name is "remote.<url>.<key>". Git config keys carry no dots, so the
191+ // final dotted component is the key and everything between "remote." and it
192+ // is the subsection (the URL, whose case git preserves). Compare the
193+ // subsection exactly so a longer URL that shares a prefix (e.g. a
194+ // ".../repo.git" section vs a ".../repo" fetch) is not a false match.
195+ for line := range strings .SplitSeq (string (out ), "\n " ) {
196+ rest , ok := strings .CutPrefix (line , "remote." )
197+ if ! ok {
198+ continue
199+ }
200+ lastDot := strings .LastIndexByte (rest , '.' )
201+ if lastDot < 0 {
202+ continue
203+ }
204+ if rest [:lastDot ] == url {
205+ return true
206+ }
207+ }
208+ return false
162209}
163210
164211// FetchBlobs fetches specific objects (typically blobs) by hash from a remote.
0 commit comments