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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: luv-cli
3
- Version: 0.3.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
- # Open any GitHub PR by URL
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]` | Open any GitHub PR by URL |
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}` and
274
- pre-slug `{repo}-{N}` alike. Anything else in the workspace root is left alone,
275
- and a target that doesn't resolve to one is an error rather than an `rm -rf`.
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
- # Open any GitHub PR by URL
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]` | Open any GitHub PR by URL |
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}` and
251
- pre-slug `{repo}-{N}` alike. Anything else in the workspace root is left alone,
252
- and a target that doesn't resolve to one is an error rather than an `rm -rf`.
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` — the
353
- laptop pins the session name down before dispatching, so `tmux new-session -A`
354
- doubles as "attach if it's already running". It can't compute that name, though,
355
- since the slug belongs to the machine holding the folder: it takes it from the
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, or bare `-n`/`-r` — the laptop uses
360
- a placeholder `luv-pending-<id>` and the remote renames it once the clone lands.
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}` and pre-slug
234
- `{repo}-{N}` alike — checked again immediately before the `rm -rf` rather than
235
- only at selection time. A stray directory in the workspace root is never a
236
- candidate, and a target that resolves to one is an error, not a deletion.
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) -> str:
222
- """Folder name for a workspace: '{repo}-{machine}-{number}'."""
223
- return f"{repo}-{slug or machine_slug()}-{number}"
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. Two foreign candidates is
266
- genuinely ambiguous — the number alone cannot say which was meant.
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
- foreign, legacy = [], None
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
- if m.group(1) is None:
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 e in foreign)
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[0]
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 or not parts[1].isdigit():
375
+ if len(parts) != 2:
343
376
  return None
344
- return int(parts[1])
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
- clone_url = pr_data["head"]["repo"]["clone_url"]
1419
-
1420
- clone_dir = PRS_DIR / workspace_name(repo, number)
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] open any GitHub PR by URL
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
- number = pr_hint = int(m.group(3))
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.3.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 test_pr_url_derives_session_from_url(self):
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