devlaunch 0.0.18__tar.gz → 0.0.20__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {devlaunch-0.0.18 → devlaunch-0.0.20}/PKG-INFO +50 -7
- {devlaunch-0.0.18 → devlaunch-0.0.20}/README.md +49 -6
- {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/dl.py +298 -48
- {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/worktree/config.py +3 -12
- devlaunch-0.0.20/devlaunch/worktree/locks.py +55 -0
- {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/worktree/repo_manager.py +69 -15
- {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/worktree/storage.py +48 -24
- {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/worktree/workspace_clone.py +50 -18
- devlaunch-0.0.20/devlaunch/xdg.py +47 -0
- {devlaunch-0.0.18 → devlaunch-0.0.20}/pyproject.toml +5 -1
- devlaunch-0.0.18/devlaunch/xdg.py +0 -21
- {devlaunch-0.0.18 → devlaunch-0.0.20}/.gitignore +0 -0
- {devlaunch-0.0.18 → devlaunch-0.0.20}/LICENSE +0 -0
- {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/__init__.py +0 -0
- {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/aid.py +0 -0
- {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/completion.py +0 -0
- {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/completion_loader.py +0 -0
- {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/completions/__init__.py +0 -0
- {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/completions/dl.bash +0 -0
- {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/devpod_provider.py +0 -0
- {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/devpod_ssh.py +0 -0
- {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/gh_auth.py +0 -0
- {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/tools.py +0 -0
- {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/tty_session.py +0 -0
- {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/workspace_id.py +0 -0
- {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/worktree/__init__.py +0 -0
- {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/worktree/branch_manager.py +0 -0
- {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/worktree/migration.py +0 -0
- {devlaunch-0.0.18 → devlaunch-0.0.20}/devlaunch/worktree/models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devlaunch
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.20
|
|
4
4
|
Summary: DevLaunch - A streamlined CLI for devpod workspaces
|
|
5
5
|
Project-URL: Source, https://github.com/blooop/devlaunch
|
|
6
6
|
Project-URL: Home, https://github.com/blooop/devlaunch
|
|
@@ -340,7 +340,7 @@ existed — picks the tools up on its next `dl <workspace> restart`.
|
|
|
340
340
|
|---------|-------------|
|
|
341
341
|
| `dl --ls` | List all workspaces |
|
|
342
342
|
| `dl --install` | Install shell completions |
|
|
343
|
-
| `dl --purge [-y]` | Remove all devlaunch data |
|
|
343
|
+
| `dl --purge [-y]` | Remove all devlaunch data — [the workspaces devlaunch created](#what-purge-deletes), and its caches |
|
|
344
344
|
| `dl --prune-worktrees [days]` | Remove unused worktrees (default: 30 days) |
|
|
345
345
|
| `dl --refresh` | Refresh completion cache |
|
|
346
346
|
| `dl --help, -h` | Show this help |
|
|
@@ -362,6 +362,40 @@ dl 0.0.9 (dev, editable from /path/to/your/devlaunch)
|
|
|
362
362
|
from the installed package's own PEP 610 metadata; an install that records none
|
|
363
363
|
just prints the bare version.
|
|
364
364
|
|
|
365
|
+
### What purge deletes
|
|
366
|
+
|
|
367
|
+
devpod's workspace list is shared. A workspace you made with `devpod up`, or that
|
|
368
|
+
another tool made, sits in the same list as the ones `dl` made, and `dl --purge`
|
|
369
|
+
has no business destroying it. So it deletes only the workspaces devlaunch
|
|
370
|
+
created — the clones it made under its own cache directory (`$XDG_CACHE_HOME` or
|
|
371
|
+
`~/.cache`, then `devlaunch/repos/<owner>/<repo>/<id>`), which is exactly the
|
|
372
|
+
directory the purge is about to remove anyway. Everything else keeps working
|
|
373
|
+
afterwards, because nothing a purge touches backs it.
|
|
374
|
+
|
|
375
|
+
Anything it is leaving is named before it asks:
|
|
376
|
+
|
|
377
|
+
```
|
|
378
|
+
$ dl --purge
|
|
379
|
+
This will remove all devlaunch data:
|
|
380
|
+
- 4 DevPod workspace(s)
|
|
381
|
+
- /home/you/.cache/devlaunch/ (workspace clones, repo caches, completions)
|
|
382
|
+
|
|
383
|
+
Leaving 2 workspace(s) devlaunch did not create:
|
|
384
|
+
- pythontemplate
|
|
385
|
+
- my-hand-made-workspace
|
|
386
|
+
|
|
387
|
+
Are you sure? [y/N]
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
Three things `dl` does create are in that second list rather than the first.
|
|
391
|
+
`dl ./some/path` and `dl <git-url>` open a source `dl` did not clone, so it
|
|
392
|
+
cannot tell them from a workspace you made by hand — and a `config.toml` that
|
|
393
|
+
points `repos_dir` outside the cache puts the clones somewhere `--purge` does not
|
|
394
|
+
remove either, so those are left too. Delete any of them with `dl <workspace> rm`.
|
|
395
|
+
Erring this way is deliberate — a purge that skips one of your own workspaces
|
|
396
|
+
costs you a command, and the other kind of mistake costs you work you cannot get
|
|
397
|
+
back.
|
|
398
|
+
|
|
365
399
|
## Examples
|
|
366
400
|
|
|
367
401
|
```bash
|
|
@@ -452,10 +486,19 @@ pixi run style
|
|
|
452
486
|
`pixi run test` skips the e2e tests, which need devpod and a Docker daemon and
|
|
453
487
|
build real containers. CI runs `pixi run test-e2e` in a job of its own, outside
|
|
454
488
|
the Python matrix, on a throwaway runner — on every push to `main` and on every
|
|
455
|
-
pull request
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
489
|
+
pull request, whatever branch that pull request targets. Stacked chains, where
|
|
490
|
+
each link targets its predecessor rather than `main`, get the same CI as anything
|
|
491
|
+
else.
|
|
492
|
+
|
|
493
|
+
Alongside the matrix and e2e there is a `gate` job that does nothing but fail
|
|
494
|
+
unless every other job in that workflow succeeded. It exists so that a branch
|
|
495
|
+
ruleset has one stable name to require rather than a list: requiring the jobs one
|
|
496
|
+
by one means literal strings in a repository setting, which nobody reviews and
|
|
497
|
+
which goes stale the moment a job is added or renamed — and a required check that
|
|
498
|
+
no longer exists does not turn a merge red, it stops gating it. Adding a job
|
|
499
|
+
means adding it to `gate`'s `needs`, in the same pull request, where it can be
|
|
500
|
+
seen. It reaches only as far as its own workflow file, so the `prek` lint job is
|
|
501
|
+
not behind it and has to be required alongside it.
|
|
459
502
|
|
|
460
503
|
Running it yourself is a different proposition. This repo's devcontainer carries
|
|
461
504
|
a Docker daemon of its own, through the `docker-in-docker` feature, and pins the
|
|
@@ -475,7 +518,7 @@ a healthy one. Every run also prints what it actually built:
|
|
|
475
518
|
|
|
476
519
|
```
|
|
477
520
|
--------------------------------- e2e session ---------------------------------
|
|
478
|
-
22 e2e tests attempted,
|
|
521
|
+
22 e2e tests attempted, 5 workspaces created: e2e-test-create, e2e-test-lifecycle, e2e-test-git, e2e-purge-devlaunchs, e2e-purge-hand-made
|
|
479
522
|
```
|
|
480
523
|
|
|
481
524
|
A run whose workspace-building tests built nothing does not pass: the shortfall
|
|
@@ -318,7 +318,7 @@ existed — picks the tools up on its next `dl <workspace> restart`.
|
|
|
318
318
|
|---------|-------------|
|
|
319
319
|
| `dl --ls` | List all workspaces |
|
|
320
320
|
| `dl --install` | Install shell completions |
|
|
321
|
-
| `dl --purge [-y]` | Remove all devlaunch data |
|
|
321
|
+
| `dl --purge [-y]` | Remove all devlaunch data — [the workspaces devlaunch created](#what-purge-deletes), and its caches |
|
|
322
322
|
| `dl --prune-worktrees [days]` | Remove unused worktrees (default: 30 days) |
|
|
323
323
|
| `dl --refresh` | Refresh completion cache |
|
|
324
324
|
| `dl --help, -h` | Show this help |
|
|
@@ -340,6 +340,40 @@ dl 0.0.9 (dev, editable from /path/to/your/devlaunch)
|
|
|
340
340
|
from the installed package's own PEP 610 metadata; an install that records none
|
|
341
341
|
just prints the bare version.
|
|
342
342
|
|
|
343
|
+
### What purge deletes
|
|
344
|
+
|
|
345
|
+
devpod's workspace list is shared. A workspace you made with `devpod up`, or that
|
|
346
|
+
another tool made, sits in the same list as the ones `dl` made, and `dl --purge`
|
|
347
|
+
has no business destroying it. So it deletes only the workspaces devlaunch
|
|
348
|
+
created — the clones it made under its own cache directory (`$XDG_CACHE_HOME` or
|
|
349
|
+
`~/.cache`, then `devlaunch/repos/<owner>/<repo>/<id>`), which is exactly the
|
|
350
|
+
directory the purge is about to remove anyway. Everything else keeps working
|
|
351
|
+
afterwards, because nothing a purge touches backs it.
|
|
352
|
+
|
|
353
|
+
Anything it is leaving is named before it asks:
|
|
354
|
+
|
|
355
|
+
```
|
|
356
|
+
$ dl --purge
|
|
357
|
+
This will remove all devlaunch data:
|
|
358
|
+
- 4 DevPod workspace(s)
|
|
359
|
+
- /home/you/.cache/devlaunch/ (workspace clones, repo caches, completions)
|
|
360
|
+
|
|
361
|
+
Leaving 2 workspace(s) devlaunch did not create:
|
|
362
|
+
- pythontemplate
|
|
363
|
+
- my-hand-made-workspace
|
|
364
|
+
|
|
365
|
+
Are you sure? [y/N]
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Three things `dl` does create are in that second list rather than the first.
|
|
369
|
+
`dl ./some/path` and `dl <git-url>` open a source `dl` did not clone, so it
|
|
370
|
+
cannot tell them from a workspace you made by hand — and a `config.toml` that
|
|
371
|
+
points `repos_dir` outside the cache puts the clones somewhere `--purge` does not
|
|
372
|
+
remove either, so those are left too. Delete any of them with `dl <workspace> rm`.
|
|
373
|
+
Erring this way is deliberate — a purge that skips one of your own workspaces
|
|
374
|
+
costs you a command, and the other kind of mistake costs you work you cannot get
|
|
375
|
+
back.
|
|
376
|
+
|
|
343
377
|
## Examples
|
|
344
378
|
|
|
345
379
|
```bash
|
|
@@ -430,10 +464,19 @@ pixi run style
|
|
|
430
464
|
`pixi run test` skips the e2e tests, which need devpod and a Docker daemon and
|
|
431
465
|
build real containers. CI runs `pixi run test-e2e` in a job of its own, outside
|
|
432
466
|
the Python matrix, on a throwaway runner — on every push to `main` and on every
|
|
433
|
-
pull request
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
467
|
+
pull request, whatever branch that pull request targets. Stacked chains, where
|
|
468
|
+
each link targets its predecessor rather than `main`, get the same CI as anything
|
|
469
|
+
else.
|
|
470
|
+
|
|
471
|
+
Alongside the matrix and e2e there is a `gate` job that does nothing but fail
|
|
472
|
+
unless every other job in that workflow succeeded. It exists so that a branch
|
|
473
|
+
ruleset has one stable name to require rather than a list: requiring the jobs one
|
|
474
|
+
by one means literal strings in a repository setting, which nobody reviews and
|
|
475
|
+
which goes stale the moment a job is added or renamed — and a required check that
|
|
476
|
+
no longer exists does not turn a merge red, it stops gating it. Adding a job
|
|
477
|
+
means adding it to `gate`'s `needs`, in the same pull request, where it can be
|
|
478
|
+
seen. It reaches only as far as its own workflow file, so the `prek` lint job is
|
|
479
|
+
not behind it and has to be required alongside it.
|
|
437
480
|
|
|
438
481
|
Running it yourself is a different proposition. This repo's devcontainer carries
|
|
439
482
|
a Docker daemon of its own, through the `docker-in-docker` feature, and pins the
|
|
@@ -453,7 +496,7 @@ a healthy one. Every run also prints what it actually built:
|
|
|
453
496
|
|
|
454
497
|
```
|
|
455
498
|
--------------------------------- e2e session ---------------------------------
|
|
456
|
-
22 e2e tests attempted,
|
|
499
|
+
22 e2e tests attempted, 5 workspaces created: e2e-test-create, e2e-test-lifecycle, e2e-test-git, e2e-purge-devlaunchs, e2e-purge-hand-made
|
|
457
500
|
```
|
|
458
501
|
|
|
459
502
|
A run whose workspace-building tests built nothing does not pass: the shortfall
|
|
@@ -22,13 +22,12 @@ import sys
|
|
|
22
22
|
import subprocess
|
|
23
23
|
import json
|
|
24
24
|
import logging
|
|
25
|
-
import os
|
|
26
25
|
import pathlib
|
|
27
26
|
import re
|
|
28
27
|
import shlex
|
|
29
28
|
import time
|
|
30
29
|
from importlib.metadata import version as pkg_version, PackageNotFoundError, distribution
|
|
31
|
-
from typing import List, Optional,
|
|
30
|
+
from typing import Any, Dict, List, Mapping, NoReturn, Optional, Sequence, Tuple
|
|
32
31
|
from dataclasses import dataclass
|
|
33
32
|
from urllib.parse import urlparse
|
|
34
33
|
from urllib.request import url2pathname
|
|
@@ -39,6 +38,7 @@ from .workspace_id import TARGET_LENGTH, WorkspaceId, slug, source_workspace_id,
|
|
|
39
38
|
from .worktree.config import get_worktree_config
|
|
40
39
|
from .worktree.migration import migrate_cache
|
|
41
40
|
from .worktree.workspace_clone import WorkspaceCloneManager
|
|
41
|
+
from .xdg import devlaunch_cache
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
class MissingBinary(Exception):
|
|
@@ -162,11 +162,15 @@ logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
|
162
162
|
|
|
163
163
|
|
|
164
164
|
def _get_cache_dir() -> pathlib.Path:
|
|
165
|
-
"""Get the cache directory, honoring XDG_CACHE_HOME.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
165
|
+
"""Get the cache directory, honoring XDG_CACHE_HOME.
|
|
166
|
+
|
|
167
|
+
The answer comes from devlaunch.xdg so that this, the worktree config's
|
|
168
|
+
default `repos_dir` and metadata.json's default path cannot drift apart --
|
|
169
|
+
is_devlaunch_clone decides what `--purge` may delete by asking whether a
|
|
170
|
+
workspace's source is under this directory, and the clones it is asking
|
|
171
|
+
about were put there by the other two.
|
|
172
|
+
"""
|
|
173
|
+
return devlaunch_cache()
|
|
170
174
|
|
|
171
175
|
|
|
172
176
|
# Cache configuration (honors XDG_CACHE_HOME)
|
|
@@ -428,32 +432,39 @@ def update_cache_background(force: bool = False) -> None:
|
|
|
428
432
|
|
|
429
433
|
|
|
430
434
|
def purge_all_data() -> int:
|
|
431
|
-
"""Purge
|
|
435
|
+
"""Purge devlaunch's data: the workspaces it created, and its caches.
|
|
432
436
|
|
|
433
437
|
This:
|
|
434
|
-
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.
|
|
435
440
|
2. Removes ~/.cache/devlaunch/ which contains:
|
|
436
441
|
- completions.json, completions.bash (completion caches)
|
|
442
|
+
|
|
443
|
+
Workspaces devlaunch did not create are not deleted and not reported here;
|
|
444
|
+
the report belongs with the confirmation, before anything is destroyed, so
|
|
445
|
+
it lives in main() where the user still has a decision to make.
|
|
437
446
|
"""
|
|
438
447
|
import shutil
|
|
439
448
|
|
|
440
449
|
cache_dir = _get_cache_dir()
|
|
441
450
|
|
|
442
|
-
# First, delete
|
|
443
|
-
# caller printed the count from
|
|
444
|
-
# the
|
|
445
|
-
|
|
446
|
-
|
|
451
|
+
# First, delete the DevPod workspaces devlaunch made. The list is the same
|
|
452
|
+
# snapshot the caller printed the count from -- list_workspaces() is
|
|
453
|
+
# memoized per command and nothing between the two reads changes what devpod
|
|
454
|
+
# would say -- so the confirmation the user answered and the set actually
|
|
455
|
+
# deleted cannot disagree.
|
|
456
|
+
owned = workspace_ownership(list_workspaces(), cache_dir)
|
|
457
|
+
for ws in owned.mine:
|
|
447
458
|
print(f"Deleting DevPod workspace: {ws.id}")
|
|
448
459
|
result = run_devpod(["delete", ws.id, "--force"], capture=True)
|
|
449
460
|
if result.returncode != 0:
|
|
450
461
|
logging.warning(f"Failed to delete workspace {ws.id}: {result.stderr}")
|
|
451
|
-
if
|
|
462
|
+
if owned.mine:
|
|
452
463
|
invalidate_workspace_list_cache()
|
|
453
464
|
|
|
454
465
|
# Then remove local cache
|
|
455
466
|
if not cache_dir.exists():
|
|
456
|
-
if not
|
|
467
|
+
if not owned.mine:
|
|
457
468
|
print("No data to purge.")
|
|
458
469
|
return 0
|
|
459
470
|
|
|
@@ -697,41 +708,235 @@ def validate_workspace_spec(spec: str, existing_ids: List[str]) -> Optional[str]
|
|
|
697
708
|
return f"Unknown workspace '{spec}'. Use 'dl --ls' to list workspaces, or specify owner/repo or ./path"
|
|
698
709
|
|
|
699
710
|
|
|
711
|
+
@dataclass(frozen=True)
|
|
712
|
+
class LocalFolder:
|
|
713
|
+
"""devpod is opening a directory on this machine."""
|
|
714
|
+
|
|
715
|
+
path: str
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
@dataclass(frozen=True)
|
|
719
|
+
class GitRepository:
|
|
720
|
+
"""devpod is opening a repository it clones itself, named by URL.
|
|
721
|
+
|
|
722
|
+
Never a path on this machine, which is why the purge predicate can refuse
|
|
723
|
+
this arm outright instead of asking where it points.
|
|
724
|
+
"""
|
|
725
|
+
|
|
726
|
+
url: str
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
@dataclass(frozen=True)
|
|
730
|
+
class UnrecognisedSource:
|
|
731
|
+
"""A `devpod list` source devlaunch has no reading for, kept verbatim.
|
|
732
|
+
|
|
733
|
+
Reachable rather than defensive: devpod's workspace source also carries
|
|
734
|
+
`image` and `container`, so `devpod up ubuntu:24.04` lands here. Holding the
|
|
735
|
+
payload rather than a rendering of it is the whole point -- the field this
|
|
736
|
+
replaced was typed as a path and filled with `str(the dict)`, so the one
|
|
737
|
+
thing a caller could not do with it was read what devpod had said.
|
|
738
|
+
|
|
739
|
+
It deliberately has no path and no URL. There is nothing to make it say
|
|
740
|
+
where the source lives, because devlaunch does not know.
|
|
741
|
+
"""
|
|
742
|
+
|
|
743
|
+
payload: Mapping[str, Any]
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
WorkspaceSource = LocalFolder | GitRepository | UnrecognisedSource
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _unhandled_source(source: NoReturn) -> NoReturn:
|
|
750
|
+
"""Reject a source arm nobody handled -- at type-check time, not at runtime.
|
|
751
|
+
|
|
752
|
+
`ty` runs in CI, so passing anything but `Never` here is a build failure:
|
|
753
|
+
adding an arm to WorkspaceSource breaks every reader that has not grown a
|
|
754
|
+
case for it. That is the property this type exists for, and it is why this
|
|
755
|
+
body should be unreachable in a passing build.
|
|
756
|
+
|
|
757
|
+
Hand-rolled rather than `typing.assert_never`, which is 3.11+ while this
|
|
758
|
+
package supports 3.10. A parameter typed `NoReturn` gets the same treatment
|
|
759
|
+
from the checker.
|
|
760
|
+
"""
|
|
761
|
+
raise AssertionError(f"Unhandled workspace source: {source!r}")
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _readable_text(source: Mapping[str, Any], key: str) -> Optional[str]:
|
|
765
|
+
"""*key* from *source* if devpod filled it with text, else None.
|
|
766
|
+
|
|
767
|
+
devpod's listing is JSON dl did not write, so `source[key]` is whatever the
|
|
768
|
+
JSON held. Returning None for anything else is what keeps the arms below
|
|
769
|
+
from being handed a value of a type their field does not describe.
|
|
770
|
+
"""
|
|
771
|
+
value = source.get(key)
|
|
772
|
+
return value if isinstance(value, str) and value else None
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def parse_workspace_source(source: Mapping[str, Any]) -> WorkspaceSource:
|
|
776
|
+
"""Read one `devpod list --output json` source object.
|
|
777
|
+
|
|
778
|
+
The keys are checked in devpod's own order of specificity: a source object
|
|
779
|
+
carrying both is a devpod that has changed under us, and taking the first
|
|
780
|
+
match keeps that from silently becoming the other one.
|
|
781
|
+
|
|
782
|
+
An arm is only as honest as the value put into it, so a key has to be
|
|
783
|
+
non-empty text before it counts. Both halves of that are load-bearing rather
|
|
784
|
+
than tidy. `git -C ""` is a no-op that succeeds, so a `LocalFolder("")`
|
|
785
|
+
reaching repo discovery would be credited with whatever repository the
|
|
786
|
+
person running `dl` happened to be standing in. And a `localFolder` that is
|
|
787
|
+
itself an object would put a dict in a field typed as a path -- the exact
|
|
788
|
+
thing this type exists to make unrepresentable -- which the two readers that
|
|
789
|
+
treat it as a path raise `TypeError` on.
|
|
790
|
+
|
|
791
|
+
Neither is an unreadable *listing*, unlike a `source` that is not an object
|
|
792
|
+
at all: the object is right here and can be kept whole. It is a source dl
|
|
793
|
+
cannot read, which is an arm.
|
|
794
|
+
|
|
795
|
+
Note this is the one link in the chain the type checker cannot stand in for
|
|
796
|
+
a test. A *reader* that misses an arm is a build failure, but this is a
|
|
797
|
+
producer, and a producer that quietly stops producing an arm type-checks
|
|
798
|
+
fine -- so the arms it picks are pinned by tests instead.
|
|
799
|
+
"""
|
|
800
|
+
path = _readable_text(source, "localFolder")
|
|
801
|
+
if path is not None:
|
|
802
|
+
return LocalFolder(path)
|
|
803
|
+
url = _readable_text(source, "gitRepository")
|
|
804
|
+
if url is not None:
|
|
805
|
+
return GitRepository(url)
|
|
806
|
+
return UnrecognisedSource(dict(source))
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def describe_source(source: WorkspaceSource) -> Tuple[str, str]:
|
|
810
|
+
"""How a source reads in `dl --ls` and in the fzf picker: (kind, detail).
|
|
811
|
+
|
|
812
|
+
One function for both columns, and one function for both callers, so the
|
|
813
|
+
kind shown and the detail shown cannot come from two different readings of
|
|
814
|
+
the same source -- which is what a tag beside a parallel field allowed.
|
|
815
|
+
|
|
816
|
+
An unreadable source shows the payload as JSON. That is a debug rendering
|
|
817
|
+
and is meant to be: the listing's job is to put the row on screen rather
|
|
818
|
+
than pass over it, and devlaunch has nothing truer to say about it.
|
|
819
|
+
"""
|
|
820
|
+
if isinstance(source, LocalFolder):
|
|
821
|
+
return "local", source.path
|
|
822
|
+
if isinstance(source, GitRepository):
|
|
823
|
+
return "git", source.url
|
|
824
|
+
if isinstance(source, UnrecognisedSource):
|
|
825
|
+
return "unknown", json.dumps(source.payload)
|
|
826
|
+
_unhandled_source(source)
|
|
827
|
+
|
|
828
|
+
|
|
700
829
|
@dataclass
|
|
701
830
|
class Workspace:
|
|
702
831
|
"""Represents a devpod workspace."""
|
|
703
832
|
|
|
704
833
|
id: str
|
|
705
|
-
|
|
706
|
-
source: str
|
|
834
|
+
source: WorkspaceSource
|
|
707
835
|
last_used: str
|
|
708
836
|
provider: str
|
|
709
837
|
ide: str
|
|
710
838
|
|
|
711
839
|
@classmethod
|
|
712
|
-
def from_json(cls, data:
|
|
840
|
+
def from_json(cls, data: Mapping[str, Any]) -> "Workspace":
|
|
713
841
|
"""Parse workspace from devpod JSON output."""
|
|
714
|
-
source = data.get("source", {})
|
|
715
|
-
if "localFolder" in source:
|
|
716
|
-
source_type = "local"
|
|
717
|
-
source_path = source["localFolder"]
|
|
718
|
-
elif "gitRepository" in source:
|
|
719
|
-
source_type = "git"
|
|
720
|
-
source_path = source["gitRepository"]
|
|
721
|
-
else:
|
|
722
|
-
source_type = "unknown"
|
|
723
|
-
source_path = str(source)
|
|
724
|
-
|
|
725
842
|
return cls(
|
|
726
843
|
id=data.get("id", ""),
|
|
727
|
-
|
|
728
|
-
source=source_path,
|
|
844
|
+
source=parse_workspace_source(data.get("source", {})),
|
|
729
845
|
last_used=data.get("lastUsed", ""),
|
|
730
846
|
provider=data.get("provider", {}).get("name", ""),
|
|
731
847
|
ide=data.get("ide", {}).get("name", ""),
|
|
732
848
|
)
|
|
733
849
|
|
|
734
850
|
|
|
851
|
+
def is_devlaunch_clone(workspace: Workspace, cache_dir: pathlib.Path) -> bool:
|
|
852
|
+
"""Whether *workspace* is one devlaunch made, rather than someone else's.
|
|
853
|
+
|
|
854
|
+
devpod's workspace namespace is shared: `devpod up` by hand, another tool
|
|
855
|
+
and an older devlaunch all land in the same list, and devlaunch has no
|
|
856
|
+
business destroying any of them. The question this answers is the narrow
|
|
857
|
+
one -- *did I make this* -- and it is answered from where the workspace's
|
|
858
|
+
source lives.
|
|
859
|
+
|
|
860
|
+
Every workspace `dl owner/repo[@branch]` creates is a clone devlaunch put
|
|
861
|
+
under its own cache directory (`<cache>/repos/<owner>/<repo>/<id>`, see
|
|
862
|
+
WorkspaceCloneManager.get_workspace_path) and then handed to `devpod up` as
|
|
863
|
+
a path, so devpod records that path as the source. That makes the predicate
|
|
864
|
+
say something true of `--purge` rather than merely correlated with it: the
|
|
865
|
+
cache directory is exactly what a purge removes, so the workspaces it
|
|
866
|
+
deletes are the ones whose source it is about to delete anyway. Anything
|
|
867
|
+
else keeps working afterwards, because nothing a purge touches backs it.
|
|
868
|
+
|
|
869
|
+
Chosen over reading metadata.json, which also records these ids, for two
|
|
870
|
+
reasons. metadata.json lives *inside* the cache directory a purge removes,
|
|
871
|
+
and `purge_all_data` only warns when `devpod delete` fails -- so one failed
|
|
872
|
+
delete plus one successful purge would leave a workspace no later purge
|
|
873
|
+
could ever recognise. And the record is append-mostly: nothing prunes it,
|
|
874
|
+
so it accumulates entries for workspaces that are long gone. A source path
|
|
875
|
+
is read back from devpod itself every time, and survives the cache it names.
|
|
876
|
+
|
|
877
|
+
Deliberately conservative in two places. A `git` or unrecognised source is
|
|
878
|
+
never ours: devlaunch always passes devpod a local path. And the comparison
|
|
879
|
+
is a path containment test, not a string prefix -- `<cache>-scratch` shares
|
|
880
|
+
six characters with `<cache>` and is not inside it.
|
|
881
|
+
|
|
882
|
+
That first refusal is now half structural. An unrecognised source has no
|
|
883
|
+
path on it at all, so there is nothing to test for containment and no way
|
|
884
|
+
to write the mistake; a git source does carry a string that could be one --
|
|
885
|
+
`devpod up <path-to-bare-repo>` records a `gitRepository`, and nothing
|
|
886
|
+
stops that repo living in the cache -- so refusing that arm is a decision
|
|
887
|
+
this function still has to make, and it is made by name.
|
|
888
|
+
|
|
889
|
+
Not every workspace dl creates is recognised. `dl ./path` and `dl <git-url>`
|
|
890
|
+
open a source dl did not clone and does not record anywhere, and a
|
|
891
|
+
`config.toml` that points `repos_dir` outside the cache puts the clones
|
|
892
|
+
somewhere `--purge` does not remove either -- so all three read as someone
|
|
893
|
+
else's. That is the safe direction to be wrong in, it keeps this predicate
|
|
894
|
+
and what a purge actually destroys answering to the same directory, and
|
|
895
|
+
`--purge` names what it leaves rather than passing over it in silence.
|
|
896
|
+
"""
|
|
897
|
+
source = workspace.source
|
|
898
|
+
if isinstance(source, LocalFolder):
|
|
899
|
+
path = pathlib.PurePath(source.path)
|
|
900
|
+
# Purely lexical, so a clone whose directory has already been removed is
|
|
901
|
+
# still recognisable from the source devpod kept.
|
|
902
|
+
return path != pathlib.PurePath(cache_dir) and path.is_relative_to(cache_dir)
|
|
903
|
+
if isinstance(source, (GitRepository, UnrecognisedSource)):
|
|
904
|
+
return False
|
|
905
|
+
_unhandled_source(source)
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
@dataclass(frozen=True)
|
|
909
|
+
class WorkspaceOwnership:
|
|
910
|
+
"""A `devpod list` answer split by whether devlaunch created each workspace.
|
|
911
|
+
|
|
912
|
+
A value rather than a filter applied at the point of deletion: `--purge`
|
|
913
|
+
prints a count and then deletes a set, and those two cannot disagree if they
|
|
914
|
+
read the same object. It also gives the workspaces devlaunch does *not* own
|
|
915
|
+
somewhere to be named from, which is the difference between a purge that
|
|
916
|
+
surprises a user with survivors and one that lists them.
|
|
917
|
+
"""
|
|
918
|
+
|
|
919
|
+
mine: Tuple[Workspace, ...]
|
|
920
|
+
foreign: Tuple[Workspace, ...]
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def workspace_ownership(
|
|
924
|
+
workspaces: Sequence[Workspace], cache_dir: pathlib.Path
|
|
925
|
+
) -> WorkspaceOwnership:
|
|
926
|
+
"""Split *workspaces* into the ones devlaunch made and the ones it did not.
|
|
927
|
+
|
|
928
|
+
Total: every workspace lands in exactly one arm, and listing order is kept
|
|
929
|
+
within each, so what is printed reads in the order devpod gave.
|
|
930
|
+
"""
|
|
931
|
+
mine: List[Workspace] = []
|
|
932
|
+
foreign: List[Workspace] = []
|
|
933
|
+
for ws in workspaces:
|
|
934
|
+
# One pass, so the predicate is asked once per workspace and the two
|
|
935
|
+
# arms cannot be built from two different answers to the same question.
|
|
936
|
+
(mine if is_devlaunch_clone(ws, cache_dir) else foreign).append(ws)
|
|
937
|
+
return WorkspaceOwnership(mine=tuple(mine), foreign=tuple(foreign))
|
|
938
|
+
|
|
939
|
+
|
|
735
940
|
# Regex patterns for parsing git URLs
|
|
736
941
|
GIT_URL_PATTERNS = [
|
|
737
942
|
# git@github.com:owner/repo.git
|
|
@@ -800,22 +1005,39 @@ def discover_repos_from_workspaces(workspaces: List[Workspace]) -> Dict[str, Lis
|
|
|
800
1005
|
"""Discover owner/repo from workspace git remotes.
|
|
801
1006
|
|
|
802
1007
|
Returns dict mapping owner -> list of repos.
|
|
1008
|
+
|
|
1009
|
+
Every source is answered, including the ones devlaunch cannot read. There
|
|
1010
|
+
is no owner/repo to be had from an image reference, so the answer for that
|
|
1011
|
+
arm is still "nothing discovered" -- but it is said rather than reached by
|
|
1012
|
+
falling off the end of the chain, which is what used to make it
|
|
1013
|
+
indistinguishable from a source that was read fine and held no repo.
|
|
803
1014
|
"""
|
|
804
1015
|
repos: Dict[str, List[str]] = {}
|
|
805
1016
|
|
|
806
1017
|
for ws in workspaces:
|
|
807
1018
|
owner_repo = None
|
|
1019
|
+
source = ws.source
|
|
808
1020
|
|
|
809
1021
|
# For git workspaces, parse the source URL directly
|
|
810
|
-
if
|
|
811
|
-
owner_repo = parse_owner_repo_from_url(
|
|
1022
|
+
if isinstance(source, GitRepository):
|
|
1023
|
+
owner_repo = parse_owner_repo_from_url(source.url)
|
|
812
1024
|
|
|
813
1025
|
# For local workspaces, try to get git remote
|
|
814
|
-
elif
|
|
815
|
-
remote_url = get_git_remote_url(
|
|
1026
|
+
elif isinstance(source, LocalFolder):
|
|
1027
|
+
remote_url = get_git_remote_url(source.path)
|
|
816
1028
|
if remote_url:
|
|
817
1029
|
owner_repo = parse_owner_repo_from_url(remote_url)
|
|
818
1030
|
|
|
1031
|
+
elif isinstance(source, UnrecognisedSource):
|
|
1032
|
+
logging.warning(
|
|
1033
|
+
f"Not looking for a repo in workspace '{ws.id}': "
|
|
1034
|
+
f"devpod describes its source as {json.dumps(source.payload)}, "
|
|
1035
|
+
"which devlaunch cannot read."
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
else:
|
|
1039
|
+
_unhandled_source(source)
|
|
1040
|
+
|
|
819
1041
|
if owner_repo:
|
|
820
1042
|
owner, repo = owner_repo
|
|
821
1043
|
if owner not in repos:
|
|
@@ -959,6 +1181,12 @@ def parse_workspaces(listing: str) -> List[Workspace]:
|
|
|
959
1181
|
that there is nothing to list. Silence gets a branch of its own rather than
|
|
960
1182
|
falling into the JSON parser, whose report of it -- `not JSON: ''` -- reads
|
|
961
1183
|
like a bug in dl rather than a devpod that never spoke.
|
|
1184
|
+
|
|
1185
|
+
A `source` that is not an object is refused here for the same reason. The
|
|
1186
|
+
source arms below are total over the object devpod documents, and the arm
|
|
1187
|
+
for a source dl cannot read holds that object -- so something that is not
|
|
1188
|
+
one is not an unreadable source, it is an unreadable *listing*, and that is
|
|
1189
|
+
the answer this function already knows how to give.
|
|
962
1190
|
"""
|
|
963
1191
|
if not listing.strip():
|
|
964
1192
|
raise UnreadableWorkspaceList(
|
|
@@ -979,6 +1207,12 @@ def parse_workspaces(listing: str) -> List[Workspace]:
|
|
|
979
1207
|
raise UnreadableWorkspaceList(
|
|
980
1208
|
f"expected each listed workspace to be an object, got {type(entry).__name__}"
|
|
981
1209
|
)
|
|
1210
|
+
source = entry.get("source", {})
|
|
1211
|
+
if not isinstance(source, dict):
|
|
1212
|
+
raise UnreadableWorkspaceList(
|
|
1213
|
+
f"expected workspace {entry.get('id', '')!r} to have an object for its "
|
|
1214
|
+
f"source, got {type(source).__name__}"
|
|
1215
|
+
)
|
|
982
1216
|
return [Workspace.from_json(ws) for ws in parsed]
|
|
983
1217
|
|
|
984
1218
|
|
|
@@ -1036,10 +1270,14 @@ def print_workspaces():
|
|
|
1036
1270
|
print("No workspaces found.")
|
|
1037
1271
|
return
|
|
1038
1272
|
|
|
1273
|
+
# Describe each source once, so the widths are measured on the same strings
|
|
1274
|
+
# that get printed.
|
|
1275
|
+
rows = [(ws, *describe_source(ws.source)) for ws in workspaces]
|
|
1276
|
+
|
|
1039
1277
|
# Calculate column widths
|
|
1040
|
-
id_width = max(len(ws.id) for ws in
|
|
1041
|
-
type_width = max(len(
|
|
1042
|
-
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)
|
|
1043
1281
|
|
|
1044
1282
|
# Print header
|
|
1045
1283
|
print(
|
|
@@ -1048,11 +1286,9 @@ def print_workspaces():
|
|
|
1048
1286
|
print("-" * (id_width + type_width + source_width + 30))
|
|
1049
1287
|
|
|
1050
1288
|
# Print rows
|
|
1051
|
-
for ws in
|
|
1289
|
+
for ws, kind, detail in rows:
|
|
1052
1290
|
last_used = ws.last_used[:19].replace("T", " ") if ws.last_used else "never"
|
|
1053
|
-
print(
|
|
1054
|
-
f"{ws.id:<{id_width}} {ws.source_type:<{type_width}} {ws.source:<{source_width}} {last_used}"
|
|
1055
|
-
)
|
|
1291
|
+
print(f"{ws.id:<{id_width}} {kind:<{type_width}} {detail:<{source_width}} {last_used}")
|
|
1056
1292
|
|
|
1057
1293
|
|
|
1058
1294
|
def fuzzy_select_workspace() -> Optional[str]:
|
|
@@ -1072,7 +1308,8 @@ def fuzzy_select_workspace() -> Optional[str]:
|
|
|
1072
1308
|
options = []
|
|
1073
1309
|
ws_map = {}
|
|
1074
1310
|
for ws in workspaces:
|
|
1075
|
-
|
|
1311
|
+
kind, detail = describe_source(ws.source)
|
|
1312
|
+
label = f"{ws.id} | {kind} | {detail}"
|
|
1076
1313
|
options.append(label)
|
|
1077
1314
|
ws_map[label] = ws.id
|
|
1078
1315
|
|
|
@@ -1383,7 +1620,7 @@ Global commands:
|
|
|
1383
1620
|
dl --ls List all workspaces
|
|
1384
1621
|
dl --install Install shell completions
|
|
1385
1622
|
dl --refresh Refresh completion cache
|
|
1386
|
-
dl --purge [-y] Remove
|
|
1623
|
+
dl --purge [-y] Remove devlaunch's workspaces and caches
|
|
1387
1624
|
dl --help, -h Show this help
|
|
1388
1625
|
dl --version Show version (editable installs name their tree)
|
|
1389
1626
|
|
|
@@ -1422,7 +1659,12 @@ def _get_clone_manager() -> WorkspaceCloneManager:
|
|
|
1422
1659
|
if "clone_manager" not in _cache:
|
|
1423
1660
|
manager = WorkspaceCloneManager()
|
|
1424
1661
|
try:
|
|
1425
|
-
|
|
1662
|
+
# Under the metadata lock so two dl processes cannot migrate at
|
|
1663
|
+
# once: the renames are not idempotent mid-flight, and exclusive()
|
|
1664
|
+
# reloads first so the version check sees the other side's result.
|
|
1665
|
+
# migrate_cache calls save() directly, never a locked mutator.
|
|
1666
|
+
with manager.storage.exclusive():
|
|
1667
|
+
migrate_cache(manager.storage, pathlib.Path(manager.config.repos_dir))
|
|
1426
1668
|
except OSError as e:
|
|
1427
1669
|
# A failed migration must not take the command with it. The renames
|
|
1428
1670
|
# that did happen are still resumable: the version header is only
|
|
@@ -1573,10 +1815,18 @@ def _run_cli(argv: Optional[List[str]] = None) -> int:
|
|
|
1573
1815
|
# Check for -y flag to skip confirmation
|
|
1574
1816
|
skip_confirm = len(args) > 1 and args[1] in ("-y", "--yes")
|
|
1575
1817
|
cache_dir = _get_cache_dir()
|
|
1576
|
-
|
|
1818
|
+
owned = workspace_ownership(list_workspaces(), cache_dir)
|
|
1577
1819
|
print("This will remove all devlaunch data:")
|
|
1578
|
-
print(f" - {len(
|
|
1820
|
+
print(f" - {len(owned.mine)} DevPod workspace(s)")
|
|
1579
1821
|
print(f" - {cache_dir}/ (workspace clones, repo caches, completions)")
|
|
1822
|
+
# Named, not merely excluded from the count: a user who asked for a
|
|
1823
|
+
# clean slate and gets survivors should learn it here, while saying no
|
|
1824
|
+
# is still an option, rather than from a later `dl --ls`.
|
|
1825
|
+
if owned.foreign:
|
|
1826
|
+
print()
|
|
1827
|
+
print(f"Leaving {len(owned.foreign)} workspace(s) devlaunch did not create:")
|
|
1828
|
+
for ws in owned.foreign:
|
|
1829
|
+
print(f" - {ws.id}")
|
|
1580
1830
|
print()
|
|
1581
1831
|
if skip_confirm:
|
|
1582
1832
|
return purge_all_data()
|
|
@@ -1,21 +1,12 @@
|
|
|
1
1
|
"""Configuration management for worktree backend."""
|
|
2
2
|
|
|
3
|
-
import os
|
|
4
3
|
from dataclasses import dataclass, field
|
|
5
4
|
from pathlib import Path
|
|
6
5
|
from typing import Dict, Optional, Union
|
|
7
6
|
|
|
8
7
|
import tomli
|
|
9
8
|
|
|
10
|
-
from devlaunch.xdg import config_home
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def _get_cache_base() -> Path:
|
|
14
|
-
"""Get the base cache directory, honoring XDG_CACHE_HOME."""
|
|
15
|
-
xdg_cache = os.environ.get("XDG_CACHE_HOME")
|
|
16
|
-
if xdg_cache:
|
|
17
|
-
return Path(xdg_cache) / "devlaunch"
|
|
18
|
-
return Path.home() / ".cache" / "devlaunch"
|
|
9
|
+
from devlaunch.xdg import config_home, devlaunch_cache
|
|
19
10
|
|
|
20
11
|
|
|
21
12
|
@dataclass
|
|
@@ -28,7 +19,7 @@ class WorktreeConfig:
|
|
|
28
19
|
"""
|
|
29
20
|
|
|
30
21
|
enabled: bool = True # Enabled by default
|
|
31
|
-
repos_dir: Union[Path, str] = field(default_factory=lambda:
|
|
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),
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Inter-process locks for the shared cache.
|
|
2
|
+
|
|
3
|
+
Several dl processes can run at once — two agents launched on their own
|
|
4
|
+
branches, a completion refresh in the background — and they share one bare-clone
|
|
5
|
+
cache and one metadata.json. These locks are what keeps simultaneous runs from
|
|
6
|
+
racing each other over that state: without them, two first launches of a repo
|
|
7
|
+
both ran ``git clone --bare`` into the same path (and the loser's cleanup
|
|
8
|
+
deleted the winner's half-written clone), and metadata writers rewrote the file
|
|
9
|
+
from stale in-memory copies, dropping each other's records.
|
|
10
|
+
|
|
11
|
+
``flock`` rather than a pid file: the kernel releases it when the process dies,
|
|
12
|
+
however it dies, so a crashed dl never leaves the cache wedged.
|
|
13
|
+
|
|
14
|
+
Two deliberate limits, both load-bearing:
|
|
15
|
+
|
|
16
|
+
- **Not reentrant.** Acquiring a path twice in one process deadlocks (the second
|
|
17
|
+
open file description blocks on the first). Call sites are structured so no
|
|
18
|
+
lock is ever taken while the same lock is held — see the acquisition comments
|
|
19
|
+
at each site.
|
|
20
|
+
- **The lock file is never deleted.** Unlinking an flock'd file is the classic
|
|
21
|
+
self-defeating move: a process that opened the old inode still "holds" a lock
|
|
22
|
+
nobody else can see, while new arrivals lock a fresh file and walk straight
|
|
23
|
+
past it. A few empty ``.lock`` files in the cache are the price of the
|
|
24
|
+
guarantee; ``dl --purge`` sweeps them away with everything else.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import contextlib
|
|
28
|
+
import fcntl
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Iterator, Optional
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@contextlib.contextmanager
|
|
36
|
+
def hold_lock(lock_path: Path, waiting_note: Optional[str] = None) -> Iterator[None]:
|
|
37
|
+
"""Hold an exclusive inter-process lock on *lock_path* for the block.
|
|
38
|
+
|
|
39
|
+
Blocks until the lock is free. When another process already holds it and
|
|
40
|
+
*waiting_note* is given, one line is printed to stderr first, so a dl run
|
|
41
|
+
that sits waiting on a sibling's long clone says why it is sitting.
|
|
42
|
+
"""
|
|
43
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
45
|
+
try:
|
|
46
|
+
try:
|
|
47
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
48
|
+
except BlockingIOError:
|
|
49
|
+
if waiting_note:
|
|
50
|
+
print(f"dl: waiting for {waiting_note}", file=sys.stderr)
|
|
51
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
52
|
+
yield
|
|
53
|
+
finally:
|
|
54
|
+
# Closing the descriptor releases the lock; nothing is unlinked.
|
|
55
|
+
os.close(fd)
|
|
@@ -7,6 +7,7 @@ from datetime import datetime
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import Optional, TYPE_CHECKING
|
|
9
9
|
|
|
10
|
+
from .locks import hold_lock
|
|
10
11
|
from .models import BaseRepository
|
|
11
12
|
from .storage import MetadataStorage
|
|
12
13
|
|
|
@@ -41,6 +42,15 @@ class RepositoryManager:
|
|
|
41
42
|
"""Get the bare git directory for a repository."""
|
|
42
43
|
return self.get_repo_path(owner, repo) / ".bare"
|
|
43
44
|
|
|
45
|
+
def lock_path(self, owner: str, repo: str) -> Path:
|
|
46
|
+
"""The lock every process takes before mutating repos/<owner>/<repo>.
|
|
47
|
+
|
|
48
|
+
A file, not a directory, inside the repo dir: every walker of the cache
|
|
49
|
+
filters on ``is_dir()``, so it is invisible to discovery, migration and
|
|
50
|
+
completion scans.
|
|
51
|
+
"""
|
|
52
|
+
return self.get_repo_path(owner, repo) / ".lock"
|
|
53
|
+
|
|
44
54
|
def clone_repo(self, owner: str, repo: str, remote_url: str) -> BaseRepository:
|
|
45
55
|
"""Clone a new base repository as bare (no working directory).
|
|
46
56
|
|
|
@@ -58,7 +68,21 @@ class RepositoryManager:
|
|
|
58
68
|
existing_repo = self.get_repo(owner, repo)
|
|
59
69
|
if existing_repo:
|
|
60
70
|
return existing_repo
|
|
61
|
-
|
|
71
|
+
if (bare_path / "HEAD").exists():
|
|
72
|
+
# The bare clone is already on disk but this process has no
|
|
73
|
+
# record of it -- another process just made it (this process's
|
|
74
|
+
# metadata was loaded before that one saved), or an earlier run
|
|
75
|
+
# died between clone and save. Either way the clone on disk is
|
|
76
|
+
# the authority and the record is derived state: rebuild the
|
|
77
|
+
# record. Cloning over it instead is not an option -- git
|
|
78
|
+
# refuses the non-empty destination, and the failure cleanup
|
|
79
|
+
# below would then delete a cache another launch is using.
|
|
80
|
+
return self._register_existing_bare(owner, repo, remote_url, bare_path)
|
|
81
|
+
# No HEAD: a dead run's partial clone. Holding the repo lock (every
|
|
82
|
+
# caller comes through ensure_repo) means no live process owns it,
|
|
83
|
+
# so clear it and clone fresh.
|
|
84
|
+
logger.warning(f"Removing partial clone at {bare_path}")
|
|
85
|
+
shutil.rmtree(bare_path)
|
|
62
86
|
|
|
63
87
|
# Create parent directory
|
|
64
88
|
bare_path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -97,11 +121,28 @@ class RepositoryManager:
|
|
|
97
121
|
|
|
98
122
|
except subprocess.CalledProcessError as e:
|
|
99
123
|
logger.debug(f"Failed to clone repository: {e.stderr}")
|
|
100
|
-
# Clean up partial clone
|
|
124
|
+
# Clean up the partial clone. Safe to delete: the exists-cases were
|
|
125
|
+
# all handled above, so this directory is one this call created.
|
|
101
126
|
if bare_path.exists():
|
|
102
127
|
shutil.rmtree(bare_path)
|
|
103
128
|
raise RuntimeError(f"Failed to clone repository: {e.stderr}") from e
|
|
104
129
|
|
|
130
|
+
def _register_existing_bare(
|
|
131
|
+
self, owner: str, repo: str, remote_url: str, bare_path: Path
|
|
132
|
+
) -> BaseRepository:
|
|
133
|
+
"""Rebuild the metadata record for a bare clone already on disk."""
|
|
134
|
+
base_repo = BaseRepository(
|
|
135
|
+
owner=owner,
|
|
136
|
+
repo=repo,
|
|
137
|
+
remote_url=remote_url,
|
|
138
|
+
local_path=bare_path,
|
|
139
|
+
default_branch=self._get_default_branch(bare_path),
|
|
140
|
+
last_fetched=datetime.now(),
|
|
141
|
+
worktrees=[],
|
|
142
|
+
)
|
|
143
|
+
self.storage.add_repository(base_repo)
|
|
144
|
+
return base_repo
|
|
145
|
+
|
|
105
146
|
def fetch_repo(self, owner: str, repo: str) -> None:
|
|
106
147
|
"""Fetch latest changes from remote."""
|
|
107
148
|
bare_path = self.get_bare_path(owner, repo)
|
|
@@ -166,20 +207,33 @@ class RepositoryManager:
|
|
|
166
207
|
"""Ensure repo exists locally, clone if needed.
|
|
167
208
|
|
|
168
209
|
Uses lazy fetch: only fetches if fetch_interval has elapsed since last fetch.
|
|
169
|
-
"""
|
|
170
|
-
if self.repo_exists(owner, repo):
|
|
171
|
-
existing_repo = self.get_repo(owner, repo)
|
|
172
|
-
if existing_repo:
|
|
173
|
-
# Only fetch if interval has elapsed (lazy fetch)
|
|
174
|
-
if auto_fetch and self._should_fetch(existing_repo):
|
|
175
|
-
try:
|
|
176
|
-
self.fetch_repo(owner, repo)
|
|
177
|
-
except Exception as e:
|
|
178
|
-
logger.warning(f"Failed to fetch updates: {e}")
|
|
179
|
-
return existing_repo
|
|
180
|
-
# Metadata doesn't exist but repo exists - fall through to clone (which will add metadata)
|
|
181
210
|
|
|
182
|
-
|
|
211
|
+
The whole exists-check-then-clone sequence runs under the repo lock:
|
|
212
|
+
without it, two processes launching the same repo at once both saw no
|
|
213
|
+
clone and both ran ``git clone --bare`` into the same path — and the
|
|
214
|
+
loser's cleanup in clone_repo deleted the winner's half-written cache.
|
|
215
|
+
Serialized, the loser just waits and then reuses the winner's clone.
|
|
216
|
+
clone_repo and fetch_repo rely on this lock rather than taking it
|
|
217
|
+
themselves (hold_lock is not reentrant).
|
|
218
|
+
"""
|
|
219
|
+
with hold_lock(
|
|
220
|
+
self.lock_path(owner, repo),
|
|
221
|
+
waiting_note=f"another dl run preparing {owner}/{repo}",
|
|
222
|
+
):
|
|
223
|
+
if self.repo_exists(owner, repo):
|
|
224
|
+
existing_repo = self.get_repo(owner, repo)
|
|
225
|
+
if existing_repo:
|
|
226
|
+
# Only fetch if interval has elapsed (lazy fetch)
|
|
227
|
+
if auto_fetch and self._should_fetch(existing_repo):
|
|
228
|
+
try:
|
|
229
|
+
self.fetch_repo(owner, repo)
|
|
230
|
+
except Exception as e:
|
|
231
|
+
logger.warning(f"Failed to fetch updates: {e}")
|
|
232
|
+
return existing_repo
|
|
233
|
+
# Metadata doesn't exist but repo exists - fall through to clone
|
|
234
|
+
# (which will add metadata)
|
|
235
|
+
|
|
236
|
+
return self.clone_repo(owner, repo, remote_url)
|
|
183
237
|
|
|
184
238
|
def repo_exists(self, owner: str, repo: str) -> bool:
|
|
185
239
|
"""Check if repository exists locally."""
|
|
@@ -10,6 +10,9 @@ import tempfile
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from typing import Any, Dict, List, Optional, Tuple
|
|
12
12
|
|
|
13
|
+
from devlaunch.xdg import devlaunch_cache
|
|
14
|
+
|
|
15
|
+
from .locks import hold_lock
|
|
13
16
|
from .models import BaseRepository, WorktreeInfo, unknown_fields
|
|
14
17
|
|
|
15
18
|
# Version of the on-disk metadata.json format.
|
|
@@ -40,10 +43,7 @@ _ENTRY_ERRORS = (KeyError, TypeError, ValueError)
|
|
|
40
43
|
|
|
41
44
|
def _get_default_metadata_path() -> Path:
|
|
42
45
|
"""Get the default metadata path, honoring XDG_CACHE_HOME."""
|
|
43
|
-
|
|
44
|
-
if xdg_cache:
|
|
45
|
-
return Path(xdg_cache) / "devlaunch" / "metadata.json"
|
|
46
|
-
return Path.home() / ".cache" / "devlaunch" / "metadata.json"
|
|
46
|
+
return devlaunch_cache() / "metadata.json"
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
def _warn(message: str) -> None:
|
|
@@ -84,8 +84,28 @@ class MetadataStorage:
|
|
|
84
84
|
self.metadata_path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
85
|
# Every file operation targets the real file, not a symlink pointing at it.
|
|
86
86
|
self._file_path = _resolve_link(self.metadata_path)
|
|
87
|
+
# A sidecar rather than the file itself: save() replaces metadata.json
|
|
88
|
+
# by rename, and a lock taken on a replaced inode guards nothing.
|
|
89
|
+
self._lock_path = self._file_path.with_name(self._file_path.name + ".lock")
|
|
87
90
|
self._load()
|
|
88
91
|
|
|
92
|
+
@contextlib.contextmanager
|
|
93
|
+
def exclusive(self):
|
|
94
|
+
"""Hold the metadata lock and reload before the block runs.
|
|
95
|
+
|
|
96
|
+
Every mutation goes through this: the in-memory copy was loaded whenever
|
|
97
|
+
this process started, and other dl processes may have written since.
|
|
98
|
+
Rewriting the file from that stale copy silently drops their records —
|
|
99
|
+
reloading under the lock is what makes read-modify-write safe. The
|
|
100
|
+
caller applies its change and calls save() before the block ends.
|
|
101
|
+
|
|
102
|
+
Not reentrant (see locks.py), so mutators must never be called from
|
|
103
|
+
inside an exclusive() block — they take this lock themselves.
|
|
104
|
+
"""
|
|
105
|
+
with hold_lock(self._lock_path, waiting_note="another dl run updating the workspace list"):
|
|
106
|
+
self._load()
|
|
107
|
+
yield
|
|
108
|
+
|
|
89
109
|
def _quarantine(self, reason: str) -> None:
|
|
90
110
|
"""Move an unusable metadata file aside so the data stays inspectable.
|
|
91
111
|
|
|
@@ -289,8 +309,9 @@ class MetadataStorage:
|
|
|
289
309
|
def add_repository(self, repo: BaseRepository) -> None:
|
|
290
310
|
"""Add or update a repository."""
|
|
291
311
|
key = f"{repo.owner}/{repo.repo}"
|
|
292
|
-
self.
|
|
293
|
-
|
|
312
|
+
with self.exclusive():
|
|
313
|
+
self.repositories[key] = repo
|
|
314
|
+
self.save()
|
|
294
315
|
|
|
295
316
|
def get_repository(self, owner: str, repo: str) -> Optional[BaseRepository]:
|
|
296
317
|
"""Get a repository by owner and name."""
|
|
@@ -304,22 +325,24 @@ class MetadataStorage:
|
|
|
304
325
|
def remove_repository(self, owner: str, repo: str) -> None:
|
|
305
326
|
"""Remove a repository."""
|
|
306
327
|
key = f"{owner}/{repo}"
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
328
|
+
with self.exclusive():
|
|
329
|
+
if key in self.repositories:
|
|
330
|
+
del self.repositories[key]
|
|
331
|
+
self.save()
|
|
310
332
|
|
|
311
333
|
def add_worktree(self, worktree: WorktreeInfo) -> None:
|
|
312
334
|
"""Add or update a worktree."""
|
|
313
335
|
key = f"{worktree.owner}/{worktree.repo}/{worktree.branch}"
|
|
314
|
-
self.
|
|
336
|
+
with self.exclusive():
|
|
337
|
+
self.worktrees[key] = worktree
|
|
315
338
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
339
|
+
# Update repository's worktree list in memory, then write once.
|
|
340
|
+
repo = self.get_repository(worktree.owner, worktree.repo)
|
|
341
|
+
if repo and worktree.branch not in repo.worktrees:
|
|
342
|
+
repo.worktrees.append(worktree.branch)
|
|
343
|
+
self.repositories[f"{worktree.owner}/{worktree.repo}"] = repo
|
|
321
344
|
|
|
322
|
-
|
|
345
|
+
self.save()
|
|
323
346
|
|
|
324
347
|
def get_worktree(self, owner: str, repo: str, branch: str) -> Optional[WorktreeInfo]:
|
|
325
348
|
"""Get a worktree by repository and branch."""
|
|
@@ -349,13 +372,14 @@ class MetadataStorage:
|
|
|
349
372
|
def remove_worktree(self, owner: str, repo: str, branch: str) -> None:
|
|
350
373
|
"""Remove a worktree."""
|
|
351
374
|
key = f"{owner}/{repo}/{branch}"
|
|
352
|
-
|
|
353
|
-
|
|
375
|
+
with self.exclusive():
|
|
376
|
+
if key in self.worktrees:
|
|
377
|
+
del self.worktrees[key]
|
|
354
378
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
379
|
+
# Update repository's worktree list in memory, then write once.
|
|
380
|
+
repo_obj = self.get_repository(owner, repo)
|
|
381
|
+
if repo_obj and branch in repo_obj.worktrees:
|
|
382
|
+
repo_obj.worktrees.remove(branch)
|
|
383
|
+
self.repositories[f"{owner}/{repo}"] = repo_obj
|
|
360
384
|
|
|
361
|
-
|
|
385
|
+
self.save()
|
|
@@ -25,6 +25,7 @@ from typing import Optional
|
|
|
25
25
|
from ..workspace_id import WorkspaceId, validate_ref_name
|
|
26
26
|
from .branch_manager import BranchManager
|
|
27
27
|
from .config import WorktreeConfig, get_worktree_config
|
|
28
|
+
from .locks import hold_lock
|
|
28
29
|
from .models import WorktreeInfo
|
|
29
30
|
from .repo_manager import RepositoryManager
|
|
30
31
|
from .storage import MetadataStorage
|
|
@@ -173,27 +174,36 @@ class WorkspaceCloneManager:
|
|
|
173
174
|
|
|
174
175
|
Fetches latest refs, then uses BranchManager to create the branch
|
|
175
176
|
locally if needed. Does not push to the remote.
|
|
177
|
+
|
|
178
|
+
Runs under the repo lock: the fetch and the branch creation both write
|
|
179
|
+
refs in the shared bare repo, and two processes doing so at once trip
|
|
180
|
+
over git's own ref locks. (hold_lock is not reentrant; no callee here
|
|
181
|
+
takes the repo lock.)
|
|
176
182
|
"""
|
|
177
183
|
bare_path = self.repo_manager.get_bare_path(owner, repo)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
184
|
+
with hold_lock(
|
|
185
|
+
self.repo_manager.lock_path(owner, repo),
|
|
186
|
+
waiting_note=f"another dl run preparing {owner}/{repo}",
|
|
187
|
+
):
|
|
188
|
+
# Lazy-fetch: only hits the network when the fetch interval has elapsed
|
|
189
|
+
try:
|
|
190
|
+
self.repo_manager.lazy_fetch(owner, repo)
|
|
191
|
+
except (RuntimeError, ValueError, OSError) as e:
|
|
192
|
+
logger.warning(f"Failed to fetch before branch ensure: {e}")
|
|
183
193
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
194
|
+
try:
|
|
195
|
+
default_branch = self.repo_manager.get_default_branch(owner, repo)
|
|
196
|
+
except (RuntimeError, subprocess.CalledProcessError, OSError) as e:
|
|
197
|
+
logger.warning(f"Failed to resolve default branch: {e}")
|
|
198
|
+
default_branch = None
|
|
199
|
+
|
|
200
|
+
self.branch_manager.ensure_branch_exists(
|
|
201
|
+
bare_path,
|
|
202
|
+
branch,
|
|
203
|
+
create_remote=False,
|
|
204
|
+
start_point=default_branch or "HEAD",
|
|
205
|
+
use_local_refs=True,
|
|
206
|
+
)
|
|
197
207
|
|
|
198
208
|
def ensure_workspace(
|
|
199
209
|
self,
|
|
@@ -223,6 +233,28 @@ class WorkspaceCloneManager:
|
|
|
223
233
|
bare_repo_path = self.repo_manager.get_bare_path(owner, repo)
|
|
224
234
|
|
|
225
235
|
ws_path = self.get_workspace_path(owner, repo, branch)
|
|
236
|
+
|
|
237
|
+
# Steps 2-6 mutate the workspace clone, so they run under the repo
|
|
238
|
+
# lock: fire the same workspace twice at once and, unserialized, each
|
|
239
|
+
# process saw no clone, both cloned into the same path, and the loser's
|
|
240
|
+
# cleanup deleted the winner's. The lock is taken only after
|
|
241
|
+
# ensure_repo (which takes the same lock) has returned -- hold_lock is
|
|
242
|
+
# not reentrant.
|
|
243
|
+
with hold_lock(
|
|
244
|
+
self.repo_manager.lock_path(owner, repo),
|
|
245
|
+
waiting_note=f"another dl run preparing {owner}/{repo}",
|
|
246
|
+
):
|
|
247
|
+
return self._prepare_workspace(workspace, bare_repo_path, ws_path, remote_url)
|
|
248
|
+
|
|
249
|
+
def _prepare_workspace(
|
|
250
|
+
self,
|
|
251
|
+
workspace: WorkspaceId,
|
|
252
|
+
bare_repo_path: Path,
|
|
253
|
+
ws_path: Path,
|
|
254
|
+
remote_url: str,
|
|
255
|
+
) -> Path:
|
|
256
|
+
"""Steps 2-6 of ensure_workspace; the caller holds the repo lock."""
|
|
257
|
+
owner, repo, branch = workspace.owner, workspace.repo, workspace.ref
|
|
226
258
|
is_new_workspace = False
|
|
227
259
|
if not self.workspace_exists(owner, repo, branch):
|
|
228
260
|
is_new_workspace = True
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Where the XDG base directories point on this machine.
|
|
2
|
+
|
|
3
|
+
Two unrelated places ask for the config home: the worktree loader, which reads
|
|
4
|
+
`config.toml` under it, and the gh-token warning, which names it so a user whose
|
|
5
|
+
shell scoped the variable can see why `gh auth token` refused. Those two have to
|
|
6
|
+
agree -- a warning that names one directory while the loader reads another is
|
|
7
|
+
worse than no warning -- so they share the answer rather than each spelling it.
|
|
8
|
+
|
|
9
|
+
The cache home has the same problem and one more caller's worth of it. Three
|
|
10
|
+
places used to spell it out identically: dl's own cache directory, the worktree
|
|
11
|
+
config's default `repos_dir`, and the metadata file's default path. They have to
|
|
12
|
+
agree because `dl --purge` reads the first to decide which workspaces are
|
|
13
|
+
devlaunch's -- workspaces whose clones the other two put on disk -- so a copy
|
|
14
|
+
that drifted would make a purge silently stop recognising its own work.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def config_home() -> Path:
|
|
22
|
+
"""`$XDG_CONFIG_HOME`, or the `~/.config` the spec falls back to.
|
|
23
|
+
|
|
24
|
+
An empty value counts as unset, which is what the XDG basedir spec says and
|
|
25
|
+
what a shell exporting the variable with no value means. Reading it any other
|
|
26
|
+
way resolves the config path relative to the working directory instead.
|
|
27
|
+
"""
|
|
28
|
+
return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cache_home() -> Path:
|
|
32
|
+
"""`$XDG_CACHE_HOME`, or the `~/.cache` the spec falls back to.
|
|
33
|
+
|
|
34
|
+
Empty counts as unset, for the same reason as above.
|
|
35
|
+
"""
|
|
36
|
+
return Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def devlaunch_cache() -> Path:
|
|
40
|
+
"""Everything devlaunch stores on this machine, under one directory.
|
|
41
|
+
|
|
42
|
+
The bare repo clones, the workspace clones, the completion caches and
|
|
43
|
+
metadata.json all live here, and `dl --purge` removes exactly this. One
|
|
44
|
+
function rather than a copy per caller, because a purge decides what is its
|
|
45
|
+
own to delete by asking whether a workspace's source is inside it.
|
|
46
|
+
"""
|
|
47
|
+
return cache_home() / "devlaunch"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "devlaunch"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.20"
|
|
4
4
|
authors = [{ name = "Austin Gregg-Smith", email = "blooop@gmail.com" }]
|
|
5
5
|
description = "DevLaunch - A streamlined CLI for devpod workspaces"
|
|
6
6
|
readme = "README.md"
|
|
@@ -158,6 +158,10 @@ exclude_also = [
|
|
|
158
158
|
"if 0:",
|
|
159
159
|
"raise AssertionError",
|
|
160
160
|
"raise NotImplementedError",
|
|
161
|
+
# The exhaustiveness sentinel. `ty` runs in CI and rejects any call to it that
|
|
162
|
+
# is reachable, so in a build that type-checks these lines cannot be executed
|
|
163
|
+
# -- the same reason `raise AssertionError` is already on this list.
|
|
164
|
+
"_unhandled_source\\(",
|
|
161
165
|
"if __name__ == .__main__.:",
|
|
162
166
|
"pass",
|
|
163
167
|
"(_):",
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
"""Where the XDG base directories point on this machine.
|
|
2
|
-
|
|
3
|
-
Two unrelated places ask for the config home: the worktree loader, which reads
|
|
4
|
-
`config.toml` under it, and the gh-token warning, which names it so a user whose
|
|
5
|
-
shell scoped the variable can see why `gh auth token` refused. Those two have to
|
|
6
|
-
agree -- a warning that names one directory while the loader reads another is
|
|
7
|
-
worse than no warning -- so they share the answer rather than each spelling it.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
import os
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def config_home() -> Path:
|
|
15
|
-
"""`$XDG_CONFIG_HOME`, or the `~/.config` the spec falls back to.
|
|
16
|
-
|
|
17
|
-
An empty value counts as unset, which is what the XDG basedir spec says and
|
|
18
|
-
what a shell exporting the variable with no value means. Reading it any other
|
|
19
|
-
way resolves the config path relative to the working directory instead.
|
|
20
|
-
"""
|
|
21
|
-
return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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
|