devlaunch 0.0.17__tar.gz → 0.0.19__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 (28) hide show
  1. {devlaunch-0.0.17 → devlaunch-0.0.19}/.gitignore +0 -1
  2. {devlaunch-0.0.17 → devlaunch-0.0.19}/PKG-INFO +130 -2
  3. {devlaunch-0.0.17 → devlaunch-0.0.19}/README.md +129 -1
  4. {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/devpod_provider.py +29 -6
  5. {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/dl.py +397 -64
  6. {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/worktree/config.py +3 -12
  7. {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/worktree/storage.py +3 -4
  8. devlaunch-0.0.19/devlaunch/xdg.py +47 -0
  9. {devlaunch-0.0.17 → devlaunch-0.0.19}/pyproject.toml +26 -3
  10. devlaunch-0.0.17/devlaunch/xdg.py +0 -21
  11. {devlaunch-0.0.17 → devlaunch-0.0.19}/LICENSE +0 -0
  12. {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/__init__.py +0 -0
  13. {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/aid.py +0 -0
  14. {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/completion.py +0 -0
  15. {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/completion_loader.py +0 -0
  16. {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/completions/__init__.py +0 -0
  17. {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/completions/dl.bash +0 -0
  18. {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/devpod_ssh.py +0 -0
  19. {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/gh_auth.py +0 -0
  20. {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/tools.py +0 -0
  21. {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/tty_session.py +0 -0
  22. {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/workspace_id.py +0 -0
  23. {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/worktree/__init__.py +0 -0
  24. {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/worktree/branch_manager.py +0 -0
  25. {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/worktree/migration.py +0 -0
  26. {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/worktree/models.py +0 -0
  27. {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/worktree/repo_manager.py +0 -0
  28. {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/worktree/workspace_clone.py +0 -0
@@ -50,7 +50,6 @@ coverage.xml
50
50
  .hypothesis/
51
51
  .pytest_cache/
52
52
  cover/
53
- test/docker/results/
54
53
 
55
54
  # Translations
56
55
  *.mo
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlaunch
3
- Version: 0.0.17
3
+ Version: 0.0.19
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
@@ -57,6 +57,13 @@ Note: When using pip, you must install [devpod](https://devpod.sh/docs/getting-s
57
57
  If `devpod` is not on `PATH`, every command that needs it prints a single install hint on stderr and exits `127`
58
58
  (the shell's "command not found" code). `dl --help` and `dl --version` keep working without it.
59
59
 
60
+ A `devpod` that is installed but cannot answer is a different failure and gets a different exit code. If
61
+ `devpod list` exits non-zero, or prints something that is not a `--output json` workspace listing, `dl` quotes
62
+ what devpod said on stderr and exits `1` rather than reporting that you have no workspaces — so `dl --purge`
63
+ stops instead of deleting caches it never checked. Shell completion is the deliberate exception: `dl --install`,
64
+ `dl --refresh` and `dl --completion-data` log the failure and carry on with the repos and branches they can
65
+ still discover on local disk, so an unreachable devpod costs you workspace-name completion and nothing more.
66
+
60
67
  ### Shell Completions
61
68
 
62
69
  After installation, set up shell completions for `dl` and `aid`:
@@ -333,7 +340,7 @@ existed — picks the tools up on its next `dl <workspace> restart`.
333
340
  |---------|-------------|
334
341
  | `dl --ls` | List all workspaces |
335
342
  | `dl --install` | Install shell completions |
336
- | `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 |
337
344
  | `dl --prune-worktrees [days]` | Remove unused worktrees (default: 30 days) |
338
345
  | `dl --refresh` | Refresh completion cache |
339
346
  | `dl --help, -h` | Show this help |
@@ -355,6 +362,40 @@ dl 0.0.9 (dev, editable from /path/to/your/devlaunch)
355
362
  from the installed package's own PEP 610 metadata; an install that records none
356
363
  just prints the bare version.
357
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
+
358
399
  ## Examples
359
400
 
360
401
  ```bash
@@ -432,9 +473,96 @@ This project uses [pixi](https://pixi.sh) for environment management.
432
473
  # Run tests
433
474
  pixi run test
434
475
 
476
+ # Run the e2e suite: real devpod, real containers
477
+ pixi run test-e2e
478
+
435
479
  # Run full CI suite
436
480
  pixi run ci
437
481
 
438
482
  # Format and lint
439
483
  pixi run style
440
484
  ```
485
+
486
+ `pixi run test` skips the e2e tests, which need devpod and a Docker daemon and
487
+ build real containers. CI runs `pixi run test-e2e` in a job of its own, outside
488
+ the Python matrix, on a throwaway runner — on every push to `main` and on every
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.
502
+
503
+ Running it yourself is a different proposition. This repo's devcontainer carries
504
+ a Docker daemon of its own, through the `docker-in-docker` feature, and pins the
505
+ same devpod a host installs, so `pixi run test-e2e` from inside it builds its
506
+ containers in there rather than on your Docker. You can also run it on a machine
507
+ you do not mind it writing to — an ephemeral CI runner, say. It is skipped by
508
+ default rather than gated on a container, because what it needs is a daemon, not
509
+ nesting. Either way the suite exercises `dl --purge`, so it gives itself a
510
+ private devpod namespace before collection begins — but the containers it builds
511
+ are real ones, and it wants several minutes and a 1.25 GB image pull the first
512
+ time.
513
+
514
+ Its skips mean one thing only. A test that opts out does so through
515
+ `fixtures.e2e_guard.opt_out`, and any other skip is reported as a failure,
516
+ because a run that could not reach a registry used to be indistinguishable from
517
+ a healthy one. Every run also prints what it actually built:
518
+
519
+ ```
520
+ --------------------------------- e2e session ---------------------------------
521
+ 22 e2e tests attempted, 5 workspaces created: e2e-test-create, e2e-test-lifecycle, e2e-test-git, e2e-purge-devlaunchs, e2e-purge-hand-made
522
+ ```
523
+
524
+ A run whose workspace-building tests built nothing does not pass: the shortfall
525
+ is counted into the last line of the run, so `4 passed, 18 skipped` becomes
526
+ `1 failed, 4 passed, 18 skipped`. A run with no workspace-building tests in it —
527
+ `pytest -m e2e test/e2e/test_interactive_session.py`, say — has nothing to
528
+ answer for and says so instead.
529
+
530
+ `DEVLAUNCH_E2E_WORKSPACE=<id>` opts in to the interactive-session tests, which
531
+ attach to a workspace you already have running rather than building one.
532
+
533
+ The nested daemon is also why the devcontainer does not join the host's network
534
+ namespace: a nested daemon needs a namespace of its own, or it co-manages the
535
+ host's `docker0` bridge and writes its NAT rules into the host's netfilter
536
+ tables.
537
+
538
+ ### Disk cost of the dev container
539
+
540
+ Opening a devcontainer for a branch costs about **2 GB on the host before you do
541
+ anything in it**: ~600 MB of image layers unique to this image, a ~680 MB container
542
+ writable layer, and a ~520 MB `<workspace>-pixi` volume.
543
+
544
+ The container carries its own Docker daemon, and that daemon's `/var/lib/docker`
545
+ lives on a second named volume. One `pixi run test-e2e` plus a couple of nested
546
+ workspaces puts **~2.3 GB** in there, and nothing garbage-collects it — the inner
547
+ daemon reports ~45% of its images reclaimable with no reclaimer. Nested daemons
548
+ share no layers with the host or with each other, so this is paid once per branch.
549
+
550
+ **Budget ~4 GB per branch you are actively developing and e2e-testing — about 12 GB
551
+ for three concurrent branches.**
552
+
553
+ The time cost is cold pulls in a fresh nested daemon: the first `devpod up` inside a
554
+ new container takes ~25s, ~16s of which is pulling a base image the host already has.
555
+ Workspaces after that reuse it and take ~8s.
556
+
557
+ **These volumes are not reclaimed automatically.** `devpod delete` removes the
558
+ container with `docker rm` and never touches volumes, and Docker never
559
+ garbage-collects a *named* volume — so `<workspace>-pixi` and
560
+ `dind-var-lib-docker-*` outlive the workspace that created them. To see what has
561
+ piled up:
562
+
563
+ ```bash
564
+ docker system df -v # under Local Volumes, LINKS 0 means no container uses it
565
+ ```
566
+
567
+ Cross-check a name against `devpod list` before removing it with `docker volume rm`:
568
+ a volume belonging to a live workspace shows `LINKS 1`.
@@ -35,6 +35,13 @@ Note: When using pip, you must install [devpod](https://devpod.sh/docs/getting-s
35
35
  If `devpod` is not on `PATH`, every command that needs it prints a single install hint on stderr and exits `127`
36
36
  (the shell's "command not found" code). `dl --help` and `dl --version` keep working without it.
37
37
 
38
+ A `devpod` that is installed but cannot answer is a different failure and gets a different exit code. If
39
+ `devpod list` exits non-zero, or prints something that is not a `--output json` workspace listing, `dl` quotes
40
+ what devpod said on stderr and exits `1` rather than reporting that you have no workspaces — so `dl --purge`
41
+ stops instead of deleting caches it never checked. Shell completion is the deliberate exception: `dl --install`,
42
+ `dl --refresh` and `dl --completion-data` log the failure and carry on with the repos and branches they can
43
+ still discover on local disk, so an unreachable devpod costs you workspace-name completion and nothing more.
44
+
38
45
  ### Shell Completions
39
46
 
40
47
  After installation, set up shell completions for `dl` and `aid`:
@@ -311,7 +318,7 @@ existed — picks the tools up on its next `dl <workspace> restart`.
311
318
  |---------|-------------|
312
319
  | `dl --ls` | List all workspaces |
313
320
  | `dl --install` | Install shell completions |
314
- | `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 |
315
322
  | `dl --prune-worktrees [days]` | Remove unused worktrees (default: 30 days) |
316
323
  | `dl --refresh` | Refresh completion cache |
317
324
  | `dl --help, -h` | Show this help |
@@ -333,6 +340,40 @@ dl 0.0.9 (dev, editable from /path/to/your/devlaunch)
333
340
  from the installed package's own PEP 610 metadata; an install that records none
334
341
  just prints the bare version.
335
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
+
336
377
  ## Examples
337
378
 
338
379
  ```bash
@@ -410,9 +451,96 @@ This project uses [pixi](https://pixi.sh) for environment management.
410
451
  # Run tests
411
452
  pixi run test
412
453
 
454
+ # Run the e2e suite: real devpod, real containers
455
+ pixi run test-e2e
456
+
413
457
  # Run full CI suite
414
458
  pixi run ci
415
459
 
416
460
  # Format and lint
417
461
  pixi run style
418
462
  ```
463
+
464
+ `pixi run test` skips the e2e tests, which need devpod and a Docker daemon and
465
+ build real containers. CI runs `pixi run test-e2e` in a job of its own, outside
466
+ the Python matrix, on a throwaway runner — on every push to `main` and on every
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.
480
+
481
+ Running it yourself is a different proposition. This repo's devcontainer carries
482
+ a Docker daemon of its own, through the `docker-in-docker` feature, and pins the
483
+ same devpod a host installs, so `pixi run test-e2e` from inside it builds its
484
+ containers in there rather than on your Docker. You can also run it on a machine
485
+ you do not mind it writing to — an ephemeral CI runner, say. It is skipped by
486
+ default rather than gated on a container, because what it needs is a daemon, not
487
+ nesting. Either way the suite exercises `dl --purge`, so it gives itself a
488
+ private devpod namespace before collection begins — but the containers it builds
489
+ are real ones, and it wants several minutes and a 1.25 GB image pull the first
490
+ time.
491
+
492
+ Its skips mean one thing only. A test that opts out does so through
493
+ `fixtures.e2e_guard.opt_out`, and any other skip is reported as a failure,
494
+ because a run that could not reach a registry used to be indistinguishable from
495
+ a healthy one. Every run also prints what it actually built:
496
+
497
+ ```
498
+ --------------------------------- e2e session ---------------------------------
499
+ 22 e2e tests attempted, 5 workspaces created: e2e-test-create, e2e-test-lifecycle, e2e-test-git, e2e-purge-devlaunchs, e2e-purge-hand-made
500
+ ```
501
+
502
+ A run whose workspace-building tests built nothing does not pass: the shortfall
503
+ is counted into the last line of the run, so `4 passed, 18 skipped` becomes
504
+ `1 failed, 4 passed, 18 skipped`. A run with no workspace-building tests in it —
505
+ `pytest -m e2e test/e2e/test_interactive_session.py`, say — has nothing to
506
+ answer for and says so instead.
507
+
508
+ `DEVLAUNCH_E2E_WORKSPACE=<id>` opts in to the interactive-session tests, which
509
+ attach to a workspace you already have running rather than building one.
510
+
511
+ The nested daemon is also why the devcontainer does not join the host's network
512
+ namespace: a nested daemon needs a namespace of its own, or it co-manages the
513
+ host's `docker0` bridge and writes its NAT rules into the host's netfilter
514
+ tables.
515
+
516
+ ### Disk cost of the dev container
517
+
518
+ Opening a devcontainer for a branch costs about **2 GB on the host before you do
519
+ anything in it**: ~600 MB of image layers unique to this image, a ~680 MB container
520
+ writable layer, and a ~520 MB `<workspace>-pixi` volume.
521
+
522
+ The container carries its own Docker daemon, and that daemon's `/var/lib/docker`
523
+ lives on a second named volume. One `pixi run test-e2e` plus a couple of nested
524
+ workspaces puts **~2.3 GB** in there, and nothing garbage-collects it — the inner
525
+ daemon reports ~45% of its images reclaimable with no reclaimer. Nested daemons
526
+ share no layers with the host or with each other, so this is paid once per branch.
527
+
528
+ **Budget ~4 GB per branch you are actively developing and e2e-testing — about 12 GB
529
+ for three concurrent branches.**
530
+
531
+ The time cost is cold pulls in a fresh nested daemon: the first `devpod up` inside a
532
+ new container takes ~25s, ~16s of which is pulling a base image the host already has.
533
+ Workspaces after that reuse it and take ~8s.
534
+
535
+ **These volumes are not reclaimed automatically.** `devpod delete` removes the
536
+ container with `docker rm` and never touches volumes, and Docker never
537
+ garbage-collects a *named* volume — so `<workspace>-pixi` and
538
+ `dind-var-lib-docker-*` outlive the workspace that created them. To see what has
539
+ piled up:
540
+
541
+ ```bash
542
+ docker system df -v # under Local Volumes, LINKS 0 means no container uses it
543
+ ```
544
+
545
+ Cross-check a name against `devpod list` before removing it with `docker volume rm`:
546
+ a volume belonging to a live workspace shows `LINKS 1`.
@@ -27,11 +27,25 @@ import subprocess
27
27
  from typing import Callable, Optional, Sequence, Set
28
28
 
29
29
 
30
- class UnreadableProviderList(RuntimeError):
30
+ class UnreadableProviderList(Exception):
31
31
  """devpod's provider listing could not be read.
32
32
 
33
33
  Distinct from "no providers are registered", which is a listing that reads
34
34
  fine and is empty.
35
+
36
+ Not a RuntimeError. It used to be, and the CLI below then had to name
37
+ RuntimeError as well to catch the add failure -- which made the handler
38
+ broad enough to report any RuntimeError in the process as a devpod problem.
39
+ The two failures devpod can hand this module now have a type each, and the
40
+ handler names both and nothing else.
41
+ """
42
+
43
+
44
+ class ProviderAddFailed(Exception):
45
+ """`devpod provider add` ran and failed.
46
+
47
+ Distinct from a listing that could not be read: devpod answered the question
48
+ it was asked, and then refused to do the thing.
35
49
  """
36
50
 
37
51
 
@@ -62,7 +76,8 @@ def list_provider_names(
62
76
  )
63
77
  if result.returncode != 0:
64
78
  raise UnreadableProviderList(
65
- f"`devpod provider list` exited {result.returncode}: {(result.stderr or '').strip()[:200]}"
79
+ f"`devpod provider list` exited {result.returncode}: "
80
+ f"{(result.stderr or '').strip()[:200]!r}"
66
81
  )
67
82
  return parse_provider_names(result.stdout or "")
68
83
 
@@ -74,13 +89,21 @@ def ensure_provider(
74
89
 
75
90
  Returns True if it had to be added, False if it was already there. Raises
76
91
  `UnreadableProviderList` rather than guessing when devpod's answer cannot
77
- be read.
92
+ be read, and `ProviderAddFailed` when the add itself fails.
78
93
  """
79
94
  if name in list_provider_names(run=run):
80
95
  return False
81
- result = run(["devpod", "provider", "add", name], check=False)
96
+ result = run(
97
+ ["devpod", "provider", "add", name],
98
+ capture_output=True,
99
+ text=True,
100
+ check=False,
101
+ )
82
102
  if result.returncode != 0:
83
- raise RuntimeError(f"`devpod provider add {name}` exited {result.returncode}")
103
+ raise ProviderAddFailed(
104
+ f"`devpod provider add {name}` exited {result.returncode}: "
105
+ f"{(result.stderr or '').strip()[:200]!r}"
106
+ )
84
107
  return True
85
108
 
86
109
 
@@ -105,7 +128,7 @@ def main(
105
128
 
106
129
  try:
107
130
  added = ensure_provider(args.name, run=run)
108
- except (UnreadableProviderList, RuntimeError) as exc:
131
+ except (UnreadableProviderList, ProviderAddFailed) as exc:
109
132
  print(f"error: {exc}")
110
133
  return 1
111
134
  print(f"devpod provider {args.name}: {'added' if added else 'already registered'}")
@@ -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):
@@ -65,6 +65,26 @@ class SshNotInstalled(MissingBinary):
65
65
  """
66
66
 
67
67
 
68
+ class UnreadableWorkspaceList(Exception):
69
+ """devpod's workspace listing could not be read.
70
+
71
+ Distinct from "this machine has no workspaces", which is a listing that
72
+ reads fine and is empty. The two used to share one representation -- an
73
+ empty list -- so `dl --purge` could report that there was nothing to purge
74
+ when the truth was that it never found out, and a workspace spec could be
75
+ called unknown when nothing had been asked.
76
+
77
+ Deliberately not a RuntimeError, for the same reason MissingBinary is not:
78
+ dl catches RuntimeError broadly wherever a flaky command should degrade
79
+ rather than abort, and an answer dl could not read is precisely the thing
80
+ that must not degrade. It travels as a type nothing in between catches, and
81
+ main() is the only place that handles it.
82
+
83
+ The sibling shape in devpod_provider deliberately reads the same way. Two
84
+ listings devpod can fail to produce, two exceptions that say so, one rule.
85
+ """
86
+
87
+
68
88
  # One line, so a completion helper that trips over it cannot spew into the
69
89
  # user's shell. It names both install routes because devpod ships with the
70
90
  # pixi/conda package and does not ship with the pip one (see README).
@@ -86,6 +106,10 @@ SSH_MISSING_MESSAGE = (
86
106
  # cannot be confused with a devpod command that ran and failed.
87
107
  DEVPOD_MISSING_EXIT_CODE = 127
88
108
 
109
+ # devpod is there and dl asked it, but what came back could not be read. A plain
110
+ # failure rather than 127: nothing is missing and nothing needs installing.
111
+ UNREADABLE_WORKSPACE_LIST_EXIT_CODE = 1
112
+
89
113
 
90
114
  def _install_provenance() -> Optional[str]:
91
115
  """Describe the install this dl was launched from, or None if unremarkable.
@@ -138,11 +162,15 @@ logging.basicConfig(level=logging.INFO, format="%(message)s")
138
162
 
139
163
 
140
164
  def _get_cache_dir() -> pathlib.Path:
141
- """Get the cache directory, honoring XDG_CACHE_HOME."""
142
- xdg_cache = os.environ.get("XDG_CACHE_HOME")
143
- if xdg_cache:
144
- return pathlib.Path(xdg_cache) / "devlaunch"
145
- 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()
146
174
 
147
175
 
148
176
  # Cache configuration (honors XDG_CACHE_HOME)
@@ -268,8 +296,31 @@ def get_local_branches(owner_repo: str) -> List[str]:
268
296
 
269
297
 
270
298
  def update_completion_cache() -> Dict[str, Any]:
271
- """Update the completion cache with current data."""
272
- workspaces = list_workspaces()
299
+ """Update the completion cache with current data.
300
+
301
+ The one reader of the workspace list that has something to do with a listing
302
+ it cannot read. Everywhere else, an unreadable listing means the question
303
+ being asked cannot be answered at all, and UnreadableWorkspaceList travels
304
+ up to main() to say so. Here the workspace names are one of four things
305
+ being collected, and the other three -- repos, owners, branches -- come off
306
+ the local disk without asking devpod anything. So this catches, logs, and
307
+ builds completions out of what it can still see, deliberately: refusing
308
+ would mean an unreachable devpod stops `dl --install` from installing
309
+ completions at all, when what it costs is the workspace names.
310
+
311
+ The failure is logged rather than swallowed, because `dl --refresh` prints
312
+ the workspace count it got and zero-because-we-could-not-ask must not read
313
+ as zero-because-there-are-none. The cache this then writes offers no
314
+ workspace names until a later refresh succeeds -- which is what it did
315
+ before the listing learned to refuse -- and list_workspaces() still declines
316
+ to remember a list it never got, so the next command asks devpod again
317
+ instead of being served this one.
318
+ """
319
+ try:
320
+ workspaces = list_workspaces()
321
+ except UnreadableWorkspaceList as exc:
322
+ logging.warning(f"Completing without workspace names: {exc}")
323
+ workspaces = []
273
324
  workspace_ids = [ws.id for ws in workspaces]
274
325
  repos = discover_repos_from_workspaces(workspaces)
275
326
 
@@ -381,32 +432,39 @@ def update_cache_background(force: bool = False) -> None:
381
432
 
382
433
 
383
434
  def purge_all_data() -> int:
384
- """Purge all devlaunch data including DevPod workspaces and caches.
435
+ """Purge devlaunch's data: the workspaces it created, and its caches.
385
436
 
386
437
  This:
387
- 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.
388
440
  2. Removes ~/.cache/devlaunch/ which contains:
389
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.
390
446
  """
391
447
  import shutil
392
448
 
393
449
  cache_dir = _get_cache_dir()
394
450
 
395
- # First, delete all DevPod workspaces. The list is the same snapshot the
396
- # caller printed the count from, so the confirmation the user answered and
397
- # the set actually deleted cannot disagree.
398
- workspaces = list_workspaces()
399
- 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:
400
458
  print(f"Deleting DevPod workspace: {ws.id}")
401
459
  result = run_devpod(["delete", ws.id, "--force"], capture=True)
402
460
  if result.returncode != 0:
403
461
  logging.warning(f"Failed to delete workspace {ws.id}: {result.stderr}")
404
- if workspaces:
462
+ if owned.mine:
405
463
  invalidate_workspace_list_cache()
406
464
 
407
465
  # Then remove local cache
408
466
  if not cache_dir.exists():
409
- if not workspaces:
467
+ if not owned.mine:
410
468
  print("No data to purge.")
411
469
  return 0
412
470
 
@@ -650,41 +708,235 @@ def validate_workspace_spec(spec: str, existing_ids: List[str]) -> Optional[str]
650
708
  return f"Unknown workspace '{spec}'. Use 'dl --ls' to list workspaces, or specify owner/repo or ./path"
651
709
 
652
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
+
653
829
  @dataclass
654
830
  class Workspace:
655
831
  """Represents a devpod workspace."""
656
832
 
657
833
  id: str
658
- source_type: str # "local" or "git"
659
- source: str
834
+ source: WorkspaceSource
660
835
  last_used: str
661
836
  provider: str
662
837
  ide: str
663
838
 
664
839
  @classmethod
665
- def from_json(cls, data: Dict[str, Any]) -> "Workspace":
840
+ def from_json(cls, data: Mapping[str, Any]) -> "Workspace":
666
841
  """Parse workspace from devpod JSON output."""
667
- source = data.get("source", {})
668
- if "localFolder" in source:
669
- source_type = "local"
670
- source_path = source["localFolder"]
671
- elif "gitRepository" in source:
672
- source_type = "git"
673
- source_path = source["gitRepository"]
674
- else:
675
- source_type = "unknown"
676
- source_path = str(source)
677
-
678
842
  return cls(
679
843
  id=data.get("id", ""),
680
- source_type=source_type,
681
- source=source_path,
844
+ source=parse_workspace_source(data.get("source", {})),
682
845
  last_used=data.get("lastUsed", ""),
683
846
  provider=data.get("provider", {}).get("name", ""),
684
847
  ide=data.get("ide", {}).get("name", ""),
685
848
  )
686
849
 
687
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
+
688
940
  # Regex patterns for parsing git URLs
689
941
  GIT_URL_PATTERNS = [
690
942
  # git@github.com:owner/repo.git
@@ -753,22 +1005,39 @@ def discover_repos_from_workspaces(workspaces: List[Workspace]) -> Dict[str, Lis
753
1005
  """Discover owner/repo from workspace git remotes.
754
1006
 
755
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.
756
1014
  """
757
1015
  repos: Dict[str, List[str]] = {}
758
1016
 
759
1017
  for ws in workspaces:
760
1018
  owner_repo = None
1019
+ source = ws.source
761
1020
 
762
1021
  # For git workspaces, parse the source URL directly
763
- if ws.source_type == "git":
764
- owner_repo = parse_owner_repo_from_url(ws.source)
1022
+ if isinstance(source, GitRepository):
1023
+ owner_repo = parse_owner_repo_from_url(source.url)
765
1024
 
766
1025
  # For local workspaces, try to get git remote
767
- elif ws.source_type == "local" and ws.source:
768
- remote_url = get_git_remote_url(ws.source)
1026
+ elif isinstance(source, LocalFolder):
1027
+ remote_url = get_git_remote_url(source.path)
769
1028
  if remote_url:
770
1029
  owner_repo = parse_owner_repo_from_url(remote_url)
771
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
+
772
1041
  if owner_repo:
773
1042
  owner, repo = owner_repo
774
1043
  if owner not in repos:
@@ -903,6 +1172,50 @@ def invalidate_workspace_list_cache() -> None:
903
1172
  _workspace_list_cache.pop(_WORKSPACE_LIST_KEY, None)
904
1173
 
905
1174
 
1175
+ def parse_workspaces(listing: str) -> List[Workspace]:
1176
+ """The workspaces in a `devpod list --output json` listing.
1177
+
1178
+ Anything that is not such a listing raises rather than parsing to nothing.
1179
+ That includes the empty string: devpod prints `[]` for a machine with no
1180
+ workspaces, so silence is devpod failing to answer, not devpod answering
1181
+ that there is nothing to list. Silence gets a branch of its own rather than
1182
+ falling into the JSON parser, whose report of it -- `not JSON: ''` -- reads
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.
1190
+ """
1191
+ if not listing.strip():
1192
+ raise UnreadableWorkspaceList(
1193
+ "devpod said nothing when asked to list workspaces; it prints `[]` when there are none"
1194
+ )
1195
+ try:
1196
+ parsed = json.loads(listing)
1197
+ except json.JSONDecodeError as exc:
1198
+ raise UnreadableWorkspaceList(
1199
+ f"devpod's workspace listing is not JSON: {listing[:120]!r}"
1200
+ ) from exc
1201
+ if not isinstance(parsed, list):
1202
+ raise UnreadableWorkspaceList(
1203
+ f"expected devpod to list workspaces, got {type(parsed).__name__}"
1204
+ )
1205
+ for entry in parsed:
1206
+ if not isinstance(entry, dict):
1207
+ raise UnreadableWorkspaceList(
1208
+ f"expected each listed workspace to be an object, got {type(entry).__name__}"
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
+ )
1216
+ return [Workspace.from_json(ws) for ws in parsed]
1217
+
1218
+
906
1219
  def list_workspaces(refresh: bool = False) -> List[Workspace]:
907
1220
  """List all devpod workspaces, reading devpod at most once per command.
908
1221
 
@@ -916,10 +1229,15 @@ def list_workspaces(refresh: bool = False) -> List[Workspace]:
916
1229
  refresh=True bypasses the snapshot for a caller that must have the
917
1230
  post-mutation truth even if nothing announced the mutation.
918
1231
 
919
- Only an answer devpod actually gave is remembered. A failed or unparsable
920
- read returns an empty list without caching it, so a transient failure and
921
- a missing devpod, which raises out of here can never be served again as
922
- "this machine has no workspaces".
1232
+ Only an answer devpod actually gave is remembered, and only an answer devpod
1233
+ actually gave is returned: a read that failed or could not be parsed raises
1234
+ UnreadableWorkspaceList rather than answering with an empty list, so neither
1235
+ a transient failure nor a missing devpod can be served to a caller as "this
1236
+ machine has no workspaces".
1237
+
1238
+ Which of the workspaces devpod lists belong to dl is a separate question,
1239
+ and not one this function answers. It answers only whether the list can be
1240
+ believed at all.
923
1241
  """
924
1242
  if not refresh:
925
1243
  cached = _workspace_list_cache.get(_WORKSPACE_LIST_KEY)
@@ -928,14 +1246,14 @@ def list_workspaces(refresh: bool = False) -> List[Workspace]:
928
1246
  # be rewriting what the next caller sees.
929
1247
  return list(cached)
930
1248
  result = run_devpod(["list", "--output", "json"], capture=True)
931
- if result.returncode != 0 or not result.stdout.strip():
932
- return []
933
- try:
934
- data = json.loads(result.stdout)
935
- except json.JSONDecodeError:
936
- logging.error("Failed to parse devpod output")
937
- return []
938
- workspaces = [Workspace.from_json(ws) for ws in data]
1249
+ if result.returncode != 0:
1250
+ # !r for the same reason the parse path uses it: devpod's stderr is
1251
+ # routinely several lines, and DEVPOD_MISSING_MESSAGE's comment sets the
1252
+ # rule that one of dl's failure messages is one line.
1253
+ raise UnreadableWorkspaceList(
1254
+ f"`devpod list` exited {result.returncode}: {(result.stderr or '').strip()[:200]!r}"
1255
+ )
1256
+ workspaces = parse_workspaces(result.stdout or "")
939
1257
  _workspace_list_cache[_WORKSPACE_LIST_KEY] = workspaces
940
1258
  return list(workspaces)
941
1259
 
@@ -952,10 +1270,14 @@ def print_workspaces():
952
1270
  print("No workspaces found.")
953
1271
  return
954
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
+
955
1277
  # Calculate column widths
956
- id_width = max(len(ws.id) for ws in workspaces)
957
- type_width = max(len(ws.source_type) for ws in workspaces)
958
- 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)
959
1281
 
960
1282
  # Print header
961
1283
  print(
@@ -964,11 +1286,9 @@ def print_workspaces():
964
1286
  print("-" * (id_width + type_width + source_width + 30))
965
1287
 
966
1288
  # Print rows
967
- for ws in workspaces:
1289
+ for ws, kind, detail in rows:
968
1290
  last_used = ws.last_used[:19].replace("T", " ") if ws.last_used else "never"
969
- print(
970
- f"{ws.id:<{id_width}} {ws.source_type:<{type_width}} {ws.source:<{source_width}} {last_used}"
971
- )
1291
+ print(f"{ws.id:<{id_width}} {kind:<{type_width}} {detail:<{source_width}} {last_used}")
972
1292
 
973
1293
 
974
1294
  def fuzzy_select_workspace() -> Optional[str]:
@@ -988,7 +1308,8 @@ def fuzzy_select_workspace() -> Optional[str]:
988
1308
  options = []
989
1309
  ws_map = {}
990
1310
  for ws in workspaces:
991
- label = f"{ws.id} | {ws.source_type} | {ws.source}"
1311
+ kind, detail = describe_source(ws.source)
1312
+ label = f"{ws.id} | {kind} | {detail}"
992
1313
  options.append(label)
993
1314
  ws_map[label] = ws.id
994
1315
 
@@ -1299,7 +1620,7 @@ Global commands:
1299
1620
  dl --ls List all workspaces
1300
1621
  dl --install Install shell completions
1301
1622
  dl --refresh Refresh completion cache
1302
- dl --purge [-y] Remove all DevPod workspaces and caches
1623
+ dl --purge [-y] Remove devlaunch's workspaces and caches
1303
1624
  dl --help, -h Show this help
1304
1625
  dl --version Show version (editable installs name their tree)
1305
1626
 
@@ -1372,9 +1693,10 @@ def wants_startup_cache_refresh(args: List[str]) -> bool:
1372
1693
  def main(argv: Optional[List[str]] = None) -> int:
1373
1694
  """Main entry point for dl CLI.
1374
1695
 
1375
- Thin wrapper so there is exactly one handler for a missing devpod, however
1376
- deep in the command it was noticed. The message goes to stderr because
1377
- stdout is parsed by the completion machinery (--repos, --completion-data).
1696
+ Thin wrapper so there is exactly one handler for a missing devpod, and one
1697
+ for a devpod that answered with something dl could not read, however deep in
1698
+ the command either was noticed. Both messages go to stderr because stdout is
1699
+ parsed by the completion machinery (--repos, --completion-data).
1378
1700
 
1379
1701
  argv is the argument list without the program name, defaulting to the real
1380
1702
  one. It is a parameter so that a sibling entry point can hand dl a command
@@ -1390,6 +1712,9 @@ def main(argv: Optional[List[str]] = None) -> int:
1390
1712
  except MissingBinary as e:
1391
1713
  print(e, file=sys.stderr)
1392
1714
  return DEVPOD_MISSING_EXIT_CODE
1715
+ except UnreadableWorkspaceList as e:
1716
+ print(f"error: {e}", file=sys.stderr)
1717
+ return UNREADABLE_WORKSPACE_LIST_EXIT_CODE
1393
1718
 
1394
1719
 
1395
1720
  def _run_cli(argv: Optional[List[str]] = None) -> int:
@@ -1485,10 +1810,18 @@ def _run_cli(argv: Optional[List[str]] = None) -> int:
1485
1810
  # Check for -y flag to skip confirmation
1486
1811
  skip_confirm = len(args) > 1 and args[1] in ("-y", "--yes")
1487
1812
  cache_dir = _get_cache_dir()
1488
- workspaces = list_workspaces()
1813
+ owned = workspace_ownership(list_workspaces(), cache_dir)
1489
1814
  print("This will remove all devlaunch data:")
1490
- print(f" - {len(workspaces)} DevPod workspace(s)")
1815
+ print(f" - {len(owned.mine)} DevPod workspace(s)")
1491
1816
  print(f" - {cache_dir}/ (workspace clones, repo caches, completions)")
1817
+ # Named, not merely excluded from the count: a user who asked for a
1818
+ # clean slate and gets survivors should learn it here, while saying no
1819
+ # is still an option, rather than from a later `dl --ls`.
1820
+ if owned.foreign:
1821
+ print()
1822
+ print(f"Leaving {len(owned.foreign)} workspace(s) devlaunch did not create:")
1823
+ for ws in owned.foreign:
1824
+ print(f" - {ws.id}")
1492
1825
  print()
1493
1826
  if skip_confirm:
1494
1827
  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),
@@ -10,6 +10,8 @@ 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
+
13
15
  from .models import BaseRepository, WorktreeInfo, unknown_fields
14
16
 
15
17
  # Version of the on-disk metadata.json format.
@@ -40,10 +42,7 @@ _ENTRY_ERRORS = (KeyError, TypeError, ValueError)
40
42
 
41
43
  def _get_default_metadata_path() -> Path:
42
44
  """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"
45
+ return devlaunch_cache() / "metadata.json"
47
46
 
48
47
 
49
48
  def _warn(message: str) -> None:
@@ -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.17"
3
+ version = "0.0.19"
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"
@@ -90,6 +90,22 @@ lint = { depends-on = ["ruff-lint", "ty", "pylint"] }
90
90
  style = { depends-on = ["format", "lint"] }
91
91
  commit-format = "git commit -a -m'autoformat code' || true"
92
92
  test = "pytest"
93
+ # Needs a real Docker daemon and a real devpod. Inside this repo's devcontainer
94
+ # that daemon is the container's own, via the docker-in-docker feature; on an
95
+ # ephemeral CI runner or a machine you do not mind writing to, it is the host's.
96
+ #
97
+ # The default run excludes e2e (see addopts); this is the way back in. A
98
+ # trailing `-m e2e` beats the one in addopts, and `-ra` puts every skip's reason
99
+ # in the summary, which is the whole difference between a run that opted out of
100
+ # something and a run that could not do it.
101
+ #
102
+ # Deliberately no `depends-on = ["dev-add-docker"]`. The suite repoints
103
+ # DEVPOD_HOME at a fresh directory in pytest_configure, before collection, so a
104
+ # provider registered in the ambient ~/.devpod is one this run never reads:
105
+ # test/e2e/conftest.py installs it into the run's own home instead. An edge here
106
+ # would buy the suite nothing and write to the developer's real devpod home as a
107
+ # side effect of running tests.
108
+ test-e2e = "pytest -m e2e -ra test/e2e/"
93
109
  coverage = "coverage run -m pytest && coverage xml -o coverage.xml"
94
110
  coverage-report = "coverage report -m"
95
111
  update-lock = "pixi update && git commit -a -m'update pixi.lock' || true"
@@ -123,10 +139,13 @@ enable = "no-else-return,consider-using-in"
123
139
  markers = [
124
140
  "unit: Pure logic tests with no external commands. Fast, runs everywhere.",
125
141
  "integration: Real git commands, mocked DevPod. Catches git errors and path issues.",
126
- "e2e: Full E2E with Docker-in-Docker. Real DevPod creating real containers.",
142
+ "e2e: Real DevPod creating real containers. Needs a Docker daemon.",
143
+ "creates_workspace: This e2e test builds a real workspace. The session floor in test/e2e/conftest.py holds a run that attempted one of these and built nothing to be a failure, so the promise is declared by the test rather than inferred afterwards.",
127
144
  ]
128
145
  testpaths = ["test"]
129
- # Default: run unit and integration tests, skip e2e (requires Docker-in-Docker)
146
+ # Default: run unit and integration tests, skip e2e (which needs a daemon, and
147
+ # creates and deletes real containers -- in a devpod home the suite makes for
148
+ # the run, never the developer's)
130
149
  addopts = "-m 'not e2e'"
131
150
 
132
151
  [tool.coverage.run]
@@ -139,6 +158,10 @@ exclude_also = [
139
158
  "if 0:",
140
159
  "raise AssertionError",
141
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\\(",
142
165
  "if __name__ == .__main__.:",
143
166
  "pass",
144
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