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.
- {devlaunch-0.0.17 → devlaunch-0.0.19}/.gitignore +0 -1
- {devlaunch-0.0.17 → devlaunch-0.0.19}/PKG-INFO +130 -2
- {devlaunch-0.0.17 → devlaunch-0.0.19}/README.md +129 -1
- {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/devpod_provider.py +29 -6
- {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/dl.py +397 -64
- {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/worktree/config.py +3 -12
- {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/worktree/storage.py +3 -4
- devlaunch-0.0.19/devlaunch/xdg.py +47 -0
- {devlaunch-0.0.17 → devlaunch-0.0.19}/pyproject.toml +26 -3
- devlaunch-0.0.17/devlaunch/xdg.py +0 -21
- {devlaunch-0.0.17 → devlaunch-0.0.19}/LICENSE +0 -0
- {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/__init__.py +0 -0
- {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/aid.py +0 -0
- {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/completion.py +0 -0
- {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/completion_loader.py +0 -0
- {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/completions/__init__.py +0 -0
- {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/completions/dl.bash +0 -0
- {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/devpod_ssh.py +0 -0
- {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/gh_auth.py +0 -0
- {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/tools.py +0 -0
- {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/tty_session.py +0 -0
- {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/workspace_id.py +0 -0
- {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/worktree/__init__.py +0 -0
- {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/worktree/branch_manager.py +0 -0
- {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/worktree/migration.py +0 -0
- {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/worktree/models.py +0 -0
- {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/worktree/repo_manager.py +0 -0
- {devlaunch-0.0.17 → devlaunch-0.0.19}/devlaunch/worktree/workspace_clone.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devlaunch
|
|
3
|
-
Version: 0.0.
|
|
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(
|
|
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}:
|
|
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(
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
|
435
|
+
"""Purge devlaunch's data: the workspaces it created, and its caches.
|
|
385
436
|
|
|
386
437
|
This:
|
|
387
|
-
1. Deletes
|
|
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
|
|
396
|
-
# caller printed the count from
|
|
397
|
-
# the
|
|
398
|
-
|
|
399
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
764
|
-
owner_repo = parse_owner_repo_from_url(
|
|
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
|
|
768
|
-
remote_url = get_git_remote_url(
|
|
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
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
workspaces =
|
|
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
|
|
957
|
-
type_width = max(len(
|
|
958
|
-
source_width = max(len(
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
1376
|
-
|
|
1377
|
-
|
|
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
|
-
|
|
1813
|
+
owned = workspace_ownership(list_workspaces(), cache_dir)
|
|
1489
1814
|
print("This will remove all devlaunch data:")
|
|
1490
|
-
print(f" - {len(
|
|
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:
|
|
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",
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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 (
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|