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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project are recorded here. The format follows Keep a

## [Unreleased]

## [0.6.0] - 2026-09-16

### Added

- `--note <text>` on `claim` and `with`, and `note:` in a batch record: one line saying why the lock is held (#8). It is stored in the record and carried on every line that names the lock: the claim line, `show`, `list`, `check` on a held or expired path, and the refusal another claimant gets, which can now read "held by alice: building the release bundle" rather than just "held by alice". `extend` and a child admission keep it. Optional in the schema; absent when none was given.

## [0.5.0] - 2026-09-16

### Added
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@ Output is JSON Lines on every command; there is no text mode.

| Command | Does | Stdout line(s) | Exit |
|---|---|---|---|
| `claim --job <id> --holder <name> [--ttl <s>] <path>...` | atomically lock the paths for the job; re-claiming with the same job replaces its record | one `claimed` object with `record`; refusals on stderr | 0 claimed, 1 refused, 2 usage |
| `claim --job <id> --holder <name> [--ttl <s>] [--note <text>] <path>...` | atomically lock the paths for the job; re-claiming with the same job replaces its record; `--note` is one line saying why, carried on every line that names the lock | one `claimed` object with `record`; refusals on stderr | 0 claimed, 1 refused, 2 usage |
| `check <path>...` | who holds each path, in argument order | one object per path as it is examined | 0 all free, 1 any held |
| `list` | every lock, live or expired, with its paths | one object per lock; nothing when empty | 0 |
| `sweep` | delete expired locks | one `swept` object per lock, as it goes | 0 |
Expand All @@ -413,7 +413,7 @@ 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._-]*`. 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.
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 note, given with `--note`, is one line saying why the lock is held; it rides on the claim, `show`, `list`, `check` and refusal lines, so the claimant who loses reads the reason and not only the name. 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.

Expand Down
101 changes: 69 additions & 32 deletions bin/git-locks

Large diffs are not rendered by default.

14 changes: 8 additions & 6 deletions lib/000-prelude.sh
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,13 @@ DEFAULT_TTL=14400
SCHEMA='git-locks/1'
SEM_SCHEMA='git-locks-sem/1'
SLOT_SCHEMA='git-locks-slot/1'
VERSION='0.5.0'
VERSION='0.6.0'
RETRIES=200 # a plan refused for a stale expectation is re-read and re-planned this many times
NOW_CACHED='' # the clock, read once per invocation by now()

usage_text() {
cat <<'EOF'
usage: git locks claim --job <id> --holder <name> [--ttl <seconds>] [--parent <id>] <path>...
usage: git locks claim --job <id> --holder <name> [--ttl <seconds>] [--parent <id>] [--note <text>] <path>...
git locks batch < records several claims in ONE transaction, all or nothing
git locks release --job <id> [--record <oid> | --acquisition <id>] [--job <id>...]
git locks check <path>...
Expand All @@ -76,7 +76,7 @@ usage: git locks claim --job <id> --holder <name> [--ttl <seconds>] [--parent
git locks show --job <id>
git locks ttl --job <id>
git locks extend --job <id> --ttl <seconds>
git locks with --job <id> --holder <name> [--ttl <seconds>] [--wait <seconds>] [--sem <name>] [<path>...] -- <command>...
git locks with --job <id> --holder <name> [--ttl <seconds>] [--wait <seconds>] [--sem <name>] [--note <text>] [<path>...] -- <command>...
git locks sem create <name> --capacity <n> | acquire <name> --job <id> --holder <name> [--ttl <s>] [--wait <s>]
| release <name> --job <id> [--record <oid> | --acquisition <id>] | show <name> | list | delete <name>
git locks doctor
Expand All @@ -86,7 +86,9 @@ usage: git locks claim --job <id> --holder <name> [--ttl <seconds>] [--parent
claim lock the paths for the job, atomically; re-claiming with the same job replaces its path set and
its record; --parent makes it a child: the parent must be live and held by the same holder, and the
child is released or swept with it. The claim line carries the record id of this acquisition.
batch read lock records on stdin (blank-line separated: job:, holder:, ttl:, parent:, paths: then
--note is one line saying why, carried on every line that names the lock: a refusal reads
'held by alice: building the release bundle' instead of just 'held by alice'
batch read lock records on stdin (blank-line separated: job:, holder:, ttl:, parent:, note:, paths: then
one path per line) and claim them all in one transaction, or none
release drop the named jobs' locks and all their descendants, in one transaction; --acquisition releases
only if the job's current record belongs to that acquisition (an id that survives extend), --record
Expand Down Expand Up @@ -143,7 +145,7 @@ sub_usage() { # subcommand -> its usage as a usage object on stdout

sub_usage_text() {
case "$1" in
claim) printf 'usage: git locks claim --job <id> --holder <name> [--ttl <seconds>] [--parent <id>] <path>...\n' ;;
claim) printf 'usage: git locks claim --job <id> --holder <name> [--ttl <seconds>] [--parent <id>] [--note <text>] <path>...\n' ;;
batch) printf 'usage: git locks batch < records\n' ;;
release) printf 'usage: git locks release --job <id> [--record <oid> | --acquisition <id>] [--job <id>...]\n' ;;
check) printf 'usage: git locks check <path>...\n' ;;
Expand All @@ -153,7 +155,7 @@ sub_usage_text() {
show) printf 'usage: git locks show --job <id>\n' ;;
ttl) printf 'usage: git locks ttl --job <id>\n' ;;
extend) printf 'usage: git locks extend --job <id> --ttl <seconds>\n' ;;
with) printf 'usage: git locks with --job <id> --holder <name> [--ttl <seconds>] [--wait <seconds>] [--sem <name>] [<path>...] -- <command>...\n' ;;
with) printf 'usage: git locks with --job <id> --holder <name> [--ttl <seconds>] [--wait <seconds>] [--sem <name>] [--note <text>] [<path>...] -- <command>...\n' ;;
doctor) printf 'usage: git locks doctor\n' ;;
sem) printf 'usage: git locks sem create <name> --capacity <n> | acquire <name> --job <id> --holder <name> [--ttl <s>] [--wait <s>] | release <name> --job <id> [--record <oid> | --acquisition <id>] | show <name> | list | delete <name>\n' ;;
*) usage_text ;;
Expand Down
2 changes: 2 additions & 0 deletions lib/030-time-refs-records.sh
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ valid_job() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; }

valid_holder() { [[ -n "$1" && "$1" != *$'\n'* && "$1" != *$'\r'* ]]; } # one line: the record is line-oriented; any other byte is stored whole and escaped on output

valid_note() { [[ "$1" != *$'\n'* && "$1" != *$'\r'* ]]; } # one line; empty means no note

valid_ttl() { # VAR value: VAR = the value as a decimal number of seconds; 1 unless it is digits only and positive (010 is ten, never octal eight)
[[ "$2" =~ ^[0-9]+$ ]] || return 1
local _vt=$((10#$2))
Expand Down
11 changes: 10 additions & 1 deletion lib/050-the-snapshot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,18 @@ D_JOB=''
D_EXPIRES=0
D_REMAINING=0
D_STATE=''
D_NOTE=''
D_NOTE_JSON='' # ',"note":<json>' when the record has one, else empty: splice it after the holder

describe() { # oid -> D_HOLDER D_JOB D_EXPIRES D_REMAINING D_STATE; no fork
describe() { # oid -> D_HOLDER D_JOB D_EXPIRES D_REMAINING D_STATE D_NOTE D_NOTE_JSON; no fork
field_v D_HOLDER "$1" holder
field_v D_NOTE "$1" note
D_NOTE_JSON=''
if [[ -n "${D_NOTE}" ]]; then
local _dn
json_str _dn "${D_NOTE}"
D_NOTE_JSON=",\"note\":${_dn}"
fi
field_v D_JOB "$1" job
local exp at
field_v exp "$1" expires
Expand Down
2 changes: 1 addition & 1 deletion lib/070-refusals.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ refusal() { # path, after describe(): one refusal line on stderr
json_str _j1 "$1"
json_str _j2 "${D_HOLDER}"
json_str _j3 "${D_JOB}"
printf '{"event":"refused","path":%s,"holder":%s,"job":%s,"expires":%s}\n' "${_j1}" "${_j2}" "${_j3}" "${D_EXPIRES}" >&2
printf '{"event":"refused","path":%s,"holder":%s%s,"job":%s,"expires":%s}\n' "${_j1}" "${_j2}" "${D_NOTE_JSON}" "${_j3}" "${D_EXPIRES}" >&2
}

parent_refusal() { # child parent detail
Expand Down
11 changes: 7 additions & 4 deletions lib/080-families.sh
Original file line number Diff line number Diff line change
Expand Up @@ -83,19 +83,21 @@ new_acquisition() { # VAR: a fresh acquisition id. The record oid changes on eve
printf -v "$1" '%s-%05d-%05d%05d' "${at}" "$$" "${RANDOM}" "${RANDOM}"
}

record_text() { # VAR job holder claimed expires parent family acquisition paths-newline-separated
record_text() { # VAR job holder claimed expires parent family acquisition paths-newline-separated [note]
local body
body="$(
printf 'schema: %s\njob: %s\nholder: %s\nclaimed: %s\nexpires: %s\n' "${SCHEMA}" "$2" "$3" "$4" "$5"
[[ -n "$6" ]] && printf 'parent: %s\n' "$6"
printf 'family: %s\nacquisition: %s\npaths:\n%s' "$7" "$8" "$9"
printf 'family: %s\nacquisition: %s\n' "$7" "$8"
[[ -n "${10:-}" ]] && printf 'note: %s\n' "${10}"
printf 'paths:\n%s' "$9"
)"
printf -v "$1" '%s' "${body}"
}

bump_parent() { # parent-job parent-oid -> plans the parent's blob rewrite with family+1 on its job ref and path refs
ensure_snapshot # in this shell, so the $(…) reads below inherit one fresh snapshot instead of each taking their own
local pjob="$1" poid="$2" fam newfam claimed expires holder parent paths record newoid p ref have acq
local pjob="$1" poid="$2" fam newfam claimed expires holder parent paths record newoid p ref have acq note
fam="$(field "${poid}" family)"
acq="$(field "${poid}" acquisition)"
newfam=$((${fam:-0} + 1))
Expand All @@ -104,7 +106,8 @@ bump_parent() { # parent-job parent-oid -> plans the parent's blob rewrite wit
expires="$(field "${poid}" expires)"
parent="$(field "${poid}" parent)"
paths="$(record_paths "${poid}")"
record_text record "${pjob}" "${holder}" "${claimed}" "${expires}" "${parent}" "${newfam}" "${acq}" "${paths}"
field_v note "${poid}" note
record_text record "${pjob}" "${holder}" "${claimed}" "${expires}" "${parent}" "${newfam}" "${acq}" "${paths}" "${note}"
write_blob newoid "${record}" || fail 'could not write the parent record'
local pjref
pjref="$(job_ref "${pjob}")"
Expand Down
27 changes: 19 additions & 8 deletions lib/090-claim-planning.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ CLAIM_LINE=''
TERMINATED_PATHS=0
TERMINATED_CASCADE='[]'

plan_claim() { # job holder ttl parent path... -> plans one claim; sets CLAIM_LINE/CLAIM_OID; CONFLICTS=1 on refusal
plan_claim() { # job holder ttl parent note path... -> plans one claim; sets CLAIM_LINE/CLAIM_OID; CONFLICTS=1 on refusal
ensure_snapshot # in this shell, so the $(…) reads below inherit one fresh snapshot instead of each taking their own
local job="$1" holder="$2" ttl="$3" parent="$4"
shift 4
local job="$1" holder="$2" ttl="$3" parent="$4" note="$5"
shift 5
local paths=("$@") p n norm=() sorted wanted=()
for p in "${paths[@]}"; do
n="$(normalize_path "${p}")" || exit 2
Expand Down Expand Up @@ -69,7 +69,7 @@ plan_claim() { # job holder ttl parent path... -> plans one claim; sets CLAIM
local record new_oid acq joined
new_acquisition acq
joined="$(printf '%s\n' "${wanted[@]}")"
record_text record "${job}" "${holder}" "${at}" "${expires}" "${parent}" "${old_family:-0}" "${acq}" "${joined}"
record_text record "${job}" "${holder}" "${at}" "${expires}" "${parent}" "${old_family:-0}" "${acq}" "${joined}" "${note}"
write_blob new_oid "${record}" || fail 'could not write the lock record'

local evict=() ref cur rjob rexp
Expand Down Expand Up @@ -137,7 +137,7 @@ plan_claim() { # job holder ttl parent path... -> plans one claim; sets CLAIM

BATCH_JOBS+=("${job}")
BATCH_HOLDER["${job}"]="${holder}"
local jpaths _j1 _j2 _j3 _j4 pj=''
local jpaths _j1 _j2 _j3 _j4 pj='' nj=''
json_paths jpaths < <(printf '%s\n' "${wanted[@]}")
json_str _j1 "${job}"
json_str _j2 "${holder}"
Expand All @@ -147,7 +147,11 @@ plan_claim() { # job holder ttl parent path... -> plans one claim; sets CLAIM
json_str pj "${parent}"
pj=",\"parent\":${pj}"
fi
CLAIM_LINE="{\"event\":\"claimed\",\"job\":${_j1},\"holder\":${_j2},\"claimed\":${at},\"expires\":${expires}${pj},\"paths\":${jpaths},\"record\":${_j3},\"acquisition\":${_j4}}"
if [[ -n "${note}" ]]; then
json_str nj "${note}"
nj=",\"note\":${nj}"
fi
CLAIM_LINE="{\"event\":\"claimed\",\"job\":${_j1},\"holder\":${_j2}${nj},\"claimed\":${at},\"expires\":${expires}${pj},\"paths\":${jpaths},\"record\":${_j3},\"acquisition\":${_j4}}"
return 0
}

Expand Down Expand Up @@ -183,11 +187,12 @@ commit_plan() { # -> 0 committed; 1 lost a race (refusals printed)
return 1
}

claim_args() { # parses claim arguments into CA_JOB CA_HOLDER CA_TTL CA_PARENT CA_PATHS
claim_args() { # parses claim arguments into CA_JOB CA_HOLDER CA_TTL CA_PARENT CA_NOTE CA_PATHS
CA_JOB=''
CA_HOLDER=''
CA_TTL="${DEFAULT_TTL}"
CA_PARENT=''
CA_NOTE=''
CA_PATHS=()
while (($# > 0)); do
case "$1" in
Expand All @@ -211,6 +216,11 @@ claim_args() { # parses claim arguments into CA_JOB CA_HOLDER CA_TTL CA_PARENT C
CA_PARENT="$2"
shift 2
;;
--note)
[[ $# -ge 2 ]] || usage
CA_NOTE="$2"
shift 2
;;
--)
shift
CA_PATHS+=("$@")
Expand All @@ -227,13 +237,14 @@ claim_args() { # parses claim arguments into CA_JOB CA_HOLDER CA_TTL CA_PARENT C
valid_job "${CA_JOB}" || fail "job id '${CA_JOB}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2
valid_ttl CA_TTL "${CA_TTL}" || fail '--ttl is a positive number of seconds' 2
valid_holder "${CA_HOLDER}" || fail 'holder must be one line' 2
valid_note "${CA_NOTE}" || fail '--note must be one line' 2
((${#CA_PATHS[@]} > 0)) || usage
}

cmd_claim() {
claim_args "$@"
plan_reset
plan_claim "${CA_JOB}" "${CA_HOLDER}" "${CA_TTL}" "${CA_PARENT}" "${CA_PATHS[@]}"
plan_claim "${CA_JOB}" "${CA_HOLDER}" "${CA_TTL}" "${CA_PARENT}" "${CA_NOTE}" "${CA_PATHS[@]}"
((CONFLICTS)) && exit 1
commit_plan || exit 1
printf '%s\n' "${CLAIM_LINE}"
Expand Down
8 changes: 5 additions & 3 deletions lib/100-batch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,23 @@

cmd_batch() {
(($# == 0)) || usage
local line key val job='' holder='' ttl='' parent='' paths=() in_paths=0 count=0 lines_out=()
local line key val job='' holder='' ttl='' parent='' note='' paths=() in_paths=0 count=0 lines_out=()
plan_reset
finish_record() {
if [[ -z "${job}" && -z "${holder}" && -z "${ttl}" && -z "${parent}" && ${#paths[@]} -eq 0 ]]; then return 0; fi # only a wholly empty record is skipped; one with just parent: or ttl: is malformed
if [[ -z "${job}" && -z "${holder}" && -z "${ttl}" && -z "${parent}" && -z "${note}" && ${#paths[@]} -eq 0 ]]; then return 0; fi # only a wholly empty record is skipped; one with just parent: or ttl: is malformed
[[ -n "${job}" && -n "${holder}" && ${#paths[@]} -gt 0 ]] || fail 'batch: every record needs job:, holder: and at least one path under paths:' 2
valid_job "${job}" || fail "batch: job id '${job}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2
[[ -z "${ttl}" ]] && ttl="${DEFAULT_TTL}"
valid_ttl ttl "${ttl}" || fail 'batch: ttl is a positive number of seconds' 2
valid_holder "${holder}" || fail 'batch: holder must be one line' 2
plan_claim "${job}" "${holder}" "${ttl}" "${parent}" "${paths[@]}"
plan_claim "${job}" "${holder}" "${ttl}" "${parent}" "${note}" "${paths[@]}"
lines_out+=("${CLAIM_LINE}")
count=$((count + 1))
job=''
holder=''
ttl=''
parent=''
note=''
paths=()
in_paths=0
}
Expand All @@ -38,6 +39,7 @@ cmd_batch() {
holder) holder="${val}" ;;
ttl) ttl="${val}" ;;
parent) parent="${val}" ;;
note) note="${val}" ;;
paths) in_paths=1 ;;
*) fail "batch: unknown line '${line}'" 2 ;;
esac
Expand Down
4 changes: 2 additions & 2 deletions lib/120-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ cmd_check() {
json_str _j1 "${D_HOLDER}"
json_str _j2 "${D_JOB}"
if [[ "${D_EXPIRES}" -gt "${at}" ]]; then
printf '{"path":%s,"state":"held","holder":%s,"job":%s,"expires":%s,"remaining":%s}\n' "${jp}" "${_j1}" "${_j2}" "${D_EXPIRES}" "${D_REMAINING}"
printf '{"path":%s,"state":"held","holder":%s%s,"job":%s,"expires":%s,"remaining":%s}\n' "${jp}" "${_j1}" "${D_NOTE_JSON}" "${_j2}" "${D_EXPIRES}" "${D_REMAINING}"
held=1
else
printf '{"path":%s,"state":"expired","holder":%s,"job":%s,"expires":%s,"remaining":0}\n' "${jp}" "${_j1}" "${_j2}" "${D_EXPIRES}"
printf '{"path":%s,"state":"expired","holder":%s%s,"job":%s,"expires":%s,"remaining":0}\n' "${jp}" "${_j1}" "${D_NOTE_JSON}" "${_j2}" "${D_EXPIRES}"
fi
done
return "${held}"
Expand Down
4 changes: 2 additions & 2 deletions lib/130-list-show-ttl.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ lock_line() { # oid -> one JSON line for list and show; no fork per line, so
json_str _j2 "${D_HOLDER}"
json_str _j3 "$1"
parent_json pj "$1"
printf '{"job":%s,"holder":%s,"state":"%s","claimed":%s,"expires":%s,"remaining":%s%s,"paths":%s,"record":%s,"acquisition":%s}\n' \
"${_j1}" "${_j2}" "${D_STATE}" "${claimed:-0}" "${D_EXPIRES}" "${D_REMAINING}" "${pj}" "${jpaths}" "${_j3}" "${_j4}"
printf '{"job":%s,"holder":%s%s,"state":"%s","claimed":%s,"expires":%s,"remaining":%s%s,"paths":%s,"record":%s,"acquisition":%s}\n' \
"${_j1}" "${_j2}" "${D_NOTE_JSON}" "${D_STATE}" "${claimed:-0}" "${D_EXPIRES}" "${D_REMAINING}" "${pj}" "${jpaths}" "${_j3}" "${_j4}"
}

cmd_list() {
Expand Down
5 changes: 3 additions & 2 deletions lib/140-extend.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# ---------------------------------------------------------------- extend

cmd_extend() {
local _j1 oid jref at expires record new_oid paths p ref have claimed parent family attempt acq ttl
local _j1 oid jref at expires record new_oid paths p ref have claimed parent family attempt acq ttl note
job_arg "$@"
valid_ttl ttl "${TTL_ARG}" || fail '--ttl is a positive number of seconds' 2
jref="$(job_ref "${JOB_ARG}")"
Expand All @@ -18,7 +18,8 @@ cmd_extend() {
parent="$(field "${oid}" parent)"
family="$(field "${oid}" family)"
acq="$(field "${oid}" acquisition)"
record_text record "${D_JOB}" "${D_HOLDER}" "${claimed}" "${expires}" "${parent}" "${family:-0}" "${acq}" "${paths}"
field_v note "${oid}" note
record_text record "${D_JOB}" "${D_HOLDER}" "${claimed}" "${expires}" "${parent}" "${family:-0}" "${acq}" "${paths}" "${note}"
write_blob new_oid "${record}" || fail 'could not write the lock record'
plan_set "${jref}" "${oid}" "${new_oid}" || fail "${PLAN_CONFLICT}" 1
while IFS= read -r p; do
Expand Down
Loading
Loading