devlaunch 0.0.28__tar.gz → 0.0.29__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 (32) hide show
  1. {devlaunch-0.0.28 → devlaunch-0.0.29}/PKG-INFO +1 -1
  2. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/worktree/workspace_clone.py +139 -55
  3. {devlaunch-0.0.28 → devlaunch-0.0.29}/pyproject.toml +2 -1
  4. {devlaunch-0.0.28 → devlaunch-0.0.29}/.gitignore +0 -0
  5. {devlaunch-0.0.28 → devlaunch-0.0.29}/LICENSE +0 -0
  6. {devlaunch-0.0.28 → devlaunch-0.0.29}/README.md +0 -0
  7. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/__init__.py +0 -0
  8. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/aid.py +0 -0
  9. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/completion.py +0 -0
  10. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/completion_loader.py +0 -0
  11. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/completions/__init__.py +0 -0
  12. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/completions/dl.bash +0 -0
  13. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/devpod_provider.py +0 -0
  14. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/devpod_ssh.py +0 -0
  15. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/disk_usage.py +0 -0
  16. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/dl.py +0 -0
  17. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/gh_auth.py +0 -0
  18. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/timing.py +0 -0
  19. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/tools.py +0 -0
  20. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/tty_session.py +0 -0
  21. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/workspace_id.py +0 -0
  22. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/workspace_state.py +0 -0
  23. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/worktree/__init__.py +0 -0
  24. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/worktree/branch_manager.py +0 -0
  25. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/worktree/config.py +0 -0
  26. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/worktree/git_errors.py +0 -0
  27. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/worktree/locks.py +0 -0
  28. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/worktree/migration.py +0 -0
  29. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/worktree/models.py +0 -0
  30. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/worktree/repo_manager.py +0 -0
  31. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/worktree/storage.py +0 -0
  32. {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/xdg.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: devlaunch
3
- Version: 0.0.28
3
+ Version: 0.0.29
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
@@ -21,7 +21,7 @@ import shutil
21
21
  import subprocess
22
22
  from dataclasses import dataclass
23
23
  from pathlib import Path
24
- from typing import Optional, Union
24
+ from typing import NoReturn, Optional, Union
25
25
 
26
26
  from .. import timing
27
27
  from ..workspace_id import WorkspaceId, validate_ref_name
@@ -81,6 +81,67 @@ class StaleBase:
81
81
  BranchBase = Union[FreshBase, StaleBase]
82
82
 
83
83
 
84
+ @dataclass(frozen=True)
85
+ class _DefaultBranchNamed:
86
+ """The repository's default branch is known, and *name* is it."""
87
+
88
+ name: str
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class _DefaultBranchUnknown:
93
+ """No default branch could be named, and *reason* is why.
94
+
95
+ One arm for both ways that happens -- the resolver raised, or it answered
96
+ with an empty name -- because what follows is the same for each: nothing to
97
+ fetch, and a new branch cut from the bare cache's own ``HEAD``. The reason
98
+ is what tells the two apart afterwards, and it is carried rather than
99
+ rebuilt at the print site.
100
+ """
101
+
102
+ reason: str
103
+
104
+
105
+ # The name-or-why of a repository's default branch. A pair of locals held this
106
+ # before, and nothing stopped them disagreeing: an error string beside a name,
107
+ # or neither set. The two facts travel as one value now, and "no name" carries
108
+ # its reason by construction rather than by a second variable being consulted.
109
+ _DefaultBranch = Union[_DefaultBranchNamed, _DefaultBranchUnknown]
110
+
111
+
112
+ def _unhandled_default_branch(default: NoReturn) -> NoReturn:
113
+ """Reject a default-branch answer nobody handled. See :func:`unhandled_branch_base`."""
114
+ raise AssertionError(f"Unhandled default branch: {default!r}")
115
+
116
+
117
+ def _start_point(default: _DefaultBranch) -> str:
118
+ """What a new branch is cut from, whichever arm *default* is.
119
+
120
+ Exists so the two call sites do not each hand-roll the fallback, the way
121
+ :func:`devlaunch.disk_usage.known_bytes` exists for its own sum.
122
+ """
123
+ if isinstance(default, _DefaultBranchNamed):
124
+ return default.name
125
+ if isinstance(default, _DefaultBranchUnknown):
126
+ return "HEAD"
127
+ _unhandled_default_branch(default)
128
+
129
+
130
+ def unhandled_branch_base(base: NoReturn) -> NoReturn:
131
+ """Reject a base answer nobody handled -- at type-check time, not at runtime.
132
+
133
+ Exported for the same reason
134
+ :func:`devlaunch.worktree.repo_manager.unhandled_fetch_outcome` is: an
135
+ ``else`` hand-rolled at a call site is exactly how a third arm gets read as
136
+ "the base is fresh", which is the one thing this sum exists to prevent.
137
+
138
+ Hand-rolled rather than :func:`typing.assert_never`, which is 3.11+ while
139
+ this package supports 3.10; a parameter typed ``NoReturn`` gets the same
140
+ treatment from the checker.
141
+ """
142
+ raise AssertionError(f"Unhandled branch base: {base!r}")
143
+
144
+
84
145
  def _on_disk(path: Path) -> Optional[bool]:
85
146
  """Whether *path* is there: True, False, or None for "could not look".
86
147
 
@@ -498,7 +559,12 @@ class WorkspaceCloneManager:
498
559
  with self.repo_manager.hold_repo_lock(owner, repo) as lock:
499
560
  self.repo_manager.clone_if_missing(lock, owner, repo, remote_url)
500
561
  base = self.ensure_branch(lock, owner, repo, branch)
501
- if isinstance(base, StaleBase):
562
+ # Named arm by arm, like every other sum this package reads: a third
563
+ # arm absorbed by a bare `else` here would launch from a stale cache
564
+ # in silence, which is the defect this reporting was built to close.
565
+ if isinstance(base, FreshBase):
566
+ pass
567
+ elif isinstance(base, StaleBase):
502
568
  # The one consequence-stating line for the whole degraded
503
569
  # family, in dl's own output because wf reads that output: the
504
570
  # fetch-level warnings above it say what failed, this says what
@@ -508,6 +574,8 @@ class WorkspaceCloneManager:
508
574
  f"which could not be refreshed ({base.reason}); "
509
575
  f"it may be behind the remote."
510
576
  )
577
+ else:
578
+ unhandled_branch_base(base)
511
579
  return self._prepare_workspace(
512
580
  lock,
513
581
  workspace,
@@ -555,14 +623,7 @@ class WorkspaceCloneManager:
555
623
  lock.require(owner, repo)
556
624
  bare_path = self.repo_manager.get_bare_path(owner, repo)
557
625
  outcome = self.repo_manager.fetch_ref(owner, repo, branch)
558
-
559
- try:
560
- default_branch = self.repo_manager.get_default_branch(owner, repo)
561
- default_branch_error = None
562
- except (RuntimeError, subprocess.CalledProcessError, OSError) as e:
563
- logger.warning(f"Failed to resolve default branch: {e}")
564
- default_branch = None
565
- default_branch_error = str(e)
626
+ default = self._resolve_default_branch(owner, repo)
566
627
 
567
628
  # Each arm named, so a fourth one cannot be silently read as one of
568
629
  # these three.
@@ -575,53 +636,15 @@ class WorkspaceCloneManager:
575
636
  # current. Whatever this second fetch answers, the branch creation
576
637
  # below proceeds -- there is no third ref to fall back to, and the
577
638
  # cache's own default branch is the best remaining start point.
578
- if default_branch:
579
- try:
580
- base_outcome = self.repo_manager.fetch_ref(owner, repo, default_branch)
581
- except ValueError as e:
582
- # Unlike `branch`, this name is read back from metadata.json
583
- # and carries no proof. Caught rather than propagated so a
584
- # hand-edited record cannot change what this method raises:
585
- # dl's launch path guards it with (RuntimeError, OSError),
586
- # so a ValueError here would surface as a traceback.
587
- logger.warning(f"Cannot fetch recorded default branch: {e}")
588
- base = StaleBase(base=default_branch, reason=str(e))
589
- else:
590
- # Named arm by arm for the same reason as the dispatch
591
- # around it: the sum exists so a fourth outcome cannot be
592
- # quietly read as one of these three.
593
- if isinstance(base_outcome, Updated):
594
- base = FreshBase()
595
- elif isinstance(base_outcome, RefMissingOnRemote):
596
- # The remote has no default branch under that name
597
- # either. Nothing more to try -- there is no third ref
598
- # -- and the branch creation below still has the cache's
599
- # own default branch to start from. Unverifiable is
600
- # stale here: nothing fetched backs it.
601
- base = StaleBase(
602
- base=default_branch,
603
- reason=f"the remote has no branch '{default_branch}' to refresh from",
604
- )
605
- elif isinstance(base_outcome, FetchFailed):
606
- # Same condition as the arm below, warned the same
607
- # way: the new branch is about to be cut from a
608
- # possibly stale cache, and the reason is carried
609
- # precisely so it can be printed here.
610
- logger.warning(
611
- f"Could not fetch {default_branch} for {owner}/{repo}: "
612
- f"{base_outcome.reason}"
613
- )
614
- base = StaleBase(base=default_branch, reason=base_outcome.reason)
615
- else:
616
- unhandled_fetch_outcome(base_outcome)
617
- else:
639
+ if isinstance(default, _DefaultBranchNamed):
640
+ base = self._fetch_base_branch(owner, repo, default.name)
641
+ elif isinstance(default, _DefaultBranchUnknown):
618
642
  # No default branch could even be named, so nothing was
619
643
  # fetched and the creation below starts from the bare cache's
620
644
  # own HEAD -- a ref of unbounded age, and the report says so.
621
- base = StaleBase(
622
- base="HEAD",
623
- reason=default_branch_error or "no default branch is recorded",
624
- )
645
+ base = StaleBase(base="HEAD", reason=default.reason)
646
+ else:
647
+ _unhandled_default_branch(default)
625
648
  elif isinstance(outcome, FetchFailed):
626
649
  # Not an error here: a cached branch still launches. Deliberately
627
650
  # no default-branch fetch -- nothing was learned about the remote,
@@ -635,11 +658,72 @@ class WorkspaceCloneManager:
635
658
  bare_path,
636
659
  branch,
637
660
  create_remote=False,
638
- start_point=default_branch or "HEAD",
661
+ start_point=_start_point(default),
639
662
  use_local_refs=True,
640
663
  )
641
664
  return base
642
665
 
666
+ def _resolve_default_branch(self, owner: str, repo: str) -> _DefaultBranch:
667
+ """The repository's default branch, or why it could not be named.
668
+
669
+ An empty answer is folded into :class:`_DefaultBranchUnknown` here
670
+ rather than being left to read as falsey downstream: a branch named
671
+ ``""`` is not a thing, so the one place that can tell is the one place
672
+ that asked.
673
+
674
+ Both arms guard against an empty *reason* for the same underlying
675
+ rule: whatever ends up in a :class:`StaleBase` gets printed inside
676
+ "could not be refreshed (…)", and an exception whose ``str()`` is empty
677
+ would print that as "()" -- a sentence with the reason cut out of it.
678
+ """
679
+ try:
680
+ name = self.repo_manager.get_default_branch(owner, repo)
681
+ except (RuntimeError, subprocess.CalledProcessError, OSError) as e:
682
+ logger.warning(f"Failed to resolve default branch: {e}")
683
+ return _DefaultBranchUnknown(reason=str(e) or f"{type(e).__name__} with no message")
684
+ if not name:
685
+ return _DefaultBranchUnknown(reason="no default branch is recorded")
686
+ return _DefaultBranchNamed(name=name)
687
+
688
+ def _fetch_base_branch(self, owner: str, repo: str, default_branch: str) -> BranchBase:
689
+ """Refresh *default_branch* to cut a brand-new branch from, and report it.
690
+
691
+ Split out of :meth:`ensure_branch` so the "which ref is the base" question
692
+ is answered in one place per outcome rather than nested three deep inside
693
+ the requested ref's own dispatch.
694
+ """
695
+ try:
696
+ outcome = self.repo_manager.fetch_ref(owner, repo, default_branch)
697
+ except ValueError as e:
698
+ # Unlike the requested branch, this name is read back from
699
+ # metadata.json and carries no proof. Caught rather than propagated
700
+ # so a hand-edited record cannot change what ensure_branch raises:
701
+ # dl's launch path guards it with (RuntimeError, OSError), so a
702
+ # ValueError here would surface as a traceback.
703
+ logger.warning(f"Cannot fetch recorded default branch: {e}")
704
+ return StaleBase(base=default_branch, reason=str(e))
705
+
706
+ # Named arm by arm for the same reason as the dispatch around it: the sum
707
+ # exists so a fourth outcome cannot be quietly read as one of these three.
708
+ if isinstance(outcome, Updated):
709
+ return FreshBase()
710
+ if isinstance(outcome, RefMissingOnRemote):
711
+ # The remote has no default branch under that name either. Nothing
712
+ # more to try -- there is no third ref -- and the branch creation
713
+ # still has the cache's own default branch to start from.
714
+ # Unverifiable is stale here: nothing fetched backs it.
715
+ return StaleBase(
716
+ base=default_branch,
717
+ reason=f"the remote has no branch '{default_branch}' to refresh from",
718
+ )
719
+ if isinstance(outcome, FetchFailed):
720
+ # Warned here as well as reported, because the reason is carried
721
+ # precisely so it can be printed: the new branch is about to be cut
722
+ # from a possibly stale cache.
723
+ logger.warning(f"Could not fetch {default_branch} for {owner}/{repo}: {outcome.reason}")
724
+ return StaleBase(base=default_branch, reason=outcome.reason)
725
+ unhandled_fetch_outcome(outcome)
726
+
643
727
  def _prepare_workspace(
644
728
  self,
645
729
  lock: RepoLock,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devlaunch"
3
- version = "0.0.28"
3
+ version = "0.0.29"
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"
@@ -175,6 +175,7 @@ exclude_also = [
175
175
  # The exhaustiveness sentinel. `ty` runs in CI and rejects any call to it that
176
176
  # is reachable, so in a build that type-checks these lines cannot be executed
177
177
  # -- the same reason `raise AssertionError` is already on this list.
178
+ "_unhandled_default_branch\\(",
178
179
  "_unhandled_removal\\(",
179
180
  "_unhandled_source\\(",
180
181
  "_unhandled_status\\(",
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes