Skip to content

Commit f12a526

Browse files
Merge branch 'main' into fix/1743-defer-checkpoint-push-empty-remote
2 parents b5ddba3 + 85d9ec5 commit f12a526

9 files changed

Lines changed: 569 additions & 107 deletions

File tree

cmd/entire/cli/checkpoint/remote/git.go

Lines changed: 99 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)