Reference

On this page

Run gup <command> --help for the same information in your terminal. Recipes that use these live in the cookbook.

Commands #

CommandWhat it does
gup update [BINARY...]Reinstall binaries at their update channel, in parallel
gup check [BINARY...]Report what is out of date; installs nothing
gup listList every binary under $GOBIN with its import path and version
gup exportWrite the installed set to gup.json
gup importInstall the set recorded in gup.json
gup pin TOOL[@VERSION] [VERSION]Hold a tool at an exact version
gup unpin TOOLLet a pinned tool update again
gup migrate BEFORE_PATH AFTER_PATH [BINARY...]Reinstall binaries from one $GOBIN into another
gup remove BINARY...Delete binaries from $GOBIN
gup completion [SHELL]Print or install shell completion
gup manGenerate man pages (Linux, macOS)
gup versionPrint the version, same as gup --version
gup bug-reportOpen a pre-filled GitHub issue

gup rm is an alias for gup remove. --no-color works on every command, as does the NO_COLOR environment variable.

Flags #

FlagCommandsMeaning
-n, --dry-runupdate, import, migrateReport what would happen, change nothing
-e, --excludeupdateComma-separated binaries to skip
-f, --fileupdate, check, list, import, export, pin, unpinUse this gup.json instead of the auto-detected one
-o, --outputexportPrint the config to STDOUT instead of writing it
--jsonupdate, check, listMachine-readable output
-q, --quietupdate, checkDrop up-to-date lines; keep changes, failures, and a summary
-j, --jobsupdate, check, import, migrateParallel workers (default: CPU count)
--timeoutupdate, check, import, migratePer-package limit, e.g. 90s, 5m; 0 means none
--ignore-go-updateupdate, checkCompare versions only, ignore Go-toolchain rebuilds
-m, --mainupdateUpdate these by @main (falls back to @master only when no main branch exists)
--masterupdateUpdate these by @master
--latestupdateUpdate these by @latest
-N, --notifyupdate, import, migrateDesktop notification when the run finishes
--forceremove (-f), migrateSkip the confirmation / overwrite an existing binary
--installcompletionWrite completion files to the user shell config paths
--no-colorallDisable colorized output
-V, --versionrootPrint the version

--json wins over --quiet when both are given: you get the full array.

gup.json #

export writes it, import reads it, and update/check read the update channel from it. The path is $XDG_CONFIG_HOME/gup/gup.json, or ./gup.json, in that order; --file overrides both. If both exist and no --file is given, gup fails and asks you to choose rather than picking one.

{
  "schema_version": 2,
  "packages": [
    {
      "name": "gal",
      "import_path": "github.com/nao1215/gal/cmd/gal",
      "version": "v1.1.1",
      "channel": "latest"
    },
    {
      "name": "golangci-lint",
      "import_path": "github.com/golangci/golangci-lint/cmd/golangci-lint",
      "version": "v1.62.0",
      "channel": "pinned"
    }
  ]
}

schema_version is 1 while nothing is pinned and 2 once anything is, so an environment with no pins keeps writing files older gup releases can read. gup reads both. A malformed file, an unknown channel, an unsupported schema_version, or a pinned entry with no concrete version is an error, not something to ignore — a saved channel is never quietly downgraded to latest.

Running two gup commands at once #

The commands that change state take a lock on each resource they write, so a second one refuses to start instead of interleaving:

CommandWhat it locks
update$GOBIN and the gup.json it may write
import$GOBIN
remove$GOBIN
migrateBEFORE_PATH and AFTER_PATH
export, pin$GOBIN and the gup.json they write
unpinthe gup.json it writes

The lock is the operating system’s own — flock on Linux and macOS, LockFileEx on Windows — taken on a file gup keeps open for as long as it holds the resource. A lock the kernel owns is released the moment the process holding it ends, however it ends, so there is no such thing as a stale gup lock and never a file for you to delete.

The files it takes the lock on sit next to what they guard: $GOBIN/.gup.lock and <gup.json>.lock. $GOBIN and your config directory move independently, so a per-project XDG_CONFIG_HOME still shares one $GOBIN with every other project, and two commands given the same --file may come from different config directories — a lock kept in the config directory would serialize neither. .gup.lock is dot-prefixed, so gup list never shows it, and gup remove refuses to delete it: it is gup’s, not a tool you installed.

A lock is scoped to the file, not to the path that names it. Two arguments that reach one directory — gup migrate ~/go/bin ~/bin where ~/bin is a symlink to ~/go/bin, or a $GOBIN spelled two ways on the case-insensitive filesystems macOS and Windows use — take one lock rather than two, so gup never waits for itself. When a command locks several resources it takes them in the order the filesystem’s own identities put them in, which is the one order every gup agrees on however each of them spelled the paths.

The lock guarding a gup.json is its neighbor, named after it, so --file is resolved to the single name the operating system agrees the file has before the lock is derived from it. On Windows that matters twice over: NTFS answers to an 8.3 alias like GUP~1.JSO as well as to the long name, and Win32 strips trailing dots and spaces, so --file gup.json. writes gup.json. Both used to produce a lock file of their own — GUP~1.JSO.lock, gup.json..lock — and two gups writing one config would each hold a lock the other could not see. A gup.json reached through a symlink is locked at the file the write lands on, for the same reason.

A hard link is the one alias that cannot be resolved this way, because neither of two names for one file is more truly its own. gup refuses a --file whose file has a second name rather than locking it at a name that only sometimes means it:

can not lock /home/you/.config/gup/gup.json: the file has a second name (a hard link), so gup can not tell which lock protects it: remove the extra name while no gup is running, or point gup at a file that has only one

