luv-cli 0.3.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {luv_cli-0.3.0 → luv_cli-0.4.0}/PKG-INFO +24 -7
- {luv_cli-0.3.0 → luv_cli-0.4.0}/README.md +23 -6
- {luv_cli-0.3.0 → luv_cli-0.4.0}/docs/remote-sessions.md +15 -7
- {luv_cli-0.3.0 → luv_cli-0.4.0}/docs/sessions.md +6 -5
- {luv_cli-0.3.0 → luv_cli-0.4.0}/luv/__init__.py +100 -30
- {luv_cli-0.3.0 → luv_cli-0.4.0}/pyproject.toml +1 -1
- {luv_cli-0.3.0 → luv_cli-0.4.0}/tests/test_agents.py +156 -1
- {luv_cli-0.3.0 → luv_cli-0.4.0}/.claude/settings.json +0 -0
- {luv_cli-0.3.0 → luv_cli-0.4.0}/.failproofai/policies-config.json +0 -0
- {luv_cli-0.3.0 → luv_cli-0.4.0}/.github/workflows/publish.yml +0 -0
- {luv_cli-0.3.0 → luv_cli-0.4.0}/.gitignore +0 -0
- {luv_cli-0.3.0 → luv_cli-0.4.0}/LICENSE +0 -0
- {luv_cli-0.3.0 → luv_cli-0.4.0}/claude-output.txt +0 -0
- {luv_cli-0.3.0 → luv_cli-0.4.0}/docs/configuration.md +0 -0
- {luv_cli-0.3.0 → luv_cli-0.4.0}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: luv-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Launch Claude Code or Codex agents on GitHub repos with isolated workspaces and optional Docker dev environments
|
|
5
5
|
Project-URL: Homepage, https://github.com/exospherehost/luv
|
|
6
6
|
Project-URL: Repository, https://github.com/exospherehost/luv
|
|
@@ -63,7 +63,7 @@ luv other-org/my-repo "fix the bug"
|
|
|
63
63
|
# Reopen workspace #42
|
|
64
64
|
luv my-repo 42
|
|
65
65
|
|
|
66
|
-
#
|
|
66
|
+
# Clone any GitHub PR by URL into a fresh workspace
|
|
67
67
|
luv -l https://github.com/org/repo/pull/123
|
|
68
68
|
|
|
69
69
|
# Open a shell instead of Claude
|
|
@@ -97,6 +97,22 @@ The machine name is what keeps two computers apart. Each one works out the next
|
|
|
97
97
|
|
|
98
98
|
With a remote host configured, steps 1–4 happen on that machine inside a tmux session instead — see [Remote sessions](#remote-sessions).
|
|
99
99
|
|
|
100
|
+
### Opening an existing PR
|
|
101
|
+
|
|
102
|
+
`luv -l <PR URL>` gives you the PR as it is *right now*: a full clone into a
|
|
103
|
+
workspace of its own, checked out on the PR's head branch. It never reuses a
|
|
104
|
+
folder that happens to be sitting there — a URL you paste is a request to look
|
|
105
|
+
at that PR, not at whatever was checked out in `myrepo-mbp-123` last week. Run
|
|
106
|
+
it twice on the same PR and you get `myrepo-mbp-123` and `myrepo-mbp-123_2`,
|
|
107
|
+
which are independent workspaces you can run side by side. When the PR comes
|
|
108
|
+
from a fork, the base repo is added as `upstream` and fetched too, so the branch
|
|
109
|
+
you need to diff or rebase against is there.
|
|
110
|
+
|
|
111
|
+
`-r` is the exception: resuming means picking up a conversation, and a
|
|
112
|
+
conversation lives in the folder it was held in, so `luv -l <URL> -r` reopens the
|
|
113
|
+
newest workspace for that PR instead of cloning another. `luv <repo> -pr <N>`
|
|
114
|
+
also keeps reusing its workspace — it names a workspace, where a URL names a PR.
|
|
115
|
+
|
|
100
116
|
## Commands
|
|
101
117
|
|
|
102
118
|
| Command | Description |
|
|
@@ -115,8 +131,8 @@ With a remote host configured, steps 1–4 happen on that machine inside a tmux
|
|
|
115
131
|
| `luv --codex [org/]<repo> [prompt...]` | Create a workspace and launch Codex in YOLO mode |
|
|
116
132
|
| `luv [org/]<repo> -b <branch> [prompt...]` | Create a workspace based off `<branch>` instead of the default |
|
|
117
133
|
| `luv [org/]<repo> <number> [prompt]` | Reopen an existing workspace |
|
|
118
|
-
| `luv -l <PR URL> [prompt]` |
|
|
119
|
-
| `luv [org/]<repo> -pr <number> [prompt]` | Open a PR by repo + number |
|
|
134
|
+
| `luv -l <PR URL> [prompt]` | Clone any GitHub PR by URL into a fresh workspace |
|
|
135
|
+
| `luv [org/]<repo> -pr <number> [prompt]` | Open a PR by repo + number, reusing its workspace |
|
|
120
136
|
| `luv --clean` | Delete workspaces where the branch is fully pushed/merged |
|
|
121
137
|
| `luv --clean -f` | Force delete all workspaces |
|
|
122
138
|
| `luv --clean --safe -f` | Force delete only workspaces older than 24h |
|
|
@@ -270,9 +286,10 @@ registry as soon as its tmux session exits, but the clone it was working in
|
|
|
270
286
|
stays on the remote disk. Those orphans are invisible to `luv ls` and are
|
|
271
287
|
usually what is actually filling up the box.
|
|
272
288
|
|
|
273
|
-
Only folders ending in `-{N}` are ever eligible — `{repo}-{machine}-{N}
|
|
274
|
-
pre-slug `{repo}-{N}` alike.
|
|
275
|
-
|
|
289
|
+
Only folders ending in `-{N}` are ever eligible — `{repo}-{machine}-{N}`, a
|
|
290
|
+
`luv -l` copy `{repo}-{machine}-{N}_2`, and pre-slug `{repo}-{N}` alike.
|
|
291
|
+
Anything else in the workspace root is left alone, and a target that doesn't
|
|
292
|
+
resolve to one is an error rather than an `rm -rf`.
|
|
276
293
|
|
|
277
294
|
## Configuration
|
|
278
295
|
|
|
@@ -40,7 +40,7 @@ luv other-org/my-repo "fix the bug"
|
|
|
40
40
|
# Reopen workspace #42
|
|
41
41
|
luv my-repo 42
|
|
42
42
|
|
|
43
|
-
#
|
|
43
|
+
# Clone any GitHub PR by URL into a fresh workspace
|
|
44
44
|
luv -l https://github.com/org/repo/pull/123
|
|
45
45
|
|
|
46
46
|
# Open a shell instead of Claude
|
|
@@ -74,6 +74,22 @@ The machine name is what keeps two computers apart. Each one works out the next
|
|
|
74
74
|
|
|
75
75
|
With a remote host configured, steps 1–4 happen on that machine inside a tmux session instead — see [Remote sessions](#remote-sessions).
|
|
76
76
|
|
|
77
|
+
### Opening an existing PR
|
|
78
|
+
|
|
79
|
+
`luv -l <PR URL>` gives you the PR as it is *right now*: a full clone into a
|
|
80
|
+
workspace of its own, checked out on the PR's head branch. It never reuses a
|
|
81
|
+
folder that happens to be sitting there — a URL you paste is a request to look
|
|
82
|
+
at that PR, not at whatever was checked out in `myrepo-mbp-123` last week. Run
|
|
83
|
+
it twice on the same PR and you get `myrepo-mbp-123` and `myrepo-mbp-123_2`,
|
|
84
|
+
which are independent workspaces you can run side by side. When the PR comes
|
|
85
|
+
from a fork, the base repo is added as `upstream` and fetched too, so the branch
|
|
86
|
+
you need to diff or rebase against is there.
|
|
87
|
+
|
|
88
|
+
`-r` is the exception: resuming means picking up a conversation, and a
|
|
89
|
+
conversation lives in the folder it was held in, so `luv -l <URL> -r` reopens the
|
|
90
|
+
newest workspace for that PR instead of cloning another. `luv <repo> -pr <N>`
|
|
91
|
+
also keeps reusing its workspace — it names a workspace, where a URL names a PR.
|
|
92
|
+
|
|
77
93
|
## Commands
|
|
78
94
|
|
|
79
95
|
| Command | Description |
|
|
@@ -92,8 +108,8 @@ With a remote host configured, steps 1–4 happen on that machine inside a tmux
|
|
|
92
108
|
| `luv --codex [org/]<repo> [prompt...]` | Create a workspace and launch Codex in YOLO mode |
|
|
93
109
|
| `luv [org/]<repo> -b <branch> [prompt...]` | Create a workspace based off `<branch>` instead of the default |
|
|
94
110
|
| `luv [org/]<repo> <number> [prompt]` | Reopen an existing workspace |
|
|
95
|
-
| `luv -l <PR URL> [prompt]` |
|
|
96
|
-
| `luv [org/]<repo> -pr <number> [prompt]` | Open a PR by repo + number |
|
|
111
|
+
| `luv -l <PR URL> [prompt]` | Clone any GitHub PR by URL into a fresh workspace |
|
|
112
|
+
| `luv [org/]<repo> -pr <number> [prompt]` | Open a PR by repo + number, reusing its workspace |
|
|
97
113
|
| `luv --clean` | Delete workspaces where the branch is fully pushed/merged |
|
|
98
114
|
| `luv --clean -f` | Force delete all workspaces |
|
|
99
115
|
| `luv --clean --safe -f` | Force delete only workspaces older than 24h |
|
|
@@ -247,9 +263,10 @@ registry as soon as its tmux session exits, but the clone it was working in
|
|
|
247
263
|
stays on the remote disk. Those orphans are invisible to `luv ls` and are
|
|
248
264
|
usually what is actually filling up the box.
|
|
249
265
|
|
|
250
|
-
Only folders ending in `-{N}` are ever eligible — `{repo}-{machine}-{N}
|
|
251
|
-
pre-slug `{repo}-{N}` alike.
|
|
252
|
-
|
|
266
|
+
Only folders ending in `-{N}` are ever eligible — `{repo}-{machine}-{N}`, a
|
|
267
|
+
`luv -l` copy `{repo}-{machine}-{N}_2`, and pre-slug `{repo}-{N}` alike.
|
|
268
|
+
Anything else in the workspace root is left alone, and a target that doesn't
|
|
269
|
+
resolve to one is an error rather than an `rm -rf`.
|
|
253
270
|
|
|
254
271
|
## Configuration
|
|
255
272
|
|
|
@@ -345,19 +345,27 @@ a laptop and a box would routinely pick the same one and push the same branch.
|
|
|
345
345
|
Folders and branches created before slugs existed keep working — luv looks for
|
|
346
346
|
its own name first, then any other machine's, then the plain `{repo}-{number}`.
|
|
347
347
|
|
|
348
|
+
`luv -l <PR URL>` clones into a folder of its own every time, so a second run on
|
|
349
|
+
the same PR gets `{repo}-{machine}-{number}_2`, a third `_3`. The suffix is part
|
|
350
|
+
of the number's segment, so those folders are still workspace `{number}`
|
|
351
|
+
everywhere it matters — `luv ls`, `luv rm`, `--clean`, handover. Where a number
|
|
352
|
+
has several copies, the newest wins: that is the one you just made.
|
|
353
|
+
|
|
348
354
|
Sessions are named after the workspace folder: `~/prs/myrepo-box-42` becomes
|
|
349
355
|
`luv-myrepo-box-42`. tmux forbids `.` and `:` in session names, so a repo like
|
|
350
356
|
`foo.js` becomes `luv-foo_js-box-42`.
|
|
351
357
|
|
|
352
|
-
When the workspace is already known — reopening by number, `-pr`, or `-l` —
|
|
353
|
-
laptop pins the session name down before dispatching, so `tmux new-session
|
|
354
|
-
doubles as "attach if it's already running". It can't compute that name,
|
|
355
|
-
since the slug belongs to the machine holding the folder: it takes it
|
|
356
|
-
session registry, or asks the host directly, and falls back to the
|
|
358
|
+
When the workspace is already known — reopening by number, `-pr`, or `-l -r` —
|
|
359
|
+
the laptop pins the session name down before dispatching, so `tmux new-session
|
|
360
|
+
-A` doubles as "attach if it's already running". It can't compute that name,
|
|
361
|
+
though, since the slug belongs to the machine holding the folder: it takes it
|
|
362
|
+
from the session registry, or asks the host directly, and falls back to the
|
|
357
363
|
`luv-pending-<id>` rename-on-arrival path if the host doesn't answer.
|
|
358
364
|
|
|
359
|
-
When it isn't known — a brand new workspace,
|
|
360
|
-
|
|
365
|
+
When it isn't known — a brand new workspace, plain `luv -l` (whose folder the
|
|
366
|
+
remote only picks once it sees what is already there), or bare `-n`/`-r` — the
|
|
367
|
+
laptop uses a placeholder `luv-pending-<id>` and the remote renames it once the
|
|
368
|
+
clone lands.
|
|
361
369
|
If the target name is already taken by another live session, luv keeps the
|
|
362
370
|
session under `luv-myrepo-box-42-2` and warns rather than failing.
|
|
363
371
|
|
|
@@ -63,7 +63,7 @@ the session (which luv does as soon as the clone lands) breaks nothing.
|
|
|
63
63
|
| `pr_number`, `pr_url` | The session's pull request; `null` until one exists |
|
|
64
64
|
| `pr_state` | `OPEN`, `CLOSED` or `MERGED` — what `luv rm --merged` selects on |
|
|
65
65
|
| `pr_checked` | Unix time GitHub was last asked about it |
|
|
66
|
-
| `pr_hint` | PR number known at dispatch (`-l` / `-pr` only); absent otherwise |
|
|
66
|
+
| `pr_hint` | PR number known at dispatch (`-l` / `-pr` only); absent otherwise. Set even when the folder isn't yet known, which is always the case for `-l` |
|
|
67
67
|
| `adopted` | Present when this machine found the session rather than starting it |
|
|
68
68
|
|
|
69
69
|
`attached`, `activity`, and `live` are recomputed on every reconcile and are
|
|
@@ -230,10 +230,11 @@ case.
|
|
|
230
230
|
|
|
231
231
|
Two things bound the damage:
|
|
232
232
|
|
|
233
|
-
- Only names ending in `-{N}` are eligible — `{repo}-{machine}-{N}`
|
|
234
|
-
`{repo}-{N}` alike — checked again
|
|
235
|
-
only at selection time. A stray
|
|
236
|
-
|
|
233
|
+
- Only names ending in `-{N}` are eligible — `{repo}-{machine}-{N}`, a `luv -l`
|
|
234
|
+
copy `{repo}-{machine}-{N}_2`, and pre-slug `{repo}-{N}` alike — checked again
|
|
235
|
+
immediately before the `rm -rf` rather than only at selection time. A stray
|
|
236
|
+
directory in the workspace root is never a candidate, and a target that
|
|
237
|
+
resolves to one is an error, not a deletion.
|
|
237
238
|
- A named target is its own confirmation, but `--merged` and `--dead` print what
|
|
238
239
|
they matched and ask, because a selector can sweep up folders on a machine you
|
|
239
240
|
are not looking at. `-f` skips the prompt.
|
|
@@ -218,9 +218,28 @@ def machine_slug() -> str:
|
|
|
218
218
|
return sanitize_slug(socket.gethostname().split(".")[0]) or "local"
|
|
219
219
|
|
|
220
220
|
|
|
221
|
-
def workspace_name(repo: str, number: int, slug: str | None = None
|
|
222
|
-
|
|
223
|
-
|
|
221
|
+
def workspace_name(repo: str, number: int, slug: str | None = None,
|
|
222
|
+
copy: int = 1) -> str:
|
|
223
|
+
"""Folder name for a workspace: '{repo}-{machine}-{number}[_{copy}]'.
|
|
224
|
+
|
|
225
|
+
The copy suffix exists for `luv -l`, which clones a PR into a folder of its
|
|
226
|
+
own every time rather than reopening the one already there. '_' is the one
|
|
227
|
+
separator that survives every name derived from this: tmux forbids '.' and
|
|
228
|
+
':' in session names, and Compose project names forbid '.' too.
|
|
229
|
+
"""
|
|
230
|
+
base = f"{repo}-{slug or machine_slug()}-{number}"
|
|
231
|
+
return base if copy <= 1 else f"{base}_{copy}"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def next_workspace_dir(repo: str, number: int) -> Path:
|
|
235
|
+
"""A workspace path for this number that nothing occupies yet.
|
|
236
|
+
|
|
237
|
+
Second and later clones of the same PR land on '..._2', '..._3', and so on.
|
|
238
|
+
"""
|
|
239
|
+
copy = 1
|
|
240
|
+
while (PRS_DIR / workspace_name(repo, number, copy=copy)).exists():
|
|
241
|
+
copy += 1
|
|
242
|
+
return PRS_DIR / workspace_name(repo, number, copy=copy)
|
|
224
243
|
|
|
225
244
|
|
|
226
245
|
def branch_name(number: int, slug: str | None = None) -> str:
|
|
@@ -233,9 +252,10 @@ def workspace_re(repo: str) -> re.Pattern:
|
|
|
233
252
|
|
|
234
253
|
The repo name is known at every call site, so it can be anchored — which is
|
|
235
254
|
what makes the optional middle group unambiguous. Group 1 is the slug (None
|
|
236
|
-
for a pre-slug folder), group 2 the number
|
|
255
|
+
for a pre-slug folder), group 2 the number, group 3 the copy index (None for
|
|
256
|
+
the first clone of that number).
|
|
237
257
|
"""
|
|
238
|
-
return re.compile(rf"^{re.escape(repo)}-(?:([a-z0-9]+)-)?(\d+)
|
|
258
|
+
return re.compile(rf"^{re.escape(repo)}-(?:([a-z0-9]+)-)?(\d+)(?:_(\d+))?$")
|
|
239
259
|
|
|
240
260
|
|
|
241
261
|
def branch_re(number: int) -> re.Pattern:
|
|
@@ -262,34 +282,44 @@ def find_workspace(repo: str, number: int) -> Path | None:
|
|
|
262
282
|
"""Locate an existing workspace folder for {repo}-{number}, or None.
|
|
263
283
|
|
|
264
284
|
Ours first, then one that arrived here by handover (it keeps the slug of the
|
|
265
|
-
machine that created it), then a pre-slug folder.
|
|
266
|
-
|
|
285
|
+
machine that created it), then a pre-slug folder. Where `luv -l` has cloned
|
|
286
|
+
the same PR more than once, the newest copy wins — that is the one you just
|
|
287
|
+
made. Two foreign *machines* is still genuinely ambiguous: the number alone
|
|
288
|
+
cannot say which was meant.
|
|
267
289
|
"""
|
|
268
|
-
mine = PRS_DIR / workspace_name(repo, number)
|
|
269
|
-
if mine.is_dir():
|
|
270
|
-
return mine
|
|
271
290
|
if not PRS_DIR.exists():
|
|
272
291
|
return None
|
|
273
292
|
|
|
274
293
|
pattern = workspace_re(repo)
|
|
275
|
-
|
|
294
|
+
mine = machine_slug()
|
|
295
|
+
ours: list[tuple[int, Path]] = []
|
|
296
|
+
foreign: dict[str, list[tuple[int, Path]]] = {}
|
|
297
|
+
legacy = None
|
|
276
298
|
for entry in sorted(PRS_DIR.iterdir()):
|
|
277
299
|
if not entry.is_dir():
|
|
278
300
|
continue
|
|
279
301
|
m = pattern.match(entry.name)
|
|
280
302
|
if not m or int(m.group(2)) != number:
|
|
281
303
|
continue
|
|
282
|
-
|
|
304
|
+
slug, copy = m.group(1), int(m.group(3) or 1)
|
|
305
|
+
if slug is None:
|
|
283
306
|
legacy = entry
|
|
307
|
+
elif slug == mine:
|
|
308
|
+
ours.append((copy, entry))
|
|
284
309
|
else:
|
|
285
|
-
foreign.append(entry)
|
|
310
|
+
foreign.setdefault(slug, []).append((copy, entry))
|
|
311
|
+
|
|
312
|
+
def newest(copies: list[tuple[int, Path]]) -> Path:
|
|
313
|
+
return max(copies, key=lambda c: c[0])[1]
|
|
286
314
|
|
|
315
|
+
if ours:
|
|
316
|
+
return newest(ours)
|
|
287
317
|
if len(foreign) > 1:
|
|
288
|
-
names = ", ".join(e.name for
|
|
318
|
+
names = ", ".join(sorted(e.name for c in foreign.values() for _, e in c))
|
|
289
319
|
die(f"ambiguous workspace for '{repo}' {number}: {names}\n"
|
|
290
320
|
f" open one by name with 'luv {repo} -n' or remove the stale folder")
|
|
291
321
|
if foreign:
|
|
292
|
-
return foreign
|
|
322
|
+
return newest(next(iter(foreign.values())))
|
|
293
323
|
return legacy
|
|
294
324
|
|
|
295
325
|
|
|
@@ -335,13 +365,17 @@ def folder_number(name: str | None) -> int | None:
|
|
|
335
365
|
the orphan scan, `--clean` — are looking at a directory listing and have no
|
|
336
366
|
repo to anchor on. Where the repo *is* known, workspace_number reads the
|
|
337
367
|
slug too and should be preferred.
|
|
368
|
+
|
|
369
|
+
A '_{copy}' suffix is part of the number's segment, so it is stripped here
|
|
370
|
+
too: '{repo}-{slug}-41_2' is still workspace 41.
|
|
338
371
|
"""
|
|
339
372
|
if not name:
|
|
340
373
|
return None
|
|
341
374
|
parts = name.rsplit("-", 1)
|
|
342
|
-
if len(parts) != 2
|
|
375
|
+
if len(parts) != 2:
|
|
343
376
|
return None
|
|
344
|
-
|
|
377
|
+
m = re.match(r"^(\d+)(?:_\d+)?$", parts[1])
|
|
378
|
+
return int(m.group(1)) if m else None
|
|
345
379
|
|
|
346
380
|
|
|
347
381
|
def parse_github_url(url: str) -> tuple[str, str] | None:
|
|
@@ -1316,21 +1350,23 @@ def find_latest_clone(repo: str) -> Path | None:
|
|
|
1316
1350
|
"""Return the highest-numbered local workspace for a repo, or None.
|
|
1317
1351
|
|
|
1318
1352
|
Slugged and pre-slug folders compete on the number alone; a tie between our
|
|
1319
|
-
own slug and one that arrived by handover goes to ours
|
|
1353
|
+
own slug and one that arrived by handover goes to ours, and a tie between
|
|
1354
|
+
two clones of the same PR goes to the newest copy.
|
|
1320
1355
|
"""
|
|
1321
1356
|
if not PRS_DIR.exists():
|
|
1322
1357
|
return None
|
|
1323
1358
|
pattern = workspace_re(repo)
|
|
1324
1359
|
mine = machine_slug()
|
|
1325
1360
|
best: Path | None = None
|
|
1326
|
-
best_key = (-1, -1)
|
|
1361
|
+
best_key = (-1, -1, -1)
|
|
1327
1362
|
for entry in sorted(PRS_DIR.iterdir()):
|
|
1328
1363
|
if not entry.is_dir():
|
|
1329
1364
|
continue
|
|
1330
1365
|
m = pattern.match(entry.name)
|
|
1331
1366
|
if not m:
|
|
1332
1367
|
continue
|
|
1333
|
-
key = (int(m.group(2)), 1 if m.group(1) == mine else 0
|
|
1368
|
+
key = (int(m.group(2)), 1 if m.group(1) == mine else 0,
|
|
1369
|
+
int(m.group(3) or 1))
|
|
1334
1370
|
if key > best_key:
|
|
1335
1371
|
best, best_key = entry, key
|
|
1336
1372
|
return best
|
|
@@ -1393,12 +1429,20 @@ def open_existing(org: str, repo: str, number: int, prompt: str | None, nav_mode
|
|
|
1393
1429
|
launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env, model=model, agent=agent)
|
|
1394
1430
|
|
|
1395
1431
|
|
|
1396
|
-
def open_pr(org: str, repo: str, number: int, prompt: str | None, nav_mode: bool = False, resume_mode: bool = False, plan_mode: bool = False, non_interactive: bool = False, extra_env: dict[str, str] | None = None, model: str | None = None, agent: str = "claude") -> None:
|
|
1397
|
-
"""Open any GitHub PR by org/repo/number, cloning if needed.
|
|
1432
|
+
def open_pr(org: str, repo: str, number: int, prompt: str | None, nav_mode: bool = False, resume_mode: bool = False, plan_mode: bool = False, non_interactive: bool = False, extra_env: dict[str, str] | None = None, model: str | None = None, agent: str = "claude", fresh: bool = False) -> None:
|
|
1433
|
+
"""Open any GitHub PR by org/repo/number, cloning if needed.
|
|
1434
|
+
|
|
1435
|
+
With fresh=True — how `luv -l` calls this — an existing folder for the same
|
|
1436
|
+
number is left alone and the PR is cloned again next to it. A URL you paste
|
|
1437
|
+
is a request to look at that PR as it is *now*, and a folder from last week
|
|
1438
|
+
sits on whatever was checked out then. -r is the exception: resuming means
|
|
1439
|
+
picking up a conversation, and a conversation lives in the folder it was
|
|
1440
|
+
held in.
|
|
1441
|
+
"""
|
|
1398
1442
|
extra_env = extra_env or {}
|
|
1399
1443
|
clone_dir = find_workspace(repo, number)
|
|
1400
1444
|
|
|
1401
|
-
if clone_dir is not None:
|
|
1445
|
+
if clone_dir is not None and (resume_mode or not fresh):
|
|
1402
1446
|
print(f"luv: opening existing folder {clone_dir.name}")
|
|
1403
1447
|
ensure_pr_rules(agent)
|
|
1404
1448
|
if nav_mode:
|
|
@@ -1415,9 +1459,15 @@ def open_pr(org: str, repo: str, number: int, prompt: str | None, nav_mode: bool
|
|
|
1415
1459
|
die(f"PR {org}/{repo}#{number} not found.\n{r.stderr.strip()}")
|
|
1416
1460
|
pr_data = json.loads(r.stdout)
|
|
1417
1461
|
branch = pr_data["head"]["ref"]
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1462
|
+
# A fork whose repo has since been deleted comes back as head.repo = null.
|
|
1463
|
+
head_repo = pr_data["head"].get("repo") or {}
|
|
1464
|
+
clone_url = head_repo.get("clone_url")
|
|
1465
|
+
if not clone_url:
|
|
1466
|
+
die(f"PR {org}/{repo}#{number} has no head repository — "
|
|
1467
|
+
"the fork it came from was deleted")
|
|
1468
|
+
base_url = (pr_data.get("base", {}).get("repo") or {}).get("clone_url")
|
|
1469
|
+
|
|
1470
|
+
clone_dir = next_workspace_dir(repo, number)
|
|
1421
1471
|
PRS_DIR.mkdir(parents=True, exist_ok=True)
|
|
1422
1472
|
print(f"luv: cloning {clone_url} -> {clone_dir} (branch {branch})")
|
|
1423
1473
|
r = subprocess.run(["git", "clone", clone_url, str(clone_dir)])
|
|
@@ -1427,6 +1477,20 @@ def open_pr(org: str, repo: str, number: int, prompt: str | None, nav_mode: bool
|
|
|
1427
1477
|
if r.returncode != 0:
|
|
1428
1478
|
die(f"git checkout {branch} failed (exit {r.returncode})")
|
|
1429
1479
|
|
|
1480
|
+
# A clone of the head repo has every branch that repo has — but for a PR
|
|
1481
|
+
# from a fork that is not where the base branch lives, so there would be
|
|
1482
|
+
# nothing to diff or rebase against. Fetch the base repo alongside it.
|
|
1483
|
+
if base_url and base_url != clone_url:
|
|
1484
|
+
print(f"luv: fetching base repo {base_url} as 'upstream'")
|
|
1485
|
+
for cmd in (["git", "remote", "add", "upstream", base_url],
|
|
1486
|
+
["git", "fetch", "upstream"]):
|
|
1487
|
+
r = subprocess.run(cmd, cwd=str(clone_dir))
|
|
1488
|
+
if r.returncode != 0:
|
|
1489
|
+
print(f"luv: warning: {' '.join(cmd[:3])} failed "
|
|
1490
|
+
f"(exit {r.returncode}) — upstream branches unavailable",
|
|
1491
|
+
file=sys.stderr)
|
|
1492
|
+
break
|
|
1493
|
+
|
|
1430
1494
|
print(f"luv: ready — {clone_dir.name}, branch {branch}")
|
|
1431
1495
|
ensure_pr_rules(agent)
|
|
1432
1496
|
if nav_mode:
|
|
@@ -2547,8 +2611,8 @@ Commands:
|
|
|
2547
2611
|
luv [org/]<repo> [prompt...] create a new PR workspace
|
|
2548
2612
|
luv [org/]<repo> -b <branch> [prompt] create a workspace based off <branch>
|
|
2549
2613
|
luv [org/]<repo> <number> [prompt] reopen an existing work folder by number
|
|
2550
|
-
luv -l <PR URL> [prompt]
|
|
2551
|
-
luv [org/]<repo> -pr <number> [prompt] open a GitHub PR by repo + number
|
|
2614
|
+
luv -l <PR URL> [prompt] clone any GitHub PR by URL into a fresh folder
|
|
2615
|
+
luv [org/]<repo> -pr <number> [prompt] open a GitHub PR by repo + number (reuses its folder)
|
|
2552
2616
|
luv [org/]<repo> -n open shell in latest local clone
|
|
2553
2617
|
luv [org/]<repo> -r resume the selected agent in latest local clone
|
|
2554
2618
|
luv --clean [-f] [--safe] delete fully-pushed work folders
|
|
@@ -2654,7 +2718,13 @@ Docker:
|
|
|
2654
2718
|
m = re.match(r"https://github\.com/([^/]+)/([^/]+)/pull/(\d+)", args[1])
|
|
2655
2719
|
if m:
|
|
2656
2720
|
org_hint, repo_hint = m.group(1), m.group(2)
|
|
2657
|
-
|
|
2721
|
+
pr_hint = int(m.group(3))
|
|
2722
|
+
# -l clones into a folder of its own every time, so the name is
|
|
2723
|
+
# only knowable once the remote has picked it — leave it on the
|
|
2724
|
+
# luv-pending path, which renames the session on arrival. -r is
|
|
2725
|
+
# the exception: it reopens the newest folder already there.
|
|
2726
|
+
if resume_mode:
|
|
2727
|
+
number = pr_hint
|
|
2658
2728
|
elif repo_hint and "-pr" in args:
|
|
2659
2729
|
idx = args.index("-pr")
|
|
2660
2730
|
if idx + 1 < len(args) and args[idx + 1].isdigit():
|
|
@@ -2708,7 +2778,7 @@ Docker:
|
|
|
2708
2778
|
die(f"cannot parse PR URL: {url}")
|
|
2709
2779
|
org, repo, number = m.group(1), m.group(2), int(m.group(3))
|
|
2710
2780
|
prompt = " ".join(args[2:]) or None
|
|
2711
|
-
open_pr(org, repo, number, prompt, nav_mode, resume_mode, plan_mode, non_interactive, extra_env=extra_env, model=model, agent=agent)
|
|
2781
|
+
open_pr(org, repo, number, prompt, nav_mode, resume_mode, plan_mode, non_interactive, extra_env=extra_env, model=model, agent=agent, fresh=True)
|
|
2712
2782
|
return
|
|
2713
2783
|
|
|
2714
2784
|
raw = args[0].rstrip("/")
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "luv-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Launch Claude Code or Codex agents on GitHub repos with isolated workspaces and optional Docker dev environments"
|
|
9
9
|
requires-python = ">=3.10"
|
|
10
10
|
license = "MIT"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import contextlib
|
|
2
2
|
import io
|
|
3
|
+
import json
|
|
3
4
|
import re
|
|
4
5
|
import subprocess
|
|
5
6
|
import tempfile
|
|
@@ -169,11 +170,29 @@ class RemoteDispatchTests(unittest.TestCase):
|
|
|
169
170
|
|
|
170
171
|
self.assertIn("tmux new-session -A -s luv-myrepo-mbp-42", argv[-1])
|
|
171
172
|
|
|
172
|
-
def
|
|
173
|
+
def test_pr_url_gets_a_pending_session(self):
|
|
174
|
+
# -l clones a fresh folder on the remote, so its name — and with it the
|
|
175
|
+
# session name — is not knowable here even when the host answers.
|
|
173
176
|
argv = self._dispatch(["-l", "https://github.com/other/thing/pull/7"],
|
|
174
177
|
where="thing-box-7")
|
|
175
178
|
|
|
179
|
+
self.assertIn("luv-pending-", argv[-1])
|
|
180
|
+
self.assertIn("_LUV_TMUX_PENDING=", argv[-1])
|
|
181
|
+
|
|
182
|
+
def test_pr_url_with_resume_pins_the_existing_folder(self):
|
|
183
|
+
# -r reopens the newest folder that is already there, so it can attach.
|
|
184
|
+
argv = self._dispatch(["-l", "https://github.com/other/thing/pull/7", "-r"],
|
|
185
|
+
where="thing-box-7")
|
|
186
|
+
|
|
176
187
|
self.assertIn("-s luv-thing-box-7", argv[-1])
|
|
188
|
+
self.assertNotIn("_LUV_TMUX_PENDING", argv[-1])
|
|
189
|
+
|
|
190
|
+
def test_pr_url_still_records_the_pr_number(self):
|
|
191
|
+
# The PR link in `luv ls` comes from this hint: a -l session's branch is
|
|
192
|
+
# the PR's head ref, which no folder name spells out.
|
|
193
|
+
self._dispatch(["-l", "https://github.com/other/thing/pull/7"])
|
|
194
|
+
|
|
195
|
+
self.assertEqual([s.get("pr_hint") for s in luv.load_sessions()], [7])
|
|
177
196
|
|
|
178
197
|
def test_host_flag_selects_per_host_settings(self):
|
|
179
198
|
argv = self._dispatch(["-s", "gpu", "ml", "train"])
|
|
@@ -751,6 +770,55 @@ class NamingTests(unittest.TestCase):
|
|
|
751
770
|
self.assertEqual(luv.workspace_number("myrepo", "myrepo-42"), 42)
|
|
752
771
|
self.assertIsNone(luv.workspace_number("myrepo", "other-mbp-42"))
|
|
753
772
|
|
|
773
|
+
def test_repeat_clones_of_a_pr_get_their_own_folder(self):
|
|
774
|
+
with self._slug("mbp"):
|
|
775
|
+
self.assertEqual(luv.next_workspace_dir("myrepo", 42).name,
|
|
776
|
+
"myrepo-mbp-42")
|
|
777
|
+
(self.root / "myrepo-mbp-42").mkdir()
|
|
778
|
+
self.assertEqual(luv.next_workspace_dir("myrepo", 42).name,
|
|
779
|
+
"myrepo-mbp-42_2")
|
|
780
|
+
(self.root / "myrepo-mbp-42_2").mkdir()
|
|
781
|
+
self.assertEqual(luv.next_workspace_dir("myrepo", 42).name,
|
|
782
|
+
"myrepo-mbp-42_3")
|
|
783
|
+
|
|
784
|
+
def test_a_copy_is_still_workspace_42(self):
|
|
785
|
+
self.assertEqual(luv.workspace_number("myrepo", "myrepo-mbp-42_2"), 42)
|
|
786
|
+
self.assertEqual(luv.folder_number("myrepo-mbp-42_2"), 42)
|
|
787
|
+
self.assertEqual(luv.folder_number("myrepo-42_11"), 42)
|
|
788
|
+
self.assertIsNone(luv.folder_number("myrepo-main_2"))
|
|
789
|
+
|
|
790
|
+
def test_copy_suffix_survives_every_derived_name(self):
|
|
791
|
+
# tmux rejects '.' and ':' in session names and Compose rejects '.' in
|
|
792
|
+
# project names, which is why the separator is '_'.
|
|
793
|
+
name = luv.workspace_name("myrepo", 42, slug="mbp", copy=2)
|
|
794
|
+
self.assertEqual(name, "myrepo-mbp-42_2")
|
|
795
|
+
self.assertEqual(luv.tmux_session_name(name), "luv-myrepo-mbp-42_2")
|
|
796
|
+
self.assertEqual(luv.docker_project_name(self.root / name),
|
|
797
|
+
"luv-myrepo-mbp-42_2")
|
|
798
|
+
|
|
799
|
+
def test_find_workspace_opens_the_newest_copy(self):
|
|
800
|
+
for name in ("myrepo-mbp-42", "myrepo-mbp-42_2", "myrepo-mbp-42_10"):
|
|
801
|
+
(self.root / name).mkdir()
|
|
802
|
+
with self._slug("mbp"):
|
|
803
|
+
self.assertEqual(luv.find_workspace("myrepo", 42).name,
|
|
804
|
+
"myrepo-mbp-42_10")
|
|
805
|
+
|
|
806
|
+
def test_copies_of_one_machines_folder_are_not_ambiguous(self):
|
|
807
|
+
# Two copies made *here* are a -l history, not a naming conflict; only
|
|
808
|
+
# two different machines leave the number genuinely undecidable.
|
|
809
|
+
for name in ("myrepo-box-42", "myrepo-box-42_2"):
|
|
810
|
+
(self.root / name).mkdir()
|
|
811
|
+
with self._slug("mbp"):
|
|
812
|
+
self.assertEqual(luv.find_workspace("myrepo", 42).name,
|
|
813
|
+
"myrepo-box-42_2")
|
|
814
|
+
|
|
815
|
+
def test_find_latest_clone_prefers_the_newest_copy(self):
|
|
816
|
+
for name in ("myrepo-mbp-41", "myrepo-mbp-41_2"):
|
|
817
|
+
(self.root / name).mkdir()
|
|
818
|
+
with self._slug("mbp"):
|
|
819
|
+
self.assertEqual(luv.find_latest_clone("myrepo").name,
|
|
820
|
+
"myrepo-mbp-41_2")
|
|
821
|
+
|
|
754
822
|
def test_find_workspace_prefers_our_own(self):
|
|
755
823
|
for name in ("myrepo-42", "myrepo-box-42", "myrepo-mbp-42"):
|
|
756
824
|
(self.root / name).mkdir()
|
|
@@ -796,6 +864,93 @@ class NamingTests(unittest.TestCase):
|
|
|
796
864
|
self.assertEqual(out.getvalue().split(), ["myrepo-box-42", "myrepo-mbp-77"])
|
|
797
865
|
|
|
798
866
|
|
|
867
|
+
class OpenPrTests(unittest.TestCase):
|
|
868
|
+
"""`luv -l` clones the PR again rather than reopening what is lying around."""
|
|
869
|
+
|
|
870
|
+
SAME_REPO = json.dumps({
|
|
871
|
+
"head": {"ref": "feature-branch",
|
|
872
|
+
"repo": {"clone_url": "https://github.com/exo/myrepo.git"}},
|
|
873
|
+
"base": {"repo": {"clone_url": "https://github.com/exo/myrepo.git"}},
|
|
874
|
+
})
|
|
875
|
+
FROM_FORK = json.dumps({
|
|
876
|
+
"head": {"ref": "feature-branch",
|
|
877
|
+
"repo": {"clone_url": "https://github.com/someone/myrepo.git"}},
|
|
878
|
+
"base": {"repo": {"clone_url": "https://github.com/exo/myrepo.git"}},
|
|
879
|
+
})
|
|
880
|
+
|
|
881
|
+
def setUp(self):
|
|
882
|
+
self.tempdir = tempfile.TemporaryDirectory()
|
|
883
|
+
self.addCleanup(self.tempdir.cleanup)
|
|
884
|
+
self.root = Path(self.tempdir.name)
|
|
885
|
+
self.git = []
|
|
886
|
+
for p in (patch.object(luv, "PRS_DIR", self.root),
|
|
887
|
+
patch.object(luv, "machine_slug", return_value="mbp"),
|
|
888
|
+
patch.object(luv, "ensure_pr_rules")):
|
|
889
|
+
p.start()
|
|
890
|
+
self.addCleanup(p.stop)
|
|
891
|
+
|
|
892
|
+
def _open(self, pr_json=SAME_REPO, **kwargs):
|
|
893
|
+
"""Run open_pr with git stubbed out; returns the launched folder."""
|
|
894
|
+
with (patch.object(luv, "run", return_value=_completed(pr_json)),
|
|
895
|
+
patch.object(luv.subprocess, "run",
|
|
896
|
+
side_effect=lambda cmd, **kw: self.git.append(cmd)
|
|
897
|
+
or _completed()),
|
|
898
|
+
patch.object(luv, "launch") as launch,
|
|
899
|
+
patch.object(luv, "navigate") as navigate,
|
|
900
|
+
patch.object(luv, "resume") as resume,
|
|
901
|
+
contextlib.redirect_stdout(io.StringIO()),
|
|
902
|
+
contextlib.redirect_stderr(io.StringIO())):
|
|
903
|
+
luv.open_pr("exo", "myrepo", 42, None, **kwargs)
|
|
904
|
+
called = next(m for m in (launch, navigate, resume) if m.called)
|
|
905
|
+
return called.call_args.args[0]
|
|
906
|
+
|
|
907
|
+
def test_a_fresh_clone_lands_next_to_the_old_folder(self):
|
|
908
|
+
(self.root / "myrepo-mbp-42").mkdir()
|
|
909
|
+
|
|
910
|
+
folder = self._open(fresh=True)
|
|
911
|
+
|
|
912
|
+
self.assertEqual(folder.name, "myrepo-mbp-42_2")
|
|
913
|
+
self.assertIn(["git", "checkout", "feature-branch"], self.git)
|
|
914
|
+
|
|
915
|
+
def test_without_fresh_the_existing_folder_is_reused(self):
|
|
916
|
+
# -pr keeps the old behaviour: it names a workspace, not a URL.
|
|
917
|
+
(self.root / "myrepo-mbp-42").mkdir()
|
|
918
|
+
|
|
919
|
+
self.assertEqual(self._open().name, "myrepo-mbp-42")
|
|
920
|
+
self.assertEqual(self.git, [], "reopening must not clone")
|
|
921
|
+
|
|
922
|
+
def test_resume_reopens_rather_than_recloning(self):
|
|
923
|
+
# A fresh clone has no conversation in it, so there is nothing to resume.
|
|
924
|
+
(self.root / "myrepo-mbp-42").mkdir()
|
|
925
|
+
|
|
926
|
+
self.assertEqual(self._open(fresh=True, resume_mode=True).name,
|
|
927
|
+
"myrepo-mbp-42")
|
|
928
|
+
self.assertEqual(self.git, [])
|
|
929
|
+
|
|
930
|
+
def test_a_fork_pr_also_fetches_the_base_repo(self):
|
|
931
|
+
self._open(self.FROM_FORK, fresh=True)
|
|
932
|
+
|
|
933
|
+
self.assertIn(["git", "remote", "add", "upstream",
|
|
934
|
+
"https://github.com/exo/myrepo.git"], self.git)
|
|
935
|
+
self.assertIn(["git", "fetch", "upstream"], self.git)
|
|
936
|
+
|
|
937
|
+
def test_a_same_repo_pr_needs_no_second_remote(self):
|
|
938
|
+
# The clone already carries every branch; a second remote for the same
|
|
939
|
+
# URL would just duplicate them.
|
|
940
|
+
self._open(fresh=True)
|
|
941
|
+
|
|
942
|
+
self.assertNotIn("upstream", [a for cmd in self.git for a in cmd])
|
|
943
|
+
|
|
944
|
+
def test_a_deleted_fork_fails_before_touching_the_disk(self):
|
|
945
|
+
gone = json.dumps({"head": {"ref": "feature-branch", "repo": None},
|
|
946
|
+
"base": {"repo": {"clone_url": "https://x/y.git"}}})
|
|
947
|
+
|
|
948
|
+
with self.assertRaises(SystemExit), \
|
|
949
|
+
contextlib.redirect_stderr(io.StringIO()):
|
|
950
|
+
self._open(gone, fresh=True)
|
|
951
|
+
self.assertEqual(self.git, [])
|
|
952
|
+
|
|
953
|
+
|
|
799
954
|
class HandoverTests(unittest.TestCase):
|
|
800
955
|
"""Moving a workspace between machines."""
|
|
801
956
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|