Skip to content

Commit 2ec4660

Browse files
committed
Don't spam worktrees! Disk Space is Precious
1 parent c60b51f commit 2ec4660

4 files changed

Lines changed: 167 additions & 36 deletions

File tree

‎cecli/helpers/agents/service.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -826,6 +826,7 @@ async def _create_sub_agent_coder(
826826
source=metadata.get("worktree_source") or parent_root,
827827
base_ref=base_ref,
828828
base_root=parent_root,
829+
reuse_existing=True,
829830
)
830831
except Exception as e:
831832
raise RuntimeError(f"Failed to create worktree for sub-agent '{name}': {e}") from e

‎cecli/helpers/worktrees/manager.py‎

Lines changed: 109 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -119,38 +119,53 @@ def create(
119119
source: str | Path,
120120
base_ref: str | None = None,
121121
base_root: str | Path | None = None,
122+
reuse_existing: bool = True,
122123
) -> WorktreeRecord:
123124
"""Create a new worktree for *agent_name* and return its record.
124125
125126
*source* may be a local repository path or a remote URL. Remotes are
126127
cloned into a shared bare cache and fetched on reuse. The default base
127128
branch is resolved from ``origin/HEAD``, ``main``, ``master``, or the
128129
current ``HEAD`` in that order.
130+
131+
By default an already-recorded checkout for the same agent and
132+
repository is adopted (its owner is refreshed) instead of creating a
133+
duplicate; workflows never run the same pair concurrently. Pass
134+
``reuse_existing=False`` only when deliberately creating a second
135+
independent checkout for the same agent.
129136
"""
130137
base = cls._base()
131138
repo = cls._prepare_source(source, base_root=base_root)
132-
base_ref_name, base_sha = cls._resolve_base(repo, base_ref)
133-
branch = paths.branch_name(agent_name, uuid)
134-
path = paths.worktree_path(base, repo, agent_name, uuid)
135-
path.parent.mkdir(parents=True, exist_ok=True)
136139

140+
# Reuse and creation are serialized per repository so concurrent runs of
141+
# the same workflow cannot each create their own checkout.
137142
with cls._repo_lock(base, repo):
143+
if reuse_existing:
144+
existing = cls._find_reusable(repo, agent_name)
145+
146+
if existing is not None:
147+
return cls._adopt(existing)
148+
149+
base_ref_name, base_sha = cls._resolve_base(repo, base_ref)
150+
branch = paths.branch_name(agent_name, uuid)
151+
path = paths.worktree_path(base, repo, agent_name, uuid)
152+
path.parent.mkdir(parents=True, exist_ok=True)
138153
branch, path = cls._add_worktree(repo, branch, path, base_ref_name, base_sha)
139154

140-
record = WorktreeRecord(
141-
source=str(source),
142-
repo=str(Path(repo).resolve()),
143-
path=str(path.resolve()),
144-
branch=branch,
145-
base_ref=base_ref_name,
146-
base_sha=base_sha,
147-
agent=str(agent_name),
148-
uuid=str(uuid),
149-
pid=os.getpid(),
150-
host=socket.gethostname(),
151-
created_at=_now(),
152-
)
153-
cls._save_record(record)
155+
record = WorktreeRecord(
156+
source=str(source),
157+
repo=str(Path(repo).resolve()),
158+
path=str(path.resolve()),
159+
branch=branch,
160+
base_ref=base_ref_name,
161+
base_sha=base_sha,
162+
agent=str(agent_name),
163+
uuid=str(uuid),
164+
pid=os.getpid(),
165+
host=socket.gethostname(),
166+
created_at=_now(),
167+
)
168+
cls._save_record(record)
154169

155170
return record
156171

