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

- Path normalisation preserves literal `*`, `?` and bracket characters instead of expanding them against files in the working tree.

## [0.7.0] - 2026-09-16

### Added
Expand Down
3 changes: 2 additions & 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/literal-paths.sh scripts/hooks/pre-commit scripts/hooks/pre-push scripts/build.sh
PREFIX ?= $(HOME)/.local

.PHONY: build lint test test-docker install uninstall
Expand All @@ -14,6 +14,7 @@ lint:

test:
bash test/test.sh
bash test/literal-paths.sh

test-docker: # the same suite inside the official bash image, for a wall between the tests and your machine
docker run --rm -v "$(CURDIR)":/src -w /src bash:5.2 bash -c 'apk add --no-cache git python3 py3-jsonschema >/dev/null && git config --global user.email t@example.invalid && git config --global user.name t && bash test/test.sh'
Expand Down
2 changes: 1 addition & 1 deletion 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 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 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. Literal `*`, `?` and bracket characters stay unchanged; files in the working tree never expand or otherwise rewrite a requested path. 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
5 changes: 3 additions & 2 deletions bin/git-locks
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ normalize_path() { # -> prints the lexical form, or returns 2 with the reason on
# removed; absolute paths and .. segments are refused; case, symlinks and hard
# links are NOT resolved. A trailing / is kept: dir/ is a prefix that covers
# every path under it; dir is the directory entry itself, a different key.
local p="$1" part parts=() IFS='/' prefix=''
local p="$1" part parts=() raw_parts=() IFS='/' prefix=''
[[ "${p}" == */ ]] && prefix='/'
[[ "${p}" == /* ]] && {
path_error "${p}: paths are repo-relative"
Expand All @@ -320,7 +320,8 @@ normalize_path() { # -> prints the lexical form, or returns 2 with the reason on
path_error 'a path with a newline is not supported'
return 2
}
for part in ${p}; do
read -r -a raw_parts <<<"${p}"
for part in "${raw_parts[@]}"; do
[[ -z "${part}" || "${part}" == '.' ]] && continue
[[ "${part}" == '..' ]] && {
path_error "${p}: no .. components"
Expand Down
5 changes: 3 additions & 2 deletions lib/030-time-refs-records.sh
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ normalize_path() { # -> prints the lexical form, or returns 2 with the reason on
# removed; absolute paths and .. segments are refused; case, symlinks and hard
# links are NOT resolved. A trailing / is kept: dir/ is a prefix that covers
# every path under it; dir is the directory entry itself, a different key.
local p="$1" part parts=() IFS='/' prefix=''
local p="$1" part parts=() raw_parts=() IFS='/' prefix=''
[[ "${p}" == */ ]] && prefix='/'
[[ "${p}" == /* ]] && {
path_error "${p}: paths are repo-relative"
Expand All @@ -66,7 +66,8 @@ normalize_path() { # -> prints the lexical form, or returns 2 with the reason on
path_error 'a path with a newline is not supported'
return 2
}
for part in ${p}; do
read -r -a raw_parts <<<"${p}"
for part in "${raw_parts[@]}"; do
[[ -z "${part}" || "${part}" == '.' ]] && continue
[[ "${part}" == '..' ]] && {
path_error "${p}: no .. components"
Expand Down
208 changes: 208 additions & 0 deletions test/literal-paths.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
#!/usr/bin/env bash
# Focused regression coverage for literal glob characters in path identity.
set -uo pipefail

unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_OBJECT_DIRECTORY GIT_NAMESPACE

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export PATH="${HERE}/../bin:${PATH}"
PASS=0
FAIL=0
FAILED=()
CASE_NUMBER=0

check() { # label got want
if [[ "$2" == "$3" ]]; then
PASS=$((PASS + 1))
printf ' ok %s\n' "$1"
else
FAIL=$((FAIL + 1))
FAILED+=("$1")
printf ' FAIL %s\n got: %q\n want: %q\n' "$1" "$2" "$3"
fi
}

contains() { # label haystack needle
if [[ "$2" == *"$3"* ]]; then
PASS=$((PASS + 1))
printf ' ok %s\n' "$1"
else
FAIL=$((FAIL + 1))
FAILED+=("$1")
printf ' FAIL %s\n text: %q\n lacks: %q\n' "$1" "$2" "$3"
fi
}

TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/git-locks-literal-paths.XXXXXX")"
cleanup() {
if [[ -n "${TEST_ROOT:-}" && "${TEST_ROOT}" == "${TMPDIR:-/tmp}"/git-locks-literal-paths.* ]]; then
rm -rf -- "${TEST_ROOT}"
fi
}
trap cleanup EXIT

export HOME="${TEST_ROOT}/home"
mkdir -p "${HOME}"
export GIT_LOCKS_NOW=1000000

new_case() {
CASE_NUMBER=$((CASE_NUMBER + 1))
CASE_ROOT="${TEST_ROOT}/case-${CASE_NUMBER}"
WORK_ROOT="${CASE_ROOT}/work"
mkdir -p "${WORK_ROOT}"
git -C "${WORK_ROOT}" init -q -b main
export GIT_LOCKS_STORE="${CASE_ROOT}/store.git"
cd "${WORK_ROOT}" || exit 2
}

assert_claim_path() { # label job literal-path
local label="$1" job="$2" path="$3" out rc
out="$(git-locks claim --job "${job}" --holder alice "${path}" 2>&1)"
rc=$?
check "${label}: claim succeeds" "${rc}" "0"
contains "${label}: claim reports the literal path" "${out}" "\"paths\":[\"${path}\"]"
}

# Golden reproduction: a matching neighbour must not change the requested key.
new_case
touch 'report[1].md' report1.md
out="$(git-locks claim --job report --holder alice 'report[1].md' 2>&1)"
check "bracket golden path: claim succeeds" "$?" "0"
contains "bracket golden path: claim reports the literal name" "${out}" '"paths":["report[1].md"]'
out="$(git-locks show --job report 2>&1)"
contains "bracket golden path: show keeps the literal name" "${out}" '"paths":["report[1].md"]'
out="$(git-locks list 2>&1)"
contains "bracket golden path: list keeps the literal name" "${out}" '"paths":["report[1].md"]'
out="$(git-locks check 'report[1].md' 2>&1)"
check "bracket golden path: the requested name is held" "$?" "1"
contains "bracket golden path: check reports the requested name" "${out}" '"path":"report[1].md"'
git-locks check report1.md >/dev/null 2>&1
check "bracket golden path: the matching neighbour stays free" "$?" "0"
err="$(git-locks claim --job contender --holder bob 'report[1].md' 2>&1 >/dev/null)"
check "bracket golden path: a contender for the literal name is refused" "$?" "1"
contains "bracket golden path: refusal names the literal resource" "${err}" '"path":"report[1].md"'
git-locks claim --job neighbour --holder bob report1.md >/dev/null 2>&1
check "bracket golden path: the matching neighbour remains claimable" "$?" "0"

# Zero, one and several filesystem matches for every glob form.
new_case
assert_claim_path "star with zero matches" star-zero 'zero*.md'

new_case
touch one-match.md
assert_claim_path "star with one match" star-one 'one*.md'

new_case
touch many-a.md many-b.md
assert_claim_path "star with several matches" star-many 'many*.md'

new_case
touch question1.md
assert_claim_path "question mark with one match" question-one 'question?.md'

new_case
assert_claim_path "brackets with zero matches" bracket-zero 'bracket[ab].md'

new_case
touch bracketa.md
assert_claim_path "brackets with one match" bracket-one 'bracket[ab].md'

new_case
touch bracketa.md bracketb.md
assert_claim_path "brackets with several matches" bracket-many 'bracket[ab].md'

# Each component is lexical. Matches in the working tree cannot rewrite nested names.
new_case
mkdir -p 'source[1]' source1
touch 'source[1]/file?.md' source1/file1.md file1.md
assert_claim_path "nested glob components" nested 'source[1]/file?.md'
git-locks check source1/file1.md >/dev/null 2>&1
check "nested glob components: the expanded neighbour stays free" "$?" "0"

# A trailing slash remains the prefix marker while the preceding bytes stay literal.
new_case
mkdir -p 'dist[1]' dist1
assert_claim_path "literal prefix" prefix 'dist[1]/'
out="$(git-locks check 'dist[1]/asset.js' 2>&1)"
check "literal prefix: a child of the requested prefix is held" "$?" "1"
contains "literal prefix: check names the literal prefix" "${out}" '"via":"dist[1]/"'
git-locks check dist1/asset.js >/dev/null 2>&1
check "literal prefix: a child of the matching neighbour stays free" "$?" "0"

# Batch takes the same literal path contract as claim.
new_case
touch batch1.md
out="$(printf 'job: batch\nholder: alice\npaths:\nbatch[1].md\n' | git-locks batch 2>&1)"
check "batch: a literal bracket path claims" "$?" "0"
contains "batch: output keeps the literal path" "${out}" '"paths":["batch[1].md"]'
out="$(git-locks show --job batch 2>&1)"
contains "batch: the stored record keeps the literal path" "${out}" '"paths":["batch[1].md"]'
git-locks check batch1.md >/dev/null 2>&1
check "batch: the matching neighbour stays free" "$?" "0"

# A later filesystem match must not change how a read resolves an existing key.
new_case
git-locks claim --job late --holder alice 'late?.md' >/dev/null 2>&1
touch late1.md
out="$(git-locks check 'late?.md' 2>&1)"
check "read after filesystem change: the literal key remains held" "$?" "1"
contains "read after filesystem change: check reports the literal key" "${out}" '"path":"late?.md"'
git-locks check late1.md >/dev/null 2>&1
check "read after filesystem change: the new matching path is free" "$?" "0"

# Existing lexical rules remain in force around literal glob bytes.
new_case
out="$(git-locks claim --job lexical --holder alice './dir//./file[1].md' 2>&1)"
check "lexical rules: claim succeeds" "$?" "0"
contains "lexical rules: dot and empty components are removed" "${out}" '"paths":["dir/file[1].md"]'
git-locks check 'dir/file[1].md' >/dev/null 2>&1
check "lexical rules: the normalized literal path is held" "$?" "1"
out="$(git-locks claim --job lexical-prefix --holder alice './prefix//./' 2>&1)"
check "lexical rules: normalized prefix claim succeeds" "$?" "0"
contains "lexical rules: a trailing slash remains" "${out}" '"paths":["prefix/"]'
git-locks claim --job absolute --holder alice '/absolute?.md' >/dev/null 2>&1
check "lexical rules: absolute paths remain refused" "$?" "2"
git-locks claim --job parent --holder alice 'a/../b?.md' >/dev/null 2>&1
check "lexical rules: parent components remain refused" "$?" "2"

# Deterministic property stress: each generated glob-shaped key has one matching
# neighbour on disk. Reserving the key must never reserve its neighbour.
new_case
FUZZ_SEED=320032
FUZZ_CASES=64
fuzz_state=${FUZZ_SEED}
for ((i = 0; i < FUZZ_CASES; i++)); do
fuzz_state=$(((1103515245 * fuzz_state + 12345) & 0x7fffffff))
digit=$((fuzz_state % 10))
case $((i % 3)) in
0)
literal="fuzz-${i}[${digit}].lock"
neighbour="fuzz-${i}${digit}.lock"
;;
1)
literal="fuzz-${i}-*.lock"
neighbour="fuzz-${i}-${digit}.lock"
;;
*)
literal="fuzz-${i}-?.lock"
neighbour="fuzz-${i}-${digit}.lock"
;;
esac
touch "${neighbour}"
out="$(git-locks claim --job "fuzz-${i}" --holder stress "${literal}" 2>&1)"
check "fuzz ${i}: claim succeeds" "$?" "0"
contains "fuzz ${i}: claim preserves path identity" "${out}" "\"paths\":[\"${literal}\"]"
git-locks check "${neighbour}" >/dev/null 2>&1
check "fuzz ${i}: matching neighbour stays free" "$?" "0"
done
out="$(git-locks doctor 2>&1)"
check "fuzz seed ${FUZZ_SEED}: ${FUZZ_CASES} claims leave a healthy store" "$?" "0"
contains "fuzz seed ${FUZZ_SEED}: doctor reports no invariant findings" "${out}" '"healthy":true'

printf '\n%d passed, %d failed\n' "${PASS}" "${FAIL}"
if ((FAIL > 0)); then
printf 'failed:'
printf ' %s;' "${FAILED[@]}"
printf '\n'
exit 1
fi
Loading