From f1670bb4b4332e45c7719e345ed0ec6dc677e9af Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 16 Sep 2026 11:28:49 -0700 Subject: [PATCH] feat: --note on claims, carried wherever the lock is named claim --note , with --note, and note: in a batch record store one line saying why the lock is held. It rides on 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 through the record rewrite. A note is one line; empty means none, and the key is absent from every line when there is none. Optional in the schema. Version 0.6.0. Closes #8 --- CHANGELOG.md | 6 +++ README.md | 4 +- bin/git-locks | 101 ++++++++++++++++++++++++----------- lib/000-prelude.sh | 14 ++--- lib/030-time-refs-records.sh | 2 + lib/050-the-snapshot.sh | 11 +++- lib/070-refusals.sh | 2 +- lib/080-families.sh | 11 ++-- lib/090-claim-planning.sh | 27 +++++++--- lib/100-batch.sh | 8 +-- lib/120-check.sh | 4 +- lib/130-list-show-ttl.sh | 4 +- lib/140-extend.sh | 5 +- lib/160-with.sh | 11 +++- schema/git-locks.schema.json | 23 ++++++++ test/test.sh | 59 ++++++++++++++++++++ 16 files changed, 227 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7de12b0..553c68b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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 diff --git a/README.md b/README.md index e0032ec..17f8892 100644 --- a/README.md +++ b/README.md @@ -386,7 +386,7 @@ Output is JSON Lines on every command; there is no text mode. | Command | Does | Stdout line(s) | Exit | |---|---|---|---| -| `claim --job --holder [--ttl ] ...` | 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 --holder [--ttl ] [--note ] ...` | 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 ...` | 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 | @@ -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=` fixes the clock, for tests; `GIT_LOCKS_PAUSE_BEFORE_COMMIT=` makes every transaction wait for that file, so tests can force interleavings. Timestamps are epoch seconds. diff --git a/bin/git-locks b/bin/git-locks index 47dfd9a..64a8ea8 100755 --- a/bin/git-locks +++ b/bin/git-locks @@ -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 --holder [--ttl ] [--parent ] ... +usage: git locks claim --job --holder [--ttl ] [--parent ] [--note ] ... git locks batch < records several claims in ONE transaction, all or nothing git locks release --job [--record | --acquisition ] [--job ...] git locks check ... @@ -76,7 +76,7 @@ usage: git locks claim --job --holder [--ttl ] [--parent git locks show --job git locks ttl --job git locks extend --job --ttl - git locks with --job --holder [--ttl ] [--wait ] [--sem ] [...] -- ... + git locks with --job --holder [--ttl ] [--wait ] [--sem ] [--note ] [...] -- ... git locks sem create --capacity | acquire --job --holder [--ttl ] [--wait ] | release --job [--record | --acquisition ] | show | list | delete git locks doctor @@ -86,7 +86,9 @@ usage: git locks claim --job --holder [--ttl ] [--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 @@ -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 --holder [--ttl ] [--parent ] ...\n' ;; + claim) printf 'usage: git locks claim --job --holder [--ttl ] [--parent ] [--note ] ...\n' ;; batch) printf 'usage: git locks batch < records\n' ;; release) printf 'usage: git locks release --job [--record | --acquisition ] [--job ...]\n' ;; check) printf 'usage: git locks check ...\n' ;; @@ -153,7 +155,7 @@ sub_usage_text() { show) printf 'usage: git locks show --job \n' ;; ttl) printf 'usage: git locks ttl --job \n' ;; extend) printf 'usage: git locks extend --job --ttl \n' ;; - with) printf 'usage: git locks with --job --holder [--ttl ] [--wait ] [--sem ] [...] -- ...\n' ;; + with) printf 'usage: git locks with --job --holder [--ttl ] [--wait ] [--sem ] [--note ] [...] -- ...\n' ;; doctor) printf 'usage: git locks doctor\n' ;; sem) printf 'usage: git locks sem create --capacity | acquire --job --holder [--ttl ] [--wait ] | release --job [--record | --acquisition ] | show | list | delete \n' ;; *) usage_text ;; @@ -284,6 +286,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)) @@ -494,9 +498,18 @@ D_JOB='' D_EXPIRES=0 D_REMAINING=0 D_STATE='' +D_NOTE='' +D_NOTE_JSON='' # ',"note":' 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 @@ -604,7 +617,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 @@ -710,19 +723,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)) @@ -731,7 +746,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}")" @@ -754,10 +770,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 @@ -815,7 +831,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 @@ -883,7 +899,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}" @@ -893,7 +909,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 } @@ -929,11 +949,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 @@ -957,6 +978,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+=("$@") @@ -973,13 +999,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}" @@ -988,22 +1015,23 @@ cmd_claim() { 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 } @@ -1024,6 +1052,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 @@ -1147,10 +1176,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}" @@ -1170,8 +1199,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() { @@ -1238,7 +1267,7 @@ cmd_ttl() { # ---------------------------------------------------------------- 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}")" @@ -1255,7 +1284,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 @@ -1328,9 +1358,9 @@ acquire_with_wait() { # kind(lock|sem) wait-seconds errfile -> 0 acquired (ACQUI if [[ "${kind}" == sem ]]; then out="$( (sem_acquire_once "${W_SEM}" "${W_JOB}" "${W_HOLDER}" "${W_TTL}") 2>"${errfile}")" elif [[ -n "${W_PARENT}" ]]; then - out="$( (cmd_claim --job "${W_JOB}" --holder "${W_HOLDER}" --ttl "${W_TTL}" --parent "${W_PARENT}" -- "${W_PATHS[@]}") 2>"${errfile}")" + out="$( (cmd_claim --job "${W_JOB}" --holder "${W_HOLDER}" --ttl "${W_TTL}" --parent "${W_PARENT}" --note "${W_NOTE}" -- "${W_PATHS[@]}") 2>"${errfile}")" else - out="$( (cmd_claim --job "${W_JOB}" --holder "${W_HOLDER}" --ttl "${W_TTL}" -- "${W_PATHS[@]}") 2>"${errfile}")" + out="$( (cmd_claim --job "${W_JOB}" --holder "${W_HOLDER}" --ttl "${W_TTL}" --note "${W_NOTE}" -- "${W_PATHS[@]}") 2>"${errfile}")" fi rc=$? if ((rc == 0)); then @@ -1358,6 +1388,7 @@ cmd_with() { W_TTL="${DEFAULT_TTL}" W_PARENT='' W_SEM='' + W_NOTE='' W_PATHS=() local wait=0 command=() seen_dashdash=0 a while (($# > 0)); do @@ -1398,6 +1429,11 @@ cmd_with() { W_SEM="$2" shift 2 ;; + --note) + [[ $# -ge 2 ]] || usage + W_NOTE="$2" + shift 2 + ;; --) seen_dashdash=1 shift @@ -1417,6 +1453,7 @@ cmd_with() { valid_job "${W_JOB}" || fail "job id '${W_JOB}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 valid_holder "${W_HOLDER}" || fail 'holder must be one line' 2 valid_ttl W_TTL "${W_TTL}" || fail '--ttl is a positive number of seconds' 2 + valid_note "${W_NOTE}" || fail '--note must be one line' 2 [[ -z "${W_SEM}" ]] || valid_job "${W_SEM}" || fail "semaphore name '${W_SEM}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 [[ -z "${W_PARENT}" ]] || valid_job "${W_PARENT}" || fail "parent id '${W_PARENT}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 @@ -2130,7 +2167,7 @@ cmd_doctor() { cmd_schema() { # the public output schema, one JSON line; the pretty form is schema/git-locks.schema.json in the repository (($# == 0)) || usage cat <<'EOF' -{"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://raw.githubusercontent.com/git-stunts/locks/main/schema/git-locks.schema.json","title":"git-locks output","description":"Every line git-locks writes, on stdout or stderr, is one JSON object matching exactly one of the definitions below. Lines are emitted as each result is known (JSON Lines); nothing is buffered into an array. There is no plain-text mode: help is a usage object, errors are error objects, and `git locks schema` prints this document as one line. The one exception: a command wrapped by `with` owns stdout, and git-locks reports around it on stderr.","oneOf":[{"$ref":"#/$defs/check_line"},{"$ref":"#/$defs/list_line"},{"$ref":"#/$defs/claim_line"},{"$ref":"#/$defs/refusal_line"},{"$ref":"#/$defs/release_line"},{"$ref":"#/$defs/sweep_line"},{"$ref":"#/$defs/store_line"},{"$ref":"#/$defs/version_line"},{"$ref":"#/$defs/ttl_line"},{"$ref":"#/$defs/extend_line"},{"$ref":"#/$defs/missing_line"},{"$ref":"#/$defs/sem_line"},{"$ref":"#/$defs/sem_event_line"},{"$ref":"#/$defs/finding_line"},{"$ref":"#/$defs/doctor_line"},{"$ref":"#/$defs/error_line"},{"$ref":"#/$defs/usage_line"}],"$defs":{"job":{"type":"string","pattern":"^[A-Za-z0-9][A-Za-z0-9._-]*$","description":"The job id, as given to --job. Names the lock: refs/locks/jobs/."},"holder":{"type":"string","minLength":1,"description":"Whoever claimed, as given to --holder. Free text, one line."},"path":{"type":"string","minLength":1,"description":"A repo-relative path, normalised: no leading ./, never absolute, never containing a .. component or a newline."},"epoch":{"type":"integer","minimum":0,"description":"Seconds since the Unix epoch, UTC."},"check_line":{"type":"object","description":"One line per path from `git locks check`, in argument order, as each is examined.","required":["path","state"],"properties":{"path":{"$ref":"#/$defs/path"},"state":{"enum":["free","held","expired"]},"holder":{"$ref":"#/$defs/holder"},"job":{"$ref":"#/$defs/job"},"expires":{"$ref":"#/$defs/epoch"},"remaining":{"$ref":"#/$defs/remaining"}},"additionalProperties":false,"if":{"properties":{"state":{"const":"free"}}},"then":{"required":["path","state"],"not":{"anyOf":[{"required":["holder"]},{"required":["job"]},{"required":["expires"]},{"required":["remaining"]}]}},"else":{"required":["path","state","holder","job","expires","remaining"]}},"list_line":{"type":"object","description":"One line per lock from `git locks list`, live or expired, in ref order; also the single line of `git locks show --job `.","required":["job","holder","state","claimed","expires","remaining","paths","record","acquisition"],"properties":{"job":{"$ref":"#/$defs/job"},"holder":{"$ref":"#/$defs/holder"},"state":{"enum":["live","expired"]},"claimed":{"$ref":"#/$defs/epoch"},"expires":{"$ref":"#/$defs/epoch"},"paths":{"type":"array","items":{"$ref":"#/$defs/path"},"uniqueItems":true},"remaining":{"$ref":"#/$defs/remaining"},"parent":{"$ref":"#/$defs/job","description":"Present when the lock is a child: it is released or swept with this job."},"record":{"$ref":"#/$defs/record"},"acquisition":{"$ref":"#/$defs/acquisition"}},"additionalProperties":false},"claim_line":{"type":"object","description":"The single stdout line of a successful `git locks claim`; one per record for `git locks batch`.","required":["event","job","holder","claimed","expires","paths","record","acquisition"],"properties":{"event":{"const":"claimed"},"job":{"$ref":"#/$defs/job"},"holder":{"$ref":"#/$defs/holder"},"claimed":{"$ref":"#/$defs/epoch"},"expires":{"$ref":"#/$defs/epoch"},"paths":{"type":"array","items":{"$ref":"#/$defs/path"},"minItems":1,"uniqueItems":true},"parent":{"$ref":"#/$defs/job"},"record":{"$ref":"#/$defs/record"},"acquisition":{"$ref":"#/$defs/acquisition"}},"additionalProperties":false},"refusal_line":{"type":"object","description":"On stderr, one line per problem when a claim or batch is refused (exit 1): a held path naming its holder; a parent that is missing, expired or another holder's; a path named by two records of one batch; or a transaction failure with git's message. For semaphores: capacity (full), exists (create), live (delete with live slots).","oneOf":[{"required":["event","path","holder","job","expires"],"properties":{"event":{"const":"refused"},"path":{"$ref":"#/$defs/path"},"holder":{"$ref":"#/$defs/holder"},"job":{"$ref":"#/$defs/job"},"expires":{"$ref":"#/$defs/epoch"}},"additionalProperties":false},{"required":["event","reason","detail"],"properties":{"event":{"const":"refused"},"reason":{"const":"transaction"},"detail":{"type":"string"}},"additionalProperties":false},{"required":["event","reason","job","parent","detail"],"properties":{"event":{"const":"refused"},"reason":{"const":"parent"},"job":{"$ref":"#/$defs/job"},"parent":{"$ref":"#/$defs/job"},"detail":{"enum":["missing","expired","holder"],"description":"Why the parent cannot be used: no such lock, it has expired, or it belongs to another holder."}},"additionalProperties":false},{"required":["event","reason","path"],"properties":{"event":{"const":"refused"},"reason":{"const":"duplicate"},"path":{"$ref":"#/$defs/path"}},"additionalProperties":false},{"required":["event","reason","semaphore","capacity","live"],"properties":{"event":{"const":"refused"},"reason":{"const":"capacity"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"capacity":{"type":"integer","minimum":1},"live":{"type":"integer","minimum":0,"description":"Slots held by unexpired jobs."}},"additionalProperties":false},{"required":["event","reason","semaphore"],"properties":{"event":{"const":"refused"},"reason":{"const":"exists"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."}},"additionalProperties":false},{"required":["event","reason","semaphore","live"],"properties":{"event":{"const":"refused"},"reason":{"const":"live"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"live":{"type":"integer","minimum":0,"description":"Slots held by unexpired jobs."}},"additionalProperties":false}]},"release_line":{"type":"object","description":"One stdout line per --job of `git locks release`: what was released (with any descendants), or that there was nothing to release.","oneOf":[{"required":["event","job","paths"],"properties":{"event":{"const":"released"},"job":{"$ref":"#/$defs/job"},"paths":{"type":"integer","minimum":0,"description":"How many path refs were deleted, descendants included."},"cascaded":{"type":"array","items":{"$ref":"#/$defs/job"},"description":"Descendant jobs released in the same transaction, sorted; absent when there were none."}},"additionalProperties":false},{"required":["event","job"],"properties":{"event":{"const":"nothing"},"job":{"$ref":"#/$defs/job"},"reason":{"const":"superseded","description":"Present when --acquisition or --record named an acquisition the job no longer holds: nothing was released."}},"additionalProperties":false}]},"sweep_line":{"type":"object","description":"One stdout line per expired lock `git locks sweep` deleted; on stderr, one per lock it could not delete because it changed underneath.","oneOf":[{"required":["event","job","holder","expires"],"properties":{"event":{"const":"swept"},"job":{"$ref":"#/$defs/job"},"holder":{"$ref":"#/$defs/holder"},"expires":{"$ref":"#/$defs/epoch"},"cascaded":{"type":"array","items":{"$ref":"#/$defs/job"},"description":"Descendant jobs swept with this expired parent, sorted; absent when there were none."}},"additionalProperties":false},{"required":["event","job","reason"],"properties":{"event":{"const":"skipped"},"job":{"$ref":"#/$defs/job"},"reason":{"const":"changed underneath"}},"additionalProperties":false}]},"store_line":{"type":"object","description":"The single line of `git locks store`: the absolute path of the store this repository resolves to.","required":["store"],"properties":{"store":{"type":"string","minLength":1}},"additionalProperties":false},"version_line":{"type":"object","description":"The single line of `git locks version`.","required":["name","version"],"properties":{"name":{"const":"git-locks"},"version":{"type":"string","pattern":"^[0-9]+\\.[0-9]+\\.[0-9]+$"}},"additionalProperties":false},"remaining":{"type":"integer","minimum":0,"description":"Seconds until the lock expires, 0 once it has."},"ttl_line":{"type":"object","description":"The single line of `git locks ttl --job `.","required":["job","expires","remaining"],"properties":{"job":{"$ref":"#/$defs/job"},"expires":{"$ref":"#/$defs/epoch"},"remaining":{"$ref":"#/$defs/remaining"}},"additionalProperties":false},"extend_line":{"type":"object","description":"The single line of `git locks extend --job --ttl `: the new expiry.","required":["event","job","expires"],"properties":{"event":{"const":"extended"},"job":{"$ref":"#/$defs/job"},"expires":{"$ref":"#/$defs/epoch"}},"additionalProperties":false},"missing_line":{"type":"object","description":"On stderr, from show, ttl or extend, when no lock exists for the job (exit 1).","required":["event","job"],"properties":{"event":{"const":"missing"},"job":{"$ref":"#/$defs/job"}},"additionalProperties":false},"sem_line":{"type":"object","description":"One line per semaphore from `git locks sem list`, and the single line of `git locks sem show `: capacity, live count, and the live slots.","required":["semaphore","capacity","live","slots"],"properties":{"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"capacity":{"type":"integer","minimum":1},"live":{"type":"integer","minimum":0,"description":"Slots held by unexpired jobs."},"slots":{"type":"array","items":{"type":"object","required":["job","holder","claimed","expires","remaining","record","acquisition"],"properties":{"job":{"$ref":"#/$defs/job"},"holder":{"$ref":"#/$defs/holder"},"claimed":{"$ref":"#/$defs/epoch"},"expires":{"$ref":"#/$defs/epoch"},"remaining":{"$ref":"#/$defs/remaining"},"record":{"$ref":"#/$defs/record"},"acquisition":{"$ref":"#/$defs/acquisition"}},"additionalProperties":false}}},"additionalProperties":false},"sem_event_line":{"type":"object","description":"Semaphore lifecycle lines: created, acquired (with the live count after), released (likewise), nothing (the job held no slot), deleted.","oneOf":[{"required":["event","semaphore","capacity"],"properties":{"event":{"const":"created"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"capacity":{"type":"integer","minimum":1}},"additionalProperties":false},{"required":["event","semaphore","job","holder","claimed","expires","live","capacity","record","acquisition"],"properties":{"event":{"const":"acquired"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"job":{"$ref":"#/$defs/job"},"holder":{"$ref":"#/$defs/holder"},"claimed":{"$ref":"#/$defs/epoch"},"expires":{"$ref":"#/$defs/epoch"},"live":{"type":"integer","minimum":0,"description":"Slots held by unexpired jobs."},"capacity":{"type":"integer","minimum":1},"record":{"$ref":"#/$defs/record"},"acquisition":{"$ref":"#/$defs/acquisition"}},"additionalProperties":false},{"required":["event","semaphore","job","live","capacity"],"properties":{"event":{"const":"released"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"job":{"$ref":"#/$defs/job"},"live":{"type":"integer","minimum":0,"description":"Slots held by unexpired jobs."},"capacity":{"type":"integer","minimum":1}},"additionalProperties":false},{"required":["event","semaphore","job"],"properties":{"event":{"const":"nothing"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"job":{"$ref":"#/$defs/job"},"reason":{"const":"superseded"}},"additionalProperties":false},{"required":["event","semaphore"],"properties":{"event":{"const":"deleted"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."}},"additionalProperties":false},{"required":["event","semaphore"],"properties":{"event":{"const":"missing"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."}},"additionalProperties":false}]},"record":{"type":"string","pattern":"^[0-9a-f]{40}([0-9a-f]{24})?$","description":"The object id of this acquisition's record: the identity a later release or renewal names."},"error_line":{"type":"object","description":"On stderr: a usage failure (exit 2), a store that could not be read (exit 2; nothing is reported free or held), or a failed operation (exit 1).","required":["event","reason","detail"],"properties":{"event":{"const":"error"},"reason":{"enum":["usage","store-read","failed"]},"detail":{"type":"string"}},"additionalProperties":false},"usage_line":{"type":"object","description":"`git locks help` on stdout (exit 0), ` --help`, or a usage error on stderr (exit 2): the usage text as one string.","required":["event","usage"],"properties":{"event":{"const":"usage"},"usage":{"type":"string"}},"additionalProperties":false},"acquisition":{"type":"string","minLength":1,"description":"The identity of one acquisition. Minted by a claim (and by a re-claim, which is a new acquisition), kept by extend and by a child admission's rewrite of the parent record, so a caller can release the acquisition it made even after renewals. Distinct from record, the oid of the current version of its record."},"finding_line":{"type":"object","description":"One stdout line per invariant `git locks doctor` found broken, as it is found. `check` names the invariant, `subject` the job, ref or semaphore it failed for, `detail` what was seen.","required":["event","check","subject","detail"],"properties":{"event":{"const":"finding"},"check":{"enum":["record-decodes","job-ref-name","path-ref-missing","path-ref-elsewhere","path-ref-orphan","path-ref-stray","parent-missing","parent-expired","parent-holder","family-cycle","sem-meta","sem-gen","sem-record","sem-capacity","unknown-ref"]},"subject":{"type":"string","minLength":1},"detail":{"type":"string","minLength":1}},"additionalProperties":false},"doctor_line":{"type":"object","description":"The last stdout line of `git locks doctor`: the store, the reading basis (how many refs and records one snapshot held, and the clock the liveness checks used), the checks run, the number of finding lines, and the verdict. Exit 0 when healthy, 1 with findings. An unreadable store prints an error line instead and exits 2; it is never reported healthy.","required":["event","store","basis","checks","findings","healthy"],"properties":{"event":{"const":"doctor"},"store":{"type":"string","minLength":1},"basis":{"type":"object","required":["refs","records","now"],"properties":{"refs":{"type":"integer","minimum":0},"records":{"type":"integer","minimum":0},"now":{"$ref":"#/$defs/epoch"}},"additionalProperties":false},"checks":{"type":"array","items":{"enum":["record-decodes","job-ref-name","path-ref-missing","path-ref-elsewhere","path-ref-orphan","path-ref-stray","parent-missing","parent-expired","parent-holder","family-cycle","sem-meta","sem-gen","sem-record","sem-capacity","unknown-ref"]},"minItems":1},"findings":{"type":"integer","minimum":0},"healthy":{"type":"boolean"}},"additionalProperties":false}}} +{"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://raw.githubusercontent.com/git-stunts/locks/main/schema/git-locks.schema.json","title":"git-locks output","description":"Every line git-locks writes, on stdout or stderr, is one JSON object matching exactly one of the definitions below. Lines are emitted as each result is known (JSON Lines); nothing is buffered into an array. There is no plain-text mode: help is a usage object, errors are error objects, and `git locks schema` prints this document as one line. The one exception: a command wrapped by `with` owns stdout, and git-locks reports around it on stderr.","oneOf":[{"$ref":"#/$defs/check_line"},{"$ref":"#/$defs/list_line"},{"$ref":"#/$defs/claim_line"},{"$ref":"#/$defs/refusal_line"},{"$ref":"#/$defs/release_line"},{"$ref":"#/$defs/sweep_line"},{"$ref":"#/$defs/store_line"},{"$ref":"#/$defs/version_line"},{"$ref":"#/$defs/ttl_line"},{"$ref":"#/$defs/extend_line"},{"$ref":"#/$defs/missing_line"},{"$ref":"#/$defs/sem_line"},{"$ref":"#/$defs/sem_event_line"},{"$ref":"#/$defs/finding_line"},{"$ref":"#/$defs/doctor_line"},{"$ref":"#/$defs/error_line"},{"$ref":"#/$defs/usage_line"}],"$defs":{"job":{"type":"string","pattern":"^[A-Za-z0-9][A-Za-z0-9._-]*$","description":"The job id, as given to --job. Names the lock: refs/locks/jobs/."},"holder":{"type":"string","minLength":1,"description":"Whoever claimed, as given to --holder. Free text, one line."},"path":{"type":"string","minLength":1,"description":"A repo-relative path, normalised: no leading ./, never absolute, never containing a .. component or a newline."},"epoch":{"type":"integer","minimum":0,"description":"Seconds since the Unix epoch, UTC."},"check_line":{"type":"object","description":"One line per path from `git locks check`, in argument order, as each is examined.","required":["path","state"],"properties":{"path":{"$ref":"#/$defs/path"},"state":{"enum":["free","held","expired"]},"holder":{"$ref":"#/$defs/holder"},"note":{"$ref":"#/$defs/note"},"job":{"$ref":"#/$defs/job"},"expires":{"$ref":"#/$defs/epoch"},"remaining":{"$ref":"#/$defs/remaining"}},"additionalProperties":false,"if":{"properties":{"state":{"const":"free"}}},"then":{"required":["path","state"],"not":{"anyOf":[{"required":["holder"]},{"required":["job"]},{"required":["expires"]},{"required":["remaining"]},{"required":["note"]}]}},"else":{"required":["path","state","holder","job","expires","remaining"]}},"list_line":{"type":"object","description":"One line per lock from `git locks list`, live or expired, in ref order; also the single line of `git locks show --job `.","required":["job","holder","state","claimed","expires","remaining","paths","record","acquisition"],"properties":{"job":{"$ref":"#/$defs/job"},"holder":{"$ref":"#/$defs/holder"},"note":{"$ref":"#/$defs/note"},"state":{"enum":["live","expired"]},"claimed":{"$ref":"#/$defs/epoch"},"expires":{"$ref":"#/$defs/epoch"},"paths":{"type":"array","items":{"$ref":"#/$defs/path"},"uniqueItems":true},"remaining":{"$ref":"#/$defs/remaining"},"parent":{"$ref":"#/$defs/job","description":"Present when the lock is a child: it is released or swept with this job."},"record":{"$ref":"#/$defs/record"},"acquisition":{"$ref":"#/$defs/acquisition"}},"additionalProperties":false},"claim_line":{"type":"object","description":"The single stdout line of a successful `git locks claim`; one per record for `git locks batch`.","required":["event","job","holder","claimed","expires","paths","record","acquisition"],"properties":{"event":{"const":"claimed"},"job":{"$ref":"#/$defs/job"},"holder":{"$ref":"#/$defs/holder"},"note":{"$ref":"#/$defs/note"},"claimed":{"$ref":"#/$defs/epoch"},"expires":{"$ref":"#/$defs/epoch"},"paths":{"type":"array","items":{"$ref":"#/$defs/path"},"minItems":1,"uniqueItems":true},"parent":{"$ref":"#/$defs/job"},"record":{"$ref":"#/$defs/record"},"acquisition":{"$ref":"#/$defs/acquisition"}},"additionalProperties":false},"refusal_line":{"type":"object","description":"On stderr, one line per problem when a claim or batch is refused (exit 1): a held path naming its holder; a parent that is missing, expired or another holder's; a path named by two records of one batch; or a transaction failure with git's message. For semaphores: capacity (full), exists (create), live (delete with live slots).","oneOf":[{"required":["event","path","holder","job","expires"],"properties":{"event":{"const":"refused"},"path":{"$ref":"#/$defs/path"},"holder":{"$ref":"#/$defs/holder"},"note":{"$ref":"#/$defs/note"},"job":{"$ref":"#/$defs/job"},"expires":{"$ref":"#/$defs/epoch"}},"additionalProperties":false},{"required":["event","reason","detail"],"properties":{"event":{"const":"refused"},"reason":{"const":"transaction"},"detail":{"type":"string"}},"additionalProperties":false},{"required":["event","reason","job","parent","detail"],"properties":{"event":{"const":"refused"},"reason":{"const":"parent"},"job":{"$ref":"#/$defs/job"},"parent":{"$ref":"#/$defs/job"},"detail":{"enum":["missing","expired","holder"],"description":"Why the parent cannot be used: no such lock, it has expired, or it belongs to another holder."}},"additionalProperties":false},{"required":["event","reason","path"],"properties":{"event":{"const":"refused"},"reason":{"const":"duplicate"},"path":{"$ref":"#/$defs/path"}},"additionalProperties":false},{"required":["event","reason","semaphore","capacity","live"],"properties":{"event":{"const":"refused"},"reason":{"const":"capacity"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"capacity":{"type":"integer","minimum":1},"live":{"type":"integer","minimum":0,"description":"Slots held by unexpired jobs."}},"additionalProperties":false},{"required":["event","reason","semaphore"],"properties":{"event":{"const":"refused"},"reason":{"const":"exists"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."}},"additionalProperties":false},{"required":["event","reason","semaphore","live"],"properties":{"event":{"const":"refused"},"reason":{"const":"live"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"live":{"type":"integer","minimum":0,"description":"Slots held by unexpired jobs."}},"additionalProperties":false}]},"release_line":{"type":"object","description":"One stdout line per --job of `git locks release`: what was released (with any descendants), or that there was nothing to release.","oneOf":[{"required":["event","job","paths"],"properties":{"event":{"const":"released"},"job":{"$ref":"#/$defs/job"},"paths":{"type":"integer","minimum":0,"description":"How many path refs were deleted, descendants included."},"cascaded":{"type":"array","items":{"$ref":"#/$defs/job"},"description":"Descendant jobs released in the same transaction, sorted; absent when there were none."}},"additionalProperties":false},{"required":["event","job"],"properties":{"event":{"const":"nothing"},"job":{"$ref":"#/$defs/job"},"reason":{"const":"superseded","description":"Present when --acquisition or --record named an acquisition the job no longer holds: nothing was released."}},"additionalProperties":false}]},"sweep_line":{"type":"object","description":"One stdout line per expired lock `git locks sweep` deleted; on stderr, one per lock it could not delete because it changed underneath.","oneOf":[{"required":["event","job","holder","expires"],"properties":{"event":{"const":"swept"},"job":{"$ref":"#/$defs/job"},"holder":{"$ref":"#/$defs/holder"},"expires":{"$ref":"#/$defs/epoch"},"cascaded":{"type":"array","items":{"$ref":"#/$defs/job"},"description":"Descendant jobs swept with this expired parent, sorted; absent when there were none."}},"additionalProperties":false},{"required":["event","job","reason"],"properties":{"event":{"const":"skipped"},"job":{"$ref":"#/$defs/job"},"reason":{"const":"changed underneath"}},"additionalProperties":false}]},"store_line":{"type":"object","description":"The single line of `git locks store`: the absolute path of the store this repository resolves to.","required":["store"],"properties":{"store":{"type":"string","minLength":1}},"additionalProperties":false},"version_line":{"type":"object","description":"The single line of `git locks version`.","required":["name","version"],"properties":{"name":{"const":"git-locks"},"version":{"type":"string","pattern":"^[0-9]+\\.[0-9]+\\.[0-9]+$"}},"additionalProperties":false},"remaining":{"type":"integer","minimum":0,"description":"Seconds until the lock expires, 0 once it has."},"ttl_line":{"type":"object","description":"The single line of `git locks ttl --job `.","required":["job","expires","remaining"],"properties":{"job":{"$ref":"#/$defs/job"},"expires":{"$ref":"#/$defs/epoch"},"remaining":{"$ref":"#/$defs/remaining"}},"additionalProperties":false},"extend_line":{"type":"object","description":"The single line of `git locks extend --job --ttl `: the new expiry.","required":["event","job","expires"],"properties":{"event":{"const":"extended"},"job":{"$ref":"#/$defs/job"},"expires":{"$ref":"#/$defs/epoch"}},"additionalProperties":false},"missing_line":{"type":"object","description":"On stderr, from show, ttl or extend, when no lock exists for the job (exit 1).","required":["event","job"],"properties":{"event":{"const":"missing"},"job":{"$ref":"#/$defs/job"}},"additionalProperties":false},"sem_line":{"type":"object","description":"One line per semaphore from `git locks sem list`, and the single line of `git locks sem show `: capacity, live count, and the live slots.","required":["semaphore","capacity","live","slots"],"properties":{"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"capacity":{"type":"integer","minimum":1},"live":{"type":"integer","minimum":0,"description":"Slots held by unexpired jobs."},"slots":{"type":"array","items":{"type":"object","required":["job","holder","claimed","expires","remaining","record","acquisition"],"properties":{"job":{"$ref":"#/$defs/job"},"holder":{"$ref":"#/$defs/holder"},"claimed":{"$ref":"#/$defs/epoch"},"expires":{"$ref":"#/$defs/epoch"},"remaining":{"$ref":"#/$defs/remaining"},"record":{"$ref":"#/$defs/record"},"acquisition":{"$ref":"#/$defs/acquisition"}},"additionalProperties":false}}},"additionalProperties":false},"sem_event_line":{"type":"object","description":"Semaphore lifecycle lines: created, acquired (with the live count after), released (likewise), nothing (the job held no slot), deleted.","oneOf":[{"required":["event","semaphore","capacity"],"properties":{"event":{"const":"created"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"capacity":{"type":"integer","minimum":1}},"additionalProperties":false},{"required":["event","semaphore","job","holder","claimed","expires","live","capacity","record","acquisition"],"properties":{"event":{"const":"acquired"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"job":{"$ref":"#/$defs/job"},"holder":{"$ref":"#/$defs/holder"},"claimed":{"$ref":"#/$defs/epoch"},"expires":{"$ref":"#/$defs/epoch"},"live":{"type":"integer","minimum":0,"description":"Slots held by unexpired jobs."},"capacity":{"type":"integer","minimum":1},"record":{"$ref":"#/$defs/record"},"acquisition":{"$ref":"#/$defs/acquisition"}},"additionalProperties":false},{"required":["event","semaphore","job","live","capacity"],"properties":{"event":{"const":"released"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"job":{"$ref":"#/$defs/job"},"live":{"type":"integer","minimum":0,"description":"Slots held by unexpired jobs."},"capacity":{"type":"integer","minimum":1}},"additionalProperties":false},{"required":["event","semaphore","job"],"properties":{"event":{"const":"nothing"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."},"job":{"$ref":"#/$defs/job"},"reason":{"const":"superseded"}},"additionalProperties":false},{"required":["event","semaphore"],"properties":{"event":{"const":"deleted"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."}},"additionalProperties":false},{"required":["event","semaphore"],"properties":{"event":{"const":"missing"},"semaphore":{"$ref":"#/$defs/job","description":"The semaphore's name; same grammar as a job id."}},"additionalProperties":false}]},"record":{"type":"string","pattern":"^[0-9a-f]{40}([0-9a-f]{24})?$","description":"The object id of this acquisition's record: the identity a later release or renewal names."},"error_line":{"type":"object","description":"On stderr: a usage failure (exit 2), a store that could not be read (exit 2; nothing is reported free or held), or a failed operation (exit 1).","required":["event","reason","detail"],"properties":{"event":{"const":"error"},"reason":{"enum":["usage","store-read","failed"]},"detail":{"type":"string"}},"additionalProperties":false},"usage_line":{"type":"object","description":"`git locks help` on stdout (exit 0), ` --help`, or a usage error on stderr (exit 2): the usage text as one string.","required":["event","usage"],"properties":{"event":{"const":"usage"},"usage":{"type":"string"}},"additionalProperties":false},"acquisition":{"type":"string","minLength":1,"description":"The identity of one acquisition. Minted by a claim (and by a re-claim, which is a new acquisition), kept by extend and by a child admission's rewrite of the parent record, so a caller can release the acquisition it made even after renewals. Distinct from record, the oid of the current version of its record."},"finding_line":{"type":"object","description":"One stdout line per invariant `git locks doctor` found broken, as it is found. `check` names the invariant, `subject` the job, ref or semaphore it failed for, `detail` what was seen.","required":["event","check","subject","detail"],"properties":{"event":{"const":"finding"},"check":{"enum":["record-decodes","job-ref-name","path-ref-missing","path-ref-elsewhere","path-ref-orphan","path-ref-stray","parent-missing","parent-expired","parent-holder","family-cycle","sem-meta","sem-gen","sem-record","sem-capacity","unknown-ref"]},"subject":{"type":"string","minLength":1},"detail":{"type":"string","minLength":1}},"additionalProperties":false},"doctor_line":{"type":"object","description":"The last stdout line of `git locks doctor`: the store, the reading basis (how many refs and records one snapshot held, and the clock the liveness checks used), the checks run, the number of finding lines, and the verdict. Exit 0 when healthy, 1 with findings. An unreadable store prints an error line instead and exits 2; it is never reported healthy.","required":["event","store","basis","checks","findings","healthy"],"properties":{"event":{"const":"doctor"},"store":{"type":"string","minLength":1},"basis":{"type":"object","required":["refs","records","now"],"properties":{"refs":{"type":"integer","minimum":0},"records":{"type":"integer","minimum":0},"now":{"$ref":"#/$defs/epoch"}},"additionalProperties":false},"checks":{"type":"array","items":{"enum":["record-decodes","job-ref-name","path-ref-missing","path-ref-elsewhere","path-ref-orphan","path-ref-stray","parent-missing","parent-expired","parent-holder","family-cycle","sem-meta","sem-gen","sem-record","sem-capacity","unknown-ref"]},"minItems":1},"findings":{"type":"integer","minimum":0},"healthy":{"type":"boolean"}},"additionalProperties":false},"note":{"type":"string","minLength":1,"pattern":"^[^\\n\\r]*$","description":"One line saying why the lock is held, given at claim time with --note (or note: in a batch record); absent when none was given. Carried on every line that names the lock."}}} EOF } diff --git a/lib/000-prelude.sh b/lib/000-prelude.sh index c8823dc..5a679d6 100644 --- a/lib/000-prelude.sh +++ b/lib/000-prelude.sh @@ -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 --holder [--ttl ] [--parent ] ... +usage: git locks claim --job --holder [--ttl ] [--parent ] [--note ] ... git locks batch < records several claims in ONE transaction, all or nothing git locks release --job [--record | --acquisition ] [--job ...] git locks check ... @@ -76,7 +76,7 @@ usage: git locks claim --job --holder [--ttl ] [--parent git locks show --job git locks ttl --job git locks extend --job --ttl - git locks with --job --holder [--ttl ] [--wait ] [--sem ] [...] -- ... + git locks with --job --holder [--ttl ] [--wait ] [--sem ] [--note ] [...] -- ... git locks sem create --capacity | acquire --job --holder [--ttl ] [--wait ] | release --job [--record | --acquisition ] | show | list | delete git locks doctor @@ -86,7 +86,9 @@ usage: git locks claim --job --holder [--ttl ] [--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 @@ -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 --holder [--ttl ] [--parent ] ...\n' ;; + claim) printf 'usage: git locks claim --job --holder [--ttl ] [--parent ] [--note ] ...\n' ;; batch) printf 'usage: git locks batch < records\n' ;; release) printf 'usage: git locks release --job [--record | --acquisition ] [--job ...]\n' ;; check) printf 'usage: git locks check ...\n' ;; @@ -153,7 +155,7 @@ sub_usage_text() { show) printf 'usage: git locks show --job \n' ;; ttl) printf 'usage: git locks ttl --job \n' ;; extend) printf 'usage: git locks extend --job --ttl \n' ;; - with) printf 'usage: git locks with --job --holder [--ttl ] [--wait ] [--sem ] [...] -- ...\n' ;; + with) printf 'usage: git locks with --job --holder [--ttl ] [--wait ] [--sem ] [--note ] [...] -- ...\n' ;; doctor) printf 'usage: git locks doctor\n' ;; sem) printf 'usage: git locks sem create --capacity | acquire --job --holder [--ttl ] [--wait ] | release --job [--record | --acquisition ] | show | list | delete \n' ;; *) usage_text ;; diff --git a/lib/030-time-refs-records.sh b/lib/030-time-refs-records.sh index aa514a7..2808adb 100644 --- a/lib/030-time-refs-records.sh +++ b/lib/030-time-refs-records.sh @@ -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)) diff --git a/lib/050-the-snapshot.sh b/lib/050-the-snapshot.sh index 11041b4..c4766dc 100644 --- a/lib/050-the-snapshot.sh +++ b/lib/050-the-snapshot.sh @@ -138,9 +138,18 @@ D_JOB='' D_EXPIRES=0 D_REMAINING=0 D_STATE='' +D_NOTE='' +D_NOTE_JSON='' # ',"note":' 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 diff --git a/lib/070-refusals.sh b/lib/070-refusals.sh index 5e5d502..93f25bd 100644 --- a/lib/070-refusals.sh +++ b/lib/070-refusals.sh @@ -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 diff --git a/lib/080-families.sh b/lib/080-families.sh index ac0d362..ae391db 100644 --- a/lib/080-families.sh +++ b/lib/080-families.sh @@ -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)) @@ -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}")" diff --git a/lib/090-claim-planning.sh b/lib/090-claim-planning.sh index 96eb8fc..d07bd0a 100644 --- a/lib/090-claim-planning.sh +++ b/lib/090-claim-planning.sh @@ -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 @@ -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 @@ -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}" @@ -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 } @@ -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 @@ -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+=("$@") @@ -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}" diff --git a/lib/100-batch.sh b/lib/100-batch.sh index af3e183..eb0689c 100644 --- a/lib/100-batch.sh +++ b/lib/100-batch.sh @@ -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 } @@ -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 diff --git a/lib/120-check.sh b/lib/120-check.sh index f889da8..2388e13 100644 --- a/lib/120-check.sh +++ b/lib/120-check.sh @@ -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}" diff --git a/lib/130-list-show-ttl.sh b/lib/130-list-show-ttl.sh index 5e0e331..5d06774 100644 --- a/lib/130-list-show-ttl.sh +++ b/lib/130-list-show-ttl.sh @@ -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() { diff --git a/lib/140-extend.sh b/lib/140-extend.sh index c5ae15c..6434993 100644 --- a/lib/140-extend.sh +++ b/lib/140-extend.sh @@ -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}")" @@ -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 diff --git a/lib/160-with.sh b/lib/160-with.sh index 6f16cb7..b77ffc5 100644 --- a/lib/160-with.sh +++ b/lib/160-with.sh @@ -9,9 +9,9 @@ acquire_with_wait() { # kind(lock|sem) wait-seconds errfile -> 0 acquired (ACQUI if [[ "${kind}" == sem ]]; then out="$( (sem_acquire_once "${W_SEM}" "${W_JOB}" "${W_HOLDER}" "${W_TTL}") 2>"${errfile}")" elif [[ -n "${W_PARENT}" ]]; then - out="$( (cmd_claim --job "${W_JOB}" --holder "${W_HOLDER}" --ttl "${W_TTL}" --parent "${W_PARENT}" -- "${W_PATHS[@]}") 2>"${errfile}")" + out="$( (cmd_claim --job "${W_JOB}" --holder "${W_HOLDER}" --ttl "${W_TTL}" --parent "${W_PARENT}" --note "${W_NOTE}" -- "${W_PATHS[@]}") 2>"${errfile}")" else - out="$( (cmd_claim --job "${W_JOB}" --holder "${W_HOLDER}" --ttl "${W_TTL}" -- "${W_PATHS[@]}") 2>"${errfile}")" + out="$( (cmd_claim --job "${W_JOB}" --holder "${W_HOLDER}" --ttl "${W_TTL}" --note "${W_NOTE}" -- "${W_PATHS[@]}") 2>"${errfile}")" fi rc=$? if ((rc == 0)); then @@ -39,6 +39,7 @@ cmd_with() { W_TTL="${DEFAULT_TTL}" W_PARENT='' W_SEM='' + W_NOTE='' W_PATHS=() local wait=0 command=() seen_dashdash=0 a while (($# > 0)); do @@ -79,6 +80,11 @@ cmd_with() { W_SEM="$2" shift 2 ;; + --note) + [[ $# -ge 2 ]] || usage + W_NOTE="$2" + shift 2 + ;; --) seen_dashdash=1 shift @@ -98,6 +104,7 @@ cmd_with() { valid_job "${W_JOB}" || fail "job id '${W_JOB}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 valid_holder "${W_HOLDER}" || fail 'holder must be one line' 2 valid_ttl W_TTL "${W_TTL}" || fail '--ttl is a positive number of seconds' 2 + valid_note "${W_NOTE}" || fail '--note must be one line' 2 [[ -z "${W_SEM}" ]] || valid_job "${W_SEM}" || fail "semaphore name '${W_SEM}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 [[ -z "${W_PARENT}" ]] || valid_job "${W_PARENT}" || fail "parent id '${W_PARENT}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 diff --git a/schema/git-locks.schema.json b/schema/git-locks.schema.json index c9642c3..1426d18 100644 --- a/schema/git-locks.schema.json +++ b/schema/git-locks.schema.json @@ -98,6 +98,9 @@ "holder": { "$ref": "#/$defs/holder" }, + "note": { + "$ref": "#/$defs/note" + }, "job": { "$ref": "#/$defs/job" }, @@ -142,6 +145,11 @@ "required": [ "remaining" ] + }, + { + "required": [ + "note" + ] } ] } @@ -178,6 +186,9 @@ "holder": { "$ref": "#/$defs/holder" }, + "note": { + "$ref": "#/$defs/note" + }, "state": { "enum": [ "live", @@ -236,6 +247,9 @@ "holder": { "$ref": "#/$defs/holder" }, + "note": { + "$ref": "#/$defs/note" + }, "claimed": { "$ref": "#/$defs/epoch" }, @@ -284,6 +298,9 @@ "holder": { "$ref": "#/$defs/holder" }, + "note": { + "$ref": "#/$defs/note" + }, "job": { "$ref": "#/$defs/job" }, @@ -1043,6 +1060,12 @@ } }, "additionalProperties": false + }, + "note": { + "type": "string", + "minLength": 1, + "pattern": "^[^\\n\\r]*$", + "description": "One line saying why the lock is held, given at claim time with --note (or note: in a batch record); absent when none was given. Carried on every line that names the lock." } } } diff --git a/test/test.sh b/test/test.sh index 7e10288..d157301 100755 --- a/test/test.sh +++ b/test/test.sh @@ -1319,6 +1319,65 @@ jfields "the renewed lock is still there with its new expiry" "${out}" 'expires= out="$(git-locks version extra 2>&1)" check "version takes no arguments" "$?" "2" +# ---------------------------------------------------------------- #8: a note on a claim, carried wherever the lock is named + +R="$(mkrepo)" +cd "${R}" || exit 2 +out="$(git-locks claim --job rel --holder alice --note 'building the release bundle' dist/a.js 2>&1)" +check "claim --note exits 0" "$?" "0" +jfields "the claim line carries the note" "${out}" 'note="building the release bundle"' +valid "claim line with a note" "${out}" +out="$(git-locks show --job rel 2>&1)" +jfields "show carries the note" "${out}" 'note="building the release bundle"' +valid "show line with a note" "${out}" +out="$(git-locks list 2>&1)" +jfields "list carries the note" "${out}" 'note="building the release bundle"' +out="$(git-locks check dist/a.js 2>&1)" +jfields "check on the held path carries the note beside the holder" "${out}" 'state="held"' 'holder="alice"' 'note="building the release bundle"' +valid "check line with a note" "${out}" +err="$(git-locks claim --job other --holder bob dist/a.js 2>&1 >/dev/null)" +check "another job's claim is refused" "$?" "1" +jfields "and the refusal says why the path is held" "${err}" 'holder="alice"' 'job="rel"' 'note="building the release bundle"' +valid "refusal line with a note" "${err}" +git-locks extend --job rel --ttl 500 >/dev/null 2>&1 +out="$(git-locks show --job rel 2>&1)" +jfields "extend keeps the note" "${out}" 'note="building the release bundle"' +git-locks claim --job rel-child --holder alice --parent rel dist/b.js >/dev/null 2>&1 +out="$(git-locks show --job rel 2>&1)" +jfields "a child admission keeps the parent's note through the family bump" "${out}" 'note="building the release bundle"' +out="$(git-locks show --job rel-child 2>&1)" +rc=0 +[[ "${out}" != *'"note"'* ]] || rc=1 +check "the child, claimed without a note, has none" "${rc}" "0" + +out="$(printf 'job: bt\nholder: alice\nnote: from a batch\npaths:\nbt.md\n' | git-locks batch 2>&1)" +check "batch accepts a note: line" "$?" "0" +jfields "and the claim line carries it" "${out}" 'note="from a batch"' +out="$(git-locks with --job w --holder alice --note 'inside with' w.md -- git-locks check w.md 2>/dev/null)" +check "with --note runs the command" "$?" "1" +jfields "and the lock it held carried the note" "${out}" 'state="held"' 'note="inside with"' + +out="$(git-locks claim --job nl --holder alice --note $'two\nlines' nl.md 2>&1)" +check "a note with a newline is refused" "$?" "2" +git-locks check nl.md >/dev/null 2>&1 +check "and left no lock" "$?" "0" +out="$(git-locks claim --job plain --holder alice plain.md 2>&1)" +rc=0 +[[ "${out}" != *'"note"'* ]] || rc=1 +check "a claim without --note has no note key" "${rc}" "0" +out="$(git-locks check plain.md 2>&1)" +rc=0 +[[ "${out}" != *'"note"'* ]] || rc=1 +check "nor does its check line" "${rc}" "0" +out="$(git-locks claim --job empty --holder alice --note '' e.md 2>&1)" +check "an empty --note is accepted as no note" "$?" "0" +rc=0 +[[ "${out}" != *'"note"'* ]] || rc=1 +check "and prints no note key" "${rc}" "0" +out="$(git-locks claim --job q --holder alice --note 'say "hi"' q.md 2>&1)" +jfields "a note is JSON-escaped" "${out}" 'note="say \"hi\""' +valid "claim line with a quoted note" "${out}" + printf '\n%d passed, %d failed\n' "${PASS}" "${FAIL}" if ((FAIL > 0)); then printf 'failed: %s\n' "${FAILED[@]}"