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.
Files changed (32) hide show
  1. {devlaunch-0.0.27 → devlaunch-0.0.29}/PKG-INFO +1 -1
  2. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/worktree/workspace_clone.py +200 -44
  3. {devlaunch-0.0.27 → devlaunch-0.0.29}/pyproject.toml +2 -1
  4. {devlaunch-0.0.27 → devlaunch-0.0.29}/.gitignore +0 -0
  5. {devlaunch-0.0.27 → devlaunch-0.0.29}/LICENSE +0 -0
  6. {devlaunch-0.0.27 → devlaunch-0.0.29}/README.md +0 -0
  7. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/__init__.py +0 -0
  8. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/aid.py +0 -0
  9. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/completion.py +0 -0
  10. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/completion_loader.py +0 -0
  11. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/completions/__init__.py +0 -0
  12. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/completions/dl.bash +0 -0
  13. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/devpod_provider.py +0 -0
  14. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/devpod_ssh.py +0 -0
  15. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/disk_usage.py +0 -0
  16. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/dl.py +0 -0
  17. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/gh_auth.py +0 -0
  18. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/timing.py +0 -0
  19. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/tools.py +0 -0
  20. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/tty_session.py +0 -0
  21. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/workspace_id.py +0 -0
  22. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/workspace_state.py +0 -0
  23. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/worktree/__init__.py +0 -0
  24. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/worktree/branch_manager.py +0 -0
  25. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/worktree/config.py +0 -0
  26. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/worktree/git_errors.py +0 -0
  27. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/worktree/locks.py +0 -0
  28. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/worktree/migration.py +0 -0
  29. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/worktree/models.py +0 -0
  30. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/worktree/repo_manager.py +0 -0
  31. {devlaunch-0.0.27 → devlaunch-0.0.29}/devlaunch/worktree/storage.py +0 -0
  32. {devlaunch-0.0.27 → 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.27
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
@@ -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) -> None:
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
- pass
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 default_branch:
523
- try:
524
- base_outcome = self.repo_manager.fetch_ref(owner, repo, default_branch)
525
- except ValueError as e:
526
- # Unlike `branch`, this name is read back from metadata.json
527
- # and carries no proof. Caught rather than propagated so a
528
- # hand-edited record cannot change what this method raises:
529
- # dl's launch path guards it with (RuntimeError, OSError),
530
- # so a ValueError here would surface as a traceback.
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=default_branch or "HEAD",
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.27"
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