Skip to content

Commit f580ff1

Browse files
authored
Merge pull request #118 from git-stunts/fix/missing-home
fix: reject missing default home before store initialization
2 parents ac42a88 + fc1ec26 commit f580ff1

6 files changed

Lines changed: 144 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ All notable changes to this project are recorded here. The format follows Keep a
2020

2121
### Fixed
2222

23+
- Default store selection refuses an unset or empty `HOME` with a structured `store-read` error before initialization. Explicit stores and nonempty `GIT_LOCKS_HOME` overrides still work without `HOME` (related to #68).
24+
2325
- `doctor` reports an invalid job record without deriving stray-path findings from its unreadable contents. Valid stray and orphan path diagnostics remain available in the same snapshot (related to #75).
2426

2527
- Reject empty or multiline `--acquisition` values in path and semaphore releases. An empty guard previously released the current reservation unconditionally; invalid guards now return exit 2 before changing any reservation (related to #74).

‎Makefile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ test-container:
3434
python3 test/store-hooks.py
3535
python3 test/store-environment.py
3636
python3 test/store-selection.py
37+
python3 test/store-home.py
3738
python3 test/store-integrity.py
3839
python3 test/doctor-findings.py
3940
python3 test/renewal.py

‎bin/git-locks‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -594,7 +594,12 @@ resolve_store() { # sets STORE; creates the default or a custom store on first u
594594
fi
595595
case "${sel}" in
596596
'')
597-
home_root="${GIT_LOCKS_HOME:-${HOME}/.git-stunts}"
597+
if [[ -n "${GIT_LOCKS_HOME:-}" ]]; then
598+
home_root="${GIT_LOCKS_HOME}"
599+
else
600+
[[ -n "${HOME:-}" ]] || store_error 'default store requires HOME or GIT_LOCKS_HOME; alternatively select a store with GIT_LOCKS_STORE or locks.store'
601+
home_root="${HOME}/.git-stunts"
602+
fi
598603
[[ "${home_root}" == /* ]] || home_root="${key}/${home_root}"
599604
STORE="${home_root}/locks${key}"
600605
;;

‎docs/store-initialization.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ their location; `self` selects the common Git directory. Outside a repository,
1010
the physical current directory is the anchor. Paths containing newlines are
1111
refused instead of being silently shortened by shell command substitution.
1212

13+
Default selection uses a nonempty `GIT_LOCKS_HOME`, or `HOME/.git-stunts` when that override is absent or empty. If both variables are absent or empty, git-locks exits 2 with a structured `store-read` error before initialization. An explicit `GIT_LOCKS_STORE` or configured `locks.store` does not require `HOME`.
14+
1315
For example, `locks.store=.reservations/store.git` in a normal repository at
1416
`/projects/app` selects `/projects/app/.reservations/store.git` from the root,
1517
any subdirectory, and its linked worktrees. This changes earlier behavior that

‎lib/040-the-store.sh‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,12 @@ resolve_store() { # sets STORE; creates the default or a custom store on first u
7979
fi
8080
case "${sel}" in
8181
'')
82-
home_root="${GIT_LOCKS_HOME:-${HOME}/.git-stunts}"
82+
if [[ -n "${GIT_LOCKS_HOME:-}" ]]; then
83+
home_root="${GIT_LOCKS_HOME}"
84+
else
85+
[[ -n "${HOME:-}" ]] || store_error 'default store requires HOME or GIT_LOCKS_HOME; alternatively select a store with GIT_LOCKS_STORE or locks.store'
86+
home_root="${HOME}/.git-stunts"
87+
fi
8388
[[ "${home_root}" == /* ]] || home_root="${key}/${home_root}"
8489
STORE="${home_root}/locks${key}"
8590
;;

‎test/store-home.py‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
#!/usr/bin/env python3
2+
"""Missing HOME must not crash or select a root-level default authority."""
3+
from pathlib import Path
4+
import subprocess
5+
6+
ROOT = Path(__file__).resolve().parents[1]
7+
subprocess.run(['node', str(ROOT / 'scripts/require-docker.mjs')], check=True)
8+
9+
import hashlib
10+
import json
11+
import os
12+
import tempfile
13+
14+
import jsonschema
15+
16+
CLI = str(ROOT / 'bin/git-locks')
17+
BASE = {k: v for k, v in os.environ.items() if not k.startswith('GIT_')}
18+
BASE.update(GIT_CONFIG_NOSYSTEM='1', GIT_CONFIG_GLOBAL='/dev/null')
19+
VALIDATOR = jsonschema.Draft202012Validator(json.loads((ROOT / 'schema/git-locks.schema.json').read_text()))
20+
21+
22+
def git(cwd, *args):
23+
return subprocess.check_output(['git', '-C', str(cwd), *args], env=BASE,
24+
stderr=subprocess.PIPE, text=True, timeout=10).strip()
25+
26+
27+
def inventory(base):
28+
return {str(p.relative_to(base)): hashlib.sha256(p.read_bytes()).hexdigest()
29+
for p in base.rglob('*') if p.is_file()}
30+
31+
32+
def run(cwd, env, *args):
33+
return subprocess.run([CLI, *args], cwd=cwd, env=env, capture_output=True, text=True, timeout=10)
34+
35+
36+
def exercise(base, layout, home, selector):
37+
subject = base / 'subject'
38+
subject.mkdir()
39+
cwd = subject
40+
if layout != 'outside':
41+
git(subject, 'init', '-q', '-b', 'main')
42+
if layout == 'linked':
43+
git(subject, '-c', 'user.name=test', '-c', 'user.email=test@example.invalid',
44+
'commit', '--allow-empty', '-qm', 'fixture')
45+
cwd = base / 'linked'
46+
git(subject, 'worktree', 'add', '-q', '-b', 'linked', str(cwd))
47+
# Enter a nested directory to also test the linked-worktree shared anchor.
48+
cwd = cwd / 'nested'
49+
cwd.mkdir()
50+
anchor = cwd if layout == 'outside' else subject
51+
env = dict(BASE)
52+
if home == 'unset':
53+
env.pop('HOME', None)
54+
elif home == 'empty':
55+
env['HOME'] = ''
56+
else:
57+
env['HOME'] = str(base / 'home')
58+
(base / 'home').mkdir()
59+
if selector == 'absolute':
60+
expected = base / 'explicit.git'
61+
env['GIT_LOCKS_STORE'] = str(expected)
62+
elif selector in ('relative', 'config'):
63+
expected = anchor / '.reservations/store.git'
64+
if selector == 'config':
65+
git(subject, 'config', 'locks.store', '.reservations/store.git')
66+
else:
67+
env['GIT_LOCKS_STORE'] = '.reservations/store.git'
68+
elif selector == 'self':
69+
env['GIT_LOCKS_STORE'] = 'self'
70+
expected = subject / '.git'
71+
elif selector == 'override':
72+
env['GIT_LOCKS_HOME'] = str(base / 'override')
73+
expected = base / 'override' / ('locks' + str(anchor))
74+
else:
75+
if selector == 'empty-override':
76+
env['GIT_LOCKS_HOME'] = ''
77+
expected = base / 'home/.git-stunts' / ('locks' + str(anchor))
78+
if home != 'valid' and selector in ('default', 'empty-override'):
79+
before = inventory(base)
80+
marker = base / 'command-ran'
81+
for args in (['store'], ['with', '--job', 'held', '--holder', 'alice',
82+
'x.md', '--', 'touch', str(marker)]):
83+
result = run(cwd, env, *args)
84+
assert result.returncode == 2 and not result.stdout, result
85+
row = json.loads(result.stderr)
86+
VALIDATOR.validate(row)
87+
assert row['event'] == 'error' and row['reason'] == 'store-read', row
88+
assert 'HOME' in row['detail'], row
89+
assert inventory(base) == before, 'missing home modified the fixture'
90+
assert not marker.exists(), 'missing home launched a child'
91+
return
92+
selected = run(cwd, env, 'store')
93+
assert selected.returncode == 0 and not selected.stderr, selected
94+
row = json.loads(selected.stdout)
95+
VALIDATOR.validate(row)
96+
assert row['store'] == str(expected), (row, expected)
97+
claimed = run(cwd, env, 'claim', '--job', 'held', '--holder', 'alice', '--ttl', '300', 'x.md')
98+
assert claimed.returncode == 0 and not claimed.stderr, claimed
99+
VALIDATOR.validate(json.loads(claimed.stdout))
100+
# Read the state from a second working directory with an explicit authority.
101+
second_cwd = subject if selector == 'self' else base
102+
second = run(second_cwd, dict(env, GIT_LOCKS_STORE=str(expected)), 'show', '--job', 'held')
103+
assert second.returncode == 0 and not second.stderr, second
104+
row = json.loads(second.stdout)
105+
VALIDATOR.validate(row)
106+
assert row['job'] == 'held' and row['paths'] == ['x.md'], row
107+
108+
109+
failures = []
110+
checks = 0
111+
for layout in ('outside', 'normal', 'linked'):
112+
selectors = ('default', 'empty-override', 'override', 'absolute', 'relative')
113+
if layout != 'outside':
114+
selectors += ('config', 'self')
115+
cases = [(home, selector) for home in ('unset', 'empty') for selector in selectors]
116+
cases += [('valid', 'default')]
117+
for home, selector in cases:
118+
with tempfile.TemporaryDirectory(prefix='locks-home-') as tmp:
119+
try:
120+
exercise(Path(tmp), layout, home, selector)
121+
checks += 1
122+
print('PASS', layout, home, selector, flush=True)
123+
except Exception as error:
124+
failures.append((layout, home, selector, repr(error)))
125+
print('FAIL', *failures[-1], flush=True)
126+
print(f'home selection: {checks} passed; {len(failures)} failed')
127+
raise SystemExit(bool(failures))

0 commit comments

Comments
 (0)