It is also a file gup could not rewrite correctly anyway: the rewrite renames a temporary file over gup.json, which breaks the link, leaving the other name on the contents from before the command ran.

A lock path that is a symlink is refused rather than followed: gup truncates its lock file to record who holds it, so writing through a link somebody put there would truncate a file that is not gup’s. A lock path that is a hard link is refused for the same reason, and it has to be caught differently: a hard link is not a link the open can see through — the file it lands on is an ordinary file with two equally real names, so .gup.lock made a second name for a binary in $GOBIN would pass every check a symlink fails and get that file truncated. gup therefore refuses a lock file that has more than one name, because the ones it creates have exactly one:

can not open the gup lock file /home/you/go/bin/.gup.lock: the lock path is a hard link to another file, and gup will not truncate a file it does not own: delete the lock file while no gup is running, or point gup at a directory it owns

When two gup commands do overlap, the second one exits non-zero after naming the process that is in the way:

another gup process is already running (pid 40321 on carbon, running “gup update”, since 2026-08-29T17:04:11+09:00). gup serializes commands that change your $GOBIN or gup.json, so wait for it to finish and run this command again. The lock is held by the operating system, not by /home/you/go/bin/.gup.lock, so it is released the moment that process ends and there is never a file to delete by hand

Two gup update runs at once would both install and then both write gup.json, so the file would end up describing only whichever finished last; gup remove deleting a binary a concurrent gup update is reinstalling is the same collision with a worse result.

export, pin and migrate lock directories they never write to, because what they write is derived from what they read there: export describes $GOBIN, pin resolves its target against it, and migrate reinstalls the versions it read in BEFORE_PATH. A gup remove deleting a binary halfway through leaves a result describing a tool set that never existed. unpin only names an entry in gup.json, so it does not wait behind one. A $GOBIN that does not exist yet is created so that it can be locked — whether it exists is precisely what a concurrent gup import changes.

If gup cannot work out what to lock, it stops instead of running unlocked. A directory it could not create — a parent that refuses it, a read-only filesystem, a full disk — is not the same as one that can never exist, and the command that would have run there is exactly the one another gup may be running at the same moment. A path that cannot be a directory, such as a regular file where $GOBIN or AFTER_PATH should be, is left to the command, which says so more clearly than a lock error could and writes nothing either way.

gup completion --install and gup man write files and take no lock: both write atomically, and two runs of either produce byte-identical content, so a lock would only add a .zshrc.lock to your home directory.

Nothing that changes no state is blocked. --dry-run runs and export --output take no lock, and neither do the read-only commands (list, check, version, completion, man, bug-report): gup replaces gup.json with an atomic rename, so a reader sees either the previous complete file or the next one - including when the destination is read-only, which is replaced in place rather than moved aside.

The lock files stay behind, and that is fine #

An empty .gup.lock in $GOBIN, or a gup.json.lock beside your config, is not a leftover to clean up. gup never deletes them, because deleting a file another gup may already have opened is precisely what would let two processes take a lock on two different files at one path. Between commands the file holds nothing: it is a name for the kernel to hang the next lock on, and it is emptied when the lock is dropped, so it never names a process that has already finished. There is nothing to do about one, and gup remove .gup.lock is refused for that reason — as is any other name reaching the same file: a hard link, a Windows spelling with a trailing dot, an 8.3 alias like GUPLOC~1.LOC. Deleting it would not release the lock, which lives on an open handle; it would free the name, and the next gup would create a fresh file there and lock that instead.

Nothing wedges. A gup killed with kill -9, a machine that lost power mid-update, a lock file copied onto a shared home directory from another machine — none of them block anything, because none of them is holding a lock. If you interrupt a gup update, the next one runs immediately.

Ctrl-C does not release the lock; the process holding it does, by ending. Releasing it from a signal handler would free the resource while the command is still installing binaries and rewriting gup.json on its way out. An interrupted gup update stops its work, unwinds, and releases on the way out; a command killed outright never gets that far, and the kernel drops the lock as it reaps the process.

Where the lock does not reach #

Two situations are outside what this can promise. The first is a $GOBIN or a gup.json on a network filesystem — NFS, SMB, sshfs. flock and LockFileEx are the kernel’s, and what a kernel does with them on a remote mount is up to the mount: some map them to a server-side lock, some keep them local to one machine, some ignore them. Two gups on one machine are still serialized; two on different machines sharing the mount may not be.

The second is deleting a lock file while gup runs. It does not stop the running command — its lock is on an open handle — but it frees the name, and the next gup creates a new file there and locks that instead, leaving two commands changing one $GOBIN. gup never deletes these files and refuses to let gup remove do it; nothing else should either.

gup v1.8.1 and earlier take no lock at all. An older gup left on your PATH does not serialize against a current one, because it does not know there is anything to wait for.

JSON output fields #

FieldNotes
nameBinary name in $GOBIN
import_pathWhat go install would be given
module_pathModule that provides it
channellatest, main, master, or pinned
current_versionVersion of the installed binary
latest_versionEmpty for list and for pinned packages
pinned_versionOnly for channel: "pinned"
current_go_versionGo toolchain the binary was built with
installed_go_versionGo toolchain on this machine
statusinstalled, up-to-date, update-available, updated, pinned, pin-mismatch, error
errorOmitted when absent
hintNext step for the error, when gup has one

The array is valid JSON even on partial failure, and errors are also written to STDERR so STDOUT stays parseable.

Exit codes #

CodeWhen
0The command did its job — including check finding updates, and any command on an empty $GOBIN
1A usage error, a config error, or at least one package failed

Naming a binary that is not installed, or excluding every binary, is a usage error.