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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
60 changes: 58 additions & 2 deletions bin/git-locks

Large diffs are not rendered by default.

58 changes: 57 additions & 1 deletion lib/090-claim-planning.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,60 @@

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
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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions schema/git-locks.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 88 additions & 0 deletions test/family-model.py
Original file line number Diff line number Diff line change
@@ -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")
Loading
Loading