diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 1291439..5bfc4c9 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,5 +1,6 @@ -# Builds manygit for macOS + Linux (Intel & Apple Silicon) and publishes a -# GitHub Release. Triggered by pushing a v* tag (see .github/workflows/release.yml). +# Builds manygit for macOS, Linux and Windows (Intel & Apple/Arm silicon) and +# publishes a GitHub Release. Triggered by pushing a v* tag (see +# .github/workflows/release.yml). version: 2 before: @@ -15,6 +16,7 @@ builds: goos: - linux - darwin + - windows goarch: - amd64 - arm64 @@ -23,6 +25,10 @@ builds: archives: - id: manygit + # Every OS ships the same tar.gz, windows included: Windows 10 1803+ and + # Windows 11 both bundle tar.exe, so install.ps1 and the self-updater can + # both just extract it — no second archive format to keep in sync. Inside + # a windows archive goreleaser names the binary manygit.exe automatically. formats: [tar.gz] # e.g. manygit_darwin_arm64.tar.gz — the installer and self-updater expect # exactly this name. @@ -41,10 +47,11 @@ checksum: # loaded until the user trusts it. Nothing here can waive that — it is a decision # Homebrew reserves for the machine running the code. # -# Casks are macOS-only by design; Linux installs through install.sh. Note the -# cask points at the SAME archive install.sh downloads — there is no separate -# brew build — which is why manygit works out who owns its binary at runtime -# rather than from a build flag. See docs/adr/0001-runtime-install-ownership-detection.md. +# Casks are macOS-only by design; Linux installs through install.sh, Windows +# through install.ps1 (or install.sh under Git Bash/MSYS/WSL). Note the cask +# points at the SAME archive install.sh downloads — there is no separate brew +# build — which is why manygit works out who owns its binary at runtime rather +# than from a build flag. See docs/adr/0001-runtime-install-ownership-detection.md. homebrew_casks: - name: manygit binaries: diff --git a/README.md b/README.md index 9617335..7c7dc53 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,16 @@ you can run `manygit` from anywhere. On each launch it checks for a newer releas and offers to update itself (`--no-update-check`, or `MANYGIT_NO_UPDATE_CHECK=1`, skips that). +**Windows — installer script:** + +```powershell +irm https://raw.githubusercontent.com/rabeeh-ta/manygit/main/install.ps1 | iex +``` + +Drops `manygit.exe` into `%LocalAppData%\manygit\bin` and adds it to your PATH. +Same self-update behavior as above. Already have bash — Git Bash, MSYS2, Cygwin +or WSL? `install.sh` detects Windows there too and installs the same binary. + A Homebrew install works differently: Homebrew owns that binary, so manygit never replaces it. It tells you a release is out — at most once a day, after you quit — and leaves `brew update && brew upgrade --cask manygit` to you. The @@ -58,6 +68,8 @@ tag: curl -fsSL https://raw.githubusercontent.com/rabeeh-ta/manygit/main/install.sh | bash -s -- v1.0.7 ``` +On Windows: `$env:MANYGIT_VERSION = "v1.0.7"; irm .../install.ps1 | iex`. + The self-updater only ever moves *forward*, so a downgrade goes through the installer. Since the launch check will then offer to pull you back to the newest release, answer `n` or use `--no-update-check` to stay put. @@ -118,7 +130,7 @@ Actions apply to the **highlighted** repo (the `>` cursor). | `F` | show only repos with changes / ahead / behind | | `/` | filter the focused list by what it shows — repos match on name **and** current branch (`/master` finds every repo on master, and the tag too while `t` is on); branches match on name (type `feat` to find a remote branch among hundreds); scripts on name | | `:` | **plain-English git** — type a request (`rebase current onto master`, `sync everything in other/`), your AI harness turns it into git commands, you confirm them, manygit runs them. Repo/folder/branch names autocomplete with `tab`, and `@script.sh` pulls a file in as context (`@scripts/update-all.sh only the frontend apps from this`). Output goes to the Output pane. `↑`/`↓` recall this session's earlier requests; `alt+backspace` / `ctrl+w` delete a word, `ctrl+u` the line | -| `!` | **shell in the highlighted repo** — opens a `$manygit:/` prompt naming the folder you're in; type a bash command and it runs there, streaming into the Output pane. The prompt **stays open**, so it's a shell you sit in: run, read, run again. The bottom bar shows which repo you're in while you type, so it's never ambiguous. `esc` leaves the shell and lands you back on Repos — it doesn't stop a running command; `ctrl+c` does that. `↑`/`↓` recall this session's earlier commands; `alt+backspace` / `ctrl+w` delete a word, `ctrl+u` the line. Non-interactive, so `vim`, `top` and anything else wanting a terminal won't work | +| `!` | **shell in the highlighted repo** — opens a `$manygit:/` prompt naming the folder you're in; type a bash command and it runs there (via `cmd.exe` on Windows if bash isn't on PATH), streaming into the Output pane. The prompt **stays open**, so it's a shell you sit in: run, read, run again. The bottom bar shows which repo you're in while you type, so it's never ambiguous. `esc` leaves the shell and lands you back on Repos — it doesn't stop a running command; `ctrl+c` does that. `↑`/`↓` recall this session's earlier commands; `alt+backspace` / `ctrl+w` delete a word, `ctrl+u` the line. Non-interactive, so `vim`, `top` and anything else wanting a terminal won't work | | `o` | open the repo in your editor | | `z` | zoom the focused pane | | `esc` | back out one layer of state: the diff, then Changes, then zoom, then the `/` and `F` filters | @@ -200,9 +212,10 @@ is a plain push — and the only destructive thing it offers *on its own* is discarding a repo's changes (`d` / `D`), which always asks you to confirm first. `!` is the deliberate exception: it runs whatever bash command you type in the -highlighted repo, with no confirm and no blocklist, because a shell escape that -second-guessed you would be useless. What it does instead is leave a record — the -command is echoed into the Output pane with the repo it ran in, before any output +highlighted repo (falling back to `cmd.exe` on Windows if bash isn't on PATH), +with no confirm and no blocklist, because a shell escape that second-guessed +you would be useless. What it does instead is leave a record — the command is +echoed into the Output pane with the repo it ran in, before any output arrives — and `ctrl+c` stops it mid-run. `:` deliberately widens that, since merging, rebasing and tagging are the point of diff --git a/docs/index.html b/docs/index.html index 7880a5c..75421d0 100644 --- a/docs/index.html +++ b/docs/index.html @@ -59,7 +59,7 @@ "name": "manygit", "applicationCategory": "DeveloperApplication", "applicationSubCategory": "Version Control", - "operatingSystem": "macOS, Linux", + "operatingSystem": "macOS, Linux, Windows", "url": "https://rabeeh-ta.github.io/manygit/", "downloadUrl": "https://github.com/rabeeh-ta/manygit/releases", "codeRepository": "https://github.com/rabeeh-ta/manygit", @@ -103,7 +103,7 @@ "name": "Is there a CLI tool to manage multiple git repositories at once?", "acceptedAnswer": { "@type": "Answer", - "text": "manygit is one. You point it at a folder, it finds every git repo nested up to three levels underneath, and puts them all on a single screen with each repo's branch and whether it is ahead, behind, or dirty. You then fetch, pull, push, or switch branches on the repo under the cursor without cd-ing between them. It is a free single binary for macOS and Linux." + "text": "manygit is one. You point it at a folder, it finds every git repo nested up to three levels underneath, and puts them all on a single screen with each repo's branch and whether it is ahead, behind, or dirty. You then fetch, pull, push, or switch branches on the repo under the cursor without cd-ing between them. It is a free single binary for macOS, Linux, and Windows." } }, { @@ -262,7 +262,7 @@

fine. the whole pitch:

single binary
-
macOS · Linux
+
macOS · Linux · Windows

Which of your repos are dirty right now?

@@ -296,7 +296,7 @@

Which of your repos are dirty right now?

copy
- Linux and other ways to install + Linux, Windows, and other ways to install
@@ -429,6 +429,30 @@

macOS and Linux — installer script

every launch and offers to update itself — the last time you'll run this.

+

Windows — installer script

+
+
+ $irm https://raw.githubusercontent.com/rabeeh-ta/manygit/main/install.ps1 | iex +
+ +
+ +

+ PowerShell. Drops manygit.exe into + %LocalAppData%\manygit\bin and adds it to your PATH, with the same + self-update behavior as the script above. Already on Git Bash, MSYS2, Cygwin or + WSL? install.sh detects Windows there too and installs the same + binary. +

+

With Go

@@ -601,7 +625,8 @@

Keys

Shell in the highlighted repo. Opens a prompt naming where you are — $manygit:apps/api-gateway. Type a bash command — - git status -sb, npm test — and it runs there, streaming + git status -sb, npm test — and it runs there (falling back + to cmd.exe on Windows if bash isn't on PATH), streaming into the Output pane. The prompt stays open, so it is a shell you sit in: run, read, run again. The bottom bar shows which repo you are in while you type. esc leaves the shell and lands on Repos; @@ -808,7 +833,8 @@

Config

! is the deliberate exception: it runs the bash command you type, in the - highlighted repo, with no confirm and no blocklist — a shell escape that + highlighted repo (falling back to cmd.exe on Windows if bash isn't + on PATH), with no confirm and no blocklist — a shell escape that second-guessed you would be useless. What it does instead is leave a record: the command is echoed into the Output pane with the repo it ran in, before any output arrives, and ctrl+c stops it mid-run. diff --git a/docs/llms.txt b/docs/llms.txt index 901cc90..221e9e5 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -6,8 +6,9 @@ > then fetches, pulls, pushes, and switches branches on the one under the cursor, > without cd-ing between them. -manygit is a single Go binary for macOS and Linux, built with Bubble Tea and -Lip Gloss. It is free and open source at https://github.com/rabeeh-ta/manygit +manygit is a single Go binary for macOS, Linux and Windows, built with Bubble +Tea and Lip Gloss. It is free and open source at +https://github.com/rabeeh-ta/manygit ## What problem it solves @@ -53,6 +54,13 @@ macOS and Linux, via the installer script: curl -fsSL https://raw.githubusercontent.com/rabeeh-ta/manygit/main/install.sh | bash +Windows, via PowerShell: + + irm https://raw.githubusercontent.com/rabeeh-ta/manygit/main/install.ps1 | iex + +(Git Bash, MSYS2, Cygwin and WSL can use install.sh instead — it detects Windows +there too and installs the same binary.) + The installer-script build self-updates: it offers to replace itself when a newer release exists. A Homebrew or `go install` build is owned by that package manager, so manygit never replaces it — it reports the new release at most once a day, after @@ -78,7 +86,8 @@ It walks the folder three levels deep by default, skips `node_modules`, `vendor` ## Panes - `1` Repos — every repo, its current branch, and its status -- `2` Scripts — `*.sh` files near the root, runnable in place +- `2` Scripts — script files near the root, runnable in place (`*.sh` + everywhere; `*.ps1`, `*.cmd`, `*.bat` also count on Windows) - `3` Branches — the highlighted repo's local and remote branches - `4` PRs — GitHub pull requests (needs the `gh` CLI, signed in) - `5` Graph — a colored commit graph for the highlighted repo @@ -110,8 +119,8 @@ Actions apply to the repo under the `>` cursor. - `!` — shell in the highlighted repo. Opens a prompt reading `$manygit:apps/api-gateway` (the key is `!`, vim's shell-escape convention; the prompt itself renders as a shell prompt naming where you are — the repo's path relative to the scanned root, shortened - from the left with `…` on a narrow terminal). Type a bash command and it runs there, - with its + from the left with `…` on a narrow terminal). Type a bash command and it runs there + (falling back to `cmd.exe` on Windows if bash isn't on PATH), with its combined stdout and stderr streaming into the Output pane. The prompt stays open after each command, so it is a shell you sit in rather than a one-shot. The bottom bar shows the repo name while you type, so it is always clear which folder the command lands in. @@ -177,9 +186,10 @@ is a plain push. The only destructive action it offers on its own is discarding repo's changes (`d` / `D`), which always asks first. `!` is the deliberate exception: it runs the bash command you type in the -highlighted repo, with no confirm and no blocklist, because a shell escape that -second-guessed you would be useless. The command is echoed into the Output pane -with the repo it ran in, so there is a record, and `ctrl+c` stops it mid-run. +highlighted repo (falling back to `cmd.exe` on Windows if bash isn't on PATH), +with no confirm and no blocklist, because a shell escape that second-guessed +you would be useless. The command is echoed into the Output pane with the repo +it ran in, so there is a record, and `ctrl+c` stops it mid-run. `:` widens that, deliberately. It can merge, rebase, tag and push, because that is what it is for — but only commands it has shown you and you have confirmed, diff --git a/go.mod b/go.mod index 5270a2f..461c82c 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/exp/teatest v0.0.0-20260629091435-9c70f75e26a4 + golang.org/x/sys v0.36.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -27,6 +28,5 @@ require ( github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.28.0 // indirect ) diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..3697ff9 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,101 @@ +# manygit installer for native Windows (PowerShell). Usage: +# irm https://raw.githubusercontent.com/rabeeh-ta/manygit/main/install.ps1 | iex +# +# Installs the latest release by default. To pin a version (e.g. to roll back), +# set $env:MANYGIT_VERSION before running, or download this script and pass it +# as -Version: +# $env:MANYGIT_VERSION = "v1.0.7"; irm .../install.ps1 | iex +# +# On Git Bash, MSYS2, Cygwin or WSL, use install.sh instead — it detects +# Windows the same way and installs the same binary. +param( + [string]$Version = $env:MANYGIT_VERSION +) + +$ErrorActionPreference = 'Stop' + +$repo = 'rabeeh-ta/manygit' +$exe = 'manygit.exe' +$installDir = if ($env:MANYGIT_INSTALL_DIR) { $env:MANYGIT_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'manygit\bin' } + +function Die($msg) { + Write-Error "error: $msg" + exit 1 +} + +if (-not (Get-Command tar -ErrorAction SilentlyContinue)) { + Die "tar.exe is required (bundled with Windows 10 1803+ and Windows 11)" +} + +switch ($env:PROCESSOR_ARCHITECTURE) { + 'AMD64' { $arch = 'amd64' } + 'ARM64' { $arch = 'arm64' } + default { Die "unsupported architecture: $env:PROCESSOR_ARCHITECTURE" } +} + +# An explicit version (-Version or MANYGIT_VERSION) pins the install; +# otherwise the newest release wins. Pinning is how you roll back to an +# earlier build. +$pinned = [bool]$Version +if ($pinned) { + $tag = if ($Version.StartsWith('v')) { $Version } else { "v$Version" } + try { + Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases/tags/$tag" -UseBasicParsing | Out-Null + } catch { + Die "no release tagged $tag (see https://github.com/$repo/releases)" + } +} else { + $latest = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases/latest" -UseBasicParsing + $tag = $latest.tag_name + if (-not $tag) { Die "no published release found for $repo yet" } +} + +$asset = "manygit_windows_${arch}.tar.gz" +$url = "https://github.com/$repo/releases/download/$tag/$asset" + +Write-Host "Installing $exe $tag (windows/$arch)..." + +$tmp = Join-Path ([System.IO.Path]::GetTempPath()) "manygit-install-$tag-$arch" +New-Item -ItemType Directory -Force -Path $tmp | Out-Null +try { + $archivePath = Join-Path $tmp $asset + try { + Invoke-WebRequest -Uri $url -OutFile $archivePath -UseBasicParsing + } catch { + Die "download failed: $url" + } + # tar.exe (bundled since Windows 10 1803) reads .tar.gz directly — no + # separate zip archive to maintain just for this platform. + tar -xzf $archivePath -C $tmp + if ($LASTEXITCODE -ne 0) { Die "could not extract $asset" } + $extracted = Join-Path $tmp $exe + if (-not (Test-Path $extracted)) { Die "archive did not contain $exe" } + + New-Item -ItemType Directory -Force -Path $installDir | Out-Null + $dest = Join-Path $installDir $exe + Move-Item -Force $extracted $dest + Write-Host "Installed to $dest" +} finally { + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue +} + +# A pinned version is usually a rollback, and manygit's launch check would +# offer to pull it straight back to newest. Say so instead of letting it +# surprise them. +if ($pinned) { + Write-Host "Pinned to $tag. manygit checks for a newer release on launch -- answer `"n`"," + Write-Host "or use --no-update-check / MANYGIT_NO_UPDATE_CHECK=1 to stay on this version." +} + +# Put installDir on the user's PATH if it isn't already. +$userPath = [Environment]::GetEnvironmentVariable('Path', 'User') +$entries = @() +if ($userPath) { $entries = $userPath -split ';' } +if ($entries -notcontains $installDir) { + $newPath = if ($userPath) { "$installDir;$userPath" } else { $installDir } + [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') + $env:Path = "$installDir;$env:Path" + Write-Host "Added $installDir to your PATH. Open a new terminal to pick it up everywhere." +} + +Write-Host "Done. Run: manygit" diff --git a/install.sh b/install.sh index 88ef887..4f2cced 100755 --- a/install.sh +++ b/install.sh @@ -6,10 +6,13 @@ # pass it as an argument or in MANYGIT_VERSION: # curl -fsSL .../install.sh | bash -s -- v1.0.7 # MANYGIT_VERSION=v1.0.7 ./install.sh +# +# On native Windows (no bash), use install.ps1 instead. set -euo pipefail repo="rabeeh-ta/manygit" -bin="manygit" +pkg="manygit" # the asset name prefix goreleaser uses — never gains .exe +exe="manygit" # the installed executable's name — gains .exe on windows install_dir="${MANYGIT_INSTALL_DIR:-$HOME/.local/bin}" die() { printf 'error: %s\n' "$1" >&2; exit 1; } @@ -21,7 +24,10 @@ os=$(uname -s) case "$os" in Linux) os=linux ;; Darwin) os=darwin ;; - *) die "unsupported OS: $os (manygit supports Linux and macOS)" ;; + # Git Bash, MSYS2 and Cygwin all report one of these for `uname -s` on + # Windows. WSL reports Linux and needs nothing special. + MINGW*|MSYS*|CYGWIN*) os=windows; exe="manygit.exe" ;; + *) die "unsupported OS: $os (manygit supports Linux, macOS and Windows)" ;; esac arch=$(uname -m) @@ -46,21 +52,21 @@ else [ -n "$tag" ] || die "no published release found for $repo yet" fi -asset="${bin}_${os}_${arch}.tar.gz" +asset="${pkg}_${os}_${arch}.tar.gz" url="https://github.com/$repo/releases/download/$tag/$asset" -printf 'Installing %s %s (%s/%s)...\n' "$bin" "$tag" "$os" "$arch" +printf 'Installing %s %s (%s/%s)...\n' "$exe" "$tag" "$os" "$arch" tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT curl -fsSL "$url" -o "$tmp/$asset" || die "download failed: $url" tar -xzf "$tmp/$asset" -C "$tmp" || die "could not extract $asset" -[ -f "$tmp/$bin" ] || die "archive did not contain $bin" +[ -f "$tmp/$exe" ] || die "archive did not contain $exe" mkdir -p "$install_dir" -mv "$tmp/$bin" "$install_dir/$bin" -chmod +x "$install_dir/$bin" -printf 'Installed to %s\n' "$install_dir/$bin" +mv "$tmp/$exe" "$install_dir/$exe" +chmod +x "$install_dir/$exe" +printf 'Installed to %s\n' "$install_dir/$exe" # A pinned version is usually a rollback, and manygit's launch check would offer # to pull it straight back to newest. Say so instead of letting it surprise them. @@ -81,4 +87,4 @@ case ":$PATH:" in ;; esac -printf 'Done. Run: %s\n' "$bin" +printf 'Done. Run: %s\n' "$exe" diff --git a/internal/config/config.go b/internal/config/config.go index 2f5c54e..87a8870 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,5 +1,6 @@ // Package config loads manygit settings with baked-in defaults. The config -// file is optional and lives at $XDG_CONFIG_HOME/manygit/config.yml. +// file is optional and lives at $XDG_CONFIG_HOME/manygit/config.yml (or, with +// no XDG_CONFIG_HOME set, %AppData%\manygit\config.yml on Windows). package config import ( @@ -9,6 +10,7 @@ import ( "gopkg.in/yaml.v3" "github.com/rabeeh-ta/manygit/internal/discover" + "github.com/rabeeh-ta/manygit/internal/xdgdir" ) // Config is the effective configuration. @@ -35,17 +37,9 @@ func (c Config) UnicodeGlyphs() bool { return c.StatusGlyphs != "ascii" } -// ConfigPath returns the XDG config file path. +// ConfigPath returns the config file path (see internal/xdgdir). func ConfigPath() string { - base := os.Getenv("XDG_CONFIG_HOME") - if base == "" { - home, err := os.UserHomeDir() - if err != nil { - return filepath.Join(".config", "manygit", "config.yml") - } - base = filepath.Join(home, ".config") - } - return filepath.Join(base, "manygit", "config.yml") + return filepath.Join(xdgdir.ConfigHome(), "manygit", "config.yml") } // Load reads config from path (empty = ConfigPath()). A missing file yields diff --git a/internal/discover/discover.go b/internal/discover/discover.go index 0c468a0..eff9e66 100644 --- a/internal/discover/discover.go +++ b/internal/discover/discover.go @@ -5,6 +5,7 @@ import ( "io" "os" "path/filepath" + "runtime" "sort" "strings" ) @@ -98,20 +99,22 @@ func Discover(root string, opts Options) ([]Repo, error) { return repos, nil } -// Script is a shell script discovered under the root. +// Script is a runnable script discovered under the root. type Script struct { Path string // absolute path Name string // path relative to root, e.g. "scripts/sync-edx.sh" } -// Scripts finds *.sh files under root up to maxDepth directory levels (a file -// directly in root is depth 1, in root/scripts/ is depth 2), pruning the same -// junk directories as Discover. Results are sorted by relative name. +// Scripts finds runnable scripts under root up to maxDepth directory levels (a +// file directly in root is depth 1, in root/scripts/ is depth 2), pruning the +// same junk directories as Discover. Results are sorted by relative name. See +// scriptExtensionsFor for which files count. func Scripts(root string, maxDepth int, prune map[string]bool) []Script { root = filepath.Clean(root) if prune == nil { prune = DefaultPrune() } + exts := scriptExtensionsFor(runtime.GOOS) var out []Script var walk func(dir string, depth int) walk = func(dir string, depth int) { @@ -131,7 +134,7 @@ func Scripts(root string, maxDepth int, prune map[string]bool) []Script { continue } full := filepath.Join(dir, name) - if !looksLikeScript(name, full, e) { + if !looksLikeScript(name, full, e, exts) { continue } rel, err := filepath.Rel(root, full) @@ -146,16 +149,37 @@ func Scripts(root string, maxDepth int, prune map[string]bool) []Script { return out } -// looksLikeScript reports whether a file should be listed as a runnable script: -// any *.sh, or an extensionless executable whose first bytes are a "#!" shebang -// (catches helpers like scripts/sync-all without pulling in binaries or dotfiles). -func looksLikeScript(name, full string, e os.DirEntry) bool { - if strings.HasSuffix(name, ".sh") { - return true +// scriptExtensionsFor returns the extensions that always count as a runnable +// script, regardless of the executable bit: *.sh everywhere, plus — only on +// Windows — the two native Windows script kinds (*.ps1, *.cmd/*.bat). +// internal/tui commands.go's scriptInvocation is the only thing that knows how +// to run those, and it only has powershell/cmd.exe to invoke on Windows; on +// Linux/macOS neither exists on PATH, so listing them there would offer +// scripts the runner can't actually start. +func scriptExtensionsFor(goos string) []string { + if goos == "windows" { + return []string{".sh", ".ps1", ".cmd", ".bat"} + } + return []string{".sh"} +} + +// looksLikeScript reports whether a file should be listed as a runnable +// script: anything with an exts suffix, or an extensionless executable whose +// first bytes are a "#!" shebang (catches helpers like scripts/sync-all +// without pulling in binaries or dotfiles). +func looksLikeScript(name, full string, e os.DirEntry, exts []string) bool { + lower := strings.ToLower(name) + for _, ext := range exts { + if strings.HasSuffix(lower, ext) { + return true + } } if strings.Contains(name, ".") { return false } + // Windows has no executable bit to check — Mode() there never sets any of + // 0o111 — so an extensionless shebang script is never picked up on it. That + // matches the platform: Windows can't run such a file directly either way. info, err := e.Info() if err != nil || info.Mode()&0o111 == 0 { return false diff --git a/internal/discover/discover_test.go b/internal/discover/discover_test.go index 548bbef..ee6ed16 100644 --- a/internal/discover/discover_test.go +++ b/internal/discover/discover_test.go @@ -3,6 +3,7 @@ package discover import ( "os" "path/filepath" + "runtime" "sort" "testing" ) @@ -134,6 +135,51 @@ func TestScripts_IncludesExtensionlessExecutables(t *testing.T) { } } +func TestScriptExtensionsFor(t *testing.T) { + want := []string{".sh", ".ps1", ".cmd", ".bat"} + if got := scriptExtensionsFor("windows"); !eq(got, want) { + t.Errorf("scriptExtensionsFor(windows) = %v, want %v", got, want) + } + for _, goos := range []string{"linux", "darwin"} { + if got := scriptExtensionsFor(goos); !eq(got, []string{".sh"}) { + t.Errorf("scriptExtensionsFor(%s) = %v, want [.sh]", goos, got) + } + } +} + +// TestScripts_WindowsExtensionsExcludedOffWindows locks in that *.ps1/*.cmd/ +// *.bat only ever surface as runnable scripts on Windows: scriptInvocation has +// no powershell/cmd.exe to invoke them with anywhere else, so listing them +// there would offer scripts the runner can't start. +func TestScripts_WindowsExtensionsExcludedOffWindows(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("asserts the off-Windows exclusion; see TestScriptExtensionsFor for the Windows list") + } + root := t.TempDir() + write := func(parts ...string) { + p := filepath.Join(append([]string{root}, parts...)...) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("echo hi\n"), 0o644); err != nil { + t.Fatal(err) + } + } + write("scripts", "deploy.ps1") + write("scripts", "build.cmd") + write("scripts", "legacy.BAT") + write("scripts", "real.sh") + + var names []string + for _, s := range Scripts(root, 2, DefaultPrune()) { + names = append(names, s.Name) + } + want := []string{filepath.Join("scripts", "real.sh")} + if !eq(names, want) { + t.Errorf("Scripts = %v, want %v", names, want) + } +} + func TestDiscover_GroupsByParent(t *testing.T) { root := t.TempDir() mkGitRepo(t, filepath.Join(root, "edx-dev", "blendxapi")) diff --git a/internal/selfupdate/managed.go b/internal/selfupdate/managed.go index d48d031..47e0a63 100644 --- a/internal/selfupdate/managed.go +++ b/internal/selfupdate/managed.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "time" + + "github.com/rabeeh-ta/manygit/internal/xdgdir" ) // Owners a managed install can report. SelfManaged means manygit owns its own @@ -118,15 +120,7 @@ func ResolveVersion(ldflagVersion, buildInfoVersion string) string { // noticeMarkerPath records when the last update notice was shown. It sits beside // the changelog marker in the cache dir, and holds an RFC3339 timestamp. func noticeMarkerPath() string { - base := os.Getenv("XDG_CACHE_HOME") - if base == "" { - home, err := os.UserHomeDir() - if err != nil { - return filepath.Join(".cache", "manygit", "notified-at") - } - base = filepath.Join(home, ".cache") - } - return filepath.Join(base, "manygit", "notified-at") + return filepath.Join(xdgdir.CacheHome(), "manygit", "notified-at") } // LastNotified reads when the last update notice was shown. A missing or diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go index f7d37bf..2af4432 100644 --- a/internal/selfupdate/selfupdate.go +++ b/internal/selfupdate/selfupdate.go @@ -107,7 +107,7 @@ func Releases(ctx context.Context, n int) ([]Release, error) { type Stats struct { TotalReleases int // every published release BinaryDownloads int // .tar.gz downloads (installs + self-updates), not checksums - ByOS map[string]int // "linux" / "darwin" -> binary downloads + ByOS map[string]int // "linux" / "darwin" / "windows" -> binary downloads Recent []ReleaseStat // newest first, len <= the requested count } @@ -146,6 +146,8 @@ func aggregate(rs []Release, recent int) Stats { s.ByOS["darwin"] += a.DownloadCount case strings.Contains(a.Name, "linux"): s.ByOS["linux"] += a.DownloadCount + case strings.Contains(a.Name, "windows"): + s.ByOS["windows"] += a.DownloadCount } } if i < recent { @@ -198,11 +200,26 @@ func allReleases(ctx context.Context) ([]Release, error) { } // assetName is the archive name goreleaser produces for an os/arch pair, e.g. -// "manygit_darwin_arm64.tar.gz". +// "manygit_darwin_arm64.tar.gz". Every OS ships the same .tar.gz format — +// Windows 10 1803+ and Windows 11 both include tar.exe, so there's no need for +// a second archive format just for one platform. func assetName(goos, goarch string) string { return fmt.Sprintf("manygit_%s_%s.tar.gz", goos, goarch) } +// binaryName is the executable's name inside the archive: goreleaser appends +// .exe for a windows build, nothing otherwise. +func binaryName() string { + return binaryNameFor(runtime.GOOS) +} + +func binaryNameFor(goos string) string { + if goos == "windows" { + return "manygit.exe" + } + return "manygit" +} + // Apply downloads this platform's binary from r, verifies it against the // release checksums, and atomically replaces the running executable. The caller // re-execs afterwards. It needs write access to the install dir (true for the @@ -239,7 +256,7 @@ func Apply(ctx context.Context, r Release) error { } } - bin, err := extractBinary(tarData, "manygit") + bin, err := extractBinary(tarData, binaryName()) if err != nil { return err } @@ -266,17 +283,63 @@ func Apply(ctx context.Context, r Release) error { os.Remove(tmpName) return err } - if err := os.Chmod(tmpName, 0o755); err != nil { + if runtime.GOOS != "windows" { // chmod bits are meaningless there + if err := os.Chmod(tmpName, 0o755); err != nil { + os.Remove(tmpName) + return err + } + } + return replaceExecutable(tmpName, exe) +} + +// replaceExecutable puts tmpName — the newly downloaded binary — at exe, the +// currently-running executable's path. +// +// On Unix this is one atomic rename over exe: the running process keeps its +// already-open inode, so the old bytes stay readable until it exits. Windows +// has no equivalent — the loader locks the image file for writing, though +// (since Vista) it opens it with FILE_SHARE_DELETE, which still permits a +// rename. So exe is moved aside to exe+".old" first and tmpName takes its +// place; the ".old" file is almost always still locked by this very process, +// so removing it is best-effort here and retried on the next launch (see +// CleanupStale, called from main at startup). +func replaceExecutable(tmpName, exe string) error { + return replaceExecutableFor(tmpName, exe, runtime.GOOS) +} + +func replaceExecutableFor(tmpName, exe, goos string) error { + if goos != "windows" { + if err := os.Rename(tmpName, exe); err != nil { + os.Remove(tmpName) + return err + } + return nil + } + old := exe + ".old" + os.Remove(old) // a leftover from an earlier update, if any + if err := os.Rename(exe, old); err != nil { os.Remove(tmpName) return err } if err := os.Rename(tmpName, exe); err != nil { - os.Remove(tmpName) + os.Rename(old, exe) // best-effort restore return err } + os.Remove(old) // usually still locked while this process is running return nil } +// CleanupStale removes a stray ".old" left behind by replaceExecutable on +// Windows (see above) — a no-op once that file no longer exists, and a no-op +// on every other OS, which never creates one. +func CleanupStale() { + exe, err := os.Executable() + if err != nil { + return + } + _ = os.Remove(exe + ".old") +} + func download(ctx context.Context, url string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go index 0d68d66..60e901e 100644 --- a/internal/selfupdate/selfupdate_test.go +++ b/internal/selfupdate/selfupdate_test.go @@ -4,6 +4,8 @@ import ( "archive/tar" "bytes" "compress/gzip" + "os" + "path/filepath" "testing" ) @@ -50,6 +52,97 @@ func TestAssetName(t *testing.T) { if got := assetName("linux", "amd64"); got != "manygit_linux_amd64.tar.gz" { t.Errorf("assetName = %q", got) } + if got := assetName("windows", "amd64"); got != "manygit_windows_amd64.tar.gz" { + t.Errorf("assetName = %q", got) + } +} + +func TestBinaryNameFor(t *testing.T) { + if got := binaryNameFor("windows"); got != "manygit.exe" { + t.Errorf("binaryNameFor(windows) = %q, want manygit.exe", got) + } + for _, goos := range []string{"linux", "darwin"} { + if got := binaryNameFor(goos); got != "manygit" { + t.Errorf("binaryNameFor(%s) = %q, want manygit", goos, got) + } + } +} + +func TestAggregate_SplitsByOSIncludingWindows(t *testing.T) { + rs := []Release{{ + Tag: "v1.0.0", + PublishedAt: "2026-01-01T00:00:00Z", + Assets: []Asset{ + {Name: "manygit_linux_amd64.tar.gz", DownloadCount: 3}, + {Name: "manygit_darwin_arm64.tar.gz", DownloadCount: 2}, + {Name: "manygit_windows_amd64.tar.gz", DownloadCount: 5}, + {Name: "checksums.txt", DownloadCount: 100}, // not a binary; excluded + }, + }} + s := aggregate(rs, 10) + if s.BinaryDownloads != 10 { + t.Errorf("BinaryDownloads = %d, want 10", s.BinaryDownloads) + } + want := map[string]int{"linux": 3, "darwin": 2, "windows": 5} + for goos, n := range want { + if s.ByOS[goos] != n { + t.Errorf("ByOS[%s] = %d, want %d", goos, s.ByOS[goos], n) + } + } +} + +// TestReplaceExecutableFor_Windows exercises the windows rename-aside sequence +// on ordinary files (no locked-image behavior to simulate, but the sequencing +// — old exe moved to .old, new binary takes its name — is plain os.Rename and +// verifiable on any OS). +func TestReplaceExecutableFor_Windows(t *testing.T) { + dir := t.TempDir() + exe := filepath.Join(dir, "manygit.exe") + tmpName := filepath.Join(dir, ".manygit-new-abc") + if err := os.WriteFile(exe, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(tmpName, []byte("new"), 0o755); err != nil { + t.Fatal(err) + } + + if err := replaceExecutableFor(tmpName, exe, "windows"); err != nil { + t.Fatalf("replaceExecutableFor: %v", err) + } + + got, err := os.ReadFile(exe) + if err != nil { + t.Fatalf("reading replaced exe: %v", err) + } + if string(got) != "new" { + t.Errorf("exe content = %q, want %q", got, "new") + } + if _, err := os.Stat(tmpName); !os.IsNotExist(err) { + t.Errorf("tmpName should have been consumed by the rename, stat err = %v", err) + } +} + +func TestReplaceExecutableFor_Unix(t *testing.T) { + dir := t.TempDir() + exe := filepath.Join(dir, "manygit") + tmpName := filepath.Join(dir, ".manygit-new-abc") + if err := os.WriteFile(exe, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(tmpName, []byte("new"), 0o755); err != nil { + t.Fatal(err) + } + + if err := replaceExecutableFor(tmpName, exe, "linux"); err != nil { + t.Fatalf("replaceExecutableFor: %v", err) + } + got, err := os.ReadFile(exe) + if err != nil { + t.Fatalf("reading replaced exe: %v", err) + } + if string(got) != "new" { + t.Errorf("exe content = %q, want %q", got, "new") + } } func TestChecksumFor(t *testing.T) { diff --git a/internal/tui/changelog.go b/internal/tui/changelog.go index 57f1831..8ccc7dd 100644 --- a/internal/tui/changelog.go +++ b/internal/tui/changelog.go @@ -11,6 +11,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/rabeeh-ta/manygit/internal/selfupdate" + "github.com/rabeeh-ta/manygit/internal/xdgdir" ) // commitHashRe matches "* message", capturing the bullet so the hash can @@ -39,15 +40,7 @@ func updatedFrom() string { // changelogSeenPath records the last from-version shown, so the screen appears // once per update even across restarts. Sits beside the news cache. func changelogSeenPath() string { - base := os.Getenv("XDG_CACHE_HOME") - if base == "" { - home, err := os.UserHomeDir() - if err != nil { - return filepath.Join(".cache", "manygit", "changelog-seen") - } - base = filepath.Join(home, ".cache") - } - return filepath.Join(base, "manygit", "changelog-seen") + return filepath.Join(xdgdir.CacheHome(), "manygit", "changelog-seen") } func changelogSeen() string { diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 747000c..e11f4f5 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "sort" "strconv" "strings" @@ -114,29 +115,68 @@ func diffCmd(path, ref, file string) tea.Cmd { } } -// startScriptCmd runs a script with `bash` in the background (non-interactive), -// in the script's own directory. +// startScriptCmd runs a discovered script in the background (non-interactive), +// in the script's own directory, with the interpreter its extension calls for. func startScriptCmd(path string, run int) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithCancel(context.Background()) - c := exec.CommandContext(ctx, "bash", path) + prog, args := scriptInvocation(path) + c := exec.CommandContext(ctx, prog, args...) c.Dir = filepath.Dir(path) return streamCmd(c, cancel, run) } } -// startShellCmd runs one shell line (`!`) with `bash -c` in dir — the repo under -// the cursor. Identical plumbing to a script run; the only difference is how the -// command was built, which is why both go through streamCmd. +// scriptInvocation resolves the interpreter for a discover.Scripts result by +// extension: bash for *.sh (every platform manygit has ever shipped on), +// PowerShell for *.ps1, and cmd.exe for *.cmd/*.bat — the native Windows script +// kinds discover.looksLikeScript also picks up. +func scriptInvocation(path string) (prog string, args []string) { + switch strings.ToLower(filepath.Ext(path)) { + case ".ps1": + return "powershell", []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path} + case ".cmd", ".bat": + return "cmd", []string{"/C", path} + default: + return "bash", []string{path} + } +} + +// startShellCmd runs one shell line (`!`) in dir — the repo under the cursor. +// Identical plumbing to a script run; the only difference is how the command +// was built, which is why both go through streamCmd. func startShellCmd(line, dir string, run int) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithCancel(context.Background()) - c := exec.CommandContext(ctx, "bash", "-c", line) + prog, args := shellInvocation(line) + c := exec.CommandContext(ctx, prog, args...) c.Dir = dir return streamCmd(c, cancel, run) } } +// shellInvocation resolves how to run one `!` line: bash -c when bash is on +// PATH — what the docs describe, and what Git for Windows bundles — else +// cmd.exe's /C on Windows, which has no bash of its own. Off Windows, bash not +// being on PATH is left as exec's own "not found" error, same as before this +// existed. +func shellInvocation(line string) (prog string, args []string) { + _, err := exec.LookPath("bash") + return shellInvocationFor(line, err == nil, runtime.GOOS) +} + +// shellInvocationFor is shellInvocation's pure decision, split out so the +// PATH lookup and the GOOS check can be supplied directly in tests. +func shellInvocationFor(line string, haveBash bool, goos string) (prog string, args []string) { + if haveBash { + return "bash", []string{"-c", line} + } + if goos == "windows" { + return "cmd", []string{"/C", line} + } + return "bash", []string{"-c", line} +} + // streamCmd starts c with stdout+stderr merged into one pipe and hands the // reader back to the Update loop. The process's exit status is delivered to the // reader via CloseWithError, so a non-zero exit surfaces as scanner.Err() at EOF @@ -153,10 +193,18 @@ func streamCmd(c *exec.Cmd, cancel context.CancelFunc, run int) tea.Msg { cancel() return scriptOutMsg{run: run, done: true, err: err} } + releaseProcGroup := finishProcGroup(c) // Deliver the exit status to the reader: a non-zero exit surfaces as // scanner.Err() at EOF. If the TUI quits mid-stream this goroutine is // abandoned, but the child then gets SIGPIPE on its next write and exits. - go func() { pw.CloseWithError(c.Wait()) }() + // releaseProcGroup runs here too (not just on cancel) so a command that + // simply finishes on its own still releases the Windows Job Object handle + // finishProcGroup opened for it — see procgroup_windows.go. + go func() { + err := c.Wait() + releaseProcGroup() + pw.CloseWithError(err) + }() sc := bufio.NewScanner(pr) sc.Buffer(make([]byte, 0, 64*1024), 1<<20) // tolerate long lines (1 MiB) return runStartMsg{run: run, cancel: cancel, scanner: sc} diff --git a/internal/tui/news.go b/internal/tui/news.go index 5699057..f243208 100644 --- a/internal/tui/news.go +++ b/internal/tui/news.go @@ -15,6 +15,7 @@ import ( "github.com/rabeeh-ta/manygit/internal/git" "github.com/rabeeh-ta/manygit/internal/harness" + "github.com/rabeeh-ta/manygit/internal/xdgdir" ) const ( @@ -42,15 +43,7 @@ const newsFormat = 1 // newsCachePath is the single cache file, under $XDG_CACHE_HOME/manygit (or // ~/.cache/manygit). func newsCachePath() string { - base := os.Getenv("XDG_CACHE_HOME") - if base == "" { - home, err := os.UserHomeDir() - if err != nil { - return filepath.Join(".cache", "manygit", "news.json") - } - base = filepath.Join(home, ".cache") - } - return filepath.Join(base, "manygit", "news.json") + return filepath.Join(xdgdir.CacheHome(), "manygit", "news.json") } // loadNewsCache reads the news cache; ok=false when it's missing or unreadable. diff --git a/internal/tui/procgroup_other.go b/internal/tui/procgroup_other.go index 86abd2d..35c0e2c 100644 --- a/internal/tui/procgroup_other.go +++ b/internal/tui/procgroup_other.go @@ -1,10 +1,12 @@ -//go:build !unix +//go:build !unix && !windows package tui import "os/exec" -// setProcGroup is a no-op off unix: exec.CommandContext's own kill of the direct -// child is all that is available. manygit ships linux and darwin binaries only -// (see .goreleaser.yaml); this file exists so the package still builds elsewhere. -func setProcGroup(c *exec.Cmd) {} +// setProcGroup and finishProcGroup are no-ops here: exec.CommandContext's own +// kill of the direct child is all that is available. manygit ships linux, +// darwin and windows binaries (see .goreleaser.yaml); this file exists so the +// package still builds on anything else Go can target (plan9, js/wasm, ...). +func setProcGroup(c *exec.Cmd) {} +func finishProcGroup(c *exec.Cmd) func() { return func() {} } diff --git a/internal/tui/procgroup_unix.go b/internal/tui/procgroup_unix.go index 24a8ce3..6930f9b 100644 --- a/internal/tui/procgroup_unix.go +++ b/internal/tui/procgroup_unix.go @@ -20,3 +20,11 @@ func setProcGroup(c *exec.Cmd) { return syscall.Kill(-c.Process.Pid, syscall.SIGKILL) } } + +// finishProcGroup is a no-op on unix: setProcGroup already armed c.Cancel +// before Start, and unix process groups pick up every descendant automatically +// (they inherit their parent's pgid), so there's nothing left to attach once +// the process exists. See procgroup_windows.go, which needs the process handle +// Start produces and so can't do its equivalent setup any earlier, and so +// returns a cleanup func of its own to release it. +func finishProcGroup(c *exec.Cmd) func() { return func() {} } diff --git a/internal/tui/procgroup_windows.go b/internal/tui/procgroup_windows.go new file mode 100644 index 0000000..dab19d8 --- /dev/null +++ b/internal/tui/procgroup_windows.go @@ -0,0 +1,73 @@ +//go:build windows + +package tui + +import ( + "os/exec" + "sync" + "unsafe" + + "golang.org/x/sys/windows" +) + +// setProcGroup is a no-op before Start on Windows — see finishProcGroup, which +// does the real work once the process (and its handle) exists. +func setProcGroup(c *exec.Cmd) {} + +// finishProcGroup puts c's freshly-started process into a Windows Job Object +// configured to kill everything in it when the job handle closes, and arms +// c.Cancel to close that handle. exec.CommandContext on its own only kills the +// direct child, so `cmd /C "timeout 30 & type nul"` would leave the pipeline's +// grandchildren running after the pane had moved on — this is the Windows +// equivalent of procgroup_unix.go's process-group SIGKILL. +// +// It has to run after Start, unlike the unix version: assigning a process to a +// job needs a handle to that process, which doesn't exist until Start creates +// it. A child inherits its parent's job automatically at the moment it's +// created, so assigning the top-level process right after Start still catches +// every descendant it goes on to spawn. +// +// The returned func releases the job handle and must be called once the +// process is done, cancelled or not — c.Cancel only fires on cancellation, so +// a command that simply runs to completion would otherwise never close this +// handle. Both paths funnel through the same sync.Once, so whichever happens +// first (normal exit or cancel) is the only one that actually closes it. +func finishProcGroup(c *exec.Cmd) func() { + noop := func() {} + if c.Process == nil { + return noop + } + job, err := windows.CreateJobObject(nil, nil) + if err != nil { + return noop + } + info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{ + BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{ + LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }, + } + if _, err := windows.SetInformationJobObject( + job, windows.JobObjectExtendedLimitInformation, + uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info)), + ); err != nil { + windows.CloseHandle(job) + return noop + } + h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(c.Process.Pid)) + if err != nil { + windows.CloseHandle(job) + return noop + } + defer windows.CloseHandle(h) + if err := windows.AssignProcessToJobObject(job, h); err != nil { + windows.CloseHandle(job) + return noop + } + var once sync.Once + closeJob := func() { once.Do(func() { windows.CloseHandle(job) }) } + c.Cancel = func() error { + closeJob() // KILL_ON_JOB_CLOSE terminates the whole tree + return nil + } + return closeJob +} diff --git a/internal/tui/scriptinvocation_test.go b/internal/tui/scriptinvocation_test.go new file mode 100644 index 0000000..bbecb74 --- /dev/null +++ b/internal/tui/scriptinvocation_test.go @@ -0,0 +1,58 @@ +package tui + +import "testing" + +func TestScriptInvocation(t *testing.T) { + cases := []struct { + path string + wantProg string + wantArgs []string + }{ + {"scripts/sync.sh", "bash", []string{"scripts/sync.sh"}}, + {"scripts/sync-all", "bash", []string{"scripts/sync-all"}}, // extensionless shebang script + {"scripts/deploy.ps1", "powershell", []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "scripts/deploy.ps1"}}, + {"scripts/build.cmd", "cmd", []string{"/C", "scripts/build.cmd"}}, + {"scripts/build.BAT", "cmd", []string{"/C", "scripts/build.BAT"}}, // case-insensitive + } + for _, c := range cases { + prog, args := scriptInvocation(c.path) + if prog != c.wantProg || !slicesEqual(args, c.wantArgs) { + t.Errorf("scriptInvocation(%q) = %q, %v; want %q, %v", c.path, prog, args, c.wantProg, c.wantArgs) + } + } +} + +func TestShellInvocationFor(t *testing.T) { + cases := []struct { + name string + haveBash bool + goos string + wantProg string + wantArgs []string + }{ + {"bash on PATH, linux", true, "linux", "bash", []string{"-c", "git status"}}, + {"bash on PATH, windows", true, "windows", "bash", []string{"-c", "git status"}}, + {"no bash, windows falls back to cmd", false, "windows", "cmd", []string{"/C", "git status"}}, + {"no bash, non-windows surfaces bash's own error", false, "linux", "bash", []string{"-c", "git status"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + prog, args := shellInvocationFor("git status", c.haveBash, c.goos) + if prog != c.wantProg || !slicesEqual(args, c.wantArgs) { + t.Errorf("shellInvocationFor = %q, %v; want %q, %v", prog, args, c.wantProg, c.wantArgs) + } + }) + } +} + +func slicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/xdgdir/xdgdir.go b/internal/xdgdir/xdgdir.go new file mode 100644 index 0000000..1e9e86d --- /dev/null +++ b/internal/xdgdir/xdgdir.go @@ -0,0 +1,48 @@ +// Package xdgdir resolves the base directories manygit stores its config and +// cache files under. +package xdgdir + +import ( + "os" + "path/filepath" + "runtime" +) + +// ConfigHome returns the directory manygit's config file lives under: the +// value of $XDG_CONFIG_HOME when set, %AppData% on Windows, or ~/.config +// everywhere else. +func ConfigHome() string { + return homeFor("XDG_CONFIG_HOME", ".config", os.UserConfigDir, runtime.GOOS) +} + +// CacheHome returns the directory manygit's cache files live under: the value +// of $XDG_CACHE_HOME when set, %LocalAppData% on Windows, or ~/.cache +// everywhere else. +func CacheHome() string { + return homeFor("XDG_CACHE_HOME", ".cache", os.UserCacheDir, runtime.GOOS) +} + +// homeFor implements the shared lookup: an explicit XDG env var always wins +// (so a user who set one under WSL/MSYS/Cygwin keeps their existing files), +// then the platform default. macOS and Linux both keep the long-standing +// ~/.config and ~/.cache convention manygit already used before Windows +// support existed — os.UserConfigDir would move macOS to ~/Library/Application +// Support, which would silently orphan every existing install's config and +// cache. Windows has no such history, so it gets its native directory via +// windowsDir. goos is threaded through explicitly (rather than read from +// runtime.GOOS in here) so tests can exercise the windows branch on any host. +func homeFor(envVar, unixLeaf string, windowsDir func() (string, error), goos string) string { + if base := os.Getenv(envVar); base != "" { + return base + } + if goos == "windows" { + if dir, err := windowsDir(); err == nil { + return dir + } + } + home, err := os.UserHomeDir() + if err != nil { + return unixLeaf + } + return filepath.Join(home, unixLeaf) +} diff --git a/internal/xdgdir/xdgdir_test.go b/internal/xdgdir/xdgdir_test.go new file mode 100644 index 0000000..8bf75f0 --- /dev/null +++ b/internal/xdgdir/xdgdir_test.go @@ -0,0 +1,67 @@ +package xdgdir + +import ( + "os" + "path/filepath" + "testing" +) + +func TestConfigHome_XDGEnvWins(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "/xdg/config") + if got := ConfigHome(); got != "/xdg/config" { + t.Errorf("ConfigHome = %q, want /xdg/config", got) + } +} + +func TestCacheHome_XDGEnvWins(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", "/xdg/cache") + if got := CacheHome(); got != "/xdg/cache" { + t.Errorf("CacheHome = %q, want /xdg/cache", got) + } +} + +func TestHome_NonWindowsFallsBackToDotDir(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + home, err := os.UserHomeDir() + if err != nil { + t.Skip("no home dir in this environment") + } + windowsDirCalled := false + got := homeFor("XDG_CONFIG_HOME", ".config", func() (string, error) { + windowsDirCalled = true + return "", nil + }, "linux") + want := filepath.Join(home, ".config") + if got != want { + t.Errorf("home = %q, want %q", got, want) + } + if windowsDirCalled { + t.Error("windowsDir should never be consulted off windows") + } +} + +func TestHome_WindowsPrefersWindowsDirOverDotDir(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + got := homeFor("XDG_CONFIG_HOME", ".config", func() (string, error) { + return `C:\Users\me\AppData\Roaming`, nil + }, "windows") + want := `C:\Users\me\AppData\Roaming` + if got != want { + t.Errorf("home = %q, want %q", got, want) + } +} + +func TestHome_WindowsFallsBackWhenWindowsDirErrors(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + home, err := os.UserHomeDir() + if err != nil { + t.Skip("no home dir in this environment") + } + got := homeFor("XDG_CONFIG_HOME", ".config", func() (string, error) { + return "", os.ErrNotExist + }, "windows") + want := filepath.Join(home, ".config") + if got != want { + t.Errorf("home = %q, want %q", got, want) + } +} diff --git a/main.go b/main.go index 74d14a8..f6ed35b 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,6 @@ import ( "path/filepath" "runtime/debug" "strings" - "syscall" "time" tea "github.com/charmbracelet/bubbletea" @@ -48,6 +47,12 @@ Flags: } flag.Parse() + // Windows can't overwrite its own running executable, so a self-update there + // leaves the replaced binary behind as ".old" (see selfupdate.Apply). + // The lock on it clears once that old process exits, so pick it up now, on + // the next launch. A no-op everywhere else: nothing ever creates this file. + selfupdate.CleanupStale() + // GoReleaser stamps the version into the ldflag; `go install` leaves it at the // source default and puts the version in build info. Resolve before any read. version = selfupdate.ResolveVersion(version, mainModuleVersion()) @@ -199,7 +204,7 @@ func maybeSelfUpdate(current string) string { // internal/tui changelog handling. current is the OLD version (this // process was built before the update). env := append(os.Environ(), tui.EnvUpdatedFrom+"="+current) - err = syscall.Exec(exe, os.Args, env) + err = relaunch(exe, os.Args, env) } if err != nil { fmt.Println("Please restart manygit to use the new version.") @@ -220,8 +225,8 @@ func printStats() { } fmt.Printf("manygit — public download stats\n\n") fmt.Printf(" total releases %d\n", s.TotalReleases) - fmt.Printf(" all-time downloads %d (linux %d · darwin %d)\n\n", - s.BinaryDownloads, s.ByOS["linux"], s.ByOS["darwin"]) + fmt.Printf(" all-time downloads %d (linux %d · darwin %d · windows %d)\n\n", + s.BinaryDownloads, s.ByOS["linux"], s.ByOS["darwin"], s.ByOS["windows"]) fmt.Printf(" last %d releases\n", len(s.Recent)) for _, r := range s.Recent { fmt.Printf(" %-9s %s %5d\n", r.Tag, r.Date, r.Downloads) diff --git a/relaunch_unix.go b/relaunch_unix.go new file mode 100644 index 0000000..aec5294 --- /dev/null +++ b/relaunch_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package main + +import "syscall" + +// relaunch replaces the running process image with exe/argv/env — the +// self-updater's re-exec into the binary it just downloaded. It only returns +// on failure; on success the calling process is gone. +func relaunch(exe string, argv, env []string) error { + return syscall.Exec(exe, argv, env) +} diff --git a/relaunch_windows.go b/relaunch_windows.go new file mode 100644 index 0000000..3db1139 --- /dev/null +++ b/relaunch_windows.go @@ -0,0 +1,28 @@ +//go:build windows + +package main + +import ( + "os" + "os/exec" +) + +// relaunch starts exe/argv/env as a child, inherits this process's console so +// the child can keep prompting on it, waits for it to exit, and then exits +// with the same code. Windows has no execve — unlike syscall.Exec on Unix, +// the running process image can't be replaced in place, so the old process +// briefly outlives the new one instead of vanishing into it. +func relaunch(exe string, argv, env []string) error { + cmd := exec.Command(exe, argv[1:]...) + cmd.Env = env + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + runErr := cmd.Run() + if ee, ok := runErr.(*exec.ExitError); ok { + os.Exit(ee.ExitCode()) + } + if runErr != nil { + return runErr + } + os.Exit(0) + return nil // unreachable +}