devlaunch 0.0.22__tar.gz → 0.0.23__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.22 → devlaunch-0.0.23}/PKG-INFO +43 -1
- {devlaunch-0.0.22 → devlaunch-0.0.23}/README.md +42 -0
- {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/dl.py +225 -9
- {devlaunch-0.0.22 → devlaunch-0.0.23}/pyproject.toml +1 -1
- {devlaunch-0.0.22 → devlaunch-0.0.23}/.gitignore +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.23}/LICENSE +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/__init__.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/aid.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/completion.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/completion_loader.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/completions/__init__.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/completions/dl.bash +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/devpod_provider.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/devpod_ssh.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/gh_auth.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/tools.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/tty_session.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/workspace_id.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/workspace_state.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/worktree/__init__.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/worktree/branch_manager.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/worktree/config.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/worktree/locks.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/worktree/migration.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/worktree/models.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/worktree/repo_manager.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/worktree/storage.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/worktree/workspace_clone.py +0 -0
- {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/xdg.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.23
|
|
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
|
|
@@ -397,6 +397,48 @@ Erring this way is deliberate — a purge that skips one of your own workspaces
|
|
|
397
397
|
costs you a command, and the other kind of mistake costs you work you cannot get
|
|
398
398
|
back.
|
|
399
399
|
|
|
400
|
+
#### When part of the cache will not go
|
|
401
|
+
|
|
402
|
+
A container writes into its clone as its own user — `vscode`, uid 1000, in the
|
|
403
|
+
standard devcontainer base image. Where your host user is uid 1000 too, nothing
|
|
404
|
+
here comes up. Where it is not — CI, a shared machine, a container running as
|
|
405
|
+
root, or devlaunch developed inside its own devcontainer — the directories the
|
|
406
|
+
container made cannot be emptied by you, and the purge cannot remove them.
|
|
407
|
+
|
|
408
|
+
It removes everything else anyway, and names what is left:
|
|
409
|
+
|
|
410
|
+
```
|
|
411
|
+
$ dl --purge -y
|
|
412
|
+
Removed what was permitted under /home/you/.cache/devlaunch. These refused:
|
|
413
|
+
- /home/you/.cache/devlaunch/repos/blooop/bencher/bencher-main-kivagede: Permission denied
|
|
414
|
+
|
|
415
|
+
Usually this means a container wrote them as a different user, and:
|
|
416
|
+
sudo rm -rf '/home/you/.cache/devlaunch'
|
|
417
|
+
clears them. Check the reasons above first -- it does not fix all of them.
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
Exit status is `1`, because a clone you were told would go is still on disk. It
|
|
421
|
+
used to be `1` with the *whole* cache still standing: the first refusal stopped
|
|
422
|
+
the purge, so the completion caches, `metadata.json` and every other clone
|
|
423
|
+
survived on account of one directory.
|
|
424
|
+
|
|
425
|
+
What is listed is the directory, once — not the hundreds of files inside it.
|
|
426
|
+
Unlinking needs write permission on the directory rather than on the file, so
|
|
427
|
+
every entry in that clone refuses separately and they are all the same fact.
|
|
428
|
+
Two *separately* unwritable directories on one path are two lines, though,
|
|
429
|
+
because clearing the inner one would leave the outer one just as stuck.
|
|
430
|
+
|
|
431
|
+
Each line carries what the system actually said. A container running as another
|
|
432
|
+
user is the common cause, but a read-only mount, `chattr +i` and a busy
|
|
433
|
+
mountpoint all land here too — and `sudo rm -rf` does not fix those, which is
|
|
434
|
+
why the report offers the cause rather than asserting it.
|
|
435
|
+
|
|
436
|
+
If you have **moved your cache** by making `~/.cache/devlaunch` a symlink, a
|
|
437
|
+
purge refuses it and names the target rather than following it. Remove the real
|
|
438
|
+
directory yourself if you meant to: following the link would empty a directory
|
|
439
|
+
you never named, and removing just the link would report a clean sweep while
|
|
440
|
+
your clones sat on the other volume.
|
|
441
|
+
|
|
400
442
|
### Cleaning up workspaces
|
|
401
443
|
|
|
402
444
|
One workspace per branch means workspaces accumulate, and `--purge` is the wrong
|
|
@@ -375,6 +375,48 @@ Erring this way is deliberate — a purge that skips one of your own workspaces
|
|
|
375
375
|
costs you a command, and the other kind of mistake costs you work you cannot get
|
|
376
376
|
back.
|
|
377
377
|
|
|
378
|
+
#### When part of the cache will not go
|
|
379
|
+
|
|
380
|
+
A container writes into its clone as its own user — `vscode`, uid 1000, in the
|
|
381
|
+
standard devcontainer base image. Where your host user is uid 1000 too, nothing
|
|
382
|
+
here comes up. Where it is not — CI, a shared machine, a container running as
|
|
383
|
+
root, or devlaunch developed inside its own devcontainer — the directories the
|
|
384
|
+
container made cannot be emptied by you, and the purge cannot remove them.
|
|
385
|
+
|
|
386
|
+
It removes everything else anyway, and names what is left:
|
|
387
|
+
|
|
388
|
+
```
|
|
389
|
+
$ dl --purge -y
|
|
390
|
+
Removed what was permitted under /home/you/.cache/devlaunch. These refused:
|
|
391
|
+
- /home/you/.cache/devlaunch/repos/blooop/bencher/bencher-main-kivagede: Permission denied
|
|
392
|
+
|
|
393
|
+
Usually this means a container wrote them as a different user, and:
|
|
394
|
+
sudo rm -rf '/home/you/.cache/devlaunch'
|
|
395
|
+
clears them. Check the reasons above first -- it does not fix all of them.
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Exit status is `1`, because a clone you were told would go is still on disk. It
|
|
399
|
+
used to be `1` with the *whole* cache still standing: the first refusal stopped
|
|
400
|
+
the purge, so the completion caches, `metadata.json` and every other clone
|
|
401
|
+
survived on account of one directory.
|
|
402
|
+
|
|
403
|
+
What is listed is the directory, once — not the hundreds of files inside it.
|
|
404
|
+
Unlinking needs write permission on the directory rather than on the file, so
|
|
405
|
+
every entry in that clone refuses separately and they are all the same fact.
|
|
406
|
+
Two *separately* unwritable directories on one path are two lines, though,
|
|
407
|
+
because clearing the inner one would leave the outer one just as stuck.
|
|
408
|
+
|
|
409
|
+
Each line carries what the system actually said. A container running as another
|
|
410
|
+
user is the common cause, but a read-only mount, `chattr +i` and a busy
|
|
411
|
+
mountpoint all land here too — and `sudo rm -rf` does not fix those, which is
|
|
412
|
+
why the report offers the cause rather than asserting it.
|
|
413
|
+
|
|
414
|
+
If you have **moved your cache** by making `~/.cache/devlaunch` a symlink, a
|
|
415
|
+
purge refuses it and names the target rather than following it. Remove the real
|
|
416
|
+
directory yourself if you meant to: following the link would empty a directory
|
|
417
|
+
you never named, and removing just the link would report a clean sweep while
|
|
418
|
+
your clones sat on the other volume.
|
|
419
|
+
|
|
378
420
|
### Cleaning up workspaces
|
|
379
421
|
|
|
380
422
|
One workspace per branch means workspaces accumulate, and `--purge` is the wrong
|
|
@@ -22,9 +22,11 @@ import sys
|
|
|
22
22
|
import subprocess
|
|
23
23
|
import json
|
|
24
24
|
import logging
|
|
25
|
+
import os
|
|
25
26
|
import pathlib
|
|
26
27
|
import re
|
|
27
28
|
import shlex
|
|
29
|
+
import stat
|
|
28
30
|
import time
|
|
29
31
|
from importlib.metadata import version as pkg_version, PackageNotFoundError, distribution
|
|
30
32
|
from pathlib import Path
|
|
@@ -502,6 +504,199 @@ def workspaces_as_json() -> int:
|
|
|
502
504
|
return 0
|
|
503
505
|
|
|
504
506
|
|
|
507
|
+
@dataclass(frozen=True)
|
|
508
|
+
class Refusal:
|
|
509
|
+
"""One path a removal could not remove, and what the system said about it.
|
|
510
|
+
|
|
511
|
+
The reason is carried rather than reconstructed because the cause is not
|
|
512
|
+
guessable from the path. A container writing as another user is the common
|
|
513
|
+
one and the one devlaunch#131 is about, but a read-only mount, an immutable
|
|
514
|
+
file and a busy mountpoint all reach here too -- and for the last two the
|
|
515
|
+
advice that fixes the common case does not work. Printing what the errno
|
|
516
|
+
said keeps the report honest about which it is.
|
|
517
|
+
"""
|
|
518
|
+
|
|
519
|
+
path: pathlib.Path
|
|
520
|
+
reason: str
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _why(error: OSError) -> str:
|
|
524
|
+
"""What the system said, in the words it used."""
|
|
525
|
+
return error.strerror or str(error)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _present(path: pathlib.Path) -> bool:
|
|
529
|
+
"""Whether *path* is there, where "cannot tell" counts as there.
|
|
530
|
+
|
|
531
|
+
Only `FileNotFoundError` means there is nothing to do. Any other refusal --
|
|
532
|
+
an unreadable parent directory, say -- means something is there that this
|
|
533
|
+
process cannot look at, and treating that as absent is how a purge reports
|
|
534
|
+
a clean sweep over an intact cache.
|
|
535
|
+
|
|
536
|
+
`Path.exists()` cannot make that distinction and is not consistent about
|
|
537
|
+
which way it fails: it returns False for an unreadable parent on some Python
|
|
538
|
+
versions and raises PermissionError on others, so the code it replaced here
|
|
539
|
+
answered wrongly on one and crashed on the next. Symlinks count as present
|
|
540
|
+
whether or not they resolve, because the link itself is a thing to remove.
|
|
541
|
+
"""
|
|
542
|
+
try:
|
|
543
|
+
os.lstat(path)
|
|
544
|
+
except FileNotFoundError:
|
|
545
|
+
return False
|
|
546
|
+
except OSError:
|
|
547
|
+
return True
|
|
548
|
+
return True
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def remove_tree(tree: pathlib.Path) -> Tuple[Refusal, ...]:
|
|
552
|
+
"""Remove *tree* and everything under it. Returns what refused, and why.
|
|
553
|
+
|
|
554
|
+
`shutil.rmtree` is the obvious way to do this and is the wrong one here,
|
|
555
|
+
because it stops at the first failure. A container writes into its
|
|
556
|
+
bind-mounted clone as its own user -- uid 1000 in the standard devcontainer
|
|
557
|
+
base image -- and where the host user is not also uid 1000 the directories
|
|
558
|
+
it made cannot be emptied by us. That is one clone out of a cache full of
|
|
559
|
+
them, and abandoning the other clones, the completion caches and
|
|
560
|
+
metadata.json on account of it is a worse outcome than the permission error
|
|
561
|
+
(devlaunch#131). So this keeps going, and the refusals are the return value
|
|
562
|
+
rather than an exception.
|
|
563
|
+
|
|
564
|
+
**Only the obstruction is named**, which is not the same as the path that
|
|
565
|
+
raised. Unlinking needs write permission on the *directory*, not on the
|
|
566
|
+
file, so a clone directory owned by the container's user refuses every one
|
|
567
|
+
of its children separately -- on a real e2e workspace that is forty-odd
|
|
568
|
+
`.git/objects` entries, hooks and a README, none of them an ancestor of
|
|
569
|
+
another and every one of them the same single fact. So a failure is
|
|
570
|
+
attributed upward to the outermost directory that cannot be written into,
|
|
571
|
+
which is the directory the original errno named and the one a person would
|
|
572
|
+
go and look at.
|
|
573
|
+
|
|
574
|
+
A path is then suppressed when something already reported accounts for it:
|
|
575
|
+
a directory that cannot be removed because a child refused adds nothing. A
|
|
576
|
+
*separately* sealed ancestor is not suppressed and should not be, because
|
|
577
|
+
fixing the one below it would not free it -- so a chain of two sealed
|
|
578
|
+
directories is two lines, and each is work somebody has to do.
|
|
579
|
+
|
|
580
|
+
**What refused is decided from the disk, not from what raised.** A failure
|
|
581
|
+
during the walk is only a candidate; the report keeps the ones still on disk
|
|
582
|
+
when it is over. Both suppression rules are then applied to that surviving
|
|
583
|
+
list, so a path that vanished after failing can neither be reported nor
|
|
584
|
+
suppress the report of something real.
|
|
585
|
+
|
|
586
|
+
That is not a belt-and-braces check, it is load-bearing, and randomised
|
|
587
|
+
trees found the case: `os.walk` cannot scan an unlistable directory and says
|
|
588
|
+
so, but if that directory is *empty* the `rmdir` afterwards succeeds. Noting
|
|
589
|
+
it when it raised named a path that is not there, and -- through the
|
|
590
|
+
ancestor rule -- could have silenced a genuine refusal above it, which is
|
|
591
|
+
the one failure direction that matters here.
|
|
592
|
+
|
|
593
|
+
An empty result means the tree is gone, including when it was never there:
|
|
594
|
+
a purge run twice is not a failure the second time.
|
|
595
|
+
"""
|
|
596
|
+
# One lstat, three outcomes, none of them inferred. `Path.exists()` and
|
|
597
|
+
# `Path.is_symlink()` cannot be used here: they answer False for a path this
|
|
598
|
+
# process was not allowed to look at on some Python versions and raise
|
|
599
|
+
# PermissionError on others, and neither of those is "there is nothing to
|
|
600
|
+
# remove".
|
|
601
|
+
try:
|
|
602
|
+
info = os.lstat(tree)
|
|
603
|
+
except FileNotFoundError:
|
|
604
|
+
return ()
|
|
605
|
+
except OSError as error:
|
|
606
|
+
# Something is there that we are not allowed to look at. Saying so is
|
|
607
|
+
# the whole point; calling it gone is the failure this guards.
|
|
608
|
+
return (Refusal(tree, _why(error)),)
|
|
609
|
+
|
|
610
|
+
# A symlinked root is refused, which is what `shutil.rmtree` did and is the
|
|
611
|
+
# only one of the three available answers that is not a lie.
|
|
612
|
+
#
|
|
613
|
+
# `os.walk`'s `followlinks=False` governs *subdirectories*; the top is
|
|
614
|
+
# always scanned. So following it empties a directory the caller never
|
|
615
|
+
# named. Unlinking just the link is no better and is worse to diagnose: the
|
|
616
|
+
# clones are still on the other disk and the purge says "Removed". A cache
|
|
617
|
+
# root is a symlink because somebody moved their cache, so both of those
|
|
618
|
+
# answers cost them their workspaces -- one by deleting them, one by telling
|
|
619
|
+
# them they are gone.
|
|
620
|
+
#
|
|
621
|
+
# Naming the target matters: `sudo rm -rf <cache>` would remove the link and
|
|
622
|
+
# nothing else, so the reader needs the real location to act on.
|
|
623
|
+
if stat.S_ISLNK(info.st_mode):
|
|
624
|
+
try:
|
|
625
|
+
points_at = f" to {os.readlink(tree)}"
|
|
626
|
+
except OSError:
|
|
627
|
+
points_at = ""
|
|
628
|
+
return (Refusal(tree, f"is a symbolic link{points_at}, which a purge will not follow"),)
|
|
629
|
+
|
|
630
|
+
failed: List[Refusal] = []
|
|
631
|
+
|
|
632
|
+
def obstruction(path: pathlib.Path) -> pathlib.Path:
|
|
633
|
+
"""The outermost path that actually explains a failure to remove *path*.
|
|
634
|
+
|
|
635
|
+
`os.access` is advisory -- it answers for the real uid and knows nothing
|
|
636
|
+
about ACLs -- and that is acceptable precisely here, because it only
|
|
637
|
+
decides *which* path is named. A wrong answer makes the report less
|
|
638
|
+
pointed; it can never turn a refusal into a success.
|
|
639
|
+
|
|
640
|
+
`path.parent != path` bounds the walk at the filesystem root as well as
|
|
641
|
+
at *tree*. Nothing reaches here from outside *tree* today; the guard is
|
|
642
|
+
so that a future caller that does gets a wrong answer rather than a
|
|
643
|
+
hung purge.
|
|
644
|
+
"""
|
|
645
|
+
while path != tree:
|
|
646
|
+
parent = path.parent
|
|
647
|
+
if parent == path:
|
|
648
|
+
break # the filesystem root: there is nothing above to blame
|
|
649
|
+
if os.access(parent, os.W_OK | os.X_OK):
|
|
650
|
+
break # this one is reachable, so *path* is where it stops
|
|
651
|
+
path = parent
|
|
652
|
+
return path
|
|
653
|
+
|
|
654
|
+
def unreadable(error: OSError) -> None:
|
|
655
|
+
# os.walk reports a directory it could not scan here and then carries on
|
|
656
|
+
# as though it were empty. Without this, an unlistable directory holding
|
|
657
|
+
# files would be walked as though it held none.
|
|
658
|
+
if error.filename:
|
|
659
|
+
failed.append(Refusal(pathlib.Path(error.filename), _why(error)))
|
|
660
|
+
|
|
661
|
+
def remove(path: pathlib.Path) -> None:
|
|
662
|
+
try:
|
|
663
|
+
# A symlink is unlinked, never followed -- descending one would put
|
|
664
|
+
# a purge outside the cache directory it was asked to remove.
|
|
665
|
+
if path.is_dir() and not path.is_symlink():
|
|
666
|
+
path.rmdir()
|
|
667
|
+
else:
|
|
668
|
+
path.unlink()
|
|
669
|
+
except OSError as error:
|
|
670
|
+
failed.append(Refusal(path, _why(error)))
|
|
671
|
+
|
|
672
|
+
# Bottom-up, so a directory is only attempted once its contents have been.
|
|
673
|
+
for parent, dirs, files in os.walk(tree, topdown=False, onerror=unreadable):
|
|
674
|
+
here = pathlib.Path(parent)
|
|
675
|
+
for name in files:
|
|
676
|
+
remove(here / name)
|
|
677
|
+
for name in dirs:
|
|
678
|
+
remove(here / name)
|
|
679
|
+
# The root is in nobody's `dirs`, so it is removed by name.
|
|
680
|
+
remove(tree)
|
|
681
|
+
|
|
682
|
+
# Bottom-up order is what the ancestor rule needs, and `failed` is already
|
|
683
|
+
# in it.
|
|
684
|
+
refused: List[Refusal] = []
|
|
685
|
+
blocked = set()
|
|
686
|
+
for candidate in failed:
|
|
687
|
+
# _present, not `exists()`: a path this process cannot look at must be
|
|
688
|
+
# reported, not dropped. Dropping it is how the filter that exists to
|
|
689
|
+
# prevent phantom refusals would have started causing silent ones.
|
|
690
|
+
if not _present(candidate.path):
|
|
691
|
+
continue # it went in the end, so there is nothing to report
|
|
692
|
+
path = obstruction(candidate.path)
|
|
693
|
+
if path not in blocked:
|
|
694
|
+
refused.append(Refusal(path, candidate.reason))
|
|
695
|
+
blocked.add(path)
|
|
696
|
+
blocked.add(path.parent)
|
|
697
|
+
return tuple(refused)
|
|
698
|
+
|
|
699
|
+
|
|
505
700
|
def purge_all_data() -> int:
|
|
506
701
|
"""Purge devlaunch's data: the workspaces it created, and its caches.
|
|
507
702
|
|
|
@@ -514,9 +709,11 @@ def purge_all_data() -> int:
|
|
|
514
709
|
Workspaces devlaunch did not create are not deleted and not reported here;
|
|
515
710
|
the report belongs with the confirmation, before anything is destroyed, so
|
|
516
711
|
it lives in main() where the user still has a decision to make.
|
|
517
|
-
"""
|
|
518
|
-
import shutil
|
|
519
712
|
|
|
713
|
+
A cache that does not come away completely is reported rather than raised:
|
|
714
|
+
see remove_tree for why it is removed as far as it goes, and the exit code
|
|
715
|
+
below for what that leaves the caller to say.
|
|
716
|
+
"""
|
|
520
717
|
cache_dir = _get_cache_dir()
|
|
521
718
|
|
|
522
719
|
# First, delete the DevPod workspaces devlaunch made. The list is the same
|
|
@@ -533,19 +730,38 @@ def purge_all_data() -> int:
|
|
|
533
730
|
if owned.mine:
|
|
534
731
|
invalidate_workspace_list_cache()
|
|
535
732
|
|
|
536
|
-
# Then remove local cache
|
|
537
|
-
|
|
733
|
+
# Then remove local cache. See _present for why this is not `exists()`: a
|
|
734
|
+
# cache that is there but unreachable must be reached for, not reported as
|
|
735
|
+
# nothing to do.
|
|
736
|
+
if not _present(cache_dir):
|
|
538
737
|
if not owned.mine:
|
|
539
738
|
print("No data to purge.")
|
|
540
739
|
return 0
|
|
541
740
|
|
|
542
|
-
|
|
543
|
-
|
|
741
|
+
refused = remove_tree(cache_dir)
|
|
742
|
+
if not refused:
|
|
544
743
|
print(f"Removed: {cache_dir}")
|
|
545
744
|
return 0
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
745
|
+
|
|
746
|
+
# Not 0: a clone the user was told would go is still on disk. Not silent
|
|
747
|
+
# either -- an exit code cannot distinguish "removed most of it" from
|
|
748
|
+
# "removed none of it", and the difference is the whole news, so the report
|
|
749
|
+
# carries it and the exit code only says the job is unfinished.
|
|
750
|
+
print(f"Removed what was permitted under {cache_dir}. These refused:")
|
|
751
|
+
for refusal in refused:
|
|
752
|
+
print(f" - {refusal.path}: {refusal.reason}")
|
|
753
|
+
print()
|
|
754
|
+
# Hedged, because the cause is not knowable from here. A container writing
|
|
755
|
+
# as another user is the common one, but a read-only mount, `chattr +i` and
|
|
756
|
+
# a busy mountpoint all land in the same report -- and for the last two this
|
|
757
|
+
# command does not help either. Saying so flatly would be wrong more often
|
|
758
|
+
# than the errno above is.
|
|
759
|
+
print("Usually this means a container wrote them as a different user, and:")
|
|
760
|
+
# Quoted: cache_dir comes from $XDG_CACHE_HOME or $HOME, and a space in it
|
|
761
|
+
# turns a pasted `sudo rm -rf` into two targets, the first of them wrong.
|
|
762
|
+
print(f" sudo rm -rf {shlex.quote(str(cache_dir))}")
|
|
763
|
+
print("clears them. Check the reasons above first -- it does not fix all of them.")
|
|
764
|
+
return 1
|
|
549
765
|
|
|
550
766
|
|
|
551
767
|
# Regex to match owner/repo[@branch] format (not a path, not already a URL)
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|