@@ -195,6 +210,16 @@ def recreate(
195210
target_path.parent.mkdir(parents=True, exist_ok=True)
196211

197212
with cls._repo_lock(base, repo):
213+
reusable = cls._find_reusable(repo, new_agent) if path is None else None
214+
215+
if reusable is not None:
216+
adopted = cls._adopt(reusable)
217+
218+
if original.uuid != adopted.uuid:
219+
cls._discard_record(original.uuid)
220+
221+
return adopted
222+
198223
if branch_exists(branch, repo):
199224
try:
200225
run_git("worktree", "add", str(target_path), branch, cwd=repo)
@@ -493,36 +518,47 @@ def _add_worktree(
493518
base_sha: str,
494519
) -> tuple[str, Path]:
495520
start = base_ref if rev_parse(base_ref, cwd=repo, check=False) else base_sha
521+
522+
if path.exists():
523+
existing = cls._existing_branch(repo, path, branch)
524+
525+
if existing is not None:
526+
return existing, path
527+
528+
raise WorktreeError(f"worktree path already exists: {path}")
529+
496530
last_error: WorktreeError | None = None
497531

498532
for attempt in range(cls.MAX_NAME_ATTEMPTS):
499533
candidate_branch = branch if attempt == 0 else f"{branch}-{attempt + 1}"
500-
candidate_path = path if attempt == 0 else Path(f"{path}-{attempt + 1}")
501-
502-
if candidate_path.exists():
503-
last_error = WorktreeError(f"worktree path already exists: {candidate_path}")
504-
continue
505-
506-
candidate_path.parent.mkdir(parents=True, exist_ok=True)
507534

508535
try:
509536
if branch_exists(candidate_branch, repo):
510-
run_git("worktree", "add", str(candidate_path), candidate_branch, cwd=repo)
537+
run_git("worktree", "add", str(path), candidate_branch, cwd=repo)
511538
else:
512539
run_git(
513540
"worktree",
514541
"add",
515542
"-b",
516543
candidate_branch,
517-
str(candidate_path),
544+
str(path),
518545
start,
519546
cwd=repo,
520547
)
521548
except WorktreeError as exc:
522549
last_error = exc
550+
551+
if path.exists():
552+
existing = cls._existing_branch(repo, path, candidate_branch)
553+
554+
if existing is not None:
555+
return existing, path
556+
557+
break
558+
523559
continue
524560

525-
return candidate_branch, candidate_path
561+
return candidate_branch, path
526562

527563
raise WorktreeError(
528564
f"could not create worktree for branch {branch!r} in {repo}: {last_error}"
@@ -754,6 +790,51 @@ def _repo_lock(cls, base: Path, repo: str | Path) -> Iterator[None]:
754790
with _file_lock(paths.lock_path(base, f"repo-{digest}")):
755791
yield
756792

793+
@classmethod
794+
def _find_reusable(cls, repo: str | Path, agent_name: str) -> WorktreeRecord | None:
795+
"""Return a recorded checkout for *agent_name* in *repo*, if usable."""
796+
resolved_repo = str(Path(repo).resolve())
797+
798+
for record in sorted(cls._records(), key=lambda item: item.created_at, reverse=True):
799+
if record.agent != agent_name:
800+
continue
801+
802+
if str(Path(record.repo).resolve()) != resolved_repo:
803+
continue
804+
805+
if cls._is_worktree(Path(record.path), Path(repo)):
806+
return record
807+
808+
return None
809+
810+
@classmethod
811+
def _adopt(cls, record: WorktreeRecord) -> WorktreeRecord:
812+
"""Refresh *record* ownership and persist it."""
813+
adopted = WorktreeRecord(
814+
source=record.source,
815+
repo=record.repo,
816+
path=record.path,
817+
branch=record.branch,
818+
base_ref=record.base_ref,
819+
base_sha=record.base_sha,
820+
agent=record.agent,
821+
uuid=record.uuid,
822+
pid=os.getpid(),
823+
host=socket.gethostname(),
824+
created_at=record.created_at,
825+
)
826+
cls._save_record(adopted)
827+
828+
return adopted
829+
830+
@classmethod
831+
def _existing_branch(cls, repo: str | Path, path: Path, fallback: str) -> str | None:
832+
"""Return the branch checked out at an existing managed worktree."""
833+
if not cls._is_worktree(path, Path(repo)):
834+
return None
835+
836+
return current_branch(path) or fallback
837+
757838

758839
@contextmanager
759840
def _file_lock(path: Path) -> Iterator[None]:

‎cecli/website/docs/config/worktrees.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Every worktree gets a generated branch named `cecli/<agent>-<uuid>`. Agent names
1717

1818
## Lifecycle
1919

20-
1. **Create** — a worktree is created from the source repository on the generated branch. Remote sources are cached as bare repositories first.
20+
1. **Create** — a worktree is created from the source repository on the generated branch. An existing checkout for the same agent and repository is reused (ownership is refreshed) instead of creating a duplicate, so repeated or concurrent workflow runs share one checkout. Remote sources are cached as bare repositories first.
2121
2. **Run** — the agent works inside the worktree; its `root` points at the checkout.
2222
3. **Release** — on cleanup, a dirty worktree is committed with the subject `Unfinished Dirty Worktree from agent <uuid>` so uncommitted work survives. Branches with unmerged commits are kept; empty or already-merged branches are deleted.
2323
4. **Remove** — the worktree checkout under `~/.cecli/worktrees/` is removed after the commit/release step. Kept branches remain in the source repository.
@@ -34,7 +34,7 @@ A workflow or delegated agent restored from a saved session keeps its worktree r
3434
~/.cecli/worktrees/
3535
_sources/<repo-slug>-<sha8>/ # cached bare clones for remote sources
3636
<repo-slug>/
37-
<agent-slug>-<uuid>/ # one linked worktree checkout per run
37+
<agent-slug>-<uuid>/ # one linked worktree checkout per agent
3838
```
3939

4040
Local `repo:` paths are used in place as the source repository; the checkout itself still lives under the worktrees base directory. Removing a checkout or the whole base directory is safe once a run has finished: commits are on the kept branch, and empty branches are disposable.

‎tests/helpers/worktrees/test_manager.py‎

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import shutil
88
import subprocess
99
import sys
10+
from concurrent.futures import ThreadPoolExecutor
1011
from dataclasses import replace
1112
from pathlib import Path
1213

@@ -125,15 +126,50 @@ def test_branch_and_path_naming_are_sanitized(tmp_path):
125126
def test_two_creates_produce_distinct_paths_and_branches(tmp_path):
126127
repo = _init_repo(tmp_path / "repo")
127128

128-
first = _create(repo, uuid="aaaa1111")
129-
second = _create(repo, uuid="bbbb2222")
129+
first = _create(repo, uuid="aaaa1111", reuse_existing=False)
130+
second = _create(repo, uuid="bbbb2222", reuse_existing=False)
130131

131132
assert first.path != second.path
132133
assert first.branch != second.branch
133134
assert Path(first.path).is_dir()
134135
assert Path(second.path).is_dir()
135136

136137

138+
def test_create_reuses_existing_worktree_for_same_agent(tmp_path):
139+
repo = _init_repo(tmp_path / "repo")
140+
141+
first = _create(repo, uuid="aaaa1111")
142+
second = _create(repo, uuid="bbbb2222")
143+
144+
assert first.uuid == second.uuid
145+
assert first.path == second.path
146+
assert first.branch == second.branch
147+
assert len(list((tmp_path / "worktrees" / "repo").iterdir())) == 1
148+
149+
150+
def test_create_same_uuid_does_not_create_suffixed_copies(tmp_path):
151+
repo = _init_repo(tmp_path / "repo")
152+
153+
first = _create(repo, uuid="abcd1234", reuse_existing=False)
154+
second = _create(repo, uuid="abcd1234", reuse_existing=False)
155+
156+
assert first.path == second.path
157+
names = sorted(item.name for item in (tmp_path / "worktrees" / "repo").iterdir())
158+
assert names == ["wf-nightly-tidy-abcd1234"]
159+
160+
161+
def test_concurrent_creates_reuse_a_single_worktree(tmp_path):
162+
repo = _init_repo(tmp_path / "repo")
163+
uuids = [f"aaaa{index:04d}" for index in range(8)]
164+
165+
with ThreadPoolExecutor(max_workers=len(uuids)) as pool:
166+
records = list(pool.map(lambda value: _create(repo, uuid=value), uuids))
167+
168+
assert {record.path for record in records} == {records[0].path}
169+
assert {record.uuid for record in records} == {records[0].uuid}
170+
assert len(list((tmp_path / "worktrees" / "repo").iterdir())) == 1
171+
172+
137173
def test_clean_release_deletes_branch(tmp_path):
138174
repo = _init_repo(tmp_path / "repo")
139175
record = _create(repo)
@@ -314,6 +350,19 @@ def test_recreate_with_new_uuid_attaches_saved_branch(tmp_path):
314350
assert WorktreeManager.get("new99999") == recreated
315351

316352

353+
def test_recreate_reuses_another_checkout_for_same_agent(tmp_path):
354+
repo = _init_repo(tmp_path / "repo")
355+
missing = _create(repo, uuid="aaaa1111", reuse_existing=False)
356+
_git(repo, "worktree", "remove", "--force", missing.path)
357+
live = _create(repo, uuid="bbbb2222", reuse_existing=False)
358+
359+
recreated = WorktreeManager.recreate(missing, uuid="cccc3333")
360+
361+
assert recreated.path == live.path
362+
assert recreated.uuid == live.uuid
363+
assert WorktreeManager.get(missing.uuid) is None
364+
365+
317366
def test_release_keeps_saved_branch_when_agent_switched_branches(tmp_path):
318367
repo = _init_repo(tmp_path / "repo")
319368
record = _create(repo)
@@ -352,8 +401,8 @@ def test_get_unknown_uuid_returns_none():
352401

353402
def test_release_all_releases_matching_pid(tmp_path):
354403
repo = _init_repo(tmp_path / "repo")
355-
first = _create(repo, uuid="aaaa1111")
356-
second = _create(repo, uuid="bbbb2222")
404+
first = _create(repo, uuid="aaaa1111", reuse_existing=False)
405+
second = _create(repo, uuid="bbbb2222", reuse_existing=False)
357406

358407
results = WorktreeManager.release_all()
359408

@@ -431,8 +480,8 @@ def test_create_from_file_url_remote(tmp_path, isolated_base):
431480
repo = _init_repo(tmp_path / "repo")
432481
source = repo.as_uri()
433482

434-
first = _create(repo, uuid="aaaa1111", source=source)
435-
second = _create(repo, uuid="bbbb2222", source=source)
483+
first = _create(repo, uuid="aaaa1111", source=source, reuse_existing=False)
484+
second = _create(repo, uuid="bbbb2222", source=source, reuse_existing=False)
436485

437486
assert first.repo == second.repo
438487
assert Path(first.repo).is_dir()

0 commit comments

Comments
 (0)