forked from omnigent-ai/omnigent
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_git_worktree.py
More file actions
398 lines (325 loc) · 15.3 KB
/
Copy pathtest_git_worktree.py
File metadata and controls
398 lines (325 loc) · 15.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
"""Tests for host-side git worktree operations.
Exercises ``omnigent.host.git_worktree`` against real ``git`` in a
temp repository — the operations run actual ``git worktree add`` /
``remove`` / ``branch -D`` so a regression in argv construction, repo-
root resolution, or removal ordering fails loud here.
"""
from __future__ import annotations
import subprocess
from collections.abc import Iterator
from pathlib import Path
import pytest
from omnigent.host.git_worktree import (
CreatedWorktree,
WorktreeError,
create_worktree,
list_worktrees,
remove_worktree,
validate_branch_name,
)
# Deterministic identity + config so the tests don't depend on the
# developer's global git config (user.name / init.defaultBranch).
_GIT_ENV = {
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@t",
}
def _git(repo: Path, *args: str) -> None:
"""Run a git command in ``repo``, raising on failure.
:param repo: Repository directory to run in.
:param args: Git arguments after ``git``, e.g. ``("add", ".")``.
"""
import os
subprocess.run(
["git", *args],
cwd=repo,
env={**os.environ, **_GIT_ENV},
check=True,
capture_output=True,
)
def _current_branch(path: Path) -> str:
"""Return the checked-out branch name at ``path``.
:param path: A work tree (main or linked worktree) directory.
:returns: Branch name, e.g. ``"feature/login"``.
"""
import os
return subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=path,
env={**os.environ, **_GIT_ENV},
capture_output=True,
text=True,
).stdout.strip()
def _rev_parse(path: Path, ref: str = "HEAD") -> str:
"""Return the commit sha that ``ref`` resolves to at ``path``.
:param path: A work tree directory.
:param ref: Ref to resolve, e.g. ``"HEAD"`` or ``"develop"``.
:returns: The 40-char commit sha.
"""
import os
return subprocess.run(
["git", "rev-parse", ref],
cwd=path,
env={**os.environ, **_GIT_ENV},
capture_output=True,
text=True,
).stdout.strip()
def _branch_exists(repo: Path, branch: str) -> bool:
"""Return whether ``branch`` exists in ``repo``.
:param repo: Repository directory.
:param branch: Branch name to check, e.g. ``"feature/login"``.
:returns: ``True`` if the local branch exists.
"""
import os
out = subprocess.run(
["git", "branch", "--list", branch],
cwd=repo,
env={**os.environ, **_GIT_ENV},
capture_output=True,
text=True,
).stdout.strip()
return out != ""
def _worktree_count(repo: Path) -> int:
"""Return how many worktrees are registered for ``repo``.
:param repo: Repository directory.
:returns: Worktree count, where ``1`` means only the main work
tree exists (no linked worktree was added).
"""
import os
out = subprocess.run(
["git", "worktree", "list", "--porcelain"],
cwd=repo,
env={**os.environ, **_GIT_ENV},
capture_output=True,
text=True,
).stdout
# --porcelain emits one "worktree <path>" line per worktree.
return out.count("worktree ")
@pytest.fixture()
def git_repo(tmp_path: Path) -> Iterator[Path]:
"""Create a one-commit git repo and yield its resolved root.
:returns: Iterator yielding the repo root path (realpath, so it
matches what ``git rev-parse --show-toplevel`` returns).
"""
# Resolve so comparisons match git's realpath output (macOS
# /tmp -> /private/tmp).
repo = (tmp_path / "myrepo").resolve()
repo.mkdir()
_git(repo, "init", "-q", "-b", "main")
(repo / "README.md").write_text("hi")
_git(repo, "add", ".")
_git(repo, "commit", "-q", "-m", "init")
yield repo
def test_create_worktree_places_sibling_of_repo_root(git_repo: Path) -> None:
"""A new worktree lands at ``<repo>-worktrees/<branch>`` with the branch checked out."""
created = create_worktree(repo_path=str(git_repo), branch_name="feature/login")
expected = git_repo.parent / "myrepo-worktrees" / "feature-login"
# Path proves the sibling layout + slash->dash dir sanitization;
# a regression in _resolve_worktree_path would change this.
assert created.worktree_path == str(expected)
assert Path(created.worktree_path).is_dir()
# The branch is actually checked out in the worktree (not just the dir made).
assert _current_branch(Path(created.worktree_path)) == "feature/login"
assert isinstance(created, CreatedWorktree)
def test_create_worktree_resolves_repo_root_from_subdir(git_repo: Path) -> None:
"""Picking a subdir still anchors the worktree at the repo root's sibling."""
sub = git_repo / "src"
sub.mkdir()
created = create_worktree(repo_path=str(sub), branch_name="wip")
# Sibling of the repo ROOT, not of the picked subdir — proves
# rev-parse --show-toplevel is used rather than the raw repo_path.
assert created.worktree_path == str(git_repo.parent / "myrepo-worktrees" / "wip")
def test_create_worktree_from_linked_worktree_anchors_at_main_repo(git_repo: Path) -> None:
"""Creating a worktree while inside a LINKED worktree anchors at the MAIN repo.
Resolving the repo root naively (``rev-parse --show-toplevel``) from a
linked worktree would nest the new worktree under it
(``…/feature-a-worktrees/feature-b``). ``_main_work_tree`` resolves to
the main checkout so worktrees stay siblings
(``…/myrepo-worktrees/feature-b``) — the fork-resume picker prefills a
worktree as the source session's workspace, so this is the common path.
"""
# First worktree, created off the main repo.
first = create_worktree(repo_path=str(git_repo), branch_name="feature/a")
first_path = Path(first.worktree_path)
assert first_path == git_repo.parent / "myrepo-worktrees" / "feature-a"
# Second worktree, requested from INSIDE the first (linked) worktree.
second = create_worktree(repo_path=str(first_path), branch_name="feature/b")
# Sibling of the MAIN repo, NOT nested under the first worktree. A
# regression to --show-toplevel would put it under
# ``feature-a-worktrees/`` and this fails.
assert second.worktree_path == str(git_repo.parent / "myrepo-worktrees" / "feature-b")
assert "feature-a-worktrees" not in second.worktree_path
assert Path(second.worktree_path).is_dir()
assert _current_branch(Path(second.worktree_path)) == "feature/b"
def test_create_worktree_from_base_branch(git_repo: Path) -> None:
"""A worktree branches from the explicit base ref's tip, not HEAD."""
# Advance develop with its own commit so it differs from main —
# otherwise the test would pass even if base_branch were ignored
# (both would resolve to the same single commit).
_git(git_repo, "checkout", "-q", "-b", "develop")
(git_repo / "dev.txt").write_text("dev-only")
_git(git_repo, "add", ".")
_git(git_repo, "commit", "-q", "-m", "dev commit")
_git(git_repo, "checkout", "-q", "main")
created = create_worktree(
repo_path=str(git_repo), branch_name="from-develop", base_branch="develop"
)
assert _current_branch(Path(created.worktree_path)) == "from-develop"
# Points at develop's tip, not main's — proves base_branch routed
# the new branch to develop rather than falling back to HEAD.
assert _rev_parse(Path(created.worktree_path)) == _rev_parse(git_repo, "develop")
assert _rev_parse(Path(created.worktree_path)) != _rev_parse(git_repo, "main")
def test_create_worktree_unknown_base_branch_fails(git_repo: Path) -> None:
"""An unresolvable base ref fails loud (after the best-effort fetch)."""
with pytest.raises(WorktreeError) as exc:
create_worktree(repo_path=str(git_repo), branch_name="x", base_branch="nope-not-a-branch")
# Proves _ensure_base_resolvable rejects rather than silently
# branching from HEAD when the requested base is missing.
assert "base branch does not exist" in exc.value.message
@pytest.mark.parametrize("option_like", ["-f", "--exec-path"])
def test_create_worktree_option_like_base_branch_not_executed(
git_repo: Path, option_like: str
) -> None:
"""A base_branch that looks like a git flag is rejected, never executed.
``base_branch`` is user-supplied and reaches ``git rev-parse`` and
``git worktree add`` argv. An option-like value (e.g. ``"-f"``, which
is ``git worktree add``'s ``--force``) must be treated as an
unresolvable rev, not parsed as a flag. This guards the end-to-end
security property at the public API: the ref-resolution pre-check and
the ``--end-of-options`` argv terminators together keep such a value
from creating a worktree. A regression that let ``"-f"`` through as a
flag would build a worktree from the wrong base (and force-create it)
instead of failing — so the assertion below would see a linked
worktree appear.
"""
with pytest.raises(WorktreeError):
create_worktree(repo_path=str(git_repo), branch_name="from-flag", base_branch=option_like)
# Still only the main work tree — no linked worktree was added, proving
# git treated the value as a (rejected) rev rather than a flag that
# would have run `worktree add`. If `-f` were parsed as --force, the
# count would be 2.
assert _worktree_count(git_repo) == 1
def test_create_worktree_duplicate_branch_fails(git_repo: Path) -> None:
"""Creating two worktrees for the same branch name fails loud with the friendly error."""
create_worktree(repo_path=str(git_repo), branch_name="dup")
with pytest.raises(WorktreeError) as exc:
create_worktree(repo_path=str(git_repo), branch_name="dup")
# The pre-check catches the existing branch before git's raw error;
# we must NOT silently reuse the existing worktree.
assert "already exists" in exc.value.message
def test_create_worktree_existing_branch_no_worktree_fails(git_repo: Path) -> None:
"""A branch that exists WITHOUT a worktree is still rejected by the pre-check.
Proves the pre-check keys off branch existence, not directory
occupancy — creating a worktree for a plain pre-existing branch
would otherwise hit git's raw error.
"""
_git(git_repo, "branch", "preexisting")
with pytest.raises(WorktreeError) as exc:
create_worktree(repo_path=str(git_repo), branch_name="preexisting")
assert "already exists" in exc.value.message
assert "preexisting" in exc.value.message
def test_create_worktree_non_repo_fails(tmp_path: Path) -> None:
"""A directory that isn't a git repo is rejected."""
plain = tmp_path / "plain"
plain.mkdir()
with pytest.raises(WorktreeError) as exc:
create_worktree(repo_path=str(plain), branch_name="x")
assert "not a git repository" in exc.value.message
def test_remove_worktree_deletes_dir_and_branch(git_repo: Path) -> None:
"""``delete_branch=True`` removes the directory AND the branch."""
created = create_worktree(repo_path=str(git_repo), branch_name="feature/login")
remove_worktree(
worktree_path=created.worktree_path, branch="feature/login", delete_branch=True
)
# Directory gone (git worktree remove --force ran)...
assert not Path(created.worktree_path).exists()
# ...and the branch deleted (git branch -D ran, after the worktree
# was removed — git would refuse otherwise).
assert not _branch_exists(git_repo, "feature/login")
def test_remove_worktree_keeps_branch_when_flag_false(git_repo: Path) -> None:
"""``delete_branch=False`` removes the directory but keeps the branch."""
created = create_worktree(repo_path=str(git_repo), branch_name="feature/keep")
remove_worktree(
worktree_path=created.worktree_path, branch="feature/keep", delete_branch=False
)
assert not Path(created.worktree_path).exists()
# Branch survives — only the checkout directory was removed.
assert _branch_exists(git_repo, "feature/keep")
def test_remove_worktree_missing_path_fails(git_repo: Path) -> None:
"""Removing a non-existent worktree path fails loud."""
with pytest.raises(WorktreeError) as exc:
remove_worktree(
worktree_path=str(git_repo.parent / "myrepo-worktrees" / "ghost"),
branch=None,
delete_branch=False,
)
assert "does not exist" in exc.value.message
def test_list_worktrees_returns_main_first(git_repo: Path) -> None:
"""With no linked worktrees, only the main tree is listed."""
result = list_worktrees(repo_path=str(git_repo))
assert len(result) == 1
main = result[0]
assert main.path == str(git_repo)
assert main.branch == "main"
assert main.is_main is True
assert main.detached is False
def test_list_worktrees_includes_linked(git_repo: Path) -> None:
"""A created worktree shows up with its branch and is not flagged main."""
created = create_worktree(repo_path=str(git_repo), branch_name="feature/login")
result = list_worktrees(repo_path=str(git_repo))
# Main first, then the linked worktree.
assert result[0].is_main is True
linked = next(w for w in result if not w.is_main)
assert linked.path == created.worktree_path
assert linked.branch == "feature/login"
assert linked.detached is False
def test_list_worktrees_from_linked_resolves_same_list(git_repo: Path) -> None:
"""Listing from inside a linked worktree resolves the main repo's full list."""
created = create_worktree(repo_path=str(git_repo), branch_name="feature/a")
# Query from the linked worktree — should still see BOTH worktrees.
result = list_worktrees(repo_path=created.worktree_path)
paths = {w.path for w in result}
assert str(git_repo) in paths
assert created.worktree_path in paths
def test_list_worktrees_reports_detached_head(git_repo: Path) -> None:
"""A detached-HEAD worktree lists with ``branch=None`` and ``detached=True``."""
head = _rev_parse(git_repo)
wt = git_repo.parent / "myrepo-worktrees" / "detached"
wt.parent.mkdir(parents=True, exist_ok=True)
# Add a worktree checked out at a bare commit → detached HEAD.
_git(git_repo, "worktree", "add", "--detach", str(wt), head)
result = list_worktrees(repo_path=str(git_repo))
detached = next(w for w in result if w.path == str(wt))
assert detached.branch is None
assert detached.detached is True
def test_list_worktrees_non_git_path_fails(tmp_path: Path) -> None:
"""A non-git directory fails loud (the route maps this to 'no worktrees')."""
plain = (tmp_path / "plain").resolve()
plain.mkdir()
with pytest.raises(WorktreeError):
list_worktrees(repo_path=str(plain))
@pytest.mark.parametrize(
"bad",
[
"",
"-leading",
"a..b",
"a/.hidden",
"x.lock",
"x.lock/y",
"a b",
"a~b",
"a:b",
"/lead",
"trail/",
],
)
def test_validate_branch_name_rejects_bad(bad: str) -> None:
"""Branch names violating git ref-format are rejected before reaching argv."""
with pytest.raises(WorktreeError):
validate_branch_name(bad)
@pytest.mark.parametrize("good", ["feature/login", "fix-123", "a/b/c", "release_2", "v1.2"])
def test_validate_branch_name_accepts_good(good: str) -> None:
"""Well-formed branch names pass validation."""
validate_branch_name(good) # must not raise