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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
version="$(sed -n "s/^VERSION='\(.*\)'$/\1/p" bin/git-locks)"
version="$(sed -n "s/^VERSION='\(.*\)'$/\1/p" bin/git-locks)" # the built script; lib/000-prelude.sh is the source
[ -n "$version" ] || { echo "no VERSION in bin/git-locks"; exit 1; }
tag="v${version}"
if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null 2>&1 || gh release view "$tag" >/dev/null 2>&1; then
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@ All notable changes to this project are recorded here. The format follows Keep a

## [Unreleased]

## [0.4.0] - 2026-09-16

### Changed

- The script is built. `bin/git-locks` is assembled by `scripts/build.sh` from `lib/*.sh` in numeric order, with the schema module generated from `schema/git-locks.schema.json`; `make build` writes it, and the test suite refuses a committed `bin/git-locks` that is not byte-for-byte what `lib/` builds (#11). The installed artifact, the release asset and `make install` are unchanged: one file.
- `list` renders without forking. Record fields, paths, the clock and JSON arrays have `printf -v` forms (`field_v`, `record_paths_v`, `now_v`, `json_paths_v`) and the render path uses only those, so each record is parsed once and a list of n locks is O(n) bash with no processes per line; `GIT_LOCKS_TRACE` writes one `parse <oid>` line per record and the suite counts them (#24).
- The snapshot reads `cat-file --batch` output with `read -N` instead of slicing the captured text, which was quadratic in the store size. Measured on 500 locks (macOS, bash 5.3, same store, before and after): `list` 6.75 s to 0.58 s; `check` 0.87 s to 0.28 s; `show` 0.85 s to 0.25 s; `claim` about 1.0 s to 0.33 s. The 0.07 s figures the README carried for 0.3.x were not reproducible on that store and are withdrawn.
- One clock reading per invocation (`now_v` caches it), so every `remaining` in one `list` is computed against the same instant.

### Fixed

- A `--ttl` with a leading zero was octal in arithmetic (`010` gave eight seconds; `08` failed); ttl values are decimal everywhere (`claim`, `batch`, `extend`, `sem acquire`, `with`).
- Parsed record fields are stored whole, keyed by record and field name, so no byte in a holder can read as a field delimiter (the first cut of 0.4.0 joined them with control bytes). A holder is one line; `sem acquire` and `with` now refuse a newline in it as `claim` already did.
- A `batch` record with only `parent:` or `ttl:` was skipped as empty and its parent leaked into the next record; it is malformed now.
- `sweep` deletes only the record it saw expire: a lock renewed between its read and its transaction is left alone.
- `with --sem` validates its arguments before acquiring anything, and arms its release traps before the first acquisition, so a signal during the wait for the path lock gives back the slot already taken.
- `check` reads the clock in the parent shell, so `remaining` and `state` on one line agree.
- `version` refuses extra arguments like every other command.

## [0.3.2] - 2026-09-16

### Fixed
Expand Down
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Contributing

- Tests are the spec. Write the failing case in `test/test.sh` first, show it red, then change `bin/git-locks`.
- Tests are the spec. Write the failing case in `test/test.sh` first, show it red, then change the module under `lib/` and run `make build`; `bin/git-locks` is the build product and is committed beside the `lib/` change (the suite refuses a stale one).
- `make lint` must pass with zero output: shellcheck with every optional check enabled, and shfmt with the repository's settings (`-i 2 -ci -bn`). Do not add a `# shellcheck disable` without a comment saying why.
- Pure bash and git only. No jq, no Python, no external daemons. Anything that would need one belongs in a different project.
- Keep `README.md` and `CHANGELOG.md` current in the same commit as the change they describe.
Expand All @@ -12,3 +12,5 @@
- Load the store snapshot once in the parent shell before dispatch; a subshell inherits it, a subshell cannot refresh it for the parent. Invalidate explicitly after any `$(transact …)`.
- Never install this tool as a symlink into a checkout you edit. `make install` copies for that reason: a half-fixed branch went live under another project's pre-commit hook on 2026-09-15.
- Every git spawn is a test: `test/test.sh` counts them with a shim. Keep one process per protocol per command.
- A fork per record is a fork per record. `$(field …)` inside a render loop costs a process each time and forgets the parse; the `_v` helpers (`field_v`, `record_paths_v`, `now_v`, `json_paths_v`) exist so hot paths never fork. `list` on 500 locks went from 6.75 s to 0.58 s by using them.
- `${text:pos:len}` on a large string copies from `pos` every call; a loop over it is quadratic. Read structured output with `read -N` instead (the snapshot's `cat-file --batch` parse).
8 changes: 6 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
SHELL := /usr/bin/env bash
SCRIPTS := bin/git-locks test/test.sh scripts/hooks/pre-commit scripts/hooks/pre-push
# lib/*.sh are fragments of one script and only lint as the whole they build into (bin/git-locks).
SCRIPTS := bin/git-locks test/test.sh scripts/hooks/pre-commit scripts/hooks/pre-push scripts/build.sh
PREFIX ?= $(HOME)/.local

.PHONY: lint test test-docker install uninstall
.PHONY: build lint test test-docker install uninstall

build: # assemble bin/git-locks from lib/*.sh and schema/git-locks.schema.json; commit the result with the lib change
bash scripts/build.sh

lint:
shellcheck -S style -o all $(SCRIPTS)
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,13 +410,13 @@ Output is JSON Lines on every command; there is no text mode.

Every JSON line git-locks writes, on stdout or stderr, matches exactly one definition in [`schema/git-locks.schema.json`](schema/git-locks.schema.json) (JSON Schema 2020-12). `git locks schema` prints that document byte-for-byte, and the test suite validates every line it provokes against it, so the contract cannot drift from the code. Consumers can pin the `$id` URL or the file at a tagged commit.

Paths are repo-relative, `./` prefixes are stripped, and absolute or `..` paths are refused. A path may contain spaces; it may not contain a newline. Job ids match `[A-Za-z0-9][A-Za-z0-9._-]*`.
Paths are repo-relative, `./` prefixes are stripped, and absolute or `..` paths are refused. A path may contain spaces; it may not contain a newline. Job ids match `[A-Za-z0-9][A-Za-z0-9._-]*`. A holder is one line of text; any byte but a newline is stored whole and escaped on output. A ttl is a decimal number of seconds; a leading zero is not octal.

`GIT_LOCKS_NOW=<epoch seconds>` fixes the clock, for tests; `GIT_LOCKS_PAUSE_BEFORE_COMMIT=<file>` makes every transaction wait for that file, so tests can force interleavings. Timestamps are epoch seconds.

## Versioning and releases

`VERSION` in `bin/git-locks` is the version. A push to `main` whose version has no tag yet gets an annotated tag `v<version>` and a GitHub release whose notes are that version's section of `CHANGELOG.md`, with the script and the schema attached, from the `release` job in `.github/workflows/ci.yml`. So a release is: bump `VERSION`, write the changelog section, merge.
`VERSION` in `lib/000-prelude.sh` is the version (it lands in `bin/git-locks` at build time). A push to `main` whose version has no tag yet gets an annotated tag `v<version>` and a GitHub release whose notes are that version's section of `CHANGELOG.md`, with the script and the schema attached, from the `release` job in `.github/workflows/ci.yml`. So a release is: bump `VERSION`, `make build`, write the changelog section, merge.

## Install

Expand All @@ -430,18 +430,21 @@ git locks list # git dispatches `git locks` to git-locks on PATH
## Develop

```sh
make build # assemble bin/git-locks from lib/*.sh and schema/git-locks.schema.json
make lint # shellcheck with every optional check on, shfmt
make test # test/test.sh, pure bash, temporary repositories; needs python3 with jsonschema for the schema checks
git config --local core.hooksPath scripts/hooks # pre-commit lints, pre-push tests
```

The source is `lib/`, one module per section in numeric order (`000-prelude.sh` through `990-main.sh`); `bin/git-locks` is the build product and is committed, because it is what `make install`, the release asset and a `curl` of the raw file all want: one file, no runtime assembly. Edit under `lib/`, run `make build`, commit both. The suite checks that the committed script is exactly what `lib/` builds, so a `lib/` change without a rebuild fails the pre-push hook and CI. The schema module is generated at build time from `schema/git-locks.schema.json`, so there is one copy of the schema in the repository. Lint runs over the built script rather than the fragments, which do not parse on their own.

## Limits, stated

- The lock is advisory and time-bounded. Nothing stops a writer that never claimed, and nothing renews a reservation under a long command. The consumer that lands writes (a commit script, a CI step) is where refusal belongs; `check` exits 1 for exactly that use, and a `check` is an observation, not an admission.
- One machine. The store is local; a shared remote would need a fetch before every claim and is out of scope.
- `git rev-parse --path-format=absolute` and `update-ref --stdin` transactions need git 2.31 or newer.
- bash 4 or newer: the store snapshot uses associative arrays. macOS's `/bin/bash` is 3.2; the script's shebang finds a newer bash on `PATH` (Homebrew's, for instance).
- Each command reads the store once (`for-each-ref` plus one `cat-file --batch`) and every transaction invalidates that snapshot, so an invocation is a handful of git processes however many locks exist; the test suite pins the counts with a shim that counts spawns. Process count is not time: the snapshot is parsed in bash, so work grows with the store. Measured on 500 locks (macOS, bash 5.3): `check`, `claim` and `show` each about 0.07 s; `list`, which renders every record, 5.8 s. A store of hundreds of live locks is fine; one of thousands wants #11's split and a leaner `list`.
- Each command reads the store once (`for-each-ref` plus one `cat-file --batch`) and every transaction invalidates that snapshot, so an invocation is a handful of git processes however many locks exist; the test suite pins the counts with a shim that counts spawns. Process count is not time: the snapshot is parsed in bash, so work grows linearly with the store, and `list` renders every record without forking. Measured on 500 locks (macOS, bash 5.3, 0.4.0): `check` 0.28 s, `show` 0.25 s, `claim` 0.33 s, `list` 0.58 s; the same store under 0.3.2 took 0.87 s, 0.85 s, 1.0 s and 6.75 s. A store of hundreds of live locks is fine; one of many thousands will feel the snapshot.
- Every command reads the store once, plans, then commits with expectations. A racer can win in between; the transaction then fails and the command re-plans or reports who won. That is the designed outcome, not a gap.
- The tests are bounded conformance evidence. Twenty racers and one forced interleaving are what the suite shows; they are not a proof over every schedule.

Expand Down
Loading
Loading