devlaunch 0.0.19__tar.gz → 0.0.21__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.
Files changed (29) hide show
  1. {devlaunch-0.0.19 → devlaunch-0.0.21}/PKG-INFO +59 -1
  2. {devlaunch-0.0.19 → devlaunch-0.0.21}/README.md +58 -0
  3. {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/dl.py +101 -3
  4. devlaunch-0.0.21/devlaunch/workspace_state.py +121 -0
  5. devlaunch-0.0.21/devlaunch/worktree/locks.py +55 -0
  6. {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/worktree/repo_manager.py +69 -15
  7. {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/worktree/storage.py +45 -20
  8. {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/worktree/workspace_clone.py +50 -18
  9. {devlaunch-0.0.19 → devlaunch-0.0.21}/pyproject.toml +1 -1
  10. {devlaunch-0.0.19 → devlaunch-0.0.21}/.gitignore +0 -0
  11. {devlaunch-0.0.19 → devlaunch-0.0.21}/LICENSE +0 -0
  12. {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/__init__.py +0 -0
  13. {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/aid.py +0 -0
  14. {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/completion.py +0 -0
  15. {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/completion_loader.py +0 -0
  16. {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/completions/__init__.py +0 -0
  17. {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/completions/dl.bash +0 -0
  18. {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/devpod_provider.py +0 -0
  19. {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/devpod_ssh.py +0 -0
  20. {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/gh_auth.py +0 -0
  21. {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/tools.py +0 -0
  22. {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/tty_session.py +0 -0
  23. {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/workspace_id.py +0 -0
  24. {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/worktree/__init__.py +0 -0
  25. {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/worktree/branch_manager.py +0 -0
  26. {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/worktree/config.py +0 -0
  27. {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/worktree/migration.py +0 -0
  28. {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/worktree/models.py +0 -0
  29. {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/xdg.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlaunch
3
- Version: 0.0.19
3
+ Version: 0.0.21
4
4
  Summary: DevLaunch - A streamlined CLI for devpod workspaces
5
5
  Project-URL: Source, https://github.com/blooop/devlaunch
6
6
  Project-URL: Home, https://github.com/blooop/devlaunch
@@ -339,6 +339,7 @@ existed — picks the tools up on its next `dl <workspace> restart`.
339
339
  | Command | Description |
340
340
  |---------|-------------|
341
341
  | `dl --ls` | List all workspaces |
342
+ | `dl --ls --json` | The same list as JSON, with each workspace's repo, branch, state and [unsaved work](#cleaning-up-workspaces) — for tools that decide what to clean up |
342
343
  | `dl --install` | Install shell completions |
343
344
  | `dl --purge [-y]` | Remove all devlaunch data — [the workspaces devlaunch created](#what-purge-deletes), and its caches |
344
345
  | `dl --prune-worktrees [days]` | Remove unused worktrees (default: 30 days) |
@@ -396,6 +397,63 @@ Erring this way is deliberate — a purge that skips one of your own workspaces
396
397
  costs you a command, and the other kind of mistake costs you work you cannot get
397
398
  back.
398
399
 
400
+ ### Cleaning up workspaces
401
+
402
+ One workspace per branch means workspaces accumulate, and `--purge` is the wrong
403
+ tool for tidying: it is all-or-nothing and takes the caches with it.
404
+
405
+ **devlaunch does not decide which workspaces are finished.** Whether a piece of
406
+ work is over is a fact about a ticket, a review, or somebody's intent, and `dl`
407
+ knows about clones and containers. Inferring it from the branch — merged into
408
+ the default, or deleted from the remote — was tried and dropped: it reads like a
409
+ git fact but is a guess at intent, and it cannot tell a squash-merged branch
410
+ from an abandoned one. So `dl` supplies the two halves a tool that *does* know
411
+ needs, and that tool drives the cleanup:
412
+
413
+ ```bash
414
+ dl --ls --json # what exists, and what each workspace holds
415
+ dl <workspace> rm # remove one
416
+ ```
417
+
418
+ The JSON reports, per workspace: `id`, `devlaunch` (did `dl` create it),
419
+ `repo`, `branch` (what the workspace was made for), `checkedOut` (what its clone
420
+ is on now, which can differ), `path`, `state`, `lastUsed`, and — the field a
421
+ cleanup tool must not ignore — `unsaved`:
422
+
423
+ ```json
424
+ {
425
+ "id": "devlaunch-wayfinder-devlaunch-80-ladepomi",
426
+ "devlaunch": true,
427
+ "repo": "blooop/devlaunch",
428
+ "branch": "wayfinder/devlaunch-80",
429
+ "state": "Stopped",
430
+ "unsaved": "2 uncommitted change(s) and 1 unpushed commit(s)"
431
+ }
432
+ ```
433
+
434
+ `unsaved` is a description of what deleting would destroy, or `null` when the
435
+ clone holds nothing that does not also exist on a remote — uncommitted changes
436
+ (untracked files included) and commits no remote has. A workspace `dl` did not
437
+ create reports `devlaunch: false` with no repo, branch or `unsaved`: there is no
438
+ clone of `dl`'s to protect, and it has no business inspecting your checkout.
439
+
440
+ **`dl <workspace> rm` refuses when the clone holds unsaved work**, so a caller
441
+ that forgets to read the field is still caught:
442
+
443
+ ```
444
+ $ dl blooop/repo@feature rm
445
+ error: devlaunch-repo-feature-xyz holds 1 unpushed commit(s).
446
+ Push or commit it, or run: dl blooop/repo@feature rm --force
447
+ ```
448
+
449
+ That refusal is the only judgement `dl` makes here, and it is not about finished
450
+ work — it is `dl` declining to destroy the only copy of something. Say `--force`
451
+ if you mean it.
452
+
453
+ [`wf`](https://github.com/blooop/wayfinder) is the caller this was built for: it
454
+ names its branches after its tickets, so it knows which workspaces belong to
455
+ finished work and removes those.
456
+
399
457
  ## Examples
400
458
 
401
459
  ```bash
@@ -317,6 +317,7 @@ existed — picks the tools up on its next `dl <workspace> restart`.
317
317
  | Command | Description |
318
318
  |---------|-------------|
319
319
  | `dl --ls` | List all workspaces |
320
+ | `dl --ls --json` | The same list as JSON, with each workspace's repo, branch, state and [unsaved work](#cleaning-up-workspaces) — for tools that decide what to clean up |
320
321
  | `dl --install` | Install shell completions |
321
322
  | `dl --purge [-y]` | Remove all devlaunch data — [the workspaces devlaunch created](#what-purge-deletes), and its caches |
322
323
  | `dl --prune-worktrees [days]` | Remove unused worktrees (default: 30 days) |
@@ -374,6 +375,63 @@ Erring this way is deliberate — a purge that skips one of your own workspaces
374
375
  costs you a command, and the other kind of mistake costs you work you cannot get
375
376
  back.
376
377
 
378
+ ### Cleaning up workspaces
379
+
380
+ One workspace per branch means workspaces accumulate, and `--purge` is the wrong
381
+ tool for tidying: it is all-or-nothing and takes the caches with it.
382
+
383
+ **devlaunch does not decide which workspaces are finished.** Whether a piece of
384
+ work is over is a fact about a ticket, a review, or somebody's intent, and `dl`
385
+ knows about clones and containers. Inferring it from the branch — merged into
386
+ the default, or deleted from the remote — was tried and dropped: it reads like a
387
+ git fact but is a guess at intent, and it cannot tell a squash-merged branch
388
+ from an abandoned one. So `dl` supplies the two halves a tool that *does* know
389
+ needs, and that tool drives the cleanup:
390
+
391
+ ```bash
392
+ dl --ls --json # what exists, and what each workspace holds
393
+ dl <workspace> rm # remove one
394
+ ```
395
+
396
+ The JSON reports, per workspace: `id`, `devlaunch` (did `dl` create it),
397
+ `repo`, `branch` (what the workspace was made for), `checkedOut` (what its clone
398
+ is on now, which can differ), `path`, `state`, `lastUsed`, and — the field a
399
+ cleanup tool must not ignore — `unsaved`:
400
+
401
+ ```json
402
+ {
403
+ "id": "devlaunch-wayfinder-devlaunch-80-ladepomi",
404
+ "devlaunch": true,
405
+ "repo": "blooop/devlaunch",
406
+ "branch": "wayfinder/devlaunch-80",
407
+ "state": "Stopped",
408
+ "unsaved": "2 uncommitted change(s) and 1 unpushed commit(s)"
409
+ }
410
+ ```
411
+
412
+ `unsaved` is a description of what deleting would destroy, or `null` when the
413
+ clone holds nothing that does not also exist on a remote — uncommitted changes
414
+ (untracked files included) and commits no remote has. A workspace `dl` did not
415
+ create reports `devlaunch: false` with no repo, branch or `unsaved`: there is no
416
+ clone of `dl`'s to protect, and it has no business inspecting your checkout.
417
+
418
+ **`dl <workspace> rm` refuses when the clone holds unsaved work**, so a caller
419
+ that forgets to read the field is still caught:
420
+
421
+ ```
422
+ $ dl blooop/repo@feature rm
423
+ error: devlaunch-repo-feature-xyz holds 1 unpushed commit(s).
424
+ Push or commit it, or run: dl blooop/repo@feature rm --force
425
+ ```
426
+
427
+ That refusal is the only judgement `dl` makes here, and it is not about finished
428
+ work — it is `dl` declining to destroy the only copy of something. Say `--force`
429
+ if you mean it.
430
+
431
+ [`wf`](https://github.com/blooop/wayfinder) is the caller this was built for: it
432
+ names its branches after its tickets, so it knows which workspaces belong to
433
+ finished work and removes those.
434
+
377
435
  ## Examples
378
436
 
379
437
  ```bash
@@ -27,12 +27,13 @@ import re
27
27
  import shlex
28
28
  import time
29
29
  from importlib.metadata import version as pkg_version, PackageNotFoundError, distribution
30
+ from pathlib import Path
30
31
  from typing import Any, Dict, List, Mapping, NoReturn, Optional, Sequence, Tuple
31
32
  from dataclasses import dataclass
32
33
  from urllib.parse import urlparse
33
34
  from urllib.request import url2pathname
34
35
 
35
- from . import devpod_ssh, gh_auth, tools, tty_session
36
+ from . import devpod_ssh, gh_auth, tools, tty_session, workspace_state
36
37
  from .completion import install_completions
37
38
  from .workspace_id import TARGET_LENGTH, WorkspaceId, slug, source_workspace_id, validate_ref_name
38
39
  from .worktree.config import get_worktree_config
@@ -431,6 +432,76 @@ def update_cache_background(force: bool = False) -> None:
431
432
  pass
432
433
 
433
434
 
435
+ def _unsaved_work_in(workspace_id: str) -> Optional[str]:
436
+ """What deleting *workspace_id* would destroy, or None if nothing would be.
437
+
438
+ Answers None for a workspace devlaunch has no record of, which is the honest
439
+ answer rather than a permissive one: those are workspaces opened from a path
440
+ or a URL that dl never cloned and does not manage, so it has no clone of its
441
+ own to protect and no business inspecting someone's checkout to find one.
442
+ """
443
+ try:
444
+ record = _get_clone_manager().storage.get_worktree_by_workspace_id(workspace_id)
445
+ except (OSError, RuntimeError) as e:
446
+ logging.debug(f"could not read the workspace record for {workspace_id}: {e}")
447
+ return None
448
+ if record is None:
449
+ return None
450
+ return workspace_state.holds_unsaved_work(Path(record.local_path))
451
+
452
+
453
+ def workspaces_as_json() -> int:
454
+ """Print the workspace list as JSON: what exists, and what each one holds.
455
+
456
+ The machine-readable half of cleanup. devlaunch does not decide which
457
+ workspaces are finished -- that is a fact about tickets, reviews and intent,
458
+ none of which it knows -- so it reports what it does know and lets the
459
+ caller that knows the rest decide. `wf` is one such caller: it named the
460
+ branches after its tickets, so matching a workspace to a ticket is its
461
+ business, not dl's.
462
+
463
+ Every field is something dl can answer for certain:
464
+
465
+ - `repo` and `branch` come from the record dl wrote when it made the clone;
466
+ a workspace dl did not make has neither, and says so with `devlaunch:
467
+ false` rather than a guess.
468
+ - `unsaved` is the field a caller must not ignore: a description of what
469
+ deleting would destroy, or null. `dl <ws> rm` refuses on it too, so a
470
+ caller that forgets is still caught -- but a caller that reads it can
471
+ leave the workspace alone instead of arguing with a refusal.
472
+ - `state` is devpod's, one `devpod status` per workspace, which is why this
473
+ is a command someone runs rather than something on the fast path.
474
+ """
475
+ cache_dir = _get_cache_dir()
476
+ workspaces = list_workspaces()
477
+ clone_mgr = _get_clone_manager()
478
+ report: List[Dict[str, Any]] = []
479
+ for ws in workspaces:
480
+ mine = is_devlaunch_clone(ws, cache_dir)
481
+ record = clone_mgr.storage.get_worktree_by_workspace_id(ws.id) if mine else None
482
+ clone_path = Path(record.local_path) if record else None
483
+ state = workspace_state.read_clone(clone_path) if clone_path else None
484
+ report.append(
485
+ {
486
+ "id": ws.id,
487
+ "devlaunch": mine,
488
+ "repo": f"{record.owner}/{record.repo}" if record else None,
489
+ # The recorded branch is what the workspace was made for; the
490
+ # clone's current HEAD can differ (an agent checked something
491
+ # else out), so both are reported rather than one being made to
492
+ # stand for the other.
493
+ "branch": record.branch if record else None,
494
+ "checkedOut": state.branch if state else None,
495
+ "path": str(clone_path) if clone_path else None,
496
+ "state": get_workspace_state(ws.id),
497
+ "lastUsed": ws.last_used,
498
+ "unsaved": state.unsaved if state else None,
499
+ }
500
+ )
501
+ print(json.dumps(report, indent=2))
502
+ return 0
503
+
504
+
434
505
  def purge_all_data() -> int:
435
506
  """Purge devlaunch's data: the workspaces it created, and its caches.
436
507
 
@@ -1609,7 +1680,9 @@ Workspace sources:
1609
1680
 
1610
1681
  Workspace commands:
1611
1682
  dl <user/repo> stop Stop the workspace
1612
- dl <user/repo> rm, prune Delete the workspace
1683
+ dl <user/repo> rm, prune Delete the workspace. Refuses if its clone
1684
+ holds uncommitted or unpushed work; add
1685
+ --force to delete it anyway.
1613
1686
  dl <user/repo> code Open in VS Code
1614
1687
  dl <user/repo> restart Stop and start (no rebuild)
1615
1688
  dl <user/repo> recreate Recreate container
@@ -1618,6 +1691,10 @@ Workspace commands:
1618
1691
 
1619
1692
  Global commands:
1620
1693
  dl --ls List all workspaces
1694
+ dl --ls --json List them as JSON, with each one's repo,
1695
+ branch, state, and what it holds that is
1696
+ not pushed anywhere ("unsaved"). For tools
1697
+ that decide which workspaces to clean up.
1621
1698
  dl --install Install shell completions
1622
1699
  dl --refresh Refresh completion cache
1623
1700
  dl --purge [-y] Remove devlaunch's workspaces and caches
@@ -1659,7 +1736,12 @@ def _get_clone_manager() -> WorkspaceCloneManager:
1659
1736
  if "clone_manager" not in _cache:
1660
1737
  manager = WorkspaceCloneManager()
1661
1738
  try:
1662
- migrate_cache(manager.storage, pathlib.Path(manager.config.repos_dir))
1739
+ # Under the metadata lock so two dl processes cannot migrate at
1740
+ # once: the renames are not idempotent mid-flight, and exclusive()
1741
+ # reloads first so the version check sees the other side's result.
1742
+ # migrate_cache calls save() directly, never a locked mutator.
1743
+ with manager.storage.exclusive():
1744
+ migrate_cache(manager.storage, pathlib.Path(manager.config.repos_dir))
1663
1745
  except OSError as e:
1664
1746
  # A failed migration must not take the command with it. The renames
1665
1747
  # that did happen are still resumable: the version header is only
@@ -1755,6 +1837,8 @@ def _run_cli(argv: Optional[List[str]] = None) -> int:
1755
1837
  return 0
1756
1838
 
1757
1839
  if args[0] == "--ls":
1840
+ if "--json" in args[1:]:
1841
+ return workspaces_as_json()
1758
1842
  print_workspaces()
1759
1843
  return 0
1760
1844
 
@@ -1941,6 +2025,20 @@ def _run_cli(argv: Optional[List[str]] = None) -> int:
1941
2025
  return workspace_stop(workspace_id)
1942
2026
 
1943
2027
  if subcommand in ("rm", "prune"):
2028
+ # The one thing dl refuses on its own account. It is not a judgement
2029
+ # about whether the work is finished -- dl has no way to know that --
2030
+ # but about whether this clone is the only place the work exists.
2031
+ # Cleanup is expected to be driven by something that knows more than dl
2032
+ # does (a ticket tool, a script, a person), and this is what keeps a
2033
+ # confident caller from destroying an hour of somebody's afternoon.
2034
+ if "--force" not in args[2:]:
2035
+ unsaved = _unsaved_work_in(workspace_id)
2036
+ if unsaved:
2037
+ logging.error(
2038
+ f"{workspace_id} holds {unsaved}. Push or commit it, or run: "
2039
+ f"dl {raw_spec} rm --force"
2040
+ )
2041
+ return 1
1944
2042
  return workspace_delete(workspace_id)
1945
2043
 
1946
2044
  if subcommand == "code":
@@ -0,0 +1,121 @@
1
+ """What a workspace holds — the facts a cleanup decision is made from elsewhere.
2
+
3
+ A workspace per branch means workspaces accumulate, and something has to remove
4
+ the finished ones. That something is **not devlaunch**: whether a piece of work
5
+ is finished is a fact about a ticket, a review or a person's intent, and dl
6
+ knows about none of those. It knows about clones and containers.
7
+
8
+ So the split is mechanism here, policy in the caller:
9
+
10
+ - ``dl --ls --json`` reports what exists and what each workspace holds, which is
11
+ what a caller needs to decide anything at all.
12
+ - ``dl <ws> rm`` deletes one, and refuses when the clone holds work that exists
13
+ nowhere else.
14
+
15
+ The refusal is the one judgement dl does make, and it is not a policy about
16
+ finished work: it is dl declining to destroy the only copy of something. A
17
+ caller that means it says ``--force``.
18
+
19
+ The alternative — dl inferring "finished" from the branch (merged into the
20
+ default, or deleted from the remote) — was built first and thrown away. It reads
21
+ as a git fact but it is a guess at intent: a squash-merged branch and an
22
+ abandoned one are indistinguishable, a branch merged upstream may still have
23
+ work to do, and a repo whose flow does not delete branches gets nothing. The
24
+ caller that knows the answer should say the answer.
25
+ """
26
+
27
+ import logging
28
+ import subprocess
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+ from typing import List, Optional
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class CloneState:
38
+ """What one workspace clone holds, as far as git can tell.
39
+
40
+ ``unsaved`` is the load-bearing field and is deliberately a description
41
+ rather than a flag: it is printed to a person who is deciding whether to
42
+ force a delete, and "3 uncommitted change(s) and 2 unpushed commit(s)" is
43
+ the thing that answers them. ``None`` means the clone holds nothing that
44
+ does not also exist on a remote.
45
+ """
46
+
47
+ branch: Optional[str]
48
+ unsaved: Optional[str]
49
+
50
+
51
+ def _git(repo: Path, *args: str) -> Optional[str]:
52
+ """Run git in *repo*, returning stdout, or ``None`` if it refused.
53
+
54
+ A refusal is "cannot tell", never an answer: a clone that is broken, gone or
55
+ not a repository must not stop the other workspaces being reported, and must
56
+ never be reported as *safe to delete* on the strength of a failed command —
57
+ every caller here treats ``None`` as "no information", and the one place
58
+ that matters (:func:`holds_unsaved_work`) fails safe explicitly.
59
+ """
60
+ try:
61
+ result = subprocess.run(
62
+ ["git", *args], cwd=repo, capture_output=True, text=True, check=False, timeout=30
63
+ )
64
+ except (OSError, subprocess.SubprocessError) as e:
65
+ logger.debug(f"git {' '.join(args)} in {repo}: {e}")
66
+ return None
67
+ if result.returncode != 0:
68
+ logger.debug(f"git {' '.join(args)} in {repo}: {result.stderr.strip()}")
69
+ return None
70
+ return result.stdout.strip()
71
+
72
+
73
+ def read_clone(clone: Path) -> CloneState:
74
+ """Report what *clone* holds. The only function here that talks to git.
75
+
76
+ A directory that is not there, or is not a repository, holds nothing: there
77
+ is no work in it to lose. That is the truth about it rather than a special
78
+ case, and it is what lets a caller clear away a workspace whose clone was
79
+ already removed by hand.
80
+ """
81
+ if not clone.is_dir():
82
+ return CloneState(branch=None, unsaved=None)
83
+ branch = _git(clone, "rev-parse", "--abbrev-ref", "HEAD")
84
+ return CloneState(branch=branch or None, unsaved=_unsaved(clone, branch))
85
+
86
+
87
+ def _unsaved(clone: Path, branch: Optional[str]) -> Optional[str]:
88
+ """What deleting *clone* would destroy, in words, or ``None`` if nothing.
89
+
90
+ Two kinds of loss, reported together because someone deciding whether to
91
+ force a delete wants both:
92
+
93
+ - a dirty tree, **untracked files included** — an agent's scratch notes are
94
+ not less lost for never having been added;
95
+ - commits no remote-tracking ref contains. ``--not --remotes`` asks about
96
+ *any* remote ref rather than this branch's upstream, so work that was
97
+ pushed under another name, or merged and fetched back, is correctly not
98
+ counted as lost.
99
+ """
100
+ losses: List[str] = []
101
+ status = _git(clone, "status", "--porcelain")
102
+ if status:
103
+ losses.append(f"{len(status.splitlines())} uncommitted change(s)")
104
+ if branch:
105
+ # Argument order is load-bearing: `--not` flips the sense of every ref
106
+ # *after* it, so the branch has to be named before it. `log --not
107
+ # --remotes <branch>` excludes the branch as well and is silently always
108
+ # empty — which would report every clone as safe to delete.
109
+ unpushed = _git(clone, "log", "--oneline", branch, "--not", "--remotes")
110
+ if unpushed:
111
+ losses.append(f"{len(unpushed.splitlines())} unpushed commit(s)")
112
+ return " and ".join(losses) if losses else None
113
+
114
+
115
+ def holds_unsaved_work(clone: Path) -> Optional[str]:
116
+ """What would be lost by deleting *clone*, or ``None`` if nothing would be.
117
+
118
+ The guard `dl <ws> rm` consults. Thin on purpose: the interesting behaviour
119
+ is in :func:`read_clone`, and this is the name the guard reads by.
120
+ """
121
+ return read_clone(clone).unsaved
@@ -0,0 +1,55 @@
1
+ """Inter-process locks for the shared cache.
2
+
3
+ Several dl processes can run at once — two agents launched on their own
4
+ branches, a completion refresh in the background — and they share one bare-clone
5
+ cache and one metadata.json. These locks are what keeps simultaneous runs from
6
+ racing each other over that state: without them, two first launches of a repo
7
+ both ran ``git clone --bare`` into the same path (and the loser's cleanup
8
+ deleted the winner's half-written clone), and metadata writers rewrote the file
9
+ from stale in-memory copies, dropping each other's records.
10
+
11
+ ``flock`` rather than a pid file: the kernel releases it when the process dies,
12
+ however it dies, so a crashed dl never leaves the cache wedged.
13
+
14
+ Two deliberate limits, both load-bearing:
15
+
16
+ - **Not reentrant.** Acquiring a path twice in one process deadlocks (the second
17
+ open file description blocks on the first). Call sites are structured so no
18
+ lock is ever taken while the same lock is held — see the acquisition comments
19
+ at each site.
20
+ - **The lock file is never deleted.** Unlinking an flock'd file is the classic
21
+ self-defeating move: a process that opened the old inode still "holds" a lock
22
+ nobody else can see, while new arrivals lock a fresh file and walk straight
23
+ past it. A few empty ``.lock`` files in the cache are the price of the
24
+ guarantee; ``dl --purge`` sweeps them away with everything else.
25
+ """
26
+
27
+ import contextlib
28
+ import fcntl
29
+ import os
30
+ import sys
31
+ from pathlib import Path
32
+ from typing import Iterator, Optional
33
+
34
+
35
+ @contextlib.contextmanager
36
+ def hold_lock(lock_path: Path, waiting_note: Optional[str] = None) -> Iterator[None]:
37
+ """Hold an exclusive inter-process lock on *lock_path* for the block.
38
+
39
+ Blocks until the lock is free. When another process already holds it and
40
+ *waiting_note* is given, one line is printed to stderr first, so a dl run
41
+ that sits waiting on a sibling's long clone says why it is sitting.
42
+ """
43
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
44
+ fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
45
+ try:
46
+ try:
47
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
48
+ except BlockingIOError:
49
+ if waiting_note:
50
+ print(f"dl: waiting for {waiting_note}", file=sys.stderr)
51
+ fcntl.flock(fd, fcntl.LOCK_EX)
52
+ yield
53
+ finally:
54
+ # Closing the descriptor releases the lock; nothing is unlinked.
55
+ os.close(fd)
@@ -7,6 +7,7 @@ from datetime import datetime
7
7
  from pathlib import Path
8
8
  from typing import Optional, TYPE_CHECKING
9
9
 
10
+ from .locks import hold_lock
10
11
  from .models import BaseRepository
11
12
  from .storage import MetadataStorage
12
13
 
@@ -41,6 +42,15 @@ class RepositoryManager:
41
42
  """Get the bare git directory for a repository."""
42
43
  return self.get_repo_path(owner, repo) / ".bare"
43
44
 
45
+ def lock_path(self, owner: str, repo: str) -> Path:
46
+ """The lock every process takes before mutating repos/<owner>/<repo>.
47
+
48
+ A file, not a directory, inside the repo dir: every walker of the cache
49
+ filters on ``is_dir()``, so it is invisible to discovery, migration and
50
+ completion scans.
51
+ """
52
+ return self.get_repo_path(owner, repo) / ".lock"
53
+
44
54
  def clone_repo(self, owner: str, repo: str, remote_url: str) -> BaseRepository:
45
55
  """Clone a new base repository as bare (no working directory).
46
56
 
@@ -58,7 +68,21 @@ class RepositoryManager:
58
68
  existing_repo = self.get_repo(owner, repo)
59
69
  if existing_repo:
60
70
  return existing_repo
61
- # Repository path exists but metadata doesn't - continue to create metadata
71
+ if (bare_path / "HEAD").exists():
72
+ # The bare clone is already on disk but this process has no
73
+ # record of it -- another process just made it (this process's
74
+ # metadata was loaded before that one saved), or an earlier run
75
+ # died between clone and save. Either way the clone on disk is
76
+ # the authority and the record is derived state: rebuild the
77
+ # record. Cloning over it instead is not an option -- git
78
+ # refuses the non-empty destination, and the failure cleanup
79
+ # below would then delete a cache another launch is using.
80
+ return self._register_existing_bare(owner, repo, remote_url, bare_path)
81
+ # No HEAD: a dead run's partial clone. Holding the repo lock (every
82
+ # caller comes through ensure_repo) means no live process owns it,
83
+ # so clear it and clone fresh.
84
+ logger.warning(f"Removing partial clone at {bare_path}")
85
+ shutil.rmtree(bare_path)
62
86
 
63
87
  # Create parent directory
64
88
  bare_path.parent.mkdir(parents=True, exist_ok=True)
@@ -97,11 +121,28 @@ class RepositoryManager:
97
121
 
98
122
  except subprocess.CalledProcessError as e:
99
123
  logger.debug(f"Failed to clone repository: {e.stderr}")
100
- # Clean up partial clone
124
+ # Clean up the partial clone. Safe to delete: the exists-cases were
125
+ # all handled above, so this directory is one this call created.
101
126
  if bare_path.exists():
102
127
  shutil.rmtree(bare_path)
103
128
  raise RuntimeError(f"Failed to clone repository: {e.stderr}") from e
104
129
 
130
+ def _register_existing_bare(
131
+ self, owner: str, repo: str, remote_url: str, bare_path: Path
132
+ ) -> BaseRepository:
133
+ """Rebuild the metadata record for a bare clone already on disk."""
134
+ base_repo = BaseRepository(
135
+ owner=owner,
136
+ repo=repo,
137
+ remote_url=remote_url,
138
+ local_path=bare_path,
139
+ default_branch=self._get_default_branch(bare_path),
140
+ last_fetched=datetime.now(),
141
+ worktrees=[],
142
+ )
143
+ self.storage.add_repository(base_repo)
144
+ return base_repo
145
+
105
146
  def fetch_repo(self, owner: str, repo: str) -> None:
106
147
  """Fetch latest changes from remote."""
107
148
  bare_path = self.get_bare_path(owner, repo)
@@ -166,20 +207,33 @@ class RepositoryManager:
166
207
  """Ensure repo exists locally, clone if needed.
167
208
 
168
209
  Uses lazy fetch: only fetches if fetch_interval has elapsed since last fetch.
169
- """
170
- if self.repo_exists(owner, repo):
171
- existing_repo = self.get_repo(owner, repo)
172
- if existing_repo:
173
- # Only fetch if interval has elapsed (lazy fetch)
174
- if auto_fetch and self._should_fetch(existing_repo):
175
- try:
176
- self.fetch_repo(owner, repo)
177
- except Exception as e:
178
- logger.warning(f"Failed to fetch updates: {e}")
179
- return existing_repo
180
- # Metadata doesn't exist but repo exists - fall through to clone (which will add metadata)
181
210
 
182
- return self.clone_repo(owner, repo, remote_url)
211
+ The whole exists-check-then-clone sequence runs under the repo lock:
212
+ without it, two processes launching the same repo at once both saw no
213
+ clone and both ran ``git clone --bare`` into the same path — and the
214
+ loser's cleanup in clone_repo deleted the winner's half-written cache.
215
+ Serialized, the loser just waits and then reuses the winner's clone.
216
+ clone_repo and fetch_repo rely on this lock rather than taking it
217
+ themselves (hold_lock is not reentrant).
218
+ """
219
+ with hold_lock(
220
+ self.lock_path(owner, repo),
221
+ waiting_note=f"another dl run preparing {owner}/{repo}",
222
+ ):
223
+ if self.repo_exists(owner, repo):
224
+ existing_repo = self.get_repo(owner, repo)
225
+ if existing_repo:
226
+ # Only fetch if interval has elapsed (lazy fetch)
227
+ if auto_fetch and self._should_fetch(existing_repo):
228
+ try:
229
+ self.fetch_repo(owner, repo)
230
+ except Exception as e:
231
+ logger.warning(f"Failed to fetch updates: {e}")
232
+ return existing_repo
233
+ # Metadata doesn't exist but repo exists - fall through to clone
234
+ # (which will add metadata)
235
+
236
+ return self.clone_repo(owner, repo, remote_url)
183
237
 
184
238
  def repo_exists(self, owner: str, repo: str) -> bool:
185
239
  """Check if repository exists locally."""
@@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional, Tuple
12
12
 
13
13
  from devlaunch.xdg import devlaunch_cache
14
14
 
15
+ from .locks import hold_lock
15
16
  from .models import BaseRepository, WorktreeInfo, unknown_fields
16
17
 
17
18
  # Version of the on-disk metadata.json format.
@@ -83,8 +84,28 @@ class MetadataStorage:
83
84
  self.metadata_path.parent.mkdir(parents=True, exist_ok=True)
84
85
  # Every file operation targets the real file, not a symlink pointing at it.
85
86
  self._file_path = _resolve_link(self.metadata_path)
87
+ # A sidecar rather than the file itself: save() replaces metadata.json
88
+ # by rename, and a lock taken on a replaced inode guards nothing.
89
+ self._lock_path = self._file_path.with_name(self._file_path.name + ".lock")
86
90
  self._load()
87
91
 
92
+ @contextlib.contextmanager
93
+ def exclusive(self):
94
+ """Hold the metadata lock and reload before the block runs.
95
+
96
+ Every mutation goes through this: the in-memory copy was loaded whenever
97
+ this process started, and other dl processes may have written since.
98
+ Rewriting the file from that stale copy silently drops their records —
99
+ reloading under the lock is what makes read-modify-write safe. The
100
+ caller applies its change and calls save() before the block ends.
101
+
102
+ Not reentrant (see locks.py), so mutators must never be called from
103
+ inside an exclusive() block — they take this lock themselves.
104
+ """
105
+ with hold_lock(self._lock_path, waiting_note="another dl run updating the workspace list"):
106
+ self._load()
107
+ yield
108
+
88
109
  def _quarantine(self, reason: str) -> None:
89
110
  """Move an unusable metadata file aside so the data stays inspectable.
90
111
 
@@ -288,8 +309,9 @@ class MetadataStorage:
288
309
  def add_repository(self, repo: BaseRepository) -> None:
289
310
  """Add or update a repository."""
290
311
  key = f"{repo.owner}/{repo.repo}"
291
- self.repositories[key] = repo
292
- self.save()
312
+ with self.exclusive():
313
+ self.repositories[key] = repo
314
+ self.save()
293
315
 
294
316
  def get_repository(self, owner: str, repo: str) -> Optional[BaseRepository]:
295
317
  """Get a repository by owner and name."""
@@ -303,22 +325,24 @@ class MetadataStorage:
303
325
  def remove_repository(self, owner: str, repo: str) -> None:
304
326
  """Remove a repository."""
305
327
  key = f"{owner}/{repo}"
306
- if key in self.repositories:
307
- del self.repositories[key]
308
- self.save()
328
+ with self.exclusive():
329
+ if key in self.repositories:
330
+ del self.repositories[key]
331
+ self.save()
309
332
 
310
333
  def add_worktree(self, worktree: WorktreeInfo) -> None:
311
334
  """Add or update a worktree."""
312
335
  key = f"{worktree.owner}/{worktree.repo}/{worktree.branch}"
313
- self.worktrees[key] = worktree
336
+ with self.exclusive():
337
+ self.worktrees[key] = worktree
314
338
 
315
- # Update repository's worktree list in memory, then write once.
316
- repo = self.get_repository(worktree.owner, worktree.repo)
317
- if repo and worktree.branch not in repo.worktrees:
318
- repo.worktrees.append(worktree.branch)
319
- self.repositories[f"{worktree.owner}/{worktree.repo}"] = repo
339
+ # Update repository's worktree list in memory, then write once.
340
+ repo = self.get_repository(worktree.owner, worktree.repo)
341
+ if repo and worktree.branch not in repo.worktrees:
342
+ repo.worktrees.append(worktree.branch)
343
+ self.repositories[f"{worktree.owner}/{worktree.repo}"] = repo
320
344
 
321
- self.save()
345
+ self.save()
322
346
 
323
347
  def get_worktree(self, owner: str, repo: str, branch: str) -> Optional[WorktreeInfo]:
324
348
  """Get a worktree by repository and branch."""
@@ -348,13 +372,14 @@ class MetadataStorage:
348
372
  def remove_worktree(self, owner: str, repo: str, branch: str) -> None:
349
373
  """Remove a worktree."""
350
374
  key = f"{owner}/{repo}/{branch}"
351
- if key in self.worktrees:
352
- del self.worktrees[key]
375
+ with self.exclusive():
376
+ if key in self.worktrees:
377
+ del self.worktrees[key]
353
378
 
354
- # Update repository's worktree list in memory, then write once.
355
- repo_obj = self.get_repository(owner, repo)
356
- if repo_obj and branch in repo_obj.worktrees:
357
- repo_obj.worktrees.remove(branch)
358
- self.repositories[f"{owner}/{repo}"] = repo_obj
379
+ # Update repository's worktree list in memory, then write once.
380
+ repo_obj = self.get_repository(owner, repo)
381
+ if repo_obj and branch in repo_obj.worktrees:
382
+ repo_obj.worktrees.remove(branch)
383
+ self.repositories[f"{owner}/{repo}"] = repo_obj
359
384
 
360
- self.save()
385
+ self.save()
@@ -25,6 +25,7 @@ from typing import Optional
25
25
  from ..workspace_id import WorkspaceId, validate_ref_name
26
26
  from .branch_manager import BranchManager
27
27
  from .config import WorktreeConfig, get_worktree_config
28
+ from .locks import hold_lock
28
29
  from .models import WorktreeInfo
29
30
  from .repo_manager import RepositoryManager
30
31
  from .storage import MetadataStorage
@@ -173,27 +174,36 @@ class WorkspaceCloneManager:
173
174
 
174
175
  Fetches latest refs, then uses BranchManager to create the branch
175
176
  locally if needed. Does not push to the remote.
177
+
178
+ Runs under the repo lock: the fetch and the branch creation both write
179
+ refs in the shared bare repo, and two processes doing so at once trip
180
+ over git's own ref locks. (hold_lock is not reentrant; no callee here
181
+ takes the repo lock.)
176
182
  """
177
183
  bare_path = self.repo_manager.get_bare_path(owner, repo)
178
- # Lazy-fetch: only hits the network when the fetch interval has elapsed
179
- try:
180
- self.repo_manager.lazy_fetch(owner, repo)
181
- except (RuntimeError, ValueError, OSError) as e:
182
- logger.warning(f"Failed to fetch before branch ensure: {e}")
184
+ with hold_lock(
185
+ self.repo_manager.lock_path(owner, repo),
186
+ waiting_note=f"another dl run preparing {owner}/{repo}",
187
+ ):
188
+ # Lazy-fetch: only hits the network when the fetch interval has elapsed
189
+ try:
190
+ self.repo_manager.lazy_fetch(owner, repo)
191
+ except (RuntimeError, ValueError, OSError) as e:
192
+ logger.warning(f"Failed to fetch before branch ensure: {e}")
183
193
 
184
- try:
185
- default_branch = self.repo_manager.get_default_branch(owner, repo)
186
- except (RuntimeError, subprocess.CalledProcessError, OSError) as e:
187
- logger.warning(f"Failed to resolve default branch: {e}")
188
- default_branch = None
189
-
190
- self.branch_manager.ensure_branch_exists(
191
- bare_path,
192
- branch,
193
- create_remote=False,
194
- start_point=default_branch or "HEAD",
195
- use_local_refs=True,
196
- )
194
+ try:
195
+ default_branch = self.repo_manager.get_default_branch(owner, repo)
196
+ except (RuntimeError, subprocess.CalledProcessError, OSError) as e:
197
+ logger.warning(f"Failed to resolve default branch: {e}")
198
+ default_branch = None
199
+
200
+ self.branch_manager.ensure_branch_exists(
201
+ bare_path,
202
+ branch,
203
+ create_remote=False,
204
+ start_point=default_branch or "HEAD",
205
+ use_local_refs=True,
206
+ )
197
207
 
198
208
  def ensure_workspace(
199
209
  self,
@@ -223,6 +233,28 @@ class WorkspaceCloneManager:
223
233
  bare_repo_path = self.repo_manager.get_bare_path(owner, repo)
224
234
 
225
235
  ws_path = self.get_workspace_path(owner, repo, branch)
236
+
237
+ # Steps 2-6 mutate the workspace clone, so they run under the repo
238
+ # lock: fire the same workspace twice at once and, unserialized, each
239
+ # process saw no clone, both cloned into the same path, and the loser's
240
+ # cleanup deleted the winner's. The lock is taken only after
241
+ # ensure_repo (which takes the same lock) has returned -- hold_lock is
242
+ # not reentrant.
243
+ with hold_lock(
244
+ self.repo_manager.lock_path(owner, repo),
245
+ waiting_note=f"another dl run preparing {owner}/{repo}",
246
+ ):
247
+ return self._prepare_workspace(workspace, bare_repo_path, ws_path, remote_url)
248
+
249
+ def _prepare_workspace(
250
+ self,
251
+ workspace: WorkspaceId,
252
+ bare_repo_path: Path,
253
+ ws_path: Path,
254
+ remote_url: str,
255
+ ) -> Path:
256
+ """Steps 2-6 of ensure_workspace; the caller holds the repo lock."""
257
+ owner, repo, branch = workspace.owner, workspace.repo, workspace.ref
226
258
  is_new_workspace = False
227
259
  if not self.workspace_exists(owner, repo, branch):
228
260
  is_new_workspace = True
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devlaunch"
3
- version = "0.0.19"
3
+ version = "0.0.21"
4
4
  authors = [{ name = "Austin Gregg-Smith", email = "blooop@gmail.com" }]
5
5
  description = "DevLaunch - A streamlined CLI for devpod workspaces"
6
6
  readme = "README.md"
File without changes
File without changes
File without changes
File without changes