devlaunch 0.0.27__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.27 → devlaunch-0.0.29}/PKG-INFO +1 -1
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/worktree/workspace_clone.py +200 -44
- {devlaunch-0.0.27 → devlaunch-0.0.29}/pyproject.toml +2 -1
- {devlaunch-0.0.27 → devlaunch-0.0.29}/.gitignore +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/LICENSE +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/README.md +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/__init__.py +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/aid.py +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/completion.py +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/completion_loader.py +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/completions/__init__.py +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/completions/dl.bash +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/devpod_provider.py +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/devpod_ssh.py +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/disk_usage.py +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/dl.py +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/gh_auth.py +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/timing.py +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/tools.py +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/tty_session.py +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/workspace_id.py +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/workspace_state.py +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/worktree/__init__.py +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/worktree/branch_manager.py +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/worktree/config.py +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/worktree/git_errors.py +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/worktree/locks.py +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/worktree/migration.py +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/worktree/models.py +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/worktree/repo_manager.py +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/worktree/storage.py +0 -0
- {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/xdg.py +0 -0
|
@@ -19,8 +19,9 @@ import logging
|
|
|
19
19
|
import os
|
|
20
20
|
import shutil
|
|
21
21
|
import subprocess
|
|
22
|
+
from dataclasses import dataclass
|
|
22
23
|
from pathlib import Path
|
|
23
|
-
from typing import Optional
|
|
24
|
+
from typing import NoReturn, Optional, Union
|
|
24
25
|
|
|
25
26
|
from .. import timing
|
|
26
27
|
from ..workspace_id import WorkspaceId, validate_ref_name
|
|
@@ -44,6 +45,103 @@ _LFS_POINTER_PREFIX = b"version https://git-lfs"
|
|
|
44
45
|
logger = logging.getLogger(__name__)
|
|
45
46
|
|
|
46
47
|
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class FreshBase:
|
|
50
|
+
"""The ref this launch runs on was fetched from the remote this call.
|
|
51
|
+
|
|
52
|
+
Either the requested branch itself was refreshed, or the branch is new and
|
|
53
|
+
the base it was cut from was. Carries no payload: "your tip is current" is
|
|
54
|
+
the whole message.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class StaleBase:
|
|
60
|
+
"""The launch proceeds on a ref nothing refreshed this call.
|
|
61
|
+
|
|
62
|
+
*base* names that ref — the default branch a new branch was cut from, the
|
|
63
|
+
cache's own ``HEAD`` when no default branch could even be named, or the
|
|
64
|
+
requested branch itself when its fetch failed. *reason* is why nothing
|
|
65
|
+
refreshed it, carried rather than reconstructed at the print site for the
|
|
66
|
+
same reason :class:`devlaunch.worktree.repo_manager.FetchFailed` carries
|
|
67
|
+
its own.
|
|
68
|
+
|
|
69
|
+
Deliberately not an error (devlaunch#144: launch-from-cache is the offline
|
|
70
|
+
contract) and deliberately not only a log line (devlaunch#245: the caller —
|
|
71
|
+
and wf, reading dl's output — must be able to tell a fresh base from a
|
|
72
|
+
stale one).
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
base: str
|
|
76
|
+
reason: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# Two arms and no third: every path through ensure_branch answers one of these,
|
|
80
|
+
# so "the fetch quietly did not back the base" cannot exist as an unnamed state.
|
|
81
|
+
BranchBase = Union[FreshBase, StaleBase]
|
|
82
|
+
|
|
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
|
+
|
|
47
145
|
def _on_disk(path: Path) -> Optional[bool]:
|
|
48
146
|
"""Whether *path* is there: True, False, or None for "could not look".
|
|
49
147
|
|
|
@@ -460,7 +558,24 @@ class WorkspaceCloneManager:
|
|
|
460
558
|
workspace = WorkspaceId(owner, repo, branch)
|
|
461
559
|
with self.repo_manager.hold_repo_lock(owner, repo) as lock:
|
|
462
560
|
self.repo_manager.clone_if_missing(lock, owner, repo, remote_url)
|
|
463
|
-
self.ensure_branch(lock, owner, repo, branch)
|
|
561
|
+
base = self.ensure_branch(lock, owner, repo, branch)
|
|
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):
|
|
568
|
+
# The one consequence-stating line for the whole degraded
|
|
569
|
+
# family, in dl's own output because wf reads that output: the
|
|
570
|
+
# fetch-level warnings above it say what failed, this says what
|
|
571
|
+
# it means for the tree the agent is about to work on.
|
|
572
|
+
logger.warning(
|
|
573
|
+
f"Prepared '{owner}/{repo}@{branch}' from the cache's '{base.base}', "
|
|
574
|
+
f"which could not be refreshed ({base.reason}); "
|
|
575
|
+
f"it may be behind the remote."
|
|
576
|
+
)
|
|
577
|
+
else:
|
|
578
|
+
unhandled_branch_base(base)
|
|
464
579
|
return self._prepare_workspace(
|
|
465
580
|
lock,
|
|
466
581
|
workspace,
|
|
@@ -469,9 +584,15 @@ class WorkspaceCloneManager:
|
|
|
469
584
|
remote_url,
|
|
470
585
|
)
|
|
471
586
|
|
|
472
|
-
def ensure_branch(self, lock: RepoLock, owner: str, repo: str, branch: str) ->
|
|
587
|
+
def ensure_branch(self, lock: RepoLock, owner: str, repo: str, branch: str) -> BranchBase:
|
|
473
588
|
"""Ensure a branch exists in the bare repo, at the remote's current tip.
|
|
474
589
|
|
|
590
|
+
Returns which of those two things it actually delivered: a
|
|
591
|
+
:class:`FreshBase` when the tip the launch runs on was fetched this
|
|
592
|
+
call, a :class:`StaleBase` naming the unrefreshed ref and the reason
|
|
593
|
+
when it was not (devlaunch#245). The degraded arms all proceed — that
|
|
594
|
+
is the devlaunch#144 contract — but they no longer proceed silently.
|
|
595
|
+
|
|
475
596
|
This is the whole of the launch path's network use, and the staleness
|
|
476
597
|
contract devlaunch#144 settled is stated here because this is the code
|
|
477
598
|
that keeps it:
|
|
@@ -502,61 +623,34 @@ class WorkspaceCloneManager:
|
|
|
502
623
|
lock.require(owner, repo)
|
|
503
624
|
bare_path = self.repo_manager.get_bare_path(owner, repo)
|
|
504
625
|
outcome = self.repo_manager.fetch_ref(owner, repo, branch)
|
|
505
|
-
|
|
506
|
-
try:
|
|
507
|
-
default_branch = self.repo_manager.get_default_branch(owner, repo)
|
|
508
|
-
except (RuntimeError, subprocess.CalledProcessError, OSError) as e:
|
|
509
|
-
logger.warning(f"Failed to resolve default branch: {e}")
|
|
510
|
-
default_branch = None
|
|
626
|
+
default = self._resolve_default_branch(owner, repo)
|
|
511
627
|
|
|
512
628
|
# Each arm named, so a fourth one cannot be silently read as one of
|
|
513
629
|
# these three.
|
|
630
|
+
base: BranchBase
|
|
514
631
|
if isinstance(outcome, Updated):
|
|
515
|
-
|
|
632
|
+
base = FreshBase()
|
|
516
633
|
elif isinstance(outcome, RefMissingOnRemote):
|
|
517
634
|
# The remote answered, so the branch really is new: base it on the
|
|
518
635
|
# default branch, and fetch *that* so it is based on something
|
|
519
636
|
# current. Whatever this second fetch answers, the branch creation
|
|
520
637
|
# below proceeds -- there is no third ref to fall back to, and the
|
|
521
638
|
# cache's own default branch is the best remaining start point.
|
|
522
|
-
if
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
logger.warning(f"Cannot fetch recorded default branch: {e}")
|
|
532
|
-
else:
|
|
533
|
-
# Named arm by arm for the same reason as the dispatch
|
|
534
|
-
# around it: the sum exists so a fourth outcome cannot be
|
|
535
|
-
# quietly read as one of these three.
|
|
536
|
-
if isinstance(base_outcome, Updated):
|
|
537
|
-
pass
|
|
538
|
-
elif isinstance(base_outcome, RefMissingOnRemote):
|
|
539
|
-
# The remote has no default branch under that name
|
|
540
|
-
# either. Nothing more to try -- there is no third ref
|
|
541
|
-
# -- and the branch creation below still has the cache's
|
|
542
|
-
# own default branch to start from.
|
|
543
|
-
pass
|
|
544
|
-
elif isinstance(base_outcome, FetchFailed):
|
|
545
|
-
# Same condition as the arm below, warned the same
|
|
546
|
-
# way: the new branch is about to be cut from a
|
|
547
|
-
# possibly stale cache, and the reason is carried
|
|
548
|
-
# precisely so it can be printed here.
|
|
549
|
-
logger.warning(
|
|
550
|
-
f"Could not fetch {default_branch} for {owner}/{repo}: "
|
|
551
|
-
f"{base_outcome.reason}"
|
|
552
|
-
)
|
|
553
|
-
else:
|
|
554
|
-
unhandled_fetch_outcome(base_outcome)
|
|
639
|
+
if isinstance(default, _DefaultBranchNamed):
|
|
640
|
+
base = self._fetch_base_branch(owner, repo, default.name)
|
|
641
|
+
elif isinstance(default, _DefaultBranchUnknown):
|
|
642
|
+
# No default branch could even be named, so nothing was
|
|
643
|
+
# fetched and the creation below starts from the bare cache's
|
|
644
|
+
# own HEAD -- a ref of unbounded age, and the report says so.
|
|
645
|
+
base = StaleBase(base="HEAD", reason=default.reason)
|
|
646
|
+
else:
|
|
647
|
+
_unhandled_default_branch(default)
|
|
555
648
|
elif isinstance(outcome, FetchFailed):
|
|
556
649
|
# Not an error here: a cached branch still launches. Deliberately
|
|
557
650
|
# no default-branch fetch -- nothing was learned about the remote,
|
|
558
651
|
# so nothing licenses treating this branch as new.
|
|
559
652
|
logger.warning(f"Could not fetch {branch} for {owner}/{repo}: {outcome.reason}")
|
|
653
|
+
base = StaleBase(base=branch, reason=outcome.reason)
|
|
560
654
|
else:
|
|
561
655
|
unhandled_fetch_outcome(outcome)
|
|
562
656
|
|
|
@@ -564,9 +658,71 @@ class WorkspaceCloneManager:
|
|
|
564
658
|
bare_path,
|
|
565
659
|
branch,
|
|
566
660
|
create_remote=False,
|
|
567
|
-
start_point=
|
|
661
|
+
start_point=_start_point(default),
|
|
568
662
|
use_local_refs=True,
|
|
569
663
|
)
|
|
664
|
+
return base
|
|
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)
|
|
570
726
|
|
|
571
727
|
def _prepare_workspace(
|
|
572
728
|
self,
|
|
@@ -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
|