diff --git a/templates/scripts/agent-file-locks.py b/templates/scripts/agent-file-locks.py index f563747..b078210 100755 --- a/templates/scripts/agent-file-locks.py +++ b/templates/scripts/agent-file-locks.py @@ -210,8 +210,13 @@ def staged_changes(repo_root: Path) -> list[tuple[str, str]]: def list_worktree_roots(repo_root: Path) -> list[Path]: - """Every worktree path for this repo (porcelain). Falls back to [repo_root] - when git cannot enumerate, so single-checkout behavior is unchanged.""" + """Every worktree path for this repo (porcelain), ALWAYS including the + caller's own repo_root. Submodules checked out inside a LINKED worktree + report their gitdir (/.git/worktrees//modules/) as the + worktree path, not the actual working tree — repo_root (resolved via + --show-toplevel) is where claims are written, so it must be in the list + or claim/validate/status never see this checkout's own lock file. Falls + back to [repo_root] when git cannot enumerate.""" try: out = run_git(['worktree', 'list', '--porcelain'], cwd=repo_root) except LockError: @@ -221,7 +226,9 @@ def list_worktree_roots(repo_root: Path) -> list[Path]: for line in out.splitlines(): if line.startswith('worktree '): roots.append(Path(line[len('worktree '):].strip()).resolve()) - return roots or [repo_root] + if repo_root not in roots: + roots.append(repo_root) + return roots def load_all_locks(repo_root: Path) -> dict[str, list[dict[str, Any]]]: diff --git a/test/agent-file-locks.test.js b/test/agent-file-locks.test.js index a9cab4d..1d61474 100644 --- a/test/agent-file-locks.test.js +++ b/test/agent-file-locks.test.js @@ -168,6 +168,36 @@ defineSpawnSuite('agent-file-locks cross-worktree (G2)', () => { assert.match(v1.stderr, /another owner/); }); + test('claims in a submodule under a LINKED worktree are visible to validate (gitdir-root quirk)', () => { + // A submodule checked out inside a linked worktree reports its gitdir + // (/.git/worktrees//modules/) as the worktree path in + // `git worktree list`, so load_all_locks used to miss the REAL working + // tree's lock file — validate rejected claims it had just recorded. + const subSrc = makeRepo(); + const parent = makeRepo(); + assert.equal( + runHumanCmd('git', ['-c', 'protocol.file.allow=always', 'submodule', 'add', subSrc, 'sub'], parent).status, + 0, + 'submodule add must succeed', + ); + assert.equal(runHumanCmd('git', ['commit', '-m', 'add submodule'], parent).status, 0); + + const wtSub = path.join(parent, '..', 'wt-sub'); + assert.equal(runHumanCmd('git', ['worktree', 'add', '-q', '-b', 'agent/sub/lane', wtSub], parent).status, 0); + assert.equal( + runHumanCmd('git', ['-c', 'protocol.file.allow=always', 'submodule', 'update', '--init'], wtSub).status, + 0, + 'submodule init inside the linked worktree must succeed', + ); + + const subWt = path.join(wtSub, 'sub'); + writeFile(subWt, 'sub-file.txt'); + assert.equal(locksAt(subWt, ['claim', '--branch', 'agent/sub/lane', 'sub-file.txt']).status, 0); + + const v = locksAt(subWt, ['validate', '--branch', 'agent/sub/lane', 'sub-file.txt']); + assert.equal(v.status, 0, `validate must see the claim it just wrote: ${v.stderr}`); + }); + test('a lane can still claim + commit a file no other worktree owns', () => { const repoDir = makeRepo(); writeFile(repoDir, 'mine.txt');