devlaunch 0.0.18__tar.gz → 0.0.20__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.18 → devlaunch-0.0.20}/PKG-INFO +50 -7
  2. {devlaunch-0.0.18 → devlaunch-0.0.20}/README.md +49 -6
  3. {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/dl.py +298 -48
  4. {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/worktree/config.py +3 -12
  5. devlaunch-0.0.20/devlaunch/worktree/locks.py +55 -0
  6. {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/worktree/repo_manager.py +69 -15
  7. {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/worktree/storage.py +48 -24
  8. {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/worktree/workspace_clone.py +50 -18
  9. devlaunch-0.0.20/devlaunch/xdg.py +47 -0
  10. {devlaunch-0.0.18 → devlaunch-0.0.20}/pyproject.toml +5 -1
  11. devlaunch-0.0.18/devlaunch/xdg.py +0 -21
  12. {devlaunch-0.0.18 → devlaunch-0.0.20}/.gitignore +0 -0
  13. {devlaunch-0.0.18 → devlaunch-0.0.20}/LICENSE +0 -0
  14. {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/__init__.py +0 -0
  15. {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/aid.py +0 -0
  16. {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/completion.py +0 -0
  17. {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/completion_loader.py +0 -0
  18. {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/completions/__init__.py +0 -0
  19. {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/completions/dl.bash +0 -0
  20. {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/devpod_provider.py +0 -0
  21. {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/devpod_ssh.py +0 -0
  22. {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/gh_auth.py +0 -0
  23. {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/tools.py +0 -0
  24. {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/tty_session.py +0 -0
  25. {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/workspace_id.py +0 -0
  26. {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/worktree/__init__.py +0 -0
  27. {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/worktree/branch_manager.py +0 -0
  28. {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/worktree/migration.py +0 -0
  29. {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/worktree/models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlaunch
3
- Version: 0.0.18
3
+ Version: 0.0.20
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
@@ -340,7 +340,7 @@ existed — picks the tools up on its next `dl <workspace> restart`.
340
340
  |---------|-------------|
341
341
  | `dl --ls` | List all workspaces |
342
342
  | `dl --install` | Install shell completions |
343
- | `dl --purge [-y]` | Remove all devlaunch data |
343
+ | `dl --purge [-y]` | Remove all devlaunch data — [the workspaces devlaunch created](#what-purge-deletes), and its caches |
344
344
  | `dl --prune-worktrees [days]` | Remove unused worktrees (default: 30 days) |
345
345
  | `dl --refresh` | Refresh completion cache |
346
346
  | `dl --help, -h` | Show this help |
@@ -362,6 +362,40 @@ dl 0.0.9 (dev, editable from /path/to/your/devlaunch)
362
362
  from the installed package's own PEP 610 metadata; an install that records none
363
363
  just prints the bare version.
364
364
 
365
+ ### What purge deletes
366
+
367
+ devpod's workspace list is shared. A workspace you made with `devpod up`, or that
368
+ another tool made, sits in the same list as the ones `dl` made, and `dl --purge`
369
+ has no business destroying it. So it deletes only the workspaces devlaunch
370
+ created — the clones it made under its own cache directory (`$XDG_CACHE_HOME` or
371
+ `~/.cache`, then `devlaunch/repos/<owner>/<repo>/<id>`), which is exactly the
372
+ directory the purge is about to remove anyway. Everything else keeps working
373
+ afterwards, because nothing a purge touches backs it.
374
+
375
+ Anything it is leaving is named before it asks:
376
+
377
+ ```
378
+ $ dl --purge
379
+ This will remove all devlaunch data:
380
+ - 4 DevPod workspace(s)
381
+ - /home/you/.cache/devlaunch/ (workspace clones, repo caches, completions)
382
+
383
+ Leaving 2 workspace(s) devlaunch did not create:
384
+ - pythontemplate
385
+ - my-hand-made-workspace
386
+
387
+ Are you sure? [y/N]
388
+ ```
389
+
390
+ Three things `dl` does create are in that second list rather than the first.
391
+ `dl ./some/path` and `dl <git-url>` open a source `dl` did not clone, so it
392
+ cannot tell them from a workspace you made by hand — and a `config.toml` that
393
+ points `repos_dir` outside the cache puts the clones somewhere `--purge` does not
394
+ remove either, so those are left too. Delete any of them with `dl <workspace> rm`.
395
+ Erring this way is deliberate — a purge that skips one of your own workspaces
396
+ costs you a command, and the other kind of mistake costs you work you cannot get
397
+ back.
398
+
365
399
  ## Examples
366
400
 
367
401
  ```bash
@@ -452,10 +486,19 @@ pixi run style
452
486
  `pixi run test` skips the e2e tests, which need devpod and a Docker daemon and
453
487
  build real containers. CI runs `pixi run test-e2e` in a job of its own, outside
454
488
  the Python matrix, on a throwaway runner — on every push to `main` and on every
455
- pull request that targets `main`. A pull request onto any other base runs no CI
456
- at all, e2e included, because the whole workflow is triggered by
457
- `pull_request: branches: [main]`; that gap covers every job here rather than
458
- this one, and stacked PRs are what walk into it.
489
+ pull request, whatever branch that pull request targets. Stacked chains, where
490
+ each link targets its predecessor rather than `main`, get the same CI as anything
491
+ else.
492
+
493
+ Alongside the matrix and e2e there is a `gate` job that does nothing but fail
494
+ unless every other job in that workflow succeeded. It exists so that a branch
495
+ ruleset has one stable name to require rather than a list: requiring the jobs one
496
+ by one means literal strings in a repository setting, which nobody reviews and
497
+ which goes stale the moment a job is added or renamed — and a required check that
498
+ no longer exists does not turn a merge red, it stops gating it. Adding a job
499
+ means adding it to `gate`'s `needs`, in the same pull request, where it can be
500
+ seen. It reaches only as far as its own workflow file, so the `prek` lint job is
501
+ not behind it and has to be required alongside it.
459
502
 
460
503
  Running it yourself is a different proposition. This repo's devcontainer carries
461
504
  a Docker daemon of its own, through the `docker-in-docker` feature, and pins the
@@ -475,7 +518,7 @@ a healthy one. Every run also prints what it actually built:
475
518
 
476
519
  ```
477
520
  --------------------------------- e2e session ---------------------------------
478
- 22 e2e tests attempted, 4 workspaces created: e2e-test-create, e2e-test-lifecycle, e2e-test-git, e2e-test-purge
521
+ 22 e2e tests attempted, 5 workspaces created: e2e-test-create, e2e-test-lifecycle, e2e-test-git, e2e-purge-devlaunchs, e2e-purge-hand-made
479
522
  ```
480
523
 
481
524
  A run whose workspace-building tests built nothing does not pass: the shortfall
@@ -318,7 +318,7 @@ existed — picks the tools up on its next `dl <workspace> restart`.
318
318
  |---------|-------------|
319
319
  | `dl --ls` | List all workspaces |
320
320
  | `dl --install` | Install shell completions |
321
- | `dl --purge [-y]` | Remove all devlaunch data |
321
+ | `dl --purge [-y]` | Remove all devlaunch data — [the workspaces devlaunch created](#what-purge-deletes), and its caches |
322
322
  | `dl --prune-worktrees [days]` | Remove unused worktrees (default: 30 days) |
323
323
  | `dl --refresh` | Refresh completion cache |
324
324
  | `dl --help, -h` | Show this help |
@@ -340,6 +340,40 @@ dl 0.0.9 (dev, editable from /path/to/your/devlaunch)
340
340
  from the installed package's own PEP 610 metadata; an install that records none
341
341
  just prints the bare version.
342
342
 
343
+ ### What purge deletes
344
+
345
+ devpod's workspace list is shared. A workspace you made with `devpod up`, or that
346
+ another tool made, sits in the same list as the ones `dl` made, and `dl --purge`
347
+ has no business destroying it. So it deletes only the workspaces devlaunch
348
+ created — the clones it made under its own cache directory (`$XDG_CACHE_HOME` or
349
+ `~/.cache`, then `devlaunch/repos/<owner>/<repo>/<id>`), which is exactly the
350
+ directory the purge is about to remove anyway. Everything else keeps working
351
+ afterwards, because nothing a purge touches backs it.
352
+
353
+ Anything it is leaving is named before it asks:
354
+
355
+ ```
356
+ $ dl --purge
357
+ This will remove all devlaunch data:
358
+ - 4 DevPod workspace(s)
359
+ - /home/you/.cache/devlaunch/ (workspace clones, repo caches, completions)
360
+
361
+ Leaving 2 workspace(s) devlaunch did not create:
362
+ - pythontemplate
363
+ - my-hand-made-workspace
364
+
365
+ Are you sure? [y/N]
366
+ ```
367
+
368
+ Three things `dl` does create are in that second list rather than the first.
369
+ `dl ./some/path` and `dl <git-url>` open a source `dl` did not clone, so it
370
+ cannot tell them from a workspace you made by hand — and a `config.toml` that
371
+ points `repos_dir` outside the cache puts the clones somewhere `--purge` does not
372
+ remove either, so those are left too. Delete any of them with `dl <workspace> rm`.
373
+ Erring this way is deliberate — a purge that skips one of your own workspaces
374
+ costs you a command, and the other kind of mistake costs you work you cannot get
375
+ back.
376
+
343
377
  ## Examples
344
378
 
345
379
  ```bash
@@ -430,10 +464,19 @@ pixi run style
430
464
  `pixi run test` skips the e2e tests, which need devpod and a Docker daemon and
431
465
  build real containers. CI runs `pixi run test-e2e` in a job of its own, outside
432
466
  the Python matrix, on a throwaway runner — on every push to `main` and on every
433
- pull request that targets `main`. A pull request onto any other base runs no CI
434
- at all, e2e included, because the whole workflow is triggered by
435
- `pull_request: branches: [main]`; that gap covers every job here rather than
436
- this one, and stacked PRs are what walk into it.
467
+ pull request, whatever branch that pull request targets. Stacked chains, where
468
+ each link targets its predecessor rather than `main`, get the same CI as anything
469
+ else.
470
+
471
+ Alongside the matrix and e2e there is a `gate` job that does nothing but fail
472
+ unless every other job in that workflow succeeded. It exists so that a branch
473
+ ruleset has one stable name to require rather than a list: requiring the jobs one
474
+ by one means literal strings in a repository setting, which nobody reviews and
475
+ which goes stale the moment a job is added or renamed — and a required check that
476
+ no longer exists does not turn a merge red, it stops gating it. Adding a job
477
+ means adding it to `gate`'s `needs`, in the same pull request, where it can be
478
+ seen. It reaches only as far as its own workflow file, so the `prek` lint job is
479
+ not behind it and has to be required alongside it.
437
480
 
438
481
  Running it yourself is a different proposition. This repo's devcontainer carries
439
482
  a Docker daemon of its own, through the `docker-in-docker` feature, and pins the
@@ -453,7 +496,7 @@ a healthy one. Every run also prints what it actually built:
453
496
 
454
497
  ```
455
498
  --------------------------------- e2e session ---------------------------------
456
- 22 e2e tests attempted, 4 workspaces created: e2e-test-create, e2e-test-lifecycle, e2e-test-git, e2e-test-purge
499
+ 22 e2e tests attempted, 5 workspaces created: e2e-test-create, e2e-test-lifecycle, e2e-test-git, e2e-purge-devlaunchs, e2e-purge-hand-made
457
500
  ```
458
501
 
459
502
  A run whose workspace-building tests built nothing does not pass: the shortfall
@@ -22,13 +22,12 @@ import sys
22
22
  import subprocess
23
23
  import json
24
24
  import logging
25
- import os
26
25
  import pathlib
27
26
  import re
28
27
  import shlex
29
28
  import time
30
29
  from importlib.metadata import version as pkg_version, PackageNotFoundError, distribution
31
- from typing import List, Optional, Dict, Any
30
+ from typing import Any, Dict, List, Mapping, NoReturn, Optional, Sequence, Tuple
32
31
  from dataclasses import dataclass
33
32
  from urllib.parse import urlparse
34
33
  from urllib.request import url2pathname
@@ -39,6 +38,7 @@ from .workspace_id import TARGET_LENGTH, WorkspaceId, slug, source_workspace_id,
39
38
  from .worktree.config import get_worktree_config
40
39
  from .worktree.migration import migrate_cache
41
40
  from .worktree.workspace_clone import WorkspaceCloneManager
41
+ from .xdg import devlaunch_cache
42
42
 
43
43
 
44
44
  class MissingBinary(Exception):
@@ -162,11 +162,15 @@ logging.basicConfig(level=logging.INFO, format="%(message)s")
162
162
 
163
163
 
164
164
  def _get_cache_dir() -> pathlib.Path:
165
- """Get the cache directory, honoring XDG_CACHE_HOME."""
166
- xdg_cache = os.environ.get("XDG_CACHE_HOME")
167
- if xdg_cache:
168
- return pathlib.Path(xdg_cache) / "devlaunch"
169
- return pathlib.Path.home() / ".cache" / "devlaunch"
165
+ """Get the cache directory, honoring XDG_CACHE_HOME.
166
+
167
+ The answer comes from devlaunch.xdg so that this, the worktree config's
168
+ default `repos_dir` and metadata.json's default path cannot drift apart --
169
+ is_devlaunch_clone decides what `--purge` may delete by asking whether a
170
+ workspace's source is under this directory, and the clones it is asking
171
+ about were put there by the other two.
172
+ """
173
+ return devlaunch_cache()
170
174
 
171
175
 
172
176
  # Cache configuration (honors XDG_CACHE_HOME)
@@ -428,32 +432,39 @@ def update_cache_background(force: bool = False) -> None:
428
432
 
429
433
 
430
434
  def purge_all_data() -> int:
431
- """Purge all devlaunch data including DevPod workspaces and caches.
435
+ """Purge devlaunch's data: the workspaces it created, and its caches.
432
436
 
433
437
  This:
434
- 1. Deletes all DevPod workspaces
438
+ 1. Deletes the DevPod workspaces devlaunch created -- see
439
+ is_devlaunch_clone for what that means, and for what it leaves alone.
435
440
  2. Removes ~/.cache/devlaunch/ which contains:
436
441
  - completions.json, completions.bash (completion caches)
442
+
443
+ Workspaces devlaunch did not create are not deleted and not reported here;
444
+ the report belongs with the confirmation, before anything is destroyed, so
445
+ it lives in main() where the user still has a decision to make.
437
446
  """
438
447
  import shutil
439
448
 
440
449
  cache_dir = _get_cache_dir()
441
450
 
442
- # First, delete all DevPod workspaces. The list is the same snapshot the
443
- # caller printed the count from, so the confirmation the user answered and
444
- # the set actually deleted cannot disagree.
445
- workspaces = list_workspaces()
446
- for ws in workspaces:
451
+ # First, delete the DevPod workspaces devlaunch made. The list is the same
452
+ # snapshot the caller printed the count from -- list_workspaces() is
453
+ # memoized per command and nothing between the two reads changes what devpod
454
+ # would say -- so the confirmation the user answered and the set actually
455
+ # deleted cannot disagree.
456
+ owned = workspace_ownership(list_workspaces(), cache_dir)
457
+ for ws in owned.mine:
447
458
  print(f"Deleting DevPod workspace: {ws.id}")
448
459
  result = run_devpod(["delete", ws.id, "--force"], capture=True)
449
460
  if result.returncode != 0:
450
461
  logging.warning(f"Failed to delete workspace {ws.id}: {result.stderr}")
451
- if workspaces:
462
+ if owned.mine:
452
463
  invalidate_workspace_list_cache()
453
464
 
454
465
  # Then remove local cache
455
466
  if not cache_dir.exists():
456
- if not workspaces:
467
+ if not owned.mine:
457
468
  print("No data to purge.")
458
469
  return 0
459
470
 
@@ -697,41 +708,235 @@ def validate_workspace_spec(spec: str, existing_ids: List[str]) -> Optional[str]
697
708
  return f"Unknown workspace '{spec}'. Use 'dl --ls' to list workspaces, or specify owner/repo or ./path"
698
709
 
699
710
 
711
+ @dataclass(frozen=True)
712
+ class LocalFolder:
713
+ """devpod is opening a directory on this machine."""
714
+
715
+ path: str
716
+
717
+
718
+ @dataclass(frozen=True)
719
+ class GitRepository:
720
+ """devpod is opening a repository it clones itself, named by URL.
721
+
722
+ Never a path on this machine, which is why the purge predicate can refuse
723
+ this arm outright instead of asking where it points.
724
+ """
725
+
726
+ url: str
727
+
728
+
729
+ @dataclass(frozen=True)
730
+ class UnrecognisedSource:
731
+ """A `devpod list` source devlaunch has no reading for, kept verbatim.
732
+
733
+ Reachable rather than defensive: devpod's workspace source also carries
734
+ `image` and `container`, so `devpod up ubuntu:24.04` lands here. Holding the
735
+ payload rather than a rendering of it is the whole point -- the field this
736
+ replaced was typed as a path and filled with `str(the dict)`, so the one
737
+ thing a caller could not do with it was read what devpod had said.
738
+
739
+ It deliberately has no path and no URL. There is nothing to make it say
740
+ where the source lives, because devlaunch does not know.
741
+ """
742
+
743
+ payload: Mapping[str, Any]
744
+
745
+
746
+ WorkspaceSource = LocalFolder | GitRepository | UnrecognisedSource
747
+
748
+
749
+ def _unhandled_source(source: NoReturn) -> NoReturn:
750
+ """Reject a source arm nobody handled -- at type-check time, not at runtime.
751
+
752
+ `ty` runs in CI, so passing anything but `Never` here is a build failure:
753
+ adding an arm to WorkspaceSource breaks every reader that has not grown a
754
+ case for it. That is the property this type exists for, and it is why this
755
+ body should be unreachable in a passing build.
756
+
757
+ Hand-rolled rather than `typing.assert_never`, which is 3.11+ while this
758
+ package supports 3.10. A parameter typed `NoReturn` gets the same treatment
759
+ from the checker.
760
+ """
761
+ raise AssertionError(f"Unhandled workspace source: {source!r}")
762
+
763
+
764
+ def _readable_text(source: Mapping[str, Any], key: str) -> Optional[str]:
765
+ """*key* from *source* if devpod filled it with text, else None.
766
+
767
+ devpod's listing is JSON dl did not write, so `source[key]` is whatever the
768
+ JSON held. Returning None for anything else is what keeps the arms below
769
+ from being handed a value of a type their field does not describe.
770
+ """
771
+ value = source.get(key)
772
+ return value if isinstance(value, str) and value else None
773
+
774
+
775
+ def parse_workspace_source(source: Mapping[str, Any]) -> WorkspaceSource:
776
+ """Read one `devpod list --output json` source object.
777
+
778
+ The keys are checked in devpod's own order of specificity: a source object
779
+ carrying both is a devpod that has changed under us, and taking the first
780
+ match keeps that from silently becoming the other one.
781
+
782
+ An arm is only as honest as the value put into it, so a key has to be
783
+ non-empty text before it counts. Both halves of that are load-bearing rather
784
+ than tidy. `git -C ""` is a no-op that succeeds, so a `LocalFolder("")`
785
+ reaching repo discovery would be credited with whatever repository the
786
+ person running `dl` happened to be standing in. And a `localFolder` that is
787
+ itself an object would put a dict in a field typed as a path -- the exact
788
+ thing this type exists to make unrepresentable -- which the two readers that
789
+ treat it as a path raise `TypeError` on.
790
+
791
+ Neither is an unreadable *listing*, unlike a `source` that is not an object
792
+ at all: the object is right here and can be kept whole. It is a source dl
793
+ cannot read, which is an arm.
794
+
795
+ Note this is the one link in the chain the type checker cannot stand in for
796
+ a test. A *reader* that misses an arm is a build failure, but this is a
797
+ producer, and a producer that quietly stops producing an arm type-checks
798
+ fine -- so the arms it picks are pinned by tests instead.
799
+ """
800
+ path = _readable_text(source, "localFolder")
801
+ if path is not None:
802
+ return LocalFolder(path)
803
+ url = _readable_text(source, "gitRepository")
804
+ if url is not None:
805
+ return GitRepository(url)
806
+ return UnrecognisedSource(dict(source))
807
+
808
+
809
+ def describe_source(source: WorkspaceSource) -> Tuple[str, str]:
810
+ """How a source reads in `dl --ls` and in the fzf picker: (kind, detail).
811
+
812
+ One function for both columns, and one function for both callers, so the
813
+ kind shown and the detail shown cannot come from two different readings of
814
+ the same source -- which is what a tag beside a parallel field allowed.
815
+
816
+ An unreadable source shows the payload as JSON. That is a debug rendering
817
+ and is meant to be: the listing's job is to put the row on screen rather
818
+ than pass over it, and devlaunch has nothing truer to say about it.
819
+ """
820
+ if isinstance(source, LocalFolder):
821
+ return "local", source.path
822
+ if isinstance(source, GitRepository):
823
+ return "git", source.url
824
+ if isinstance(source, UnrecognisedSource):
825
+ return "unknown", json.dumps(source.payload)
826
+ _unhandled_source(source)
827
+
828
+
700
829
  @dataclass
701
830
  class Workspace:
702
831
  """Represents a devpod workspace."""
703
832
 
704
833
  id: str
705
- source_type: str # "local" or "git"
706
- source: str
834
+ source: WorkspaceSource
707
835
  last_used: str
708
836
  provider: str
709
837
  ide: str
710
838
 
711
839
  @classmethod
712
- def from_json(cls, data: Dict[str, Any]) -> "Workspace":
840
+ def from_json(cls, data: Mapping[str, Any]) -> "Workspace":
713
841
  """Parse workspace from devpod JSON output."""
714
- source = data.get("source", {})
715
- if "localFolder" in source:
716
- source_type = "local"
717
- source_path = source["localFolder"]
718
- elif "gitRepository" in source:
719
- source_type = "git"
720
- source_path = source["gitRepository"]
721
- else:
722
- source_type = "unknown"
723
- source_path = str(source)
724
-
725
842
  return cls(
726
843
  id=data.get("id", ""),
727
- source_type=source_type,
728
- source=source_path,
844
+ source=parse_workspace_source(data.get("source", {})),
729
845
  last_used=data.get("lastUsed", ""),
730
846
  provider=data.get("provider", {}).get("name", ""),
731
847
  ide=data.get("ide", {}).get("name", ""),
732
848
  )
733
849
 
734
850
 
851
+ def is_devlaunch_clone(workspace: Workspace, cache_dir: pathlib.Path) -> bool:
852
+ """Whether *workspace* is one devlaunch made, rather than someone else's.
853
+
854
+ devpod's workspace namespace is shared: `devpod up` by hand, another tool
855
+ and an older devlaunch all land in the same list, and devlaunch has no
856
+ business destroying any of them. The question this answers is the narrow
857
+ one -- *did I make this* -- and it is answered from where the workspace's
858
+ source lives.
859
+
860
+ Every workspace `dl owner/repo[@branch]` creates is a clone devlaunch put
861
+ under its own cache directory (`<cache>/repos/<owner>/<repo>/<id>`, see
862
+ WorkspaceCloneManager.get_workspace_path) and then handed to `devpod up` as
863
+ a path, so devpod records that path as the source. That makes the predicate
864
+ say something true of `--purge` rather than merely correlated with it: the
865
+ cache directory is exactly what a purge removes, so the workspaces it
866
+ deletes are the ones whose source it is about to delete anyway. Anything
867
+ else keeps working afterwards, because nothing a purge touches backs it.
868
+
869
+ Chosen over reading metadata.json, which also records these ids, for two
870
+ reasons. metadata.json lives *inside* the cache directory a purge removes,
871
+ and `purge_all_data` only warns when `devpod delete` fails -- so one failed
872
+ delete plus one successful purge would leave a workspace no later purge
873
+ could ever recognise. And the record is append-mostly: nothing prunes it,
874
+ so it accumulates entries for workspaces that are long gone. A source path
875
+ is read back from devpod itself every time, and survives the cache it names.
876
+
877
+ Deliberately conservative in two places. A `git` or unrecognised source is
878
+ never ours: devlaunch always passes devpod a local path. And the comparison
879
+ is a path containment test, not a string prefix -- `<cache>-scratch` shares
880
+ six characters with `<cache>` and is not inside it.
881
+
882
+ That first refusal is now half structural. An unrecognised source has no
883
+ path on it at all, so there is nothing to test for containment and no way
884
+ to write the mistake; a git source does carry a string that could be one --
885
+ `devpod up <path-to-bare-repo>` records a `gitRepository`, and nothing
886
+ stops that repo living in the cache -- so refusing that arm is a decision
887
+ this function still has to make, and it is made by name.
888
+
889
+ Not every workspace dl creates is recognised. `dl ./path` and `dl <git-url>`
890
+ open a source dl did not clone and does not record anywhere, and a
891
+ `config.toml` that points `repos_dir` outside the cache puts the clones
892
+ somewhere `--purge` does not remove either -- so all three read as someone
893
+ else's. That is the safe direction to be wrong in, it keeps this predicate
894
+ and what a purge actually destroys answering to the same directory, and
895
+ `--purge` names what it leaves rather than passing over it in silence.
896
+ """
897
+ source = workspace.source
898
+ if isinstance(source, LocalFolder):
899
+ path = pathlib.PurePath(source.path)
900
+ # Purely lexical, so a clone whose directory has already been removed is
901
+ # still recognisable from the source devpod kept.
902
+ return path != pathlib.PurePath(cache_dir) and path.is_relative_to(cache_dir)
903
+ if isinstance(source, (GitRepository, UnrecognisedSource)):
904
+ return False
905
+ _unhandled_source(source)
906
+
907
+
908
+ @dataclass(frozen=True)
909
+ class WorkspaceOwnership:
910
+ """A `devpod list` answer split by whether devlaunch created each workspace.
911
+
912
+ A value rather than a filter applied at the point of deletion: `--purge`
913
+ prints a count and then deletes a set, and those two cannot disagree if they
914
+ read the same object. It also gives the workspaces devlaunch does *not* own
915
+ somewhere to be named from, which is the difference between a purge that
916
+ surprises a user with survivors and one that lists them.
917
+ """
918
+
919
+ mine: Tuple[Workspace, ...]
920
+ foreign: Tuple[Workspace, ...]
921
+
922
+
923
+ def workspace_ownership(
924
+ workspaces: Sequence[Workspace], cache_dir: pathlib.Path
925
+ ) -> WorkspaceOwnership:
926
+ """Split *workspaces* into the ones devlaunch made and the ones it did not.
927
+
928
+ Total: every workspace lands in exactly one arm, and listing order is kept
929
+ within each, so what is printed reads in the order devpod gave.
930
+ """
931
+ mine: List[Workspace] = []
932
+ foreign: List[Workspace] = []
933
+ for ws in workspaces:
934
+ # One pass, so the predicate is asked once per workspace and the two
935
+ # arms cannot be built from two different answers to the same question.
936
+ (mine if is_devlaunch_clone(ws, cache_dir) else foreign).append(ws)
937
+ return WorkspaceOwnership(mine=tuple(mine), foreign=tuple(foreign))
938
+
939
+
735
940
  # Regex patterns for parsing git URLs
736
941
  GIT_URL_PATTERNS = [
737
942
  # git@github.com:owner/repo.git
@@ -800,22 +1005,39 @@ def discover_repos_from_workspaces(workspaces: List[Workspace]) -> Dict[str, Lis
800
1005
  """Discover owner/repo from workspace git remotes.
801
1006
 
802
1007
  Returns dict mapping owner -> list of repos.
1008
+
1009
+ Every source is answered, including the ones devlaunch cannot read. There
1010
+ is no owner/repo to be had from an image reference, so the answer for that
1011
+ arm is still "nothing discovered" -- but it is said rather than reached by
1012
+ falling off the end of the chain, which is what used to make it
1013
+ indistinguishable from a source that was read fine and held no repo.
803
1014
  """
804
1015
  repos: Dict[str, List[str]] = {}
805
1016
 
806
1017
  for ws in workspaces:
807
1018
  owner_repo = None
1019
+ source = ws.source
808
1020
 
809
1021
  # For git workspaces, parse the source URL directly
810
- if ws.source_type == "git":
811
- owner_repo = parse_owner_repo_from_url(ws.source)
1022
+ if isinstance(source, GitRepository):
1023
+ owner_repo = parse_owner_repo_from_url(source.url)
812
1024
 
813
1025
  # For local workspaces, try to get git remote
814
- elif ws.source_type == "local" and ws.source:
815
- remote_url = get_git_remote_url(ws.source)
1026
+ elif isinstance(source, LocalFolder):
1027
+ remote_url = get_git_remote_url(source.path)
816
1028
  if remote_url:
817
1029
  owner_repo = parse_owner_repo_from_url(remote_url)
818
1030
 
1031
+ elif isinstance(source, UnrecognisedSource):
1032
+ logging.warning(
1033
+ f"Not looking for a repo in workspace '{ws.id}': "
1034
+ f"devpod describes its source as {json.dumps(source.payload)}, "
1035
+ "which devlaunch cannot read."
1036
+ )
1037
+
1038
+ else:
1039
+ _unhandled_source(source)
1040
+
819
1041
  if owner_repo:
820
1042
  owner, repo = owner_repo
821
1043
  if owner not in repos:
@@ -959,6 +1181,12 @@ def parse_workspaces(listing: str) -> List[Workspace]:
959
1181
  that there is nothing to list. Silence gets a branch of its own rather than
960
1182
  falling into the JSON parser, whose report of it -- `not JSON: ''` -- reads
961
1183
  like a bug in dl rather than a devpod that never spoke.
1184
+
1185
+ A `source` that is not an object is refused here for the same reason. The
1186
+ source arms below are total over the object devpod documents, and the arm
1187
+ for a source dl cannot read holds that object -- so something that is not
1188
+ one is not an unreadable source, it is an unreadable *listing*, and that is
1189
+ the answer this function already knows how to give.
962
1190
  """
963
1191
  if not listing.strip():
964
1192
  raise UnreadableWorkspaceList(
@@ -979,6 +1207,12 @@ def parse_workspaces(listing: str) -> List[Workspace]:
979
1207
  raise UnreadableWorkspaceList(
980
1208
  f"expected each listed workspace to be an object, got {type(entry).__name__}"
981
1209
  )
1210
+ source = entry.get("source", {})
1211
+ if not isinstance(source, dict):
1212
+ raise UnreadableWorkspaceList(
1213
+ f"expected workspace {entry.get('id', '')!r} to have an object for its "
1214
+ f"source, got {type(source).__name__}"
1215
+ )
982
1216
  return [Workspace.from_json(ws) for ws in parsed]
983
1217
 
984
1218
 
@@ -1036,10 +1270,14 @@ def print_workspaces():
1036
1270
  print("No workspaces found.")
1037
1271
  return
1038
1272
 
1273
+ # Describe each source once, so the widths are measured on the same strings
1274
+ # that get printed.
1275
+ rows = [(ws, *describe_source(ws.source)) for ws in workspaces]
1276
+
1039
1277
  # Calculate column widths
1040
- id_width = max(len(ws.id) for ws in workspaces)
1041
- type_width = max(len(ws.source_type) for ws in workspaces)
1042
- source_width = max(len(ws.source) for ws in workspaces)
1278
+ id_width = max(len(ws.id) for ws, _kind, _detail in rows)
1279
+ type_width = max(len(kind) for _ws, kind, _detail in rows)
1280
+ source_width = max(len(detail) for _ws, _kind, detail in rows)
1043
1281
 
1044
1282
  # Print header
1045
1283
  print(
@@ -1048,11 +1286,9 @@ def print_workspaces():
1048
1286
  print("-" * (id_width + type_width + source_width + 30))
1049
1287
 
1050
1288
  # Print rows
1051
- for ws in workspaces:
1289
+ for ws, kind, detail in rows:
1052
1290
  last_used = ws.last_used[:19].replace("T", " ") if ws.last_used else "never"
1053
- print(
1054
- f"{ws.id:<{id_width}} {ws.source_type:<{type_width}} {ws.source:<{source_width}} {last_used}"
1055
- )
1291
+ print(f"{ws.id:<{id_width}} {kind:<{type_width}} {detail:<{source_width}} {last_used}")
1056
1292
 
1057
1293
 
1058
1294
  def fuzzy_select_workspace() -> Optional[str]:
@@ -1072,7 +1308,8 @@ def fuzzy_select_workspace() -> Optional[str]:
1072
1308
  options = []
1073
1309
  ws_map = {}
1074
1310
  for ws in workspaces:
1075
- label = f"{ws.id} | {ws.source_type} | {ws.source}"
1311
+ kind, detail = describe_source(ws.source)
1312
+ label = f"{ws.id} | {kind} | {detail}"
1076
1313
  options.append(label)
1077
1314
  ws_map[label] = ws.id
1078
1315
 
@@ -1383,7 +1620,7 @@ Global commands:
1383
1620
  dl --ls List all workspaces
1384
1621
  dl --install Install shell completions
1385
1622
  dl --refresh Refresh completion cache
1386
- dl --purge [-y] Remove all DevPod workspaces and caches
1623
+ dl --purge [-y] Remove devlaunch's workspaces and caches
1387
1624
  dl --help, -h Show this help
1388
1625
  dl --version Show version (editable installs name their tree)
1389
1626
 
@@ -1422,7 +1659,12 @@ def _get_clone_manager() -> WorkspaceCloneManager:
1422
1659
  if "clone_manager" not in _cache:
1423
1660
  manager = WorkspaceCloneManager()
1424
1661
  try:
1425
- migrate_cache(manager.storage, pathlib.Path(manager.config.repos_dir))
1662
+ # Under the metadata lock so two dl processes cannot migrate at
1663
+ # once: the renames are not idempotent mid-flight, and exclusive()
1664
+ # reloads first so the version check sees the other side's result.
1665
+ # migrate_cache calls save() directly, never a locked mutator.
1666
+ with manager.storage.exclusive():
1667
+ migrate_cache(manager.storage, pathlib.Path(manager.config.repos_dir))
1426
1668
  except OSError as e:
1427
1669
  # A failed migration must not take the command with it. The renames
1428
1670
  # that did happen are still resumable: the version header is only
@@ -1573,10 +1815,18 @@ def _run_cli(argv: Optional[List[str]] = None) -> int:
1573
1815
  # Check for -y flag to skip confirmation
1574
1816
  skip_confirm = len(args) > 1 and args[1] in ("-y", "--yes")
1575
1817
  cache_dir = _get_cache_dir()
1576
- workspaces = list_workspaces()
1818
+ owned = workspace_ownership(list_workspaces(), cache_dir)
1577
1819
  print("This will remove all devlaunch data:")
1578
- print(f" - {len(workspaces)} DevPod workspace(s)")
1820
+ print(f" - {len(owned.mine)} DevPod workspace(s)")
1579
1821
  print(f" - {cache_dir}/ (workspace clones, repo caches, completions)")
1822
+ # Named, not merely excluded from the count: a user who asked for a
1823
+ # clean slate and gets survivors should learn it here, while saying no
1824
+ # is still an option, rather than from a later `dl --ls`.
1825
+ if owned.foreign:
1826
+ print()
1827
+ print(f"Leaving {len(owned.foreign)} workspace(s) devlaunch did not create:")
1828
+ for ws in owned.foreign:
1829
+ print(f" - {ws.id}")
1580
1830
  print()
1581
1831
  if skip_confirm:
1582
1832
  return purge_all_data()
@@ -1,21 +1,12 @@
1
1
  """Configuration management for worktree backend."""
2
2
 
3
- import os
4
3
  from dataclasses import dataclass, field
5
4
  from pathlib import Path
6
5
  from typing import Dict, Optional, Union
7
6
 
8
7
  import tomli
9
8
 
10
- from devlaunch.xdg import config_home
11
-
12
-
13
- def _get_cache_base() -> Path:
14
- """Get the base cache directory, honoring XDG_CACHE_HOME."""
15
- xdg_cache = os.environ.get("XDG_CACHE_HOME")
16
- if xdg_cache:
17
- return Path(xdg_cache) / "devlaunch"
18
- return Path.home() / ".cache" / "devlaunch"
9
+ from devlaunch.xdg import config_home, devlaunch_cache
19
10
 
20
11
 
21
12
  @dataclass
@@ -28,7 +19,7 @@ class WorktreeConfig:
28
19
  """
29
20
 
30
21
  enabled: bool = True # Enabled by default
31
- repos_dir: Union[Path, str] = field(default_factory=lambda: _get_cache_base() / "repos")
22
+ repos_dir: Union[Path, str] = field(default_factory=lambda: devlaunch_cache() / "repos")
32
23
  auto_fetch: bool = True
33
24
  fetch_interval: int = 3600 # Seconds between auto-fetches
34
25
  auto_prune: bool = True
@@ -77,7 +68,7 @@ class WorktreeConfig:
77
68
 
78
69
  return cls(
79
70
  enabled=worktree_data.get("enabled", True),
80
- repos_dir=Path(worktree_data.get("repos_dir", _get_cache_base() / "repos")),
71
+ repos_dir=Path(worktree_data.get("repos_dir", devlaunch_cache() / "repos")),
81
72
  auto_fetch=worktree_data.get("auto_fetch", True),
82
73
  fetch_interval=worktree_data.get("fetch_interval", 3600),
83
74
  auto_prune=cleanup_data.get("auto_prune", True),
@@ -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."""
@@ -10,6 +10,9 @@ import tempfile
10
10
  from pathlib import Path
11
11
  from typing import Any, Dict, List, Optional, Tuple
12
12
 
13
+ from devlaunch.xdg import devlaunch_cache
14
+
15
+ from .locks import hold_lock
13
16
  from .models import BaseRepository, WorktreeInfo, unknown_fields
14
17
 
15
18
  # Version of the on-disk metadata.json format.
@@ -40,10 +43,7 @@ _ENTRY_ERRORS = (KeyError, TypeError, ValueError)
40
43
 
41
44
  def _get_default_metadata_path() -> Path:
42
45
  """Get the default metadata path, honoring XDG_CACHE_HOME."""
43
- xdg_cache = os.environ.get("XDG_CACHE_HOME")
44
- if xdg_cache:
45
- return Path(xdg_cache) / "devlaunch" / "metadata.json"
46
- return Path.home() / ".cache" / "devlaunch" / "metadata.json"
46
+ return devlaunch_cache() / "metadata.json"
47
47
 
48
48
 
49
49
  def _warn(message: str) -> None:
@@ -84,8 +84,28 @@ class MetadataStorage:
84
84
  self.metadata_path.parent.mkdir(parents=True, exist_ok=True)
85
85
  # Every file operation targets the real file, not a symlink pointing at it.
86
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")
87
90
  self._load()
88
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
+
89
109
  def _quarantine(self, reason: str) -> None:
90
110
  """Move an unusable metadata file aside so the data stays inspectable.
91
111
 
@@ -289,8 +309,9 @@ class MetadataStorage:
289
309
  def add_repository(self, repo: BaseRepository) -> None:
290
310
  """Add or update a repository."""
291
311
  key = f"{repo.owner}/{repo.repo}"
292
- self.repositories[key] = repo
293
- self.save()
312
+ with self.exclusive():
313
+ self.repositories[key] = repo
314
+ self.save()
294
315
 
295
316
  def get_repository(self, owner: str, repo: str) -> Optional[BaseRepository]:
296
317
  """Get a repository by owner and name."""
@@ -304,22 +325,24 @@ class MetadataStorage:
304
325
  def remove_repository(self, owner: str, repo: str) -> None:
305
326
  """Remove a repository."""
306
327
  key = f"{owner}/{repo}"
307
- if key in self.repositories:
308
- del self.repositories[key]
309
- self.save()
328
+ with self.exclusive():
329
+ if key in self.repositories:
330
+ del self.repositories[key]
331
+ self.save()
310
332
 
311
333
  def add_worktree(self, worktree: WorktreeInfo) -> None:
312
334
  """Add or update a worktree."""
313
335
  key = f"{worktree.owner}/{worktree.repo}/{worktree.branch}"
314
- self.worktrees[key] = worktree
336
+ with self.exclusive():
337
+ self.worktrees[key] = worktree
315
338
 
316
- # Update repository's worktree list in memory, then write once.
317
- repo = self.get_repository(worktree.owner, worktree.repo)
318
- if repo and worktree.branch not in repo.worktrees:
319
- repo.worktrees.append(worktree.branch)
320
- 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
321
344
 
322
- self.save()
345
+ self.save()
323
346
 
324
347
  def get_worktree(self, owner: str, repo: str, branch: str) -> Optional[WorktreeInfo]:
325
348
  """Get a worktree by repository and branch."""
@@ -349,13 +372,14 @@ class MetadataStorage:
349
372
  def remove_worktree(self, owner: str, repo: str, branch: str) -> None:
350
373
  """Remove a worktree."""
351
374
  key = f"{owner}/{repo}/{branch}"
352
- if key in self.worktrees:
353
- del self.worktrees[key]
375
+ with self.exclusive():
376
+ if key in self.worktrees:
377
+ del self.worktrees[key]
354
378
 
355
- # Update repository's worktree list in memory, then write once.
356
- repo_obj = self.get_repository(owner, repo)
357
- if repo_obj and branch in repo_obj.worktrees:
358
- repo_obj.worktrees.remove(branch)
359
- 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
360
384
 
361
- 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
@@ -0,0 +1,47 @@
1
+ """Where the XDG base directories point on this machine.
2
+
3
+ Two unrelated places ask for the config home: the worktree loader, which reads
4
+ `config.toml` under it, and the gh-token warning, which names it so a user whose
5
+ shell scoped the variable can see why `gh auth token` refused. Those two have to
6
+ agree -- a warning that names one directory while the loader reads another is
7
+ worse than no warning -- so they share the answer rather than each spelling it.
8
+
9
+ The cache home has the same problem and one more caller's worth of it. Three
10
+ places used to spell it out identically: dl's own cache directory, the worktree
11
+ config's default `repos_dir`, and the metadata file's default path. They have to
12
+ agree because `dl --purge` reads the first to decide which workspaces are
13
+ devlaunch's -- workspaces whose clones the other two put on disk -- so a copy
14
+ that drifted would make a purge silently stop recognising its own work.
15
+ """
16
+
17
+ import os
18
+ from pathlib import Path
19
+
20
+
21
+ def config_home() -> Path:
22
+ """`$XDG_CONFIG_HOME`, or the `~/.config` the spec falls back to.
23
+
24
+ An empty value counts as unset, which is what the XDG basedir spec says and
25
+ what a shell exporting the variable with no value means. Reading it any other
26
+ way resolves the config path relative to the working directory instead.
27
+ """
28
+ return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
29
+
30
+
31
+ def cache_home() -> Path:
32
+ """`$XDG_CACHE_HOME`, or the `~/.cache` the spec falls back to.
33
+
34
+ Empty counts as unset, for the same reason as above.
35
+ """
36
+ return Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache")
37
+
38
+
39
+ def devlaunch_cache() -> Path:
40
+ """Everything devlaunch stores on this machine, under one directory.
41
+
42
+ The bare repo clones, the workspace clones, the completion caches and
43
+ metadata.json all live here, and `dl --purge` removes exactly this. One
44
+ function rather than a copy per caller, because a purge decides what is its
45
+ own to delete by asking whether a workspace's source is inside it.
46
+ """
47
+ return cache_home() / "devlaunch"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devlaunch"
3
- version = "0.0.18"
3
+ version = "0.0.20"
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"
@@ -158,6 +158,10 @@ exclude_also = [
158
158
  "if 0:",
159
159
  "raise AssertionError",
160
160
  "raise NotImplementedError",
161
+ # The exhaustiveness sentinel. `ty` runs in CI and rejects any call to it that
162
+ # is reachable, so in a build that type-checks these lines cannot be executed
163
+ # -- the same reason `raise AssertionError` is already on this list.
164
+ "_unhandled_source\\(",
161
165
  "if __name__ == .__main__.:",
162
166
  "pass",
163
167
  "(_):",
@@ -1,21 +0,0 @@
1
- """Where the XDG base directories point on this machine.
2
-
3
- Two unrelated places ask for the config home: the worktree loader, which reads
4
- `config.toml` under it, and the gh-token warning, which names it so a user whose
5
- shell scoped the variable can see why `gh auth token` refused. Those two have to
6
- agree -- a warning that names one directory while the loader reads another is
7
- worse than no warning -- so they share the answer rather than each spelling it.
8
- """
9
-
10
- import os
11
- from pathlib import Path
12
-
13
-
14
- def config_home() -> Path:
15
- """`$XDG_CONFIG_HOME`, or the `~/.config` the spec falls back to.
16
-
17
- An empty value counts as unset, which is what the XDG basedir spec says and
18
- what a shell exporting the variable with no value means. Reading it any other
19
- way resolves the config path relative to the working directory instead.
20
- """
21
- return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
File without changes
File without changes
File without changes