diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a0a60c..ef67c34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project are recorded here. The format follows Keep a ## [Unreleased] +### Fixed + +- Parent acquisition replacement (#34) now refuses while any descendants remain stored, including expired descendants. This applies to the same holder, changed holders, reparenting and batches. Renew parents with `extend`, or release/sweep descendants before replacing them. Leaf replacement followed by new child admission remains supported. Admission rejects self-parenting and indirect cycles, verifies observed ancestor records in its transaction, and reports schema-valid `parent` refusals with `cycle` or `descendants` detail. Regression tests cover unchanged refs on refusal, renewal/recreation, both child-admission race directions and 192 seeded operations against an independent family model. + ## [0.7.0] - 2026-09-16 ### Added diff --git a/Makefile b/Makefile index fede374..9eaa537 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ SHELL := /usr/bin/env bash # lib/*.sh are fragments of one script and only lint as the whole they build into (bin/git-locks). -SCRIPTS := bin/git-locks test/test.sh scripts/hooks/pre-commit scripts/hooks/pre-push scripts/build.sh +SCRIPTS := bin/git-locks test/test.sh test/family-replacement.sh scripts/hooks/pre-commit scripts/hooks/pre-push scripts/build.sh PREFIX ?= $(HOME)/.local .PHONY: build lint test test-docker install uninstall diff --git a/README.md b/README.md index 64cdd64..ab19670 100644 --- a/README.md +++ b/README.md @@ -345,7 +345,13 @@ An outside review of 0.2.1 found the guarantees running ahead of the implementat **What binds the membership you observed to the decision you commit.** Every write is compiled into one transition per ref with the old value it expects, and sent as one transaction; a stale expectation fails the whole transaction and the command re-reads and re-plans a bounded number of times. Family membership is bound through the parent's own record: admitting a child rewrites the parent's blob with a bumped `family` generation and moves the parent's refs to it, so a release or sweep that planned against the old parent fails when a child was admitted meanwhile, and re-plans with the child in view. Semaphore capacity is bound through the semaphore's generation ref the same way. A snapshot is a cached read taken under one `for-each-ref`; it is never treated as a consistent cut, which is why every write carries expectations. -**What `parent` means.** Ownership plus lifetime, not dependency ordering. A child is admitted only under a live parent held by the same holder. Liveness and holder are checked at planning time; what the generation bump adds at commit time is that the parent's record is unchanged since that check, so a release, a renewal or another child cannot have slipped in between. The bump does not re-check the clock: a parent that expires during the microseconds between planning and commit is still bumped, and its family ends at the next sweep or claim over it. The child is released or swept whenever the parent is, by any command, including a claim that evicts an expired parent. Expiry is not inherited: a child keeps its own `expires`, and a parent's expiry ends the family. Renewing a parent (`extend`) keeps its family. Recreating a job name after its release makes a new record with a fresh family, unrelated to the old one. +**What `parent` means.** Ownership plus lifetime, not dependency ordering. A child is admitted only under a live parent held by the same holder. Liveness and holder are checked at planning time; what the generation bump adds at commit time is that the parent's record is unchanged since that check, so a release, a renewal or another child cannot have slipped in between. The bump does not re-check the clock: a parent that expires during the microseconds between planning and commit is still bumped, and its family ends at the next sweep or claim over it. The child is released or swept whenever the parent is, by any command, including a claim that evicts an expired parent. Expiry is not inherited: a child keeps its own `expires`, and a parent's expiry ends the family. Renewing a parent (`extend`) keeps its family and acquisition identity. + +A child stores its parent's **job name**, but belongs to the **acquisition** that admitted it. To preserve that binding without adding an acquisition field to each child, `claim` and `batch` refuse to replace any job with stored descendants, even for the same holder. This includes reparenting that job and descendants that have expired but have not yet been released or swept. Use `extend` to renew a parent; release or sweep its descendants before replacing it, or release the parent to end the whole family. Recreating the name after release starts a fresh acquisition with no old descendants. A leaf can still be replaced or reparented under a live parent with the same holder. Self-parenting and indirect cycles are refused. + +These rules also apply inside a batch. Replacing a leaf and then admitting a new child under it is allowed. Admitting a child and then replacing its parent is refused, as is replacing a parent with children already stored, even if the batch also replaces those children. Refusals use `reason: "parent"` with `detail: "cycle"` or `detail: "descendants"`, exit 1 and leave all refs unchanged. For `descendants`, both `job` and `parent` name the acquisition being replaced. + +The replacement transaction checks the old job record, whose family generation moves on child admission. Ancestry checks also verify the observed ancestor records, so concurrent reparenting between planning and commit invalidates the plan. The tests force child admission and replacement in both orders, and compare seeded command histories with an independent family model. These checks cover changes after the cached observation; they do not establish that membership and generation came from a coherent observation during a partially visible multi-ref transaction. That separate investigation is tracked in [#38](https://github.com/git-stunts/locks/issues/38). **What a path identifies.** The lexical form after normalisation: leading `./`, empty segments and `.` segments are removed; absolute paths and `..` are refused. `dir//file` and `dir/./file` are one key. Case, symlinks and hard links are not resolved. A trailing `/` is kept and means a prefix: `dir/` covers every path under it, and is covered by any live lock under it, in both directions and inside the transaction (a directory token ref per level, compared-and-swapped by every claim, is what makes a stale scan fail rather than land); `dir` without the slash is the directory entry itself, a different key, and a prefix does not cover it. Before 0.7.0 the slash was stripped; that is the one normalisation rule that changed. diff --git a/bin/git-locks b/bin/git-locks index ff04446..938c995 100755 --- a/bin/git-locks +++ b/bin/git-locks @@ -806,6 +806,7 @@ bump_parent() { # parent-job parent-oid -> plans the parent's blob rewrite wit BATCH_JOBS=() declare -A BATCH_HOLDER=() # job planned in this batch -> holder +declare -A BATCH_PARENT=() # job planned in this batch -> parent declare -A BUMPED=() # parent job -> 1 once its family generation is planned in this batch declare -A BATCH_PATH=() # normalised path planned in this batch -> the job claiming it CONFLICTS=0 @@ -813,6 +814,52 @@ CLAIM_LINE='' TERMINATED_PATHS=0 TERMINATED_CASCADE='[]' +plan_family() { # job parent -> refuse replacement with descendants or a cyclic proposed ancestry + local job="$1" parent="$2" ancestor="$2" ref oid next rows child child_parent + local seen=("${job}") + while [[ -n "${ancestor}" ]]; do + if in_list "${ancestor}" "${seen[@]}"; then + parent_refusal "${job}" "${parent}" cycle + return 1 + fi + seen+=("${ancestor}") + if [[ -n "${BATCH_PARENT[${ancestor}]+x}" ]]; then + ancestor="${BATCH_PARENT[${ancestor}]}" + continue + fi + ref="$(job_ref "${ancestor}")" + oid="$(ref_oid "${ref}")" + # Ancestry decisions must survive concurrent reparenting of any ancestor, + # not only the direct parent's generation bump. Earlier batch transitions + # already carry the same expectation; a verify preserves those writes. + plan_set "${ref}" "${oid}" '=' || fail "${PLAN_CONFLICT}" 1 + [[ -n "${oid}" ]] || break # the direct-parent check explains missing parents + field_v next "${oid}" parent + ancestor="${next}" + done + + # A stored child binds this acquisition even after expiry, until release or + # sweep removes it. Finding one direct child suffices to rule out replacement + # of a whole descendant tree. The job update's CAS below binds absence to its + # family generation, which each concurrent child admission moves. + rows="$(job_refs)" + while IFS=' ' read -r ref oid; do + [[ -n "${ref}" ]] || continue + field_v child_parent "${oid}" parent + if [[ "${child_parent}" == "${job}" ]]; then + parent_refusal "${job}" "${job}" descendants + return 1 + fi + done <<<"${rows}" + for child in "${!BATCH_PARENT[@]}"; do + if [[ "${BATCH_PARENT[${child}]}" == "${job}" ]]; then + parent_refusal "${job}" "${job}" descendants + return 1 + fi + done + return 0 +} + 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" note="$5" @@ -845,10 +892,17 @@ plan_claim() { # job holder ttl parent note path... -> plans one claim; sets done for bw in "${wanted[@]}"; do BATCH_PATH["${bw}"]="${job}"; done + if [[ -n "${parent}" ]]; then + valid_job "${parent}" || fail "parent id '${parent}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 + fi + if ! plan_family "${job}" "${parent}"; then + CONFLICTS=1 + return 0 + fi + # The parent, if any: live and the same holder, whether it exists already or is planned earlier in this batch. local pref poid if [[ -n "${parent}" ]]; then - valid_job "${parent}" || fail "parent id '${parent}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 if in_list "${parent}" "${BATCH_JOBS[@]}"; then if [[ "${BATCH_HOLDER[${parent}]}" != "${holder}" ]]; then parent_refusal "${job}" "${parent}" holder @@ -1032,6 +1086,7 @@ plan_claim() { # job holder ttl parent note path... -> plans one claim; sets BATCH_JOBS+=("${job}") BATCH_HOLDER["${job}"]="${holder}" + BATCH_PARENT["${job}"]="${parent}" local jpaths _j1 _j2 _j3 _j4 pj='' nj='' json_paths jpaths < <(printf '%s\n' "${wanted[@]}") json_str _j1 "${job}" @@ -1068,6 +1123,7 @@ claim_reset() { # planning state for one attempt at a claim or a batch plan_reset BATCH_JOBS=() BATCH_HOLDER=() + BATCH_PARENT=() BUMPED=() BATCH_PATH=() CONFLICTS=0 @@ -2385,7 +2441,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. A trailing / makes it a prefix: dist/ covers every path under dist/ (and is covered by any lock under it); dist without the slash is the directory entry itself, a different key."},"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"},"via":{"$ref":"#/$defs/via"},"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"]},{"required":["via"]}]}},"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"},"via":{"$ref":"#/$defs/via"},"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"},"via":{"$ref":"#/$defs/via"}},"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."},"via":{"$ref":"#/$defs/path","description":"The covering lock's own path when it differs from the one asked about or claimed: the prefix above it, or the path under a wanted prefix. Absent when the lock is on the path itself."}}} +{"$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. A trailing / makes it a prefix: dist/ covers every path under dist/ (and is covered by any lock under it); dist without the slash is the directory entry itself, a different key."},"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"},"via":{"$ref":"#/$defs/via"},"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"]},{"required":["via"]}]}},"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"},"via":{"$ref":"#/$defs/via"},"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","cycle","descendants"],"description":"Why admission is refused: the proposed parent is missing, expired, held by someone else, or creates a cycle; descendants means this job already has children and its acquisition cannot be replaced."}},"additionalProperties":false},{"required":["event","reason","path"],"properties":{"event":{"const":"refused"},"reason":{"const":"duplicate"},"path":{"$ref":"#/$defs/path"},"via":{"$ref":"#/$defs/via"}},"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."},"via":{"$ref":"#/$defs/path","description":"The covering lock's own path when it differs from the one asked about or claimed: the prefix above it, or the path under a wanted prefix. Absent when the lock is on the path itself."}}} EOF } diff --git a/lib/090-claim-planning.sh b/lib/090-claim-planning.sh index a4b45c1..ada616d 100644 --- a/lib/090-claim-planning.sh +++ b/lib/090-claim-planning.sh @@ -2,6 +2,7 @@ BATCH_JOBS=() declare -A BATCH_HOLDER=() # job planned in this batch -> holder +declare -A BATCH_PARENT=() # job planned in this batch -> parent declare -A BUMPED=() # parent job -> 1 once its family generation is planned in this batch declare -A BATCH_PATH=() # normalised path planned in this batch -> the job claiming it CONFLICTS=0 @@ -9,6 +10,52 @@ CLAIM_LINE='' TERMINATED_PATHS=0 TERMINATED_CASCADE='[]' +plan_family() { # job parent -> refuse replacement with descendants or a cyclic proposed ancestry + local job="$1" parent="$2" ancestor="$2" ref oid next rows child child_parent + local seen=("${job}") + while [[ -n "${ancestor}" ]]; do + if in_list "${ancestor}" "${seen[@]}"; then + parent_refusal "${job}" "${parent}" cycle + return 1 + fi + seen+=("${ancestor}") + if [[ -n "${BATCH_PARENT[${ancestor}]+x}" ]]; then + ancestor="${BATCH_PARENT[${ancestor}]}" + continue + fi + ref="$(job_ref "${ancestor}")" + oid="$(ref_oid "${ref}")" + # Ancestry decisions must survive concurrent reparenting of any ancestor, + # not only the direct parent's generation bump. Earlier batch transitions + # already carry the same expectation; a verify preserves those writes. + plan_set "${ref}" "${oid}" '=' || fail "${PLAN_CONFLICT}" 1 + [[ -n "${oid}" ]] || break # the direct-parent check explains missing parents + field_v next "${oid}" parent + ancestor="${next}" + done + + # A stored child binds this acquisition even after expiry, until release or + # sweep removes it. Finding one direct child suffices to rule out replacement + # of a whole descendant tree. The job update's CAS below binds absence to its + # family generation, which each concurrent child admission moves. + rows="$(job_refs)" + while IFS=' ' read -r ref oid; do + [[ -n "${ref}" ]] || continue + field_v child_parent "${oid}" parent + if [[ "${child_parent}" == "${job}" ]]; then + parent_refusal "${job}" "${job}" descendants + return 1 + fi + done <<<"${rows}" + for child in "${!BATCH_PARENT[@]}"; do + if [[ "${BATCH_PARENT[${child}]}" == "${job}" ]]; then + parent_refusal "${job}" "${job}" descendants + return 1 + fi + done + return 0 +} + 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" note="$5" @@ -41,10 +88,17 @@ plan_claim() { # job holder ttl parent note path... -> plans one claim; sets done for bw in "${wanted[@]}"; do BATCH_PATH["${bw}"]="${job}"; done + if [[ -n "${parent}" ]]; then + valid_job "${parent}" || fail "parent id '${parent}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 + fi + if ! plan_family "${job}" "${parent}"; then + CONFLICTS=1 + return 0 + fi + # The parent, if any: live and the same holder, whether it exists already or is planned earlier in this batch. local pref poid if [[ -n "${parent}" ]]; then - valid_job "${parent}" || fail "parent id '${parent}' must match [A-Za-z0-9][A-Za-z0-9._-]*" 2 if in_list "${parent}" "${BATCH_JOBS[@]}"; then if [[ "${BATCH_HOLDER[${parent}]}" != "${holder}" ]]; then parent_refusal "${job}" "${parent}" holder @@ -228,6 +282,7 @@ plan_claim() { # job holder ttl parent note path... -> plans one claim; sets BATCH_JOBS+=("${job}") BATCH_HOLDER["${job}"]="${holder}" + BATCH_PARENT["${job}"]="${parent}" local jpaths _j1 _j2 _j3 _j4 pj='' nj='' json_paths jpaths < <(printf '%s\n' "${wanted[@]}") json_str _j1 "${job}" @@ -264,6 +319,7 @@ claim_reset() { # planning state for one attempt at a claim or a batch plan_reset BATCH_JOBS=() BATCH_HOLDER=() + BATCH_PARENT=() BUMPED=() BATCH_PATH=() CONFLICTS=0 diff --git a/schema/git-locks.schema.json b/schema/git-locks.schema.json index 97f8e1a..b3aec1a 100644 --- a/schema/git-locks.schema.json +++ b/schema/git-locks.schema.json @@ -365,9 +365,11 @@ "enum": [ "missing", "expired", - "holder" + "holder", + "cycle", + "descendants" ], - "description": "Why the parent cannot be used: no such lock, it has expired, or it belongs to another holder." + "description": "Why admission is refused: the proposed parent is missing, expired, held by someone else, or creates a cycle; descendants means this job already has children and its acquisition cannot be replaced." } }, "additionalProperties": false diff --git a/test/family-model.py b/test/family-model.py new file mode 100644 index 0000000..39edeae --- /dev/null +++ b/test/family-model.py @@ -0,0 +1,88 @@ +"""Seeded CLI histories with an independent public-state oracle. + +The model stores only holder, parent and acquisition identity. Ref bytes must +remain identical after refusal; successful commands must match the model's +whole job set. Removing replacement/cycle checks breaks the fixed seeds. +""" + +import json +import os +from pathlib import Path +import random +import subprocess +import sys +import tempfile + + +binary = str(Path(sys.argv[1]).resolve()) +for seed in (34, 1701, 20260922): + rng = random.Random(seed) + with tempfile.TemporaryDirectory(prefix="git-locks-family-model-") as tmp: + env = dict(os.environ, GIT_LOCKS_STORE=f"{tmp}/store.git", GIT_LOCKS_NOW="1000000") + jobs = {} + acquisitions = {} + + def call(*args): + return subprocess.run([binary, *args], cwd=tmp, env=env, text=True, capture_output=True, timeout=15) + + def refs(): + store = Path(tmp, "store.git") + if not store.exists(): + return "" + return subprocess.check_output( + ["git", f"--git-dir={store}", "for-each-ref", "--format=%(refname) %(objectname)"], text=True + ) + + for step in range(64): + job = f"j{rng.randrange(6)}" + holder = rng.choice(("alice", "bob")) + parent = rng.choice(("", "j0", "j1", "j2", "j3", "j4", "j5")) + op = rng.randrange(8) + before = refs() + if op < 6: + # A child is never transferable to a replacement acquisition. + allowed = not any(p == job for _, p in jobs.values()) + if parent: + allowed &= parent in jobs and jobs.get(parent, (None,))[0] == holder + ancestor, seen = parent, {job} + while ancestor: + if ancestor in seen: + allowed = False + break + seen.add(ancestor) + ancestor = jobs.get(ancestor, ("", ""))[1] + args = ["claim", "--job", job, "--holder", holder] + if parent: + args += ["--parent", parent] + result = call(*args, f"{job}.md") + expected = 0 if allowed else 1 + if allowed: + jobs[job] = (holder, parent) + claimed = json.loads(result.stdout) + assert claimed["acquisition"] != acquisitions.get(job), (seed, step, "replacement identity") + acquisitions[job] = claimed["acquisition"] + elif op == 6: + result = call("extend", "--job", job, "--ttl", "16000") + expected = 0 if job in jobs else 1 + else: + result = call("release", "--job", job) + expected = 0 + removed = {job} + while True: + expanded = removed | {j for j, (_, p) in jobs.items() if p in removed} + if expanded == removed: + break + removed = expanded + jobs = {j: state for j, state in jobs.items() if j not in removed} + acquisitions = {j: acq for j, acq in acquisitions.items() if j not in removed} + assert result.returncode == expected, (seed, step, job, parent, holder, expected, result.returncode, result.stdout, result.stderr) + if expected == 1: + assert refs() == before, (seed, step, "refused plan changed refs") + listing = call("list") + assert listing.returncode == 0, (seed, step, listing.stderr) + records = [json.loads(line) for line in listing.stdout.splitlines()] + actual = {r["job"]: (r["holder"], r.get("parent", "")) for r in records} + assert actual == jobs, (seed, step, "family differs from model", actual, jobs) + assert {r["job"]: r["acquisition"] for r in records} == acquisitions, (seed, step, "acquisition changed") + assert all(r["state"] == "live" and r["paths"] == [f'{r["job"]}.md'] for r in records), (seed, step, "path or liveness") + print(f"seed {seed}: 64 operations matched public state, acquisition identity and refusal ref immutability") diff --git a/test/family-replacement.sh b/test/family-replacement.sh new file mode 100644 index 0000000..ca64305 --- /dev/null +++ b/test/family-replacement.sh @@ -0,0 +1,136 @@ +# shellcheck shell=bash +# Sourced by test.sh. Expected outcomes come from the public family policy, +# not the production descendants walker. Removing admission checks must fail +# these cases before doctor is allowed to diagnose an already-created graph. + +family_refs() { + local family_store family_line + family_line="$(git-locks store)" + jstr family_store "${family_line}" store + git --git-dir="${family_store}" for-each-ref --format='%(refname) %(objectname)' +} + +family_ok() { + local family_out + family_out="$(git-locks doctor 2>&1)" + check "$1 leaves a healthy family" "$?" 0 + jfields "$1 has no invariant findings" "${family_out}" 'healthy=true' 'findings=0' +} + +family_acq='' family_renewed='' family_new='' +R="$(mkrepo)" +cd "${R}" || exit 2 +git-locks claim --job P --holder alice p.md >/dev/null +git-locks claim --job C --holder alice --parent P c.md >/dev/null +git-locks claim --job G --holder alice --parent C g.md >/dev/null +before="$(family_refs)" +out="$(git-locks claim --job P --holder alice --parent 'bad parent' p.md 2>&1)" +check "invalid parent input is a usage error even when replacement is blocked" "$?" 2 +got="$(family_refs)" +check "invalid parent input preserves every ref" "${got}" "${before}" +for parent in P C G; do + out="$(git-locks claim --job P --holder alice --parent "${parent}" p.md 2>&1)" + check "replacement under ${parent} refuses a family cycle" "$?" 1 + got="$(family_refs)" + check "refused cycle under ${parent} preserves every ref" "${got}" "${before}" +done +for holder in alice bob; do + out="$(git-locks claim --job P --holder "${holder}" p.md 2>&1)" + check "parent replacement by ${holder} refuses while descendants exist" "$?" 1 + jfields "parent replacement by ${holder} explains descendants" "${out}" 'event="refused"' 'reason="parent"' 'detail="descendants"' + valid "parent replacement by ${holder} refusal" "${out}" + got="$(family_refs)" + check "refused replacement by ${holder} preserves every ref" "${got}" "${before}" +done +out="$(git-locks show --job P)" +jstr family_acq "${out}" acquisition +out="$(git-locks extend --job P --ttl 15000 2>&1)" +check "a parent with descendants can renew" "$?" 0 +out="$(git-locks show --job P)" +jstr family_renewed "${out}" acquisition +check "parent renewal retains acquisition identity" "${family_renewed}" "${family_acq}" +family_ok renewal +out="$(git-locks release --job P --acquisition "${family_acq}" 2>&1)" +check "release after renewal removes the original acquisition" "$?" 0 +got="$(family_refs)" +check "release removes every descendant" "${got}" '' +out="$(git-locks claim --job P --holder bob p.md 2>&1)" +check "released parent name can be recreated by another holder" "$?" 0 +jstr family_new "${out}" acquisition +family_fresh=0 +[[ "${family_new}" != "${family_acq}" ]] && family_fresh=1 +check "recreated parent starts a fresh acquisition" "${family_fresh}" 1 +out="$(git-locks claim --job P --holder bob --parent P p.md 2>&1)" +check "leaf replacement cannot parent itself" "$?" 1 +valid "self-parent refusal" "${out}" +family_ok recreation + +# Expired unswept descendants still belong to the acquisition. +git-locks claim --job C --holder bob --parent P --ttl 1 c.md >/dev/null +before="$(family_refs)" +out="$(GIT_LOCKS_NOW=1000002 git-locks claim --job P --holder bob p.md 2>&1)" +check "expired stored descendants still prevent parent replacement" "$?" 1 +got="$(family_refs)" +check "expired-descendant refusal preserves every ref" "${got}" "${before}" +GIT_LOCKS_NOW=1000002 git-locks sweep >/dev/null +out="$(git-locks claim --job P --holder alice p.md 2>&1)" +check "sweeping the last descendant permits leaf replacement" "$?" 0 +family_ok sweep + +# A fresh acquisition can be followed by new children in the same batch. +R="$(mkrepo)" +cd "${R}" || exit 2 +git-locks claim --job P --holder alice p.md >/dev/null +out="$(printf 'job: P\nholder: bob\npaths:\np.md\n\njob: C\nholder: bob\nparent: P\npaths:\nc.md\n' | git-locks batch 2>&1)" +check "batch replaces a leaf then attaches a child to its new acquisition" "$?" 0 +family_ok batch +before="$(family_refs)" +out="$(printf 'job: P\nholder: bob\npaths:\np.md\n\njob: C\nholder: bob\nparent: P\npaths:\nc.md\n' | git-locks batch 2>&1)" +check "batch cannot replace a parent with stored descendants" "$?" 1 +got="$(family_refs)" +check "refused batch replacement preserves every ref" "${got}" "${before}" + +git-locks release --job P >/dev/null +git-locks claim --job P --holder alice p.md >/dev/null +before="$(family_refs)" +out="$(printf 'job: C\nholder: alice\nparent: P\npaths:\nc.md\n\njob: P\nholder: alice\npaths:\np.md\n' | git-locks batch 2>&1)" +check "batch cannot replace a parent after planning its child" "$?" 1 +got="$(family_refs)" +check "refused earlier-child batch preserves every ref" "${got}" "${before}" +out="$(printf 'job: C\nholder: alice\nparent: P\npaths:\nc.md\n\njob: P\nholder: alice\nparent: C\npaths:\np.md\n' | git-locks batch 2>&1)" +check "batch cannot cycle through an earlier planned child" "$?" 1 +got="$(family_refs)" +check "refused batch cycle preserves every ref" "${got}" "${before}" + +# Both admitted changes use real Git transactions. Pauses only choose the +# schedule, and assertions inspect statuses, identities and final refs. +FAMILY_OUT="$(mktemp "${TMPDIR:-/tmp}/git-locks-family-out.XXXXXX")" +for first in replacement child; do + R="$(mkrepo)" + cd "${R}" || exit 2 + git-locks claim --job P --holder alice p.md >/dev/null + GATE="$(mktemp -d "${TMPDIR:-/tmp}/git-locks-family-gate.XXXXXX")/go" + if [[ "${first}" == replacement ]]; then + GIT_LOCKS_PAUSE_BEFORE_COMMIT="${GATE}" git-locks claim --job P --holder bob p.md >"${FAMILY_OUT}" 2>&1 & + family_pid=$! + reached "${GATE}" + git-locks claim --job C --holder alice --parent P c.md >/dev/null + check "child wins while parent replacement is paused" "$?" 0 + else + GIT_LOCKS_PAUSE_BEFORE_COMMIT="${GATE}" git-locks claim --job C --holder alice --parent P c.md >"${FAMILY_OUT}" 2>&1 & + family_pid=$! + reached "${GATE}" + git-locks claim --job P --holder bob p.md >/dev/null + check "parent replacement wins while child admission is paused" "$?" 0 + fi + : >"${GATE}" + wait "${family_pid}" + check "paused ${first} re-plans and refuses after its competing commit" "$?" 1 + family_ok "${first} race" +done + +# The model derives expected admission and complete state independently of +# production parsing, doctor and descendants; each failure reports seed/step. +FAMILY_TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +python3 "${FAMILY_TEST_DIR}/family-model.py" "${FAMILY_TEST_DIR}/../bin/git-locks" +check "192 seeded family operations match an independent state model" "$?" 0 diff --git a/test/test.sh b/test/test.sh index 22657a5..7ce6ac3 100755 --- a/test/test.sh +++ b/test/test.sh @@ -1571,6 +1571,10 @@ check "sibling prefixes in one batch are not an overlap" "$?" "0" lines n "${out}" check "and both records claimed" "${n}" "2" +# Family replacement coverage shares this suite's isolated HOME and helpers. +# shellcheck source=test/family-replacement.sh +source "${HERE}/family-replacement.sh" + printf '\n%d passed, %d failed\n' "${PASS}" "${FAIL}" if ((FAIL > 0)); then printf 'failed: %s\n' "${FAILED[@]}"