Skip to content

Commit 54cd504

Browse files
authored
homebrew: handle situation when local tap version is not refreshed yet during update (#57)
1 parent 9c4ee1c commit 54cd504

3 files changed

Lines changed: 149 additions & 4 deletions

File tree

internal/selfupdate.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package internal
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
67
"os"
78
"os/exec"
@@ -160,6 +161,68 @@ func repoSlug() selfupdate.RepositorySlug {
160161
return selfupdate.NewRepositorySlug(repoOwner, repoName)
161162
}
162163

164+
func parseBrewFormulaVersion(data []byte) (string, error) {
165+
var result struct {
166+
Formulae []struct {
167+
Versions struct {
168+
Stable string `json:"stable"`
169+
} `json:"versions"`
170+
} `json:"formulae"`
171+
}
172+
if err := json.Unmarshal(data, &result); err != nil {
173+
return "", err
174+
}
175+
if len(result.Formulae) == 0 || result.Formulae[0].Versions.Stable == "" {
176+
return "", fmt.Errorf("no version found in brew info output")
177+
}
178+
return result.Formulae[0].Versions.Stable, nil
179+
}
180+
181+
// brewFormulaVersion returns the version from the local Homebrew tap formula.
182+
//
183+
// Homebrew caches third-party tap formulae as local git clones and only
184+
// refreshes them when auto-update fires. Auto-update is triggered by
185+
// commands like `brew install` or `brew upgrade`, but has a 24h debounce
186+
// (HOMEBREW_AUTO_UPDATE_SECS, default 86400s) — if any brew command ran
187+
// within that window, the refresh is skipped. There is no background
188+
// process; taps stay stale until the next brew command after the debounce
189+
// expires, or until the user runs `brew update` manually.
190+
//
191+
// Unlike `go install @vX.Y.Z`, there is no way to tell brew to install a
192+
// specific version of a tap formula. The `@version` syntax (e.g.
193+
// `python@3.11`) only works when a separate versioned formula file exists
194+
// in the tap, which we don't publish.
195+
//
196+
// This function lets us detect when the local formula is behind the latest
197+
// GitHub release, so we can suppress misleading update prompts rather than
198+
// pointing the user at an upgrade that would silently no-op.
199+
func brewFormulaVersion(ctx context.Context) (string, error) {
200+
cmd := exec.CommandContext(ctx, "brew", "info", "--json=v2", "threedotslabs/tap/tdl")
201+
out, err := cmd.Output()
202+
if err != nil {
203+
return "", fmt.Errorf("brew info failed: %w", err)
204+
}
205+
return parseBrewFormulaVersion(out)
206+
}
207+
208+
// isVersionAvailableForInstallMethod checks whether the given version
209+
// is available through the user's install method. Currently only
210+
// Homebrew is checked — see brewFormulaVersion for why.
211+
// Returns true for all other methods or on any error (graceful fallback).
212+
func isVersionAvailableForInstallMethod(version string) bool {
213+
method := DetectInstallMethod()
214+
if method != InstallMethodHomebrew {
215+
return true
216+
}
217+
formulaVer, err := brewFormulaVersion(context.Background())
218+
if err != nil {
219+
logrus.WithError(err).Debug("Could not check brew formula version")
220+
return true
221+
}
222+
target := strings.TrimPrefix(version, "v")
223+
return !isNewerVersion(target, formulaVer)
224+
}
225+
163226
// RunUpdate checks for updates and applies them based on the installation method.
164227
func RunUpdate(ctx context.Context, currentVersion string, opts UpdateOptions) error {
165228
if os.Getenv("TDL_NO_UPDATE_CHECK") != "" {
@@ -243,6 +306,22 @@ func RunUpdate(ctx context.Context, currentVersion string, opts UpdateOptions) e
243306
// Branch on install method
244307
switch method {
245308
case InstallMethodHomebrew:
309+
// Homebrew's local tap may still point to an older formula version
310+
// (24h auto-update debounce). Bail out early instead of running a
311+
// doomed `brew upgrade` that would print "already installed".
312+
formulaVer, err := brewFormulaVersion(ctx)
313+
if err == nil {
314+
target := strings.TrimPrefix(targetVersion, "v")
315+
if isNewerVersion(target, formulaVer) {
316+
fmt.Println(color.YellowString(
317+
"Version %s is not yet available via Homebrew (formula has %s).",
318+
targetVersion, formulaVer,
319+
))
320+
fmt.Println("Homebrew updates its formula cache periodically. To refresh manually, run:")
321+
fmt.Printf(" %s\n\n", SprintCommand("brew update && brew upgrade tdl"))
322+
return nil
323+
}
324+
}
246325
return updateViaCommand(ctx, "brew", opts, currentVersion, targetVersion,
247326
[]string{"upgrade", "tdl"})
248327

internal/selfupdate_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,54 @@ func TestFormatReleaseNotes(t *testing.T) {
212212
assert.Contains(t, result, "Second line")
213213
})
214214
}
215+
216+
func TestParseBrewFormulaVersion(t *testing.T) {
217+
tests := []struct {
218+
name string
219+
json string
220+
want string
221+
wantErr bool
222+
}{
223+
{
224+
name: "valid response",
225+
json: `{"formulae":[{"versions":{"stable":"0.1.78"}}],"casks":[]}`,
226+
want: "0.1.78",
227+
},
228+
{
229+
name: "version with multiple formulae",
230+
json: `{"formulae":[{"versions":{"stable":"1.2.3"}},{"versions":{"stable":"4.5.6"}}],"casks":[]}`,
231+
want: "1.2.3",
232+
},
233+
{
234+
name: "empty formulae array",
235+
json: `{"formulae":[],"casks":[]}`,
236+
wantErr: true,
237+
},
238+
{
239+
name: "missing stable version",
240+
json: `{"formulae":[{"versions":{"head":"HEAD"}}],"casks":[]}`,
241+
wantErr: true,
242+
},
243+
{
244+
name: "invalid json",
245+
json: `{broken`,
246+
wantErr: true,
247+
},
248+
{
249+
name: "empty input",
250+
json: ``,
251+
wantErr: true,
252+
},
253+
}
254+
for _, tt := range tests {
255+
t.Run(tt.name, func(t *testing.T) {
256+
got, err := parseBrewFormulaVersion([]byte(tt.json))
257+
if tt.wantErr {
258+
assert.Error(t, err)
259+
} else {
260+
assert.NoError(t, err)
261+
assert.Equal(t, tt.want, got)
262+
}
263+
})
264+
}
265+
}

internal/update.go

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,16 @@ func CheckForUpdate(currentVersion string, commandName string, forcePrompt bool)
6666
isDifferent := release.Version != "" && release.Version != currentVersion
6767

6868
if isNewer || (forcePrompt && isDifferent) {
69+
// Don't advertise updates that aren't installable yet — e.g. the
70+
// Homebrew tap formula may still point to the old version (24h
71+
// auto-update debounce). The notice will appear once the tap refreshes.
72+
if !forcePrompt && !isVersionAvailableForInstallMethod(release.Version) {
73+
logrus.Debug("Version not yet available via install method, skipping notice")
74+
updateInfo.LastChecked = time.Now()
75+
_ = storeUpdateInfo(updateInfo)
76+
return
77+
}
78+
6979
updateInfo.CurrentVersion = currentVersion
7080
updateInfo.AvailableVersion = release.Version
7181
updateInfo.UpdateAvailable = true
@@ -122,10 +132,15 @@ func CheckUpdateAvailable(currentVersion string, forcePrompt bool) (available bo
122132
isNewer := release.Version != "" && isNewerVersion(release.Version, currentVersion)
123133
isDifferent := release.Version != "" && release.Version != currentVersion
124134
if isNewer || (forcePrompt && isDifferent) {
125-
updateInfo.CurrentVersion = currentVersion
126-
updateInfo.AvailableVersion = release.Version
127-
updateInfo.UpdateAvailable = true
128-
updateInfo.ReleaseNotes = release.ReleaseNotes
135+
// Same install-method gate as in CheckForUpdate — see comment there.
136+
if forcePrompt || isVersionAvailableForInstallMethod(release.Version) {
137+
updateInfo.CurrentVersion = currentVersion
138+
updateInfo.AvailableVersion = release.Version
139+
updateInfo.UpdateAvailable = true
140+
updateInfo.ReleaseNotes = release.ReleaseNotes
141+
} else {
142+
logrus.Debug("Version not yet available via install method, skipping")
143+
}
129144
} else {
130145
updateInfo.CurrentVersion = currentVersion
131146
updateInfo.AvailableVersion = ""

0 commit comments

Comments
 (0)