Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 22 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Comment thread
alexandreafj marked this conversation as resolved.
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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Comment thread
alexandreafj marked this conversation as resolved.
scoop update gitm # Scoop (Windows)
```

**What it does:**

1. Queries the [GitHub Releases API](https://github.com/alexandreafj/gitm/releases) for the latest version.
Expand Down Expand Up @@ -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.

---

Expand Down
79 changes: 77 additions & 2 deletions internal/cli/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -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/") {
Comment thread
alexandreafj marked this conversation as resolved.
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`"
Comment thread
alexandreafj marked this conversation as resolved.
}

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":
Expand Down Expand Up @@ -388,14 +445,27 @@ 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",
Long: `Download and install the latest gitm binary from GitHub releases.

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

Expand All @@ -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)
Expand Down
123 changes: 122 additions & 1 deletion internal/cli/upgrade_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
}
Comment thread
alexandreafj marked this conversation as resolved.

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) {
Expand Down
Loading