From 64e0bc1e491ea663e901c20b597b0d20a43982d0 Mon Sep 17 00:00:00 2001 From: Alexandre Ferrreira Date: Sat, 2 May 2026 05:57:00 +0100 Subject: [PATCH 1/2] fix: route package-managed installs away from self-upgrade --- README.md | 24 +++++-- internal/cli/upgrade.go | 79 +++++++++++++++++++++- internal/cli/upgrade_test.go | 123 ++++++++++++++++++++++++++++++++++- 3 files changed, 219 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 32d8952..dc000be 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Run git operations across dozens of repositories in parallel — checkout, pull, - **Parallel by default** — 10 concurrent git ops, live-streamed output. - **Safe** — never force-resets your work; dirty repos are skipped, not clobbered. - **Interactive TUI** — multi-select repos and files with bubbletea. -- **Self-updating** — `gitm upgrade` pulls signed binaries from GitHub Releases. +- **Self-updating (manual installs)** — `gitm upgrade` pulls signed binaries from GitHub Releases on macOS/Linux manual installs. - **Zero config** — single SQLite file at `~/.gitm/gitm.db`, no daemons. --- @@ -107,12 +107,19 @@ curl -fsSL https://raw.githubusercontent.com/alexandreafj/gitm/master/install.sh ### Self-update -Once installed, gitm can update itself with signature verification: +For manual macOS/Linux installs, gitm can update itself with signature verification: ```bash gitm upgrade ``` +If installed via a package manager, use the package manager upgrade flow instead: + +```bash +brew upgrade --cask gitm # Homebrew (macOS) +scoop update gitm # Scoop (Windows) +``` + ### Download pre-built binary Pre-built binaries for all major platforms are also available on the [GitHub Releases](https://github.com/alexandreafj/gitm/releases) page. @@ -159,7 +166,7 @@ gitm --help ### Verification -`gitm upgrade` verifies the signature on `checksums.txt` against this repo's release workflow before installing any new binary: +On manual macOS/Linux installs, `gitm upgrade` verifies the signature on `checksums.txt` against this repo's release workflow before installing any new binary: - The release workflow signs `checksums.txt` with [cosign](https://github.com/sigstore/cosign) in keyless mode (OIDC-bound to `release.yml` on a tagged push). The signature, certificate, and Rekor transparency-log proof are bundled into `checksums.txt.bundle` and uploaded with each release. - `gitm upgrade` downloads the bundle, verifies it against Sigstore's public-good trust root, and aborts on any failure. @@ -1262,12 +1269,19 @@ gitm untrack --repo api-gateway --path "*.log" ### `gitm upgrade` -Self-update gitm to the latest release from GitHub. Downloads the correct binary for your platform, verifies the checksum, and replaces the current binary — no manual download needed. +Self-update gitm to the latest release from GitHub for manual macOS/Linux installs. Downloads the correct binary for your platform, verifies the checksum, and replaces the current binary — no manual download needed. ``` gitm upgrade ``` +If you installed `gitm` with a package manager, use: + +```bash +brew upgrade --cask gitm # Homebrew (macOS) +scoop update gitm # Scoop (Windows) +``` + **What it does:** 1. Queries the [GitHub Releases API](https://github.com/alexandreafj/gitm/releases) for the latest version. @@ -1314,6 +1328,8 @@ gitm --version ``` > **Note:** This command does not require database access — it works even if `~/.gitm/gitm.db` doesn't exist yet. +> +> **Note:** `gitm upgrade` is disabled for package-managed installs (Homebrew/Scoop) and disabled on Windows. --- diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go index 7e24500..5ad0b23 100644 --- a/internal/cli/upgrade.go +++ b/internal/cli/upgrade.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -34,6 +35,62 @@ type ghAsset struct { BrowserDownloadURL string `json:"browser_download_url"` } +type installChannel string + +const ( + installChannelManual installChannel = "manual" + installChannelHomebrew installChannel = "homebrew" + installChannelScoop installChannel = "scoop" +) + +func resolveExecutablePath() (string, error) { + execPath, err := os.Executable() + if err != nil { + return "", err + } + + resolvedPath, err := filepath.EvalSymlinks(execPath) + if err != nil { + return execPath, nil + } + + return resolvedPath, nil +} + +func detectInstallChannel(execPath string) installChannel { + normalizedPath := strings.ToLower(strings.ReplaceAll(execPath, "\\", "/")) + + if strings.Contains(normalizedPath, "/caskroom/gitm/") { + return installChannelHomebrew + } + + if strings.Contains(normalizedPath, "/scoop/apps/gitm/") || strings.Contains(normalizedPath, "/scoop/shims/gitm") { + return installChannelScoop + } + + return installChannelManual +} + +func shouldHideUpgradeCommand(channel installChannel) bool { + return channel == installChannelHomebrew || channel == installChannelScoop +} + +func upgradeBlockedReason(goos string, channel installChannel) string { + if channel == installChannelHomebrew { + return "gitm installed via Homebrew is managed by Homebrew; run `brew upgrade --cask gitm`" + } + + if channel == installChannelScoop { + return "gitm installed via Scoop is managed by Scoop; run `scoop update gitm`" + } + + if goos == "windows" { + return "self-upgrade is not supported on Windows yet; use `scoop update gitm` if installed via Scoop, or reinstall from the latest GitHub release" + } + + return "" +} + func assetName(goos, goarch string) (string, error) { switch { case goos == "darwin" && goarch == "amd64": @@ -388,6 +445,16 @@ func copyFile(src, dst string) error { } func upgradeCmd(currentVersion string) *cobra.Command { + channel := installChannelManual + execPath, err := resolveExecutablePath() + if err == nil { + channel = detectInstallChannel(execPath) + } + + return newUpgradeCmd(currentVersion, runtime.GOOS, channel) +} + +func newUpgradeCmd(currentVersion, goos string, channel installChannel) *cobra.Command { return &cobra.Command{ Use: "upgrade", Short: "Upgrade gitm to the latest release", @@ -395,7 +462,10 @@ func upgradeCmd(currentVersion string) *cobra.Command { The binary's checksums file is verified against a Sigstore signature bundle produced by this repository's release workflow. When upgrading from a release -that predates signing, verification falls back to SHA-256 only with a warning.`, +that predates signing, verification falls back to SHA-256 only with a warning. + +For Homebrew and Scoop installs, use your package manager to update gitm. +Self-upgrade is currently disabled on Windows.`, Example: ` # Upgrade to the latest release gitm upgrade @@ -405,8 +475,13 @@ that predates signing, verification falls back to SHA-256 only with a warning.`, # Verifying signature... ok # Verifying checksum... ok # Updated gitm: v1.1.0 → v1.2.0`, - Args: cobra.NoArgs, + Hidden: shouldHideUpgradeCommand(channel), + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { + if reason := upgradeBlockedReason(goos, channel); reason != "" { + return errors.New(reason) + } + sv, err := newSigstoreVerifier() if err != nil { return fmt.Errorf("init signature verifier: %w", err) diff --git a/internal/cli/upgrade_test.go b/internal/cli/upgrade_test.go index 05db3a7..07313ba 100644 --- a/internal/cli/upgrade_test.go +++ b/internal/cli/upgrade_test.go @@ -358,13 +358,134 @@ func TestCopyFileSourceNotFound(t *testing.T) { } func TestUpgradeCmd(t *testing.T) { - cmd := upgradeCmd("v1.0.0") + cmd := newUpgradeCmd("v1.0.0", "darwin", installChannelManual) if cmd.Use != "upgrade" { t.Errorf("Use = %q, want %q", cmd.Use, "upgrade") } if cmd.Short == "" { t.Error("Short is empty") } + if cmd.Hidden { + t.Error("upgrade command should be visible for manual installs") + } +} + +func TestDetectInstallChannel(t *testing.T) { + tests := []struct { + name string + execPath string + want installChannel + }{ + {name: "homebrew cask", execPath: "/opt/homebrew/Caskroom/gitm/1.0.0/gitm", want: installChannelHomebrew}, + {name: "scoop apps", execPath: `C:\Users\alex\scoop\apps\gitm\current\gitm.exe`, want: installChannelScoop}, + {name: "scoop shim", execPath: `C:\Users\alex\scoop\shims\gitm.exe`, want: installChannelScoop}, + {name: "manual", execPath: "/usr/local/bin/gitm", want: installChannelManual}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := detectInstallChannel(tt.execPath) + if got != tt.want { + t.Errorf("detectInstallChannel(%q) = %q, want %q", tt.execPath, got, tt.want) + } + }) + } +} + +func TestShouldHideUpgradeCommand(t *testing.T) { + tests := []struct { + name string + channel installChannel + want bool + }{ + {name: "homebrew", channel: installChannelHomebrew, want: true}, + {name: "scoop", channel: installChannelScoop, want: true}, + {name: "manual", channel: installChannelManual, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shouldHideUpgradeCommand(tt.channel) + if got != tt.want { + t.Errorf("shouldHideUpgradeCommand(%q) = %v, want %v", tt.channel, got, tt.want) + } + }) + } +} + +func TestUpgradeBlockedReason(t *testing.T) { + tests := []struct { + name string + goos string + channel installChannel + want string + }{ + {name: "homebrew", goos: "darwin", channel: installChannelHomebrew, want: "brew upgrade --cask gitm"}, + {name: "scoop", goos: "windows", channel: installChannelScoop, want: "scoop update gitm"}, + {name: "windows manual", goos: "windows", channel: installChannelManual, want: "not supported on Windows"}, + {name: "linux manual", goos: "linux", channel: installChannelManual, want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := upgradeBlockedReason(tt.goos, tt.channel) + if tt.want == "" { + if got != "" { + t.Errorf("upgradeBlockedReason(%q, %q) = %q, want empty string", tt.goos, tt.channel, got) + } + return + } + + if !strings.Contains(got, tt.want) { + t.Errorf("upgradeBlockedReason(%q, %q) = %q, expected to contain %q", tt.goos, tt.channel, got, tt.want) + } + }) + } +} + +func TestUpgradeCmdHiddenForPackageManagers(t *testing.T) { + tests := []struct { + name string + channel installChannel + }{ + {name: "homebrew", channel: installChannelHomebrew}, + {name: "scoop", channel: installChannelScoop}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := newUpgradeCmd("v1.0.0", "darwin", tt.channel) + if !cmd.Hidden { + t.Errorf("upgrade command should be hidden for %q installs", tt.channel) + } + }) + } +} + +func TestUpgradeCmdBlockedRunReturnsActionableMessage(t *testing.T) { + tests := []struct { + name string + goos string + channel installChannel + want string + }{ + {name: "homebrew", goos: "darwin", channel: installChannelHomebrew, want: "brew upgrade --cask gitm"}, + {name: "scoop", goos: "windows", channel: installChannelScoop, want: "scoop update gitm"}, + {name: "windows manual", goos: "windows", channel: installChannelManual, want: "not supported on Windows"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := newUpgradeCmd("v1.0.0", tt.goos, tt.channel) + err := cmd.RunE(cmd, nil) + if err == nil { + t.Fatal("expected blocked upgrade to return an error") + } + if !strings.Contains(err.Error(), tt.want) { + t.Errorf("error = %q, expected to contain %q", err.Error(), tt.want) + } + }) + } } func TestUpgradeSubcommandRegistered(t *testing.T) { From 703f4965328afacc922479394dcd16fbc97c3bff Mon Sep 17 00:00:00 2001 From: Alexandre Ferrreira Date: Sat, 2 May 2026 06:18:38 +0100 Subject: [PATCH 2/2] docs: clarify Homebrew cask install path --- .goreleaser.yaml | 4 ++-- README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 8394a29..5414111 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -95,10 +95,10 @@ release: header: | ## Installation - ### Homebrew (macOS) + ### Homebrew Cask (macOS) ```bash brew tap alexandreafj/gitm - brew install gitm + brew install --cask gitm ``` ### Scoop (Windows) diff --git a/README.md b/README.md index dc000be..530d598 100644 --- a/README.md +++ b/README.md @@ -77,11 +77,11 @@ When working across many repositories, daily git operations become repetitive: ## Installation -### Homebrew (macOS) +### Homebrew Cask (macOS) ```bash brew tap alexandreafj/gitm -brew install gitm +brew install --cask gitm ``` ### Scoop (Windows)