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.
- {devlaunch-0.0.28 → devlaunch-0.0.29}/PKG-INFO +1 -1
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/worktree/workspace_clone.py +139 -55
- {devlaunch-0.0.28 → devlaunch-0.0.29}/pyproject.toml +2 -1
- {devlaunch-0.0.28 → devlaunch-0.0.29}/.gitignore +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/LICENSE +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/README.md +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/__init__.py +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/aid.py +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/completion.py +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/completion_loader.py +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/completions/__init__.py +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/completions/dl.bash +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/devpod_provider.py +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/devpod_ssh.py +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/disk_usage.py +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/dl.py +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/gh_auth.py +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/timing.py +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/tools.py +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/tty_session.py +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/workspace_id.py +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/workspace_state.py +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/worktree/__init__.py +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/worktree/branch_manager.py +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/worktree/config.py +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/worktree/git_errors.py +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/worktree/locks.py +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/worktree/migration.py +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/worktree/models.py +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/worktree/repo_manager.py +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/worktree/storage.py +0 -0
- {devlaunch-0.0.28 → devlaunch-0.0.29}/devlaunch/xdg.py +0 -0
|
@@ -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
|
-
|
|
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
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
623
|
-
|
|
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=
|
|
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.
|
|
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
|
|
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
|