Description
In a colocated repo, jj status can undo staging from a concurrent git add, even when both commands succeed. Working-tree contents remain intact.
jj_lib::git::update_intent_to_add reads .git/index before taking index.lock. If Git commits an updated index between that read and jj's write, jj overwrites it with its older copy.
This differs from #7530's immediate lock failure. Open PR #10160 proposes waiting for the lock, but still reads the index before acquiring it, so the lost-update race remains. This ordering is also present on main at df92b83 (checked on 2026-10-15)
Background: We found this in JayJay, built upon jj-lib. Our retry-only workaround for lock failures passed focused tests, but a regression checking preservation of another writer's staged entry failed 3/200 times under CPU load: the entry became empty intent-to-add. Retrying reloads the index, but still leaves a gap between reading it and taking the lock.
Possible fix: Compute the tree diff first, acquire index.lock with bounded backoff, then read, update, and write the index through that same lock. Invalidate the cache-tree when changing entries, as jj-lib already does. This is JayJay's workaround. We kept retries for reset_head, which intentionally rebuilds the index from the working-copy commit rather than preserving its staged entries.
Steps to Reproduce the Problem
- Save the script below as
repro.sh. It races a snapshot of a new file with staging another file, and counts lost updates only when both commands exit successfully. - Set
MAX_DELAY_MSto roughly the duration ofjj statusin the test repo (default 60;jj statustook about 50 ms here). - Run
bash repro.sh.
#!/usr/bin/env bash
set -u
rounds=${ROUNDS:-300}
max_delay_ms=${MAX_DELAY_MS:-60}
cd "$(mktemp -d)"
jj git init --colocate repo >/dev/null 2>&1
cd repo
echo base > base.txt
jj commit -m base >/dev/null 2>&1
lost=0 jj_failed=0 git_failed=0
for i in $(seq "$rounds"); do
echo "v$i" > staged.txt
echo "$i" > "new-$i.txt" # gives the snapshot an intent-to-add entry to write
jj status >/dev/null 2>&1 &
jj_pid=$!
sleep "$(printf '0.%03d' $((RANDOM % max_delay_ms)))"
if git add staged.txt 2>/dev/null; then git_status=0; else git_status=$?; git_failed=$((git_failed + 1)); fi
if wait "$jj_pid"; then jj_status=0; else jj_status=$?; jj_failed=$((jj_failed + 1)); fi
if [ "$git_status" = 0 ] && [ "$jj_status" = 0 ]; then
staged=$(git show :staged.txt 2>/dev/null)
if [ "$staged" != "v$i" ]; then
lost=$((lost + 1))
echo "round $i: staged v$i, index now has '${staged}' (jj=$jj_status git=$git_status)"
fi
fi
done
echo "rounds=$rounds lost_updates=$lost jj_failures=$jj_failed git_failures=$git_failed"Expected Behavior
Every git add that succeeds stays in the index: lost_updates=0. With jj status replaced by true, the script reports 0 lost updates in 300 rounds.
Actual Behavior
7 lost updates in 300 rounds. Both commands exited 0 in every lost round; for example, round 57 staged v57, but the index held v56. That run also had 22 jj failures and 2 Git failures (the #7530 symptom), counted separately.
Specifications
- Platform: macOS (Darwin 27.0, Apple silicon)
- Version: jj 0.46.0, Git 2.56.0
Ill reproduce and analyse this