git-arx – Internals & Design
Implementation details, design decisions, and architectural notes for contributors and maintainers.
For end-user documentation, see README.md.
Contents
- Overview
- File Structure
- Why a Bash Script
- Safety Flags
- Entry Point and Dispatch
- Abstraction Layer
- File Backend
- Refs Backend
- Command Notes
- Shell Completion
- Testing
- Benchmarking
- Versioning
- Known Limitations
Overview
git-arx is a single self-contained bash script. There are no dependencies beyond git and bash 4+. The script is structured in six sections separated by comment headers:
# --- CONFIG HELPERS ---
# --- BACKEND: FILE ---
# --- BACKEND: REFS ---
# --- ABSTRACTION LAYER ---
# --- COMMANDS ---
# --- ENTRY POINT ---
All commands go through an internal abstraction layer and never touch storage directly. This makes adding a new storage backend a matter of implementing three functions and wiring them into the layer – no commands need to change.
File Structure
git-arx Single executable bash script – the entire implementation
install.sh Installs git-arx to PATH and sets the git alias
uninstall.sh Removes installed files and the git alias
git-arx-completion.bash Bash tab completion script
test.sh Test suite
bench.sh Performance benchmark (see Benchmarking)
README.md End-user documentation
INTERNALS.md This file
LICENSE MIT License
Why a Bash Script
- No install dependencies – bash and git are already present everywhere this tool would be used
- Git aliases with
!prefix (git config alias.arx '!git-arx') invoke external scripts on$PATHnatively - The logic is simple enough that bash’s limitations (no proper data structures, string-heavy) are acceptable
- A compiled binary (Go, Rust, etc.) would be the right choice if distribution to non-developers were a goal; it’s not
The one meaningful bash requirement is associative arrays (declare -A), which need bash 4+. Git for Windows ships bash 4.4+. macOS ships bash 3.2 (due to GPL licensing), but /usr/bin/env bash on modern macOS with Homebrew resolves to bash 5.x. This is a known trade-off.
Safety Flags
set -euo pipefail
-e: exit immediately on any command error-u: treat unset variables as errors-o pipefail: propagate errors through pipes (e.g.false | truefails)
This is important for a tool that writes to storage – silent failures would corrupt the archive or leave it in a partial state.
Caveat: Commands that are expected to return non-zero must be wrapped. Examples:
git cat-file -e "$sha"– used for existence checks, returns 1 if the object is missing. Wrapped inif ! ....git update-ref -d– used when deleting refs that may not exist. Followed by|| true.(( counter++ ))– arithmetic(( expr ))returns 1 when the expression evaluates to 0. Usecounter=$(( counter + 1 ))instead.[[ $dry_run -eq 1 ]] && printf '...\n'– whendry_run=0,[[ ]]returns 1, which is the exit code of the whole&&expression, triggeringset -e. Always append|| true:[[ $dry_run -eq 1 ]] && printf '...\n' || true.
Implementing --dry-run on a command:
- Add a
local dry_run=0variable and a--dry-run) dry_run=1 ;;case in the option parser. - Keep all output (
printf) statements identical to the non-dry-run path – the user sees the same output either way. - Guard every write/delete with
[[ $dry_run -eq 0 ]] && ...or wrap inif [[ $dry_run -eq 0 ]]; then ... fi. - Append a single trailing line after the normal summary:
[[ $dry_run -eq 1 ]] && printf '(dry run – no changes written)\n' || trueThe
|| trueis mandatory – see the caveat above.
Entry Point and Dispatch
main() {
# --version, help, <cmd> -h|--help, upgrade: no repository needed
_arx_require_git # sets ARX_GIT_ROOT
[[ "$cmd" == "config" ]] && { cmd_config "$@"; return; }
_arx_load_config
...
}
Help, --version and upgrade are dispatched before _arx_require_git, so they work outside a repository and with a broken config. -h/--help counts as a help request only as the first argument after the command – later arguments belong to the command, and log passes them on to git log.
config is dispatched before _arx_load_config because the config validation in _arx_load_config exits on a broken config, and config is how a broken config gets fixed.
Startup costs exactly two git subprocesses, which matters because on Windows a git process costs more than most commands’ actual work (see Benchmarking).
_arx_require_git() runs git rev-parse --git-dir --show-toplevel, answering in one call both whether this is a repository and where its root is; ARX_GIT_ROOT is the second output line. rev-parse answers argument by argument, so a bare repository prints the git dir and then fails on --show-toplevel — non-empty output on failure is what distinguishes “bare repository” from “no repository here”, and each gets its own message.
ARX_GIT_ROOT is used by _arx_load_config() to resolve the archive path relative to the repo root rather than the current working directory. This means git arx list works correctly regardless of which subdirectory the user is in when they run it.
_arx_load_config() runs right after, reading every arx.* setting with a single git config -z --get-regexp '^arx\.' into globals: ARX_STOREREFS, ARX_STOREFILE, ARX_FILE (full archive path), ARX_REFSPREFIX, and ARX_REMOTE_PREFIX (the remote-tracking namespace derived from the refs prefix). Everything downstream reads these variables directly – config is never re-read during a run.
-z output separates records with NUL and the key from its value with a newline, so values containing spaces or newlines survive parsing intact, and a record with no newline at all is a key written without a value. Keys are matched in output order and later definitions overwrite earlier ones, which reproduces how git config --get resolves a key set in more than one scope.
With neither arx.storerefs nor arx.storefile enabled, _arx_load_config stops with an error: every command after it reads or writes the archive, and there is nowhere to do either. It has to be checked here rather than where the archive is read: _arx_read_all mostly runs inside a process substitution (done < <(_arx_read_all)), where an exit ends only that subshell – the command carries on with an empty archive, and add or update print Archived: having stored nothing.
_arx_bool() reproduces git config --type=bool for arx.storerefs and arx.storefile, the two keys that decide which storage backends a command writes to – a value git reads as false must not be read here as true. It follows git’s two stages: the names true/yes/on and false/no/off case-insensitively, then git’s integer parser, since every number is a boolean too (non-zero true, zero false). Numbers follow C literal rules – leading whitespace is skipped, 0x… is hex, a leading 0 is octal (010 is 8, while 08 is not a number at all), and a k/m/g suffix multiplies by 1024/1024²/1024³ – and the result must fit in an int, so 3g and 9999999999 are not booleans. A key written with no value at all is git’s implicit true, an explicitly empty value is false, and anything git would reject keeps the key’s default rather than aborting, since a typo in one setting should not make every command unusable.
The range is the full int range, -2147483648 included – git’s own parser accepts that value since 2.50, which fixed an off-by-one in its negative bound (-max / factor became (-max - 1) / factor); older versions reject it. test_config_bool therefore pins the expected answer for it instead of asking the installed git.
Bash arithmetic is 64-bit where git’s target is a 32-bit int, and it wraps silently rather than failing, so an over-long literal would evaluate to an arbitrary in-range value and pass the range check. Literals are therefore screened on significant digit count before evaluation (at most 10 decimal, 11 octal or 8 hex, none of which can wrap 64 bits), and the k/m/g factor is applied by dividing the limit rather than multiplying the value, which would overflow for exactly the cases being rejected.
_arx_bool assigns to REPLY rather than printing, because capturing output through a command substitution would cost the subprocess this code path exists to avoid.
Commands that don’t apply to the configured storage call _arx_require_storage at the top of their function, which prints a descriptive error and exits:
git-arx: this command requires refs storage (run: git arx config storerefs true)
Unknown commands print an error referencing git arx help.
Help text
Each command has an _arx_help_<cmd> function that prints its row of the COMMANDS overview first and, unless called with summary, the detailed help after a blank line. cmd_help builds the overview by calling every function in ARX_COMMANDS with summary, so the overview and per-command help share one copy of each row. _arx_help_command resolves aliases (rm, mv, ls) and serves git arx help <cmd>, git arx <cmd> -h and --help. A new command needs an entry in ARX_COMMANDS and its own _arx_help_<cmd>; rows are aligned by hand to the overview’s description column.
Abstraction Layer
The core of the architecture is three functions that all commands call exclusively:
_arx_read_all()
Reads from configured backend(s) and emits normalized records to stdout, one per line:
<branch-name> <full-sha> <ISO-8601-date>
Records from the refs backend carry a fourth field, the same date as seconds since the epoch, which git for-each-ref supplies for free and --sort=date uses; callers that want three fields read it into their last variable (read -r b sha date rest) and ignore it.
This is a streaming interface – callers pipe or redirect it with while read. No temporary files are needed for reads.
When both arx.storerefs and arx.storefile are enabled, the function performs a union merge:
- Emit everything from the refs backend, recording branch names in a
declare -A seenassociative array - Emit file-only entries (those whose branch name is not in
seen)
Refs are treated as primary in the union merge. This reflects the refs backend’s stronger guarantees (gc-safe, native git). The sync command surfaces conflicts between backends explicitly; _arx_read_all silently prefers refs to avoid making every command into a conflict reporter.
_arx_write(branch, sha, date)
Writes to all enabled backends. When both are enabled, writes to refs first, then the file, and the order matters. A ref is what protects the commit from gc, and prune deletes branches whose commit the archive holds – so a file entry the refs backend does not back would let a branch be deleted with nothing keeping its commit alive. Refs can be refused where the file never is (a directory/file name collision such as update vs update/x, an object that does not exist), so the ref is written first and the file only if that succeeded. The refs write is followed by || return, which keeps the order even where set -e is not in effect; the file write stays a plain command, so set -e still guards each of its steps.
_arx_write_bulk()
Reads branch sha date records from stdin and writes them to all enabled backends in one bulk pass per backend (refs first, same order as _arx_write): _arx_refs_write_bulk updates all refs in a single atomic git update-ref --stdin transaction, and _arx_file_write_bulk does a single filter-then-append rewrite of .gitarchive. The transaction is all-or-nothing – if any ref update fails (e.g. a directory/file ref-name conflict), none of the refs are written, the file is not touched, and the function says that nothing was archived, since callers such as update have already printed their per-entry Archived: lines. Used by update to flush all archive writes at once.
_arx_delete(branch)
Removes from all enabled backends. When both are enabled, removes from file first, then refs.
Helper Functions
Several helper functions are defined between the abstraction layer and the commands:
_arx_lookup_branch(branch)– calls_arx_read_alland returnssha datefor the named branch. Used by single-branch commands (remove,rename,log,checkout);addandupdatescan the archive themselves in one pass instead (see thearx addand Performance sections)._arx_sha_exists(sha)– checks object existence viagit cat-file -e; used bylogandcheckoutbefore operating on an archived SHA.
File Backend
Format
# git-arx archive – do not edit manually
feature/login a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 2025-11-15T10:30:00+01:00
fix/bug-42 deadbeefdeadbeefdeadbeefdeadbeefdeadbeef 2025-10-01T08:00:00+00:00
- Space-delimited, three fields:
branch sha date - Full 40-character SHA (abbreviated SHAs become ambiguous as repos grow)
- ISO-8601 date with timezone offset from
git log -1 --format=%aI(author date of HEAD commit) #lines are comments, skipped on read – unless they have the shape of a record. git allows branch names that begin with#, so the shape is tried first:#hotfix <sha> <date>is an entry, while the# git-arx archiveheader, whose second word is not a SHA, stays a comment. (Commenting out an entry by prefixing#therefore doesn’t disable it – remove it instead.)- Blank lines are skipped on read
- Any other line must be a record: a name, then a full 40- or 64-digit hex SHA (
_arx_is_sha; SHA-1 or SHA-256 repositories), then the date. A line that does not match is reported (file:line: not an archive record) and skipped.
That validation is a security boundary, not tidiness. The file is meant to be committed and shared, so its contents are untrusted input, and the SHA field is later handed to git – list --author and status --all look up authors with git log, where an entry like evil --output=/home/you/.bashrc ... would be an option that overwrites the file. Checking every SHA once in _arx_file_read protects every consumer; _arx_authors_by_sha additionally passes SHAs on stdin (git log --stdin), where git refuses anything option-shaped and there is no command-line length limit to hit. Fields are split by read itself, with \r added to IFS so the line ending a CRLF checkout leaves (core.autocrlf on Windows) falls away, and SHAs are lowercased. _arx_file_read splits in its own while read loop rather than matching a regex per line – it runs for every line on every read, and a regex with 40- and 64-digit intervals made reads ten times slower than the unvalidated loop they replaced; field splitting costs about twice that loop. _arx_is_record applies the same split to a single line, for _arx_file_apply, which calls it only for lines whose first word is a name being written or deleted.
The format is intentionally simple. Since git branch names cannot contain spaces (git itself rejects them), space as delimiter is unambiguous and requires no quoting or escaping.
Atomic Writes
The file is never modified in place. Every change goes through _arx_file_apply, which takes a batch on stdin – <name> <sha> <date> lines to write, bare <name> lines to delete – and uses a filter-then-append pattern:
- Read the full archive into a temp file (
${archive}.tmp.$$), skipping every record (recognized by_arx_is_record, as on read) whose name is in the batch – comments, blank lines and other records are copied verbatim - Append the batch’s new records to the temp file
- Replace the archive with the temp file
_arx_file_write, _arx_file_write_bulk and _arx_file_delete – the backend interface the abstraction layer calls – are one-line wrappers around it, and sync hands it all of its file changes at once. One rewrite per batch keeps bulk writes linear; a rewrite per entry would be O(n²) in the archive size.
The temp file name uses $$ (the shell’s PID) to avoid collisions if multiple instances run simultaneously (unlikely for an interactive CLI, but safe practice).
The temp file is created (and truncated) up front with : > "$tmpfile". The filter loops only append, so without this a delete that filters out every line (e.g. a header-less archive whose only entry is removed) would never create the temp file — the final mv would then fail after the old archive was already removed, aborting the script before the refs backend delete runs. Truncating up front also discards any stale temp file left behind by a crashed run with a recycled PID.
The replace step on Windows/MINGW64 requires an explicit rm -f before mv:
[[ -f "$archive" ]] && rm -f "$archive"
mv "$tmpfile" "$archive"
On Linux/macOS, mv over an existing file is atomic at the filesystem level. On Windows NTFS via Git Bash, mv can fail if the destination exists; the explicit remove makes it reliable.
Remove = Filter Out
Deleted entries are removed from the file entirely, not marked with a prefix like #archived. Rationale:
- The git object itself still exists in the repository (until gc) – the SHA in the record is the real audit trail
- Keeping deleted entries would mean the file grows unboundedly
_arx_file_applyalready implements filter-then-append, so delete is just filter-without-append – no new code path
Refs Backend
Namespace
Archived branches are stored as git refs under a configurable prefix, defaulting to refs/arx/. For a branch named feature/login, the default ref path is refs/arx/feature/login. The prefix is read from arx.refsprefix once at startup into ARX_REFSPREFIX (see _arx_load_config()), which also validates it: the value must name a namespace under refs/ (a bare refs/ or a prefix outside refs/ aborts with an error, since it would make git for-each-ref match refs outside the archive), and a missing trailing / is appended automatically. The namespace must also be git-arx’s own: a prefix inside refs/heads/, refs/tags/, refs/remotes/, refs/notes/, refs/replace/, refs/stash/, refs/bisect/, refs/worktree/, refs/rewritten/ or refs/prefetch/ aborts too. Everything under the prefix is taken to be the archive, so prune would delete local branches matching the “archive” and purge would delete every such ref on the remote – with refs/heads/, all branches, with nothing left to restore from.
Git ref names allow forward slashes and use them to create directory structure. refs/arx/feature/login is stored as the file .git/refs/arx/feature/login. This is the same mechanism used by refs/remotes/origin/feature/login – no special handling is needed.
The only characters illegal in git ref names are: space, ~, ^, :, ?, *, [, \, and the sequences .. and @{. Since git itself rejects branch names with these characters, any valid local branch name is a valid ref name in our namespace.
Why Refs Protect from gc
git gc prunes unreachable objects – commits, trees, and blobs that cannot be reached by following refs (branches, tags, stash, reflogs). When a local branch is deleted, its commits become unreachable unless something else references them. A ref under the arx prefix (e.g., refs/arx/) is a real git ref, so any commit it points to (and all ancestors of that commit) remain reachable and will not be pruned.
Reading Dates from Refs
The refs backend does not store dates explicitly – the date is read from the commit object at query time:
git for-each-ref \
--format='%(refname) %(objectname) %(authordate:iso-strict) %(authordate:unix)' \
"$refsprefix"
%(refname) gives the full ref path (e.g., refs/arx/feature/login). The configured prefix is then stripped in _arx_refs_read to recover the branch name.
%(authordate:iso-strict) gives the ISO-8601 author date of the commit the ref points to — the same date the file backend stores (git log -1 --format=%aI), so the normalized output of both _arx_file_read and _arx_refs_read is identical. %(creatordate) would be the committer date, which diverges from the author date after rebase, amend, or cherry-pick — the two backends would then report different dates for the same entry.
Remote Operations
Refs under the arx prefix are not pushed by default. Git only pushes refs/heads/* and refs/tags/* in a standard git push. The push command uses an explicit refspec built from arx.refsprefix (default: refs/arx/):
git push origin 'refs/arx/*:refs/arx/*'
This pushes all refs under the prefix to the same path on the remote. Supported by GitHub, GitLab, Gitea, and Bitbucket.
After a push, arx push updates a local remote-tracking namespace derived from arx.refsprefix — e.g. refs/arx/ → refs/arx-remote/origin/ — so that arx list and arx status --all can report an accurate REMOTE column without a network call. The push runs with --porcelain, which reports every ref’s outcome as <flag>\t<from>:<to>\t<summary>, and tracking refs follow exactly what the remote accepted: refs pushed or already up to date are mirrored at their local SHA, deleted ones are dropped, rejected ones keep their old tracking value. A push is not all-or-nothing – a ref re-archived at a commit that doesn’t descend from the remote copy is rejected while the rest go through – and updating tracking only after a fully successful push left the accepted refs showing as local. (--atomic would make it all-or-nothing instead, but requires server support and blocks every ref on one rejection.) The porcelain output is turned into git-arx’s own Pushed: / Rejected: lines; git’s advice for rejected refs (git pull) is switched off with -c advice.pushUpdateRejected=false, since it points at the wrong command here. All tracking updates go through one git update-ref --stdin.
arx push --delete <branch> deletes a single ref from the remote using git push origin --delete refs/arx/<branch>, then removes the corresponding local remote-tracking ref.
arx push --prune adds --prune to the glob refspec push, which causes git to delete any remote refs under the prefix that have no local counterpart. Tracking refs follow the porcelain report as above, and when the push succeeded completely the remote holds exactly the local archive, so any remaining tracking ref without a local counterpart is dropped too.
arx purge deletes every refs/arx/* ref the remote holds, independent of the local archive — the unconditional counterpart to --prune’s “delete what has no local counterpart”. It enumerates the remote refs with git ls-remote origin 'refs/arx/*' (no objects transferred), deletes them all in a single git push origin --delete <ref>..., then drops every local remote-tracking ref since the remote now holds none. It exists for the “remote as a transfer channel” workflow: secondaries push, the primary pulls, and purge clears the remote so it never accumulates an archive. Confirmation-gated like prune (bypass with --force); --dry-run lists the remote refs without deleting.
arx pull inverts this: it fetches refs/arx/* from the remote into the tracking namespace first, then copies those refs into refs/arx/* locally. This preserves a clean record of the last known remote state regardless of any local-only archive entries that were added between pulls.
The copy is a three-way decision, because the tracking refs as they were before the fetch record what the remote held at the last push or pull. For each fetched ref, with local the current refs/arx/* value, base the pre-fetch tracking value and remote the fetched one:
| Condition | Action |
|---|---|
| no local entry | add it (covers entries removed locally – recoverable, as status --all says) |
| local = remote, or remote = base | nothing – in sync, or re-archived here and not pushed yet |
| local = base | take the remote’s SHA – it changed on the remote only |
| otherwise | keep local, report it, exit 1 – changed on both sides (or no base to tell) |
Copying every tracking ref unconditionally, as pull once did, silently reverted entries re-archived locally since the last push – and refs/arx/* has no reflog, so the overwritten commit was gone for good once its branch had been pruned. Both snapshots come from a single for-each-ref over the two namespaces, and all updates go through one git update-ref --stdin transaction, each conditioned on the local value the decision was based on (create, or update <ref> <new> <old>). The fetch refspec is forced (+ prefix) so that refs re-archived at a different SHA and pushed with arx push --force from another machine are accepted (a plain refspec would reject them as non-fast-forward and abort the pull). The fetch also uses --prune, so tracking refs whose remote counterpart was deleted (e.g. via arx push --delete elsewhere) are removed — without it the REMOTE column would keep reporting pushed for refs that no longer exist on the remote. Note that pruning only affects the tracking namespace: the corresponding local refs/arx/* entry is kept, since the local archive is authoritative for local entries.
# pull fetches into tracking namespace (forced + pruned), then promotes to local refs
git fetch --prune origin '+refs/arx/*:refs/arx-remote/origin/*'
# then: git update-ref refs/arx/<branch> <sha> for each tracking ref
When the file backend is also enabled, pull syncs all fetched entries to .gitarchive via _arx_file_write_bulk — one filter-then-append rewrite for the whole batch instead of one full file rewrite per entry (which would be O(n²)). File-only entries are preserved, matching the per-entry write semantics.
arx fetch is a read-only preview of what arx pull would bring in. It uses git ls-remote to query the remote ref list without downloading any objects, then compares against local refs/arx/* and the tracking refs, making the same three-way decision as pull:
new— on the remote, not locally; pull would add itup to date— same SHA on both sides; pull is a no-op for this branchchanged— changed on the remote since the last push or pull; pull would update local to the remote SHAahead— re-archived locally, remote unchanged; pull keeps the local SHAconflict— changed on both sides; pull keeps the local SHA and reports itlocal— local only, not on the remote; unaffected by pull
This is also how fully automatic remote sync is possible without git arx push/pull when using both backends: if .gitarchive is committed to the repository, it syncs as part of the normal git object graph.
Command Notes
arx update and arx status
Both commands use %(upstream) from git for-each-ref to classify local branches:
- Empty
%(upstream)— no upstream ever configured: the branch was never pushed (“local only”). - Non-empty
%(upstream), ref does not exist locally — upstream was configured but the tracking ref no longer exists: the remote branch was deleted (aftergit fetch --pruneor manual pruning). - Non-empty
%(upstream), ref exists — live remote branch, skip.
The existence check is a lookup in an existing_refs set preloaded from a single git for-each-ref refs/heads/ refs/remotes/ call (see Performance section) — refs/remotes/ for normal remote-tracking upstreams, refs/heads/ because an upstream can also be a local branch (branch.<name>.remote = ., e.g. after git branch --track a b).
Branch names come from %(refname) with refs/heads/ stripped (and prune tells checked-out branches by %(worktreepath), not by name). The shortening forms (%(refname:short), symbolic-ref --short) emit the shortest unambiguous name, so a repo holding both refs/heads/topic and refs/tags/topic yields heads/topic — a name that misses every arc_by_name lookup, lands in the archive as a bogus entry if written, and makes git branch -D fail. update, status, and prune all strip the prefix themselves.
This approach is more robust than checking %(upstream:track) for the string [gone] because:
[gone]can vary by git version or locale- The ref-existence check is a direct, binary fact about the ref store
Note: git remote prune origin removes the remote tracking ref (refs/remotes/origin/branch) but does not clear branch.<name>.remote or branch.<name>.merge from .git/config. So %(upstream) still outputs the full ref path for pruned branches. Checking whether that ref resolves handles both the “never had a remote” and “remote was deleted” cases.
arx update only processes remote-deleted branches (non-empty upstream that doesn’t resolve). Never-pushed branches are skipped — they require an explicit git arx add if the user wants to archive them.
arx status (default) shows only remote-deleted branches, with archive states Not archived, Archived, Archived as "<name>", or Conflict. Nothing is written.
arx status --all additionally shows:
- Never-pushed local branches — shown with status
Local onlywhen not yet archived (orArchived/Conflictif they have been archived manually). - Archived branches that no longer exist locally — after the
git for-each-refloop, the command comparesarc_by_namekeys against thelocal_branchesset to find orphan entries. Their authors are fetched in a singlegit log --no-walkcall, with(gc)as a fallback for pruned commits. These rows are appended to the samerowsarray and go through the same sort and print path. - A REMOTE column (when the refs backend is active) — loaded once from
refs/arx-remote/origin/*intoremote_sha_by_branch[branch]=shabefore the print loop. Each row is classified aspushed(remote SHA matches archive SHA),ahead(remote SHA differs),local(no tracking ref),remote(not in local archive but remote tracking ref exists — branch was removed locally but not yet deleted from remote), or-(not in archive and no remote tracking ref).
arx status accepts --sort=name|date and --order=asc|desc. The default sort is name; the default order depends on the sort key — asc for name, desc for date — unless overridden explicitly. When sorting by date, name is used as a tiebreaker. Rows are collected first, then sorted as a post-processing step before printing. arx list uses the same sort/order logic.
DATE column. Both arx status and arx list render the stored ISO-8601 timestamp as YYYY-MM-DD HH:MM:SS in a %-21s field, built inline as ${date:0:10} ${date:11:8} — the two slices drop the trailing UTC offset and replace the ISO T with a space. The time shown is therefore the one recorded on the commit — the author’s local time, not the viewer’s. The space is safe in both call sites: arx status carries rows as tab-delimited fields (sort -t$'\t'), and arx list passes the value as a single printf argument. Sorting never uses the displayed text. Stored dates keep the author’s own UTC offset, so as text 10:00+09:00 sorts after 02:00+00:00 although it happened an hour earlier – entries from a team across timezones, or across a DST change, would interleave by clock reading. --sort=date sorts numerically on seconds since the epoch instead. Wherever git is already listing the refs, it supplies them for free as %(authordate:unix): for local branches in arx status’s for-each-ref, and for refs-backend records as the fourth field _arx_refs_read emits. Only records from the file need computing, which _arx_epoch does in pure bash arithmetic (days-from-civil, no date process) – for arx list, which appends the key as field 4 (and skips that pass entirely when the file backend is off), and for the archive-only rows of status --all. Ties are broken by name. Status rows are built with printf -v rather than $(printf ...), which would fork a subshell per branch inside the loop the bulk optimizations keep free of processes.
Color output. Both arx status and arx list emit ANSI color codes only when stdout is a terminal ([[ -t 1 ]]). Piped or redirected output is always plain text. arx status colors the STATUS column: red (Not archived), green (Archived), light blue / bright cyan (Archived as "..."), yellow (Conflict), dim (Local only). Both commands color the REMOTE column: green (pushed), yellow (ahead), red (local), cyan (remote), dim (-).
printf byte-vs-character width. printf %-Ns pads a field to N bytes, not N display columns. Author names containing multibyte UTF-8 characters (e.g. ć, ž) are longer in bytes than in characters, so subsequent columns shift left for those rows. arx status corrects for this before printing each row: it measures the string in both character count (${#s} with the active locale) and byte count (${#s} with LC_ALL=C), then widens the format field by the difference. The same correction is applied to the STATUS column when the REMOTE column is present — ANSI color codes add invisible bytes to the colored STATUS string, which would otherwise throw off its fixed-width padding.
Performance (arx update, arx status, arx list --author)
For repos with many branches or archived entries, naive per-branch subprocess calls add up to tens of seconds. Three commands use bulk operations to avoid this.
arx update and arx status share the same three optimisations:
-
Archive loaded once –
_arx_read_allis called once before the branch loop and its output is stored in two in-memory associative arrays:arc_by_name[branch]=shaandarc_by_sha[sha]=name. All per-branch archive lookups are then O(1) bash hash table reads instead of O(n) subprocess calls. -
Single
git for-each-refcall – a single call retrieves branch name, SHA, author date, and (forstatus) author name for every branch at once, replacing per-branchgit rev-parseandgit logcalls:
# arx status (includes the date as a sort key, and authorname for display)
git for-each-ref \
--format='%(refname)%09%(objectname)%09%(authordate:iso-strict)%09%(authordate:unix)%09%(authorname)%09%(upstream)' \
refs/heads/
# arx update (authorname not needed)
git for-each-ref \
--format='%(refname)%09%(objectname)%09%(authordate:iso-strict)%09%(upstream)' \
refs/heads/
- Upstream existence as a set lookup – before the branch loop, all refs an upstream could point to are loaded into an
existing_refsassociative array with onegit for-each-ref --format='%(refname)' refs/heads/ refs/remotes/call. The per-branch “does the upstream ref still exist” check is then a bash hash lookup instead of agit rev-parse --verifysubprocess per branch — previously the dominant cost on repos with many tracked branches, even when there was nothing to archive.
arx update batches its writes. Branches to archive are collected into a pending array during the loop and flushed once at the end through _arx_write_bulk – one .gitarchive rewrite instead of a full rewrite per branch (which would be O(n²)), and one atomic git update-ref --stdin transaction instead of one subprocess per ref. Per-branch messages are printed as branches are classified, before the flush; if the flush fails, the exit status is non-zero even though Archived: lines were already shown.
arx update also keeps the in-memory maps current after processing each branch – arc_by_name and arc_by_sha are updated regardless of --dry-run – so the simulation is accurate, and subsequent branches see the correct in-memory state whether or not writes are actually happening.
%(upstream) must be last. Tab (%09) is an IFS whitespace character. When %(upstream) is empty, it produces two consecutive tabs. Because IFS whitespace collapses, read treats <TAB><TAB> as a single separator – the empty field disappears and all subsequent fields shift left. Placing %(upstream) last avoids this: the trailing tab is stripped cleanly, and upstream_ref is assigned an empty string, which is the correct behaviour.
set -u and associative arrays. Accessing a missing key in an associative array with set -u enabled triggers an “unbound variable” error in bash 4.x. All array reads use ${arr[key]:-} to provide an explicit empty-string default and suppress the error.
arx list --author – archived SHAs are not local branch refs, so git for-each-ref does not apply. Instead, all SHAs are collected from the sorted entries and fed to a single git log --no-walk call on stdin, reducing N subprocess calls to one:
printf '%s\n' sha1 sha2 sha3 ... | git log --no-walk --ignore-missing --stdin --format='%H %an'
The result is stored in author_by_sha[sha]=name and looked up during rendering. gc’d commits are absent from the output and fall back to (gc) via ${author_by_sha[$sha]:-\(gc\)}.
arx add – Conflict Detection
Before writing, add scans _arx_read_all once, resolving two things in the same pass: the SHA stored under the target name (which may be a custom archive name), and any names the branch’s current SHA is already stored under. Four outcomes:
- Not archived – write and report
Archived:. - Archived with same SHA – exit 0 with
Already archived:. Idempotent; safe to call repeatedly. - Archived with different SHA – conflict. Exit 1 with an error and hints.
--forceoverwrites; anarchive-nameargument stores under a different name instead. - Not archived by target name, but SHA already present under a different name – the same-pass reverse lookup finds the duplicate. Prints a
Note:line, then writes anyway (the user explicitly requested this archive entry).
An archive-name, like rename’s new name, is checked by _arx_require_valid_name before anything is written: it becomes a ref under the prefix, a branch on checkout, and a whitespace-delimited field in the file, so it must be a valid branch name – git check-ref-format refs/heads/<name>, plus no leading -. Without the check, my old feat was stored as a file record that reads back as branch my, SHA old.
arx update applies the same conflict logic for every candidate branch, using the in-memory arc_by_name and arc_by_sha maps (see Performance section). If the current SHA is already stored under a different name, the branch is skipped with an Already safe: message and counted separately in the summary. This prevents silent duplicate SHA storage during automatic archiving. If the user wants the branch indexed under its natural name too, they can run git arx add <branch> explicitly.
arx rename
Implemented as _arx_write(new) + _arx_delete(old) – the abstraction layer fans out to all enabled backends automatically. There is no dedicated rename primitive in either backend; write-then-delete is equivalent.
arx log – Argument Passthrough
cmd_log() {
local branch="$1"
shift # $@ now contains only the git log flags
...
git log "$sha" "$@"
}
shift removes the branch name argument, leaving "$@" as whatever the user typed after the branch name. All git log flags, format strings, file path filters, and revision ranges work as expected.
exec is intentionally not used here. On Windows/MINGW64, exec does not properly transfer the pipe file descriptor to the replacement process, so git detects a terminal instead of a pipe, opens the pager, and the output never reaches the caller. A plain git log call avoids this and behaves correctly on all platforms.
arx checkout – gc Detection
Before attempting to restore, the script checks whether the commit still exists:
if ! git cat-file -e "$sha"; then
# commit was garbage collected
fi
git cat-file -e <object> exits 0 if the object exists in the object store, non-zero otherwise. It does not print anything. This is the correct low-level check – it works for any object type (commit, tree, blob) and does not require the object to be reachable.
arx prune
Finds all local branches whose current commit is in the archive, then deletes them.
The loop walks local branches, classifying each by the SHA it points at now. It uses the same three inputs status and update do: the arc_by_name / arc_by_sha index pair, and the existing_refs set that tells a live %(upstream) from a deleted one.
| Name in archive | Commit in archive | Upstream | Outcome |
|---|---|---|---|
| at this SHA | – | any | Delete |
| at another SHA | yes, under another name | not live | Delete, labelled branch (archived as "other") |
| no | yes, under another name | gone | Delete, labelled branch (archived as "other") |
| yes, any SHA | no | not live | Conflict – skipped, exit 1 |
| anything else | Ignored silently |
An exact name+SHA match is an unconditional delete: that commit is in the archive under that very name, so the deletion costs nothing.
The two “archived as” rows cover the case where only the commit matches, not the name. The commit is reachable from refs/arx/<other-name> either way, so what deletion costs is the branch name — restore comes back under the archived name, which is why the label spells that name out before the user types yes. Matching on the commit alone would be too loose, though: a default branch sitting on the tip of a fast-forward-merged feature branch also matches arc_by_sha and must not be touched. So the row additionally requires evidence that this branch was archived — either its name is in the archive, or its upstream is configured and gone, the state update reports as “already safe”. A live upstream disqualifies it outright.
The conflict row is the one that must not delete: the branch has moved past its archived SHA and its current commit is in no entry at all, so git branch -D would make it unreachable. Branches with a live upstream are excluded — their commits are on the remote, and update does not act on them either. Exiting 1 for skipped conflicts matches update and sync.
With both backends enabled, the table is applied twice. The union of both backends (as _arx_read_all reads it) decides whether a branch is archived; the refs backend alone decides whether it may be deleted, because a ref is what keeps the commit from gc once the branch is gone. A branch that the union marks for deletion but refs alone do not – its entry exists only in the file, through drift or a teammate’s committed .gitarchive – is listed as skipped with a pointer to git arx sync, and the run exits 1 like a conflict. _arx_prune_deletable holds the delete rows of the table so both passes use the same rule. With a single backend, that backend decides both.
Both halves are bulk operations: one git for-each-ref refs/heads/ call supplies every branch name, SHA, and upstream (the archive lookups are hash hits, not git rev-parse --verify subprocesses), and the deletion is a single git branch -D b1 b2 ... call for the whole batch – git itself prints the per-branch Deleted branch ... (was ...). lines. The existing_refs load adds one further for-each-ref, for three subprocesses total regardless of branch count (four with both backends enabled, which read the refs once more on their own).
Key behaviors:
- A branch checked out in any worktree is always skipped (git would reject the deletion anyway) and listed separately with a “Skipped (currently checked out)” notice; branches checked out in another worktree are labelled with its path.
%(worktreepath)in the branchfor-each-refsupplies this, replacing a separategit symbolic-ref HEADfor the current branch – which missed other worktrees, so the singlegit branch -Dfor the batch failed partway. Because%(upstream)and%(worktreepath)can both be empty, this call separates fields with\x1f(%1f) rather than tab: a non-whitespace IFS character never collapses, so empty fields stay in place. The skip takes precedence over conflict classification, so a branch that has moved past its archived SHA while checked out is a skip, not a failure. - Without
--force, the full list is printed and the user must type"yes"to proceed. This is intentional –git branch -Dis irreversible from git’s perspective (the archive is the only recovery path). --dry-runprints the same list and count as a real run but skips the confirmation prompt and does not delete anything. The(dry run – no changes written)marker terminates every exit path, including the ones that delete nothing, and conflicts still exit 1.- Output is in
for-each-ref’s refname order.
arx sync – Union Merge Algorithm
sync is only meaningful when both arx.storerefs and arx.storefile are enabled, since it reconciles two backends that can theoretically drift.
When drift happens:
In normal usage, drift should not occur – every write operation hits both backends atomically (within the script). Drift can arise from:
- Someone manually edits
.gitarchivewith a text editor - Someone manually creates/deletes refs with raw git commands
- A script crash between the file write and the ref write
- Running
git fetch origin 'refs/arx/*:refs/arx/*'directly instead ofgit arx pull(updates local arx refs but bypasses the tracking namespace and the file backend)
Algorithm:
for each branch in (refs ∪ file):
refs-only → write to file
file-only → write to refs
both, same SHA → no-op
both, different SHA → conflict
Non-conflicting entries are always processed. A conflict does not block other entries from being synced. After processing all entries, if any conflicts occurred, sync exits with status 1.
Every entry is decided first; the writes are then applied in one git update-ref --stdin transaction and one _arx_file_apply rewrite, refs first. A ref can only point at an object the repository has, and the file may name commits this clone never fetched, so all file SHAs are checked up front with a single git cat-file --batch-check. An entry whose commit is missing is skipped and reported (and the exit status is 1), instead of aborting the sync halfway through under set -e with whichever entries associative-array order happened to reach first.
--dry-run: Runs the same comparison logic and prints the same output as a real sync, but skips all writes. A trailing (dry run – no changes written) line is appended. Works with or without --force-file / --force-refs – output shows exactly what would happen if the flag were run without --dry-run.
--force-file / --force-refs: When a SHA conflict is detected and a force flag is present, the designated backend is treated as the source of truth and the other is overwritten. This is an escape hatch for the rare case where the user knows which side is correct.
arx config
_arx_config_read loads every arx.* definition with one git config -z --show-scope --get-regexp '^arx\.'. With --show-scope, -z output puts the scope in a NUL-terminated field of its own ahead of each key\nvalue record, so each definition takes two read -d '' calls. Definitions come out lowest scope first (system, global, local, worktree, command), so the last one for a key is the one git resolves – the same rule _arx_load_config relies on.
_arx_config_lookup finds that definition, optionally restricted to scopes that override a given one (above) or skipping one scope (except). _arx_config_effective then turns it into the value git-arx acts on, the way startup would. Booleans go through _arx_bool with an empty default, so an unusable value can be told apart from a real one. The refs prefix goes through _arx_check_refsprefix, the same function _arx_load_config calls.
A write is checked against the state it produces, not just the value written:
- Validation: the new value is validated and normalized first (booleans to
true/false, the refs prefix with its trailing/), then stored withgit config --local|--global --replace-all -- arx.<key> <value>. The--keeps a value starting with-from being read as an option. - Effective value after the change: a definition in a higher scope than the one written stays in effect. A
--globalwrite in a repo that sets the key locally changes nothing here, and is reported as such. Otherwise the new value takes effect, or, for--unset, whateverexcept <scope>resolves to. - Both backends: if both would end up disabled, the write is refused. This is checked only when the key is a storage flag, so a repo whose config is already broken can still change its other keys.
- Hidden entries: only an effective change is checked. Disabling a backend is only possible while the other is on, so it hides just the entries the other lacks.
_arx_count_one_sidedmatches by name, assyncdoes: onefor-each-refand one_arx_file_read, compared in an associative array. Changingrefsprefixorfilepathhides everything under the old one. Any warning prompts foryesunless--forceis given. Enabling a backend triggers no prompt, just a note pointing atsyncwhen the other backend holds entries it lacks.
Nothing is moved: the old refs and the old file stay where they are, so reverting the setting brings the entries back.
Shell Completion
git-arx-completion.bash defines _git_arx() — the function name git-completion.bash looks for when the user presses Tab after git arx. The naming convention is the external command name with hyphens replaced by underscores.
The function is context-aware: it offers different completions depending on the subcommand at words[2]:
- Subcommand names at
cword == 2(including aliases:ls,rm,mv) - Archived branch names (via
git arx list) forcheckout,log,remove/rm,rename/mv - Local branch names (via
__git_heads) foradd - Key names for
config, thentrue/falsefor the storage flags and file paths forfilepath - Per-subcommand flags for everything else
Archived branch names are fetched by calling git arx list at tab-press time and stripping the two header lines with awk NR > 2. This is a subprocess invocation on every completion for those commands — fast enough in practice, but noticeable on repos with very large archives.
Testing
The test suite lives in test.sh and is an integration test suite – it runs the actual git-arx script against real git repositories created in a temporary directory. No mocking.
The one exception is the boolean sweep in test_config_bool, which extracts _arx_bool from the script and calls it directly. The value table there is a property of a single pure function, and driving two dozen values through a whole command would cost hundreds of git subprocesses without covering anything the end-to-end cases in the same section already do.
Running the tests
bash test.sh # every section
bash test.sh prune sync # only the named sections (test_prune, test_sync)
No install required. The script resolves the path to git-arx relative to its own location, so it works from any working directory, and runs it as bash git-arx, so the file does not need its executable bit.
Structure
The suite is organized into sections, each exercising one command or scenario. The names without the test_ prefix are what bash test.sh <section>... accepts:
test_help git arx help / -h, per-command help (<cmd> -h, --help, help <cmd>)
test_add git arx add (normal, conflict, --force, archive-name)
test_remove git arx remove
test_rename git arx rename
test_list git arx list (sorting, --author, --storage filter)
test_update git arx update (--dry-run, --force, conflicts, already-safe)
test_sort_tiebreak name tiebreak when sorting by date
test_sort_time date sorting by actual time across UTC offsets
test_log git arx log (passthrough flags)
test_checkout git arx checkout (restore, gc'd commit)
test_prune git arx prune (--dry-run, --force, current branch skipped,
archived-under-other-name, SHA conflict not deleted,
branches checked out in other worktrees)
test_merge git arx merge (dedup, conflicts)
test_refs_backend refs-only storage
test_both_backend both backends enabled (union reads, sync)
test_push_pull git arx push / fetch / pull (requires a bare remote)
test_purge git arx purge (--dry-run, --force, empty remote)
test_sync git arx sync (--dry-run, --force-file, --force-refs)
test_file_records archive file parsing: malformed and hostile lines,
#-named branches, CRLF line endings
test_slashed_branches branch names with slashes
test_double_add idempotency of add
test_config_bool git boolean spellings for storage flags end to end, then a
direct _arx_bool sweep checked against git's own verdict
test_config git arx config (list, get, set, --unset, --global,
validation, hidden-entry prompts, fixing a broken config)
test_error_cases unknown commands, missing args, bad config, running
outside a repo or inside a bare one
test_overwrite_guard bytes past the final { main; exit; } are never executed
Assertions come in two layers. run cmd... executes a command once and keeps its combined stdout+stderr in OUT and its exit status in RC; ok / nok then check the status and has / lacks check OUT for fixed strings, so one invocation serves every assertion about it. assert_ok, assert_fails and assert_out are the one-shot forms (run, then a single check). check label [!] cmd... asserts on state instead of output – ref_exists, ref_is, remote_has, file_has, or any test expression. Output checks are intentionally coarse-grained (substring match) rather than exact, so minor wording changes in messages don’t break the suite.
The suite’s runtime is almost entirely process spawning – each git-arx invocation is a fresh bash parsing the whole script plus a few git subprocesses – so the helpers avoid spawning anything they can do in bash: matching is [[ $OUT == *"$pattern"* ]] rather than a grep pipeline, file_has reads with the read builtin, and git-arx is invoked as bash git-arx rather than through its #!/usr/bin/env bash shebang, which costs a second process (env). This matters most on Windows, where spawning a process is an order of magnitude slower than on Linux.
Test isolation
make_fixture builds one pristine fixture in a mktemp -d directory: a bare remote.git and a clone of it holding the default branch (pushed) and three never-pushed branches, feature/alpha, feature/beta and fix/gamma, with file storage. Every section then runs in the background in its own copy of that fixture (run_section), so no section can see state another one left behind, and all sections run in parallel. Each writes to its own log; the main shell waits for the sections in order, printing each log and tallying its PASS / FAIL lines as that section finishes. A section that dies part-way – an unexpected failure under set -e – is reported as one more failure, and the other sections are unaffected.
Within a section, reset_archive [file|refs|both] empties both backends (deletes .gitarchive and every refs/arx/ ref) and sets storage, file by default. reset_branches puts the three fixture branches back at their fixture commits, recreating any that a scenario deleted.
The suite sets GIT_CONFIG_GLOBAL=/dev/null, so the user’s global git config does not leak into the fixtures – commit.gpgsign would sign (or prompt for) every fixture commit, and settings like branch.autoSetupMerge or fetch.prune change the very branch states under test.
The temporary directory is removed by a trap ... EXIT, which also runs when the suite is interrupted.
Benchmarking
bench.sh times the subprocess-heavy commands (status, status --all, update, list, prune --dry-run) against a synthetic repository with many branches – each at a distinct commit (update dedups identical SHAs), each simulating a deleted remote (upstream configured, tracking ref absent). Like the test suite, it runs in a mktemp -d directory and cleans up after itself.
bash bench.sh # time the working-tree git-arx
bash bench.sh <rev> # compare <rev> against the working tree
bash bench.sh <rev-a> <rev-b> # compare two versions
N=200 bash bench.sh # override the branch count (default: 80)
Arguments may be git revisions (git-arx is extracted from them via git show) or paths to git-arx executables, so an installed copy can be compared directly: bash bench.sh ~/bin/git-arx.
When to run it: when a change touches the branch loops in update, status, or prune, or adds a subprocess call anywhere reachable from them – not on a schedule. The failure mode it guards against is a subprocess inside a per-branch loop, which shows up as times scaling with N instead of staying flat.
Times are not tracked over time. Absolute numbers depend on machine, filesystem, and platform (process spawning on Windows/MINGW64 is an order of magnitude slower than on Linux, which is exactly why the bulk patterns matter there most). For reference, the one-time snapshot that motivated the bulk-operation work (N=80, Windows 11 / MINGW64, 3e11639 vs 85738a4):
| Command | Before | After | |
|---|---|---|---|
status |
5.4 s | 1.8 s | 3× |
status --all |
5.8 s | 2.5 s | 2.4× |
update (archiving 80) |
23.4 s | 1.1 s | 22× |
list |
0.8 s | 0.7 s | – (already bulk) |
prune --dry-run |
4.2 s | 0.7 s | 6× |
The ratios are the signal; the absolute numbers are not.
Versioning
git-arx uses commit hashes as its version identifier. The source file always contains VERSION="dev" — this placeholder is replaced at install time by install.sh.
How the stamp works:
- Local install (
bash install.sh):install.shreads the short commit hash from the repo alongside it viagit rev-parse --short HEADand writes it into the installed file usingsed. - Remote install (
curl ... | bash):install.shqueries the GitHub API for the latest commit onmasterand writes that hash into the installed file.
After install, git arx --version reports the short commit hash that was current at install time (e.g. git-arx abc1234). To check whether the installed version is up to date, run git arx upgrade.
How upgrade works:
cmd_upgrade uses git ls-remote (the same mechanism as install.sh remote install) to fetch the latest commit hash on master without cloning the repo. It compares the 7-character prefix of that hash against $VERSION. If they differ, the user is prompted to confirm (or -y skips the prompt). On confirmation, install.sh is downloaded via curl and executed with the install directory derived from command -v git-arx, so the updated file lands in the same location as the running binary.
cmd_upgrade does not call _arx_require_git and is dispatched in main() before that check, so it works from any directory — not just inside a git repo.
Self-overwrite guard. upgrade overwrites the very script bash is executing. Bash reads scripts lazily: functions are parsed in full before they run, but after the last top-level command finishes, bash returns to the file to look for more input — at its saved byte offset, which now points into the middle of the replaced (differently sized) file, and it will try to execute whatever bytes it lands on. That is why the script’s last line is { main "$@"; exit; } rather than a bare main "$@": the braces force the whole block to be parsed up front, and the exit ends the process without ever reading the file again. test_overwrite_guard in the test suite asserts that bytes appended after this line are never executed.
VERSION="dev" in the source is intentional — it is never manually edited. Do not commit a real hash into the source file. Running upgrade when VERSION="dev" exits with an error.
Known Limitations
- No locking. The archive file has no write lock. Concurrent invocations (unlikely for an interactive tool) could corrupt it. Acceptable trade-off.
- bash 4+ required. Uses
declare -Aassociative arrays. macOS ships bash 3.2; users need to install bash via Homebrew and ensure it’s on their PATH. mergedoes not resolve SHA conflicts. When the same branch appears in two files with different SHAs, the conflict is reported and the entry is skipped. The user must manually decide which SHA is correct and edit the output file. There is no--force-file/--force-refsequivalent formerge(unlikesync) becausemergehas no concept of a “primary” source.push/pullhardcodesorigin. The remote name is not configurable. This could be added viaarx.remoteconfig in a future version.- Bash completion only.
git-arx-completion.bashcovers bash. zsh and fish completion scripts are not provided.