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:fine. the whole pitch:
Which of your repos are dirty right now?
@@ -296,7 +296,7 @@ Which of your repos are dirty right now?
copy
macOS and Linux — installer script
every launch and offers to update itself — the last time you'll run this.
+ 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.
+
$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 @@
! 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 "