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.
Files changed (29) hide show
  1. {devlaunch-0.0.22 → devlaunch-0.0.23}/PKG-INFO +43 -1
  2. {devlaunch-0.0.22 → devlaunch-0.0.23}/README.md +42 -0
  3. {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/dl.py +225 -9
  4. {devlaunch-0.0.22 → devlaunch-0.0.23}/pyproject.toml +1 -1
  5. {devlaunch-0.0.22 → devlaunch-0.0.23}/.gitignore +0 -0
  6. {devlaunch-0.0.22 → devlaunch-0.0.23}/LICENSE +0 -0
  7. {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/__init__.py +0 -0
  8. {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/aid.py +0 -0
  9. {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/completion.py +0 -0
  10. {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/completion_loader.py +0 -0
  11. {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/completions/__init__.py +0 -0
  12. {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/completions/dl.bash +0 -0
  13. {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/devpod_provider.py +0 -0
  14. {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/devpod_ssh.py +0 -0
  15. {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/gh_auth.py +0 -0
  16. {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/tools.py +0 -0
  17. {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/tty_session.py +0 -0
  18. {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/workspace_id.py +0 -0
  19. {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/workspace_state.py +0 -0
  20. {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/worktree/__init__.py +0 -0
  21. {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/worktree/branch_manager.py +0 -0
  22. {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/worktree/config.py +0 -0
  23. {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/worktree/locks.py +0 -0
  24. {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/worktree/migration.py +0 -0
  25. {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/worktree/models.py +0 -0
  26. {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/worktree/repo_manager.py +0 -0
  27. {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/worktree/storage.py +0 -0
  28. {devlaunch-0.0.22 → devlaunch-0.0.23}/devlaunch/worktree/workspace_clone.py +0 -0
  29. {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.22
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
- if not cache_dir.exists():
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
- try:
543
- shutil.rmtree(cache_dir)
741
+ refused = remove_tree(cache_dir)
742
+ if not refused:
544
743
  print(f"Removed: {cache_dir}")
545
744
  return 0
546
- except OSError as e:
547
- print(f"Error removing {cache_dir}: {e}")
548
- return 1
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)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devlaunch"
3
- version = "0.0.22"
3
+ version = "0.0.23"
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"
File without changes
File without changes
File without changes
File without changes