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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ All notable changes to this project are recorded here. The format follows Keep a

## [Unreleased]

## [0.7.0] - 2026-09-16

### Added

- Prefix locks (#6). A path ending in `/` is a prefix: `claim --job build dist/` covers every path under `dist/`, so `with --job build dist/ -- make` now protects what it names. A claim on a path under a live prefix held by another job is refused `via` the prefix; a claim on a prefix over a live lock under it is refused `via` that path; `check` reports the same `via`. Expired locks in the way are evicted as before. The same job may claim under its own prefix. `dist` without the slash is the directory entry itself, a different key, and is not covered.
- Overlap inside one `batch` is decided while planning, before any transaction: two records of different jobs may not claim a prefix and a path under it (the records are not in each other's snapshot, so neither the ancestor verify nor the descendant scan can see the other). The loser is a `duplicate` refusal naming the path and the record that covers it, and the batch lands nothing. One job may still hold a prefix and a path under it.
- How the race closes: prefixes above a wanted path are verified inside the transaction (absent, or unchanged), and every claim moves a directory token ref (`refs/locks/dirs/<hash of the prefix>`) for each directory above its paths by compare-and-swap from the value its snapshot saw. A prefix claim's scan of what is under it and a path claim's check of what is above it therefore cannot both be stale: one of the two transactions fails and re-plans with the other in view. Both orders are forced in the suite with the before-commit gate. The cost is one extra ref transition per directory level on every claim, and two claims under one directory can now collide once and re-plan; `doctor` knows the token refs.

### Changed

- A trailing slash is no longer stripped by normalisation; it is the prefix marker. Before 0.7.0, `dir/file/` named the same key as `dir/file`; now it asks about, or claims, everything under `dir/file`. Every other normalisation rule is unchanged (`dir//` and `./dir/./` are the prefix `dir/`).

## [0.6.0] - 2026-09-16

### Added
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ An outside review of 0.2.1 found the guarantees running ahead of the implementat

**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 a path identifies.** The lexical form after normalisation: leading `./`, empty segments, `.` segments and a trailing `/` are removed; absolute paths and `..` are refused. `dir//file`, `dir/./file` and `dir/file/` are one key. Case, symlinks and hard links are not resolved, and `dir/` does not cover `dir/file` (#6). That is a policy, stated, not an omission.
**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.

**What a lock does not do.** It is a cooperative, time-bounded reservation. `with` claims once, runs, and releases; it does not renew, so the reservation can expire under a long command and another claimant may take the path. Give `--ttl` the command's worst case, or renew with `extend` from inside it. A `check` that says free is an observation, not an admission; the protected write needs a claim.

Expand Down Expand Up @@ -387,7 +387,7 @@ Output is JSON Lines on every command; there is no text mode.
| Command | Does | Stdout line(s) | Exit |
|---|---|---|---|
| `claim --job <id> --holder <name> [--ttl <s>] [--note <text>] <path>...` | atomically lock the paths for the job; re-claiming with the same job replaces its record; `--note` is one line saying why, carried on every line that names the lock | one `claimed` object with `record`; refusals on stderr | 0 claimed, 1 refused, 2 usage |
| `check <path>...` | who holds each path, in argument order | one object per path as it is examined | 0 all free, 1 any held |
| `check <path>...` | who holds each path, in argument order; a path under a live prefix, or a prefix with a live lock under it, is held `via` that other path | 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 |
| `store` | the resolved store path | one `store` object | 0 |
Expand All @@ -413,7 +413,7 @@ Output is JSON Lines on every command; there is no text mode.

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

Paths are repo-relative, `./` prefixes are stripped, and absolute or `..` paths are refused. A path may contain spaces; it may not contain a newline. Job ids match `[A-Za-z0-9][A-Za-z0-9._-]*`. A holder is one line of text; any byte but a newline is stored whole and escaped on output. A 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.
Paths are repo-relative, `./` prefixes are stripped, and absolute or `..` paths are refused. A path ending in `/` is a prefix and covers everything under it. A path may contain spaces; it may not contain a newline. Job ids match `[A-Za-z0-9][A-Za-z0-9._-]*`. A holder is one line of text; any byte but a newline is stored whole and escaped on output. A note, given with `--note`, is one line saying why the lock is held; it rides on the claim, `show`, `list`, `check` and refusal lines, so the claimant who loses reads the reason and not only the name. A ttl is a decimal number of seconds; a leading zero is not octal.

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

Expand Down
Loading
Loading