Skip to content

Fetch exactly the branch being launched, and nothing else - #185

Merged
blooop merged 3 commits into
mainfrom
wayfinder/devlaunch-150
Aug 14, 2026
Merged

blooop merged 3 commits into
mainfrom
wayfinder/devlaunch-150

Conversation

@blooop

@blooop blooop commented Aug 14, 2026 •

Copy link
Copy Markdown
Owner

Closes #150. Implements the devlaunch#144 resolution now that the detached updater sweep (#149) has landed.

The defect

The cold launch path made an unbounded network call while holding the per-repo flock. Once the fetch interval had elapsed, ensure_repo swept every head and tag (+refs/heads/*:refs/heads/* --tags --prune) before the launch could proceed — so whichever launch drew the short straw paid for everyone's freshness, and every concurrent launch of the same repo queued behind it. The workspace clone then made a second fetch whose output nothing read.

The change

  • ensure_repo becomes the pure clone-if-missing primitive. Its auto_fetch parameter goes with the fetch it gated (production never passed False).
  • ensure_branch fetches one targeted refspec, unconditionally. The outcome is a three-way sum — Updated / RefMissingOnRemote / FetchFailed — following the Unsaved idiom in workspace_state.py, complete with an unhandled_fetch_outcome arm-checker. The distinction is load-bearing: only "the remote says the ref is not there" licenses creating the branch from the default branch, and only "the remote did not answer" means fall back to cache. A bool would have collapsed them.
  • It does not write last_fetched, keeping the sweep's bookkeeping — and one repo→metadata lock nesting — off this path.
  • _prepare_workspace's fetch is deleted. The checkout beside it is a plain git checkout <branch> that never consulted a remote-tracking ref.

Lock cycles are deliberately untouched — #143 owns that seam.

The staleness contract

Push upstream, immediately dl the branch → the workspace starts at the pushed tip. A brand-new branch is cut from the default branch's freshly fetched tip. Offline warns and launches from cache, failing only when there is nothing cached to launch from. Everything else converges via the background sweep, blocking nobody. Documented in the README and in ensure_branch's docstring.

Tests

Written test-first, and each pin was verified to fail against a reintroduction of the regression rather than merely passing against the fix:

  • test/test_cold_launch_fetches.py (new) — the headline pin. A fake subprocess.run records every git argv plus whether the repo lock was held at call time, probed by opening the lock file in a second file description and attempting a non-blocking flock (flock conflicts across separate open file descriptions even within one process, so it reads the truth about the real lock). Asserts no wildcard refspec anywhere in the foreground, and that the only fetch ever running under the lock is the single targeted one. 5 of its 6 tests fail when the two deleted fetches are put back.
  • test/integration/test_repo_manager_real.py — the staleness contract over real git against a local file-path remote: commit pushed after the cache was built, branch that reached the remote after the cache, brand-new branch off a moved default branch, nothing-to-launch-from error, and unreachable-remote degradation. The three freshness assertions fail when fetch_ref is stubbed to a no-op.
  • Unit coverage for each outcome arm, that last_fetched is not advanced, and that an unsafe ref is rejected before reaching argv.
  • One test from Interval fetch moves into the detached updater (try_hold_lock; sweep in dl --update-cache) #149 (test_the_launch_path_keeps_its_untimed_fetch) explicitly deferred its subject to this ticket; it is rewritten, since the foreground fetch it bounded no longer exists.

pixi run ci green on both default (3.14) and py310: 1464 passed, pylint 10.00/10, exit 0. Built and smoke-installed via ./dev.sh.

🤖 Generated with Claude Code

Summary by Sourcery

Clarify and enforce the cold launch staleness contract by fetching only the targeted branch into the shared bare repo, leaving broad repo-wide refreshes to the background updater, and removing redundant workspace-level fetches.

New Features:

  • Introduce a typed fetch_ref API that fetches a single branch into the bare cache and reports a structured outcome used by the launch path.
  • Document the cold-launch staleness behavior and targeted fetch model in the README, including offline and new-branch scenarios.

Enhancements:

  • Simplify RepositoryManager.ensure_repo to act solely as a clone-if-missing operation without any foreground fetching or auto_fetch flag.
  • Update WorkspaceCloneManager.ensure_branch to drive launches from fetch_ref outcomes, ensuring new branches are based on a freshly fetched default branch and cached branches still launch when offline.
  • Remove unconditional git fetches from workspace preparation and re-registration so existing clones only perform checkouts.
  • Strengthen type-safety around fetch outcomes with dedicated dataclasses and an unhandled_fetch_outcome helper to prevent silent contract changes.

Tests:

  • Add cold-launch integration tests that record git invocations and lock state to ensure only a single targeted fetch runs under the repo lock and no broad refspecs are used.
  • Extend integration tests to validate real-git staleness behavior across pushed commits, late-arriving branches, brand-new branches, empty remotes, and unreachable remotes.
  • Update unit tests for workspace cloning and repo management to reflect the new targeted-fetch behavior, removed auto_fetch parameter, and absence of workspace-level fetches.
  • Adjust background updater tests to assert that broad fetches are exclusive to the sweep and not part of the launch path.

The cold launch path made an unbounded network call while holding the
per-repo flock: once the fetch interval had elapsed, ensure_repo swept
every head and tag before the launch could proceed, so whichever launch
drew the short straw paid for everyone's freshness and every concurrent
launch of the same repo queued behind it. The workspace clone then made a
second fetch whose output nothing read.

Per the devlaunch#144 resolution, and now that the detached updater sweep
(devlaunch#149) exists to carry broad freshness:

- ensure_repo becomes the pure clone-if-missing primitive. Its auto_fetch
  parameter goes with the fetch it gated; production never passed False.
- ensure_branch fetches one targeted refspec, unconditionally. The outcome
  is a three-way sum -- Updated / RefMissingOnRemote / FetchFailed -- so
  "the remote says the ref is not there" stays distinguishable from "the
  remote did not answer": only the former licenses creating the branch
  from the default branch, and only the latter means fall back to cache.
  It does not write last_fetched, which keeps the sweep's bookkeeping and
  one repo->metadata lock nesting off this path.
- _prepare_workspace's fetch is deleted; the checkout beside it is a plain
  `git checkout <branch>` that never consulted a remote-tracking ref.

The staleness contract this buys: push upstream, immediately dl the
branch, and the workspace starts at the pushed tip. Offline still launches
from cache. Lock cycles are untouched -- devlaunch#143 owns that seam.

Closes #150
@sourcery-ai

sourcery-ai Bot commented Aug 14, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

Refactors the cold launch path to fetch only the specific branch being launched, removes foreground broad fetches and workspace-level fetches, introduces a typed fetch outcome for targeted ref fetches, and updates tests and docs to pin the new staleness contract and API surface.

Sequence diagram for the updated cold launch branch fetch path

sequenceDiagram
    actor User
    participant DevpodCLI
    participant WorkspaceCloneManager
    participant RepositoryManager
    participant GitRemote

    User->>DevpodCLI: dl owner/repo branch
    DevpodCLI->>WorkspaceCloneManager: ensure_workspace(owner, repo, branch)
    WorkspaceCloneManager->>RepositoryManager: ensure_repo(owner, repo, remote_url)
    note over RepositoryManager: clone-if-missing only, no fetch sweep
    WorkspaceCloneManager->>WorkspaceCloneManager: ensure_branch(owner, repo, branch)
    WorkspaceCloneManager->>RepositoryManager: fetch_ref(owner, repo, branch)
    RepositoryManager->>GitRemote: git fetch origin +refs/heads/branch:refs/heads/branch
    GitRemote-->>RepositoryManager: exit status + stderr

    alt outcome is Updated
        RepositoryManager-->>WorkspaceCloneManager: Updated()
        WorkspaceCloneManager->>WorkspaceCloneManager: proceed without extra fetch
    else outcome is RefMissingOnRemote
        RepositoryManager-->>WorkspaceCloneManager: RefMissingOnRemote()
        WorkspaceCloneManager->>RepositoryManager: fetch_ref(owner, repo, default_branch)
    else outcome is FetchFailed
        RepositoryManager-->>WorkspaceCloneManager: FetchFailed(reason)
        WorkspaceCloneManager->>WorkspaceCloneManager: log warning, use cached branch
    else unexpected outcome
        WorkspaceCloneManager->>WorkspaceCloneManager: unhandled_fetch_outcome(outcome)
    end

    WorkspaceCloneManager->>WorkspaceCloneManager: _prepare_workspace(workspace, ws_path, remote_url)
    note over WorkspaceCloneManager: no git fetch in workspace clone
    WorkspaceCloneManager->>WorkspaceCloneManager: git checkout branch
    WorkspaceCloneManager-->>DevpodCLI: workspace ready
    DevpodCLI-->>User: devpod launched at branch tip or cache
Loading

File-Level Changes

Change Details Files
Make RepositoryManager.ensure_repo a pure clone-if-missing operation and remove foreground broad fetch behavior and the auto_fetch parameter.
  • Deleted the auto_fetch parameter from ensure_repo and its callers, making the method signature clone-only
  • Removed lazy foreground fetch logic from ensure_repo so existing caches are returned without triggering repo-wide fetches
  • Adjusted integration and recovery tests to call ensure_repo without auto_fetch and to assert no fetches occur for existing clones
devlaunch/worktree/repo_manager.py
test/test_worktree_repo_manager.py
test/integration/test_repo_manager_real.py
test/integration/test_repo_manager_recovery.py
test/integration/test_clone_race.py
test/unit/test_updater_fetch_sweep.py
Introduce targeted single-ref fetch semantics with typed outcomes (Updated, RefMissingOnRemote, FetchFailed) and use them to drive ensure_branch behavior.
  • Added dataclasses Updated, RefMissingOnRemote, FetchFailed and the FetchOutcome union plus unhandled_fetch_outcome arm checker
  • Implemented RepositoryManager.fetch_ref to validate ref names, fetch a single branch refspec into the bare cache, and return the appropriate outcome based on git’s response
  • Refactored WorkspaceCloneManager.ensure_branch to call fetch_ref unconditionally, handle each outcome arm explicitly, and perform an additional default-branch fetch only when the requested ref is missing on the remote
  • Ensured that fetch_ref does not update last_fetched and treats missing local caches as FetchFailed rather than a separate outcome type
devlaunch/worktree/repo_manager.py
devlaunch/worktree/workspace_clone.py
devlaunch/worktree/workspace_id.py (via validate_ref_name usage)
test/test_worktree_repo_manager.py
test/test_workspace_clone.py
test/integration/test_repo_manager_real.py
Eliminate workspace-level git fetches during ensure_workspace and make cold launches rely solely on the bare-cache targeted fetch, keeping unbounded network off the repo lock.
  • Removed the unconditional git fetch origin in _prepare_workspace for existing workspaces and updated step numbering/comments accordingly
  • Ensured ensure_workspace re-registration of an existing clone performs only git checkout with no fetch calls
  • Updated tests to assert that existing workspaces and re-registrations do not perform git fetch, and that newly created workspaces also skip fetches
devlaunch/worktree/workspace_clone.py
test/test_workspace_clone.py
test/test_cold_launch_fetches.py
Document and pin the staleness contract and cold-launch fetch behavior via new tests and README updates.
  • Added test/test_cold_launch_fetches.py to record all git subprocess calls, detect whether the repo lock is held, and assert that only a single targeted fetch runs under the lock and no wildcard refspecs/tags/prunes are used
  • Expanded integration tests in test_repo_manager_real.py to cover the staleness contract: commits pushed after cache creation, late branches, brand-new branches seeded from freshly fetched default branch, empty remotes, and unreachable remotes falling back to cache
  • Updated README to describe targeted fetch behavior, the push-and-launch staleness contract, offline behavior, and clarified that repo freshness for other refs is handled by the background updater sweep
  • Rewrote the updater fetch sweep test to assert that the broad, timed fetch belongs solely to the background sweep and that the foreground path has no broad fetch to time
README.md
test/test_cold_launch_fetches.py
test/integration/test_repo_manager_real.py
test/unit/test_updater_fetch_sweep.py

Assessment against linked issues

Issue Objective Addressed Explanation
#150 Make ensure_repo a pure clone-if-missing primitive: remove the auto_fetch parameter and any foreground broad fetch logic so existing caches are returned without fetching. ✅
#150 Change branch fetching to a single targeted ref fetch implemented via a new three-way outcome (updated / ref-missing-on-remote / fetch-failed), with ensure_branch using this outcome per the staleness contract (including default-branch retry and offline behavior) and without updating last_fetched. ✅
#150 Delete the workspace-clone fetch step so ensure_workspace/_prepare_workspace perform no git fetch in the workspace clone (including re-registration), and update tests/docs to reflect the new cold-launch and staleness behavior. ✅

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@codecov

codecov Bot commented Aug 14, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.67442% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 95.57%. Comparing base (dcdb077) to head (af06dd8).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
devlaunch/worktree/workspace_clone.py 92.85% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #185      +/-   ##
==========================================
+ Coverage   95.33%   95.57%   +0.23%     
==========================================
  Files          22       22              
  Lines        3025     3051      +26     
==========================================
+ Hits         2884     2916      +32     
+ Misses        141      135       -6     
Files with missing lines Coverage Δ
devlaunch/worktree/repo_manager.py 90.28% <100.00%> (+4.66%) ⬆️
devlaunch/worktree/workspace_clone.py 91.66% <92.85%> (+0.75%) ⬆️

Impacted file tree graph

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • The fetch_ref logic relies on matching the literal substring "couldn't find remote ref" in stderr to detect a missing ref; consider using a more robust criterion (e.g. parsing git’s exit code and standardized message pattern) to avoid locale/format changes breaking this classification.
  • In fetch_ref, only refs/heads/<branch> is supported and hard-coded; if you ever need to handle tags or non-head refs, it may be cleaner to generalize the refspec construction (or validate that callers only ever pass branch names) and document this constraint near the method signature.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `fetch_ref` logic relies on matching the literal substring `"couldn't find remote ref"` in stderr to detect a missing ref; consider using a more robust criterion (e.g. parsing git’s exit code and standardized message pattern) to avoid locale/format changes breaking this classification.
- In `fetch_ref`, only `refs/heads/<branch>` is supported and hard-coded; if you ever need to handle tags or non-head refs, it may be cleaner to generalize the refspec construction (or validate that callers only ever pass branch names) and document this constraint near the method signature.

## Individual Comments

### Comment 1
<location path="devlaunch/worktree/repo_manager.py" line_range="79-82" />
<code_context>
+FetchOutcome = Union[Updated, RefMissingOnRemote, FetchFailed]
+
+
+def unhandled_fetch_outcome(outcome: NoReturn) -> NoReturn:
+    """Reject a fetch outcome nobody handled -- at type-check time, not at runtime.
+
+    The same shape as :func:`devlaunch.workspace_state.unhandled_unsaved`, and
+    exported for the same reason: the arms are read outside this module, and an
+    ``else`` hand-rolled at a call site is how a fourth arm gets silently read as
+    one of the existing three.
+    """
+    raise AssertionError(f"Unhandled fetch outcome: {outcome!r}")
+
+
</code_context>
<issue_to_address>
**suggestion:** The `NoReturn` parameter type on `unhandled_fetch_outcome` is counterintuitive and may hinder static checking.

Given that callers pass a `FetchOutcome` and this function always raises, typing the parameter as `NoReturn` is misleading and may confuse type checkers. Using `FetchOutcome` (or a union of the concrete outcome types) for the parameter and relying on the `NoReturn` return type would better express the intent, avoid call-site type incompatibilities, and keep `isinstance`-based narrowing working as expected.

```suggestion
FetchOutcome = Union[Updated, RefMissingOnRemote, FetchFailed]


def unhandled_fetch_outcome(outcome: FetchOutcome) -> NoReturn:
```
</issue_to_address>

### Comment 2
<location path="devlaunch/worktree/repo_manager.py" line_range="35" />
<code_context>
     return ref.rsplit("/", maxsplit=1)[-1]


+@dataclass(frozen=True)
+class Updated:
+    """The requested ref in the cache now matches the remote's.
</code_context>
<issue_to_address>
**issue (complexity):** Consider replacing the three separate outcome dataclasses and union with a single tagged FetchOutcome object carrying a kind enum and optional reason to simplify the result type while preserving semantics.

You can keep the three-way semantics and type-safety while collapsing the “marker dataclasses + union + unhandled helper” into a single tagged outcome object. This removes two empty classes, the union alias, and the NoReturn helper, while preserving all behavior.

For example:

```python
from dataclasses import dataclass
from enum import Enum
from typing import Optional


class FetchOutcomeKind(Enum):
    UPDATED = "updated"
    REF_MISSING_ON_REMOTE = "ref_missing_on_remote"
    FETCH_FAILED = "fetch_failed"


@dataclass(frozen=True)
class FetchOutcome:
    """Result of a fetch_ref call.

    kind:
      - UPDATED: cache now matches remote
      - REF_MISSING_ON_REMOTE: remote explicitly says ref does not exist
      - FETCH_FAILED: remote could not be queried; reason explains why
    """
    kind: FetchOutcomeKind
    reason: Optional[str] = None
```

Then `fetch_ref` becomes:

```python
def fetch_ref(self, owner: str, repo: str, branch: str) -> FetchOutcome:
    validate_ref_name(branch)
    bare_path = self.get_bare_path(owner, repo)

    if not bare_path.exists():
        return FetchOutcome(
            kind=FetchOutcomeKind.FETCH_FAILED,
            reason=f"Repository {owner}/{repo} does not exist locally",
        )

    logger.info(f"Fetching {branch} for {owner}/{repo}")
    try:
        subprocess.run(
            ["git", "fetch", "origin", f"+refs/heads/{branch}:refs/heads/{branch}"],
            cwd=bare_path,
            capture_output=True,
            text=True,
            check=True,
        )
        return FetchOutcome(kind=FetchOutcomeKind.UPDATED)
    except subprocess.CalledProcessError as e:
        stderr = (e.stderr or "").strip()
        if "couldn't find remote ref" in stderr:
            logger.debug(f"Remote has no ref {branch} for {owner}/{repo}")
            return FetchOutcome(kind=FetchOutcomeKind.REF_MISSING_ON_REMOTE)

        return FetchOutcome(
            kind=FetchOutcomeKind.FETCH_FAILED,
            reason=stderr or f"git fetch exited {e.returncode}",
        )
```

And call sites do explicit exhaustive handling on `kind` without needing `unhandled_fetch_outcome`:

```python
outcome = repo_manager.fetch_ref(owner, repo, branch)

if outcome.kind is FetchOutcomeKind.UPDATED:
    ...
elif outcome.kind is FetchOutcomeKind.REF_MISSING_ON_REMOTE:
    ...
elif outcome.kind is FetchOutcomeKind.FETCH_FAILED:
    log_error(outcome.reason)
    ...
```

Benefits:

- Removes two zero-field dataclasses and the separate `FetchOutcome` union.
- Keeps a single, obvious “result object” with a `kind` and optional `reason`.
- Maintains the semantic distinction the caller relies on (`updated` vs `no ref` vs `failed`).
- Keeps type-level clarity while making the control flow easier to read and reason about.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +79 to +82
FetchOutcome = Union[Updated, RefMissingOnRemote, FetchFailed]


def unhandled_fetch_outcome(outcome: NoReturn) -> NoReturn:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The NoReturn parameter type on unhandled_fetch_outcome is counterintuitive and may hinder static checking.

Given that callers pass a FetchOutcome and this function always raises, typing the parameter as NoReturn is misleading and may confuse type checkers. Using FetchOutcome (or a union of the concrete outcome types) for the parameter and relying on the NoReturn return type would better express the intent, avoid call-site type incompatibilities, and keep isinstance-based narrowing working as expected.

Suggested change
FetchOutcome = Union[Updated, RefMissingOnRemote, FetchFailed]
def unhandled_fetch_outcome(outcome: NoReturn) -> NoReturn:
FetchOutcome = Union[Updated, RefMissingOnRemote, FetchFailed]
def unhandled_fetch_outcome(outcome: FetchOutcome) -> NoReturn:

return ref.rsplit("/", maxsplit=1)[-1]


@dataclass(frozen=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider replacing the three separate outcome dataclasses and union with a single tagged FetchOutcome object carrying a kind enum and optional reason to simplify the result type while preserving semantics.

You can keep the three-way semantics and type-safety while collapsing the “marker dataclasses + union + unhandled helper” into a single tagged outcome object. This removes two empty classes, the union alias, and the NoReturn helper, while preserving all behavior.

For example:

from dataclasses import dataclass
from enum import Enum
from typing import Optional


class FetchOutcomeKind(Enum):
    UPDATED = "updated"
    REF_MISSING_ON_REMOTE = "ref_missing_on_remote"
    FETCH_FAILED = "fetch_failed"


@dataclass(frozen=True)
class FetchOutcome:
    """Result of a fetch_ref call.

    kind:
      - UPDATED: cache now matches remote
      - REF_MISSING_ON_REMOTE: remote explicitly says ref does not exist
      - FETCH_FAILED: remote could not be queried; reason explains why
    """
    kind: FetchOutcomeKind
    reason: Optional[str] = None

Then fetch_ref becomes:

def fetch_ref(self, owner: str, repo: str, branch: str) -> FetchOutcome:
    validate_ref_name(branch)
    bare_path = self.get_bare_path(owner, repo)

    if not bare_path.exists():
        return FetchOutcome(
            kind=FetchOutcomeKind.FETCH_FAILED,
            reason=f"Repository {owner}/{repo} does not exist locally",
        )

    logger.info(f"Fetching {branch} for {owner}/{repo}")
    try:
        subprocess.run(
            ["git", "fetch", "origin", f"+refs/heads/{branch}:refs/heads/{branch}"],
            cwd=bare_path,
            capture_output=True,
            text=True,
            check=True,
        )
        return FetchOutcome(kind=FetchOutcomeKind.UPDATED)
    except subprocess.CalledProcessError as e:
        stderr = (e.stderr or "").strip()
        if "couldn't find remote ref" in stderr:
            logger.debug(f"Remote has no ref {branch} for {owner}/{repo}")
            return FetchOutcome(kind=FetchOutcomeKind.REF_MISSING_ON_REMOTE)

        return FetchOutcome(
            kind=FetchOutcomeKind.FETCH_FAILED,
            reason=stderr or f"git fetch exited {e.returncode}",
        )

And call sites do explicit exhaustive handling on kind without needing unhandled_fetch_outcome:

outcome = repo_manager.fetch_ref(owner, repo, branch)

if outcome.kind is FetchOutcomeKind.UPDATED:
    ...
elif outcome.kind is FetchOutcomeKind.REF_MISSING_ON_REMOTE:
    ...
elif outcome.kind is FetchOutcomeKind.FETCH_FAILED:
    log_error(outcome.reason)
    ...

Benefits:

  • Removes two zero-field dataclasses and the separate FetchOutcome union.
  • Keeps a single, obvious “result object” with a kind and optional reason.
  • Maintains the semantic distinction the caller relies on (updated vs no ref vs failed).
  • Keeps type-level clarity while making the control flow easier to read and reason about.

@blooop blooop left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was generated by AI during review.

Reviewed at merge-base dcdb077, diff dcdb077...dd6c3f7. Preflight green: ref resolves, diff non-empty, all 11 checks passing (ci on 3.10–3.14, e2e, gate, prek, codecov). Two axes below were run as independent agents in fresh context and are not merged or reranked against each other — a change can pass one and fail the other.

Standards

Repo-documented standards first, then the Fowler baseline. Things tooling already enforces are skipped.

Verified clean, because the PR description makes claims worth checking: no dead code — _should_fetch is still reached via lazy_fetch:342, lazy_fetch via the detached updater at dl.py:502, fetch_repo via lazy_fetch:343. The Updated/RefMissingOnRemote/FetchFailed + unhandled_fetch_outcome shape does genuinely match workspace_state.py's Unsaved/unhandled_unsaved (frozen no-payload arms, reason-carrying failure arm, NoReturn rejector). Stringly-typed stderr matching has repo precedent at branch_manager.py:83. Comment density matches the surrounding norm. The deleted step-4 fetch was not propping up the checkout.

  1. test/integration/test_repo_manager_real.py:380-382 — two statements inside one with pytest.raises(RuntimeError):. ensure_branch raises first, so the ensure_workspace line below it is unreachable and never asserted. The test passes on half of what it names.

  2. devlaunch/worktree/workspace_clone.py:381 — the second (default-branch) fetch_ref discards its FetchOutcome entirely, so a FetchFailed there is silent, while the identical condition one arm down at :393 is warned about with its reason. Inconsistent with this module's own stated contract that the reason is carried so it can be printed.

  3. devlaunch/worktree/config.py:23,51,72 — WorktreeConfig.auto_fetch now has zero production readers but is still serialized into config.toml. Partly pre-existing (it was never wired to the parameter), but deleting ensure_repo's auto_fetch removed the last thing that name plausibly controlled. A config knob that does nothing is worse than no knob.

  4. Judgement call. workspace_clone.py:361 vs :381 — ValueError from validate_ref_name is caught for the default-branch call and not for the requested-branch call. The comment justifies this with "usually holds a WorkspaceId", but ensure_branch is public and the integration tests call it with bare strings.

  5. Minor. test/integration/test_repo_manager_real.py:226 — _commit_on(..., push=True) is never called with push=False; speculative parameter in a new helper.

  6. Judgement call. workspace_clone.py:371-372 — if isinstance(outcome, Updated): pass empty arm, where workspace_state.unsaved_as_json uses an early-return chain for the same exhaustiveness. The empty arm does buy the "each arm named" property the comment claims.

Spec

Against #150, whose criteria trace to the #144 decision. All five spec bullets are genuinely done — see the verified list at the bottom.

  1. devlaunch/worktree/repo_manager.py:311-318 — the ref-missing arm is detected by an English substring, which collapses the three-way model back to two under a non-English locale.

    #150: "Model the outcome as the three-way result from #144 (updated / ref-missing / network-failed → proceed-fresh / create-from-default / warn-and-use-cache-or-error), not a bool."

    if "couldn't find remote ref" in stderr matches a string git marks for translation (_() in builtin/fetch.c); Debian and Fedora ship git.mo. Verified: the message is exactly fatal: couldn't find remote ref refs/heads/<b>, and grep -rn "LC_ALL\|LANGUAGE" devlaunch/ returns nothing — no subprocess here pins a locale. So under LANG=de_DE etc., an ordinary "start a new branch" launch classifies as FetchFailed, which by the spec's own model means warn-and-use-cache-or-error: no default-branch fetch, the new branch is cut from a stale cached default, and the user gets a spurious warning. Pinning LC_ALL=C/LANGUAGE=C in the fetch_ref subprocess env, or classifying with git ls-remote --heads, closes it.

  2. test/integration/test_repo_manager_real.py:380-382 — criterion (3)'s "ref nowhere" pin is weaker than it reads.

    #150: "staleness contract integration: … ref nowhere → existing error."

    Confirmed ensure_branch raises first (Failed to create branch: fatal: not a valid object name: 'main'), so line 382 is dead. It does still pin that the error happens — same error as at dcdb077 — but not where, and it contradicts ensure_branch's own docstring at workspace_clone.py:342-344: "the checkout in _prepare_workspace raises — that is where its emptiness is discovered." It is not; ensure_branch is.

  3. Note, not a change request — belongs on #150. devlaunch/worktree/workspace_clone.py:494-500 — existing-workspace relaunch now opens with stale origin/* refs.

    #150: "Delete _prepare_workspace step 4 (its output is unused)."

    Its output is unused by the checkout, which is exactly what the spec asserts and what this PR argues. But the deleted git fetch origin had a second effect the checkout never read: it refreshed the workspace clone's remote-tracking refs on every relaunch. The old code gated it on not is_new_workspace, so only the relaunch path regresses — the container now comes up with origin/main as stale as the clone's creation, so in-container git status mis-reports "up to date" and git rebase origin/main rebases onto old history. Spec-sanctioned, but the sanctioning rationale is narrower than the effect, so the contract is worth restating on the ticket rather than changed here.

Verified met, with the pins that hold them: ensure_repo is clone-if-missing with no auto_fetch (test_ensure_repo_never_fetches_an_existing_clone, plus a signature test); the targeted refspec is unconditional and the ref-missing arm does exactly one extra fetch (test_absent_ref_costs_exactly_one_extra_fetch, call_count == 2); last_fetched stays unwritten on the whole path; the lock pin in test_cold_launch_fetches.py uses a real second fd + flock(LOCK_EX|LOCK_NB), so it reads the production lock, and asserts every locked fetch's refspecs equal the one targeted spec, with wildcards/--tags/--prune separately ruled out foreground-wide; criterion (2) is pinned twice; offline-launches-from-cache is pinned against a real moved remote; lock cycles are byte-identical, and the diff only removes a metadata-lock-under-repo-lock nesting, which is the direction #143 wants. No tag/SHA regression: ensure_branch_exists only ever consulted refs/heads, so the old --tags never fed a launch decision.

Verdict

Request changes (posted as a comment — GitHub refuses --request-changes on a same-account PR; this written verdict is the gate, not the GitHub state).

Blocking:

  • Spec 1 — locale-dependent classification silently collapses the three-way outcome the ticket specifically required not be a bool. One-line fix.
  • Standards 1 / Spec 2 — the unreachable assertion at test_repo_manager_real.py:382. Found independently by both axes. Split into two pytest.raises blocks, and reconcile whichever one holds with the ensure_branch docstring that currently names the wrong site.

Non-blocking, worth doing while in there: Standards 2 (discarded FetchOutcome) and Standards 3 (dead auto_fetch knob). Spec 3 is a note for #150, not a change to this PR.

Two prior Sourcery comments are, on this repo's standards, not actionable: unhandled_fetch_outcome(outcome: NoReturn) is the exact pre-existing idiom at workspace_state.py:122, documented there as the 3.10-compatible stand-in for Never; and collapsing the three dataclasses into a tagged enum would undo the thing #150 explicitly asked for.

blooop added 2 commits August 14, 2026 12:03
The ref-missing arm of fetch_ref matches "couldn't find remote ref" in
git's stderr, and git marks that message for translation -- under a
non-English locale an ordinary start-a-new-branch launch classified as
FetchFailed, collapsing the three-way outcome to two. The fetch
subprocess now runs with LC_ALL=C and LANGUAGE=C (both, so the guarantee
does not hang on the one glibc rule that lets LC_ALL=C outrank
LANGUAGE), with the rest of the environment preserved.
… fetch's outcome

The "ref nowhere" integration test held both calls inside one
pytest.raises, so ensure_branch raised first and the ensure_workspace
line was never reached -- each now sits in its own block, and both hold.
ensure_branch's docstring named _prepare_workspace's checkout as where
an empty cache is discovered; on the launch path it is ensure_branch's
own branch creation, so the docstring (and the unit test echoing it) now
say so.

Also from the same review, non-blocking: the default-branch fetch_ref
discarded its outcome, so a FetchFailed there was silent while the
identical condition one arm down warned with its reason. It now warns
the same way.
@blooop

blooop commented Aug 14, 2026

Copy link
Copy Markdown
Owner Author

Both blocking findings addressed, pushed as c7fe46a and af06dd8.

Spec 1 — locale-dependent RefMissingOnRemote detection (c7fe46a): the fetch_ref subprocess now runs with LC_ALL=C and LANGUAGE=C in its env (rest of the environment preserved), so the stderr classification always reads git's untranslated message. Both variables, not just LC_ALL: under gettext LANGUAGE outranks a non-C LC_ALL, and the guarantee shouldn't hang on the one glibc rule that exempts C. Pinned by a unit test that sets a German LANG/LC_ALL/LANGUAGE on the host env and asserts the env actually handed to git pins the locale and keeps the rest (SSH_AUTH_SOCK as the sentinel, since losing the env would break ssh auth). The review's git ls-remote alternative would have cost a second round-trip on every launch, against the ticket's one-narrow-call budget.

Standards 1 / Spec 2 — unreachable assertion in the "ref nowhere" pin (af06dd8): split into two pytest.raises blocks, and both hold — ensure_branch raises at its own branch creation, and a caller reaching ensure_workspace directly still fails in _prepare_workspace rather than getting a workspace built on nothing. The ensure_branch docstring (and the unit-test docstring echoing it) now names the branch creation in ensure_branch as the discovery site instead of _prepare_workspace.

Also picked up from the same review, non-blocking Standards 2 (af06dd8): the default-branch fetch_ref's outcome is no longer discarded — a FetchFailed there now warns with its reason, same as the arm below. Standards 3 (the dead auto_fetch config knob) is left alone here: it's partly pre-existing and touches config.toml serialization, so it reads as its own ticket rather than a rider on this one.

@blooop blooop left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was generated by AI during review.

Re-review after the fix pass. Reviewed at merge-base dcdb077, diff dcdb077...af06dd8; fix commits are c7fe46a and af06dd8. Preflight green: ref resolves, diff non-empty, all checks passing (ci 3.10–3.14, e2e, gate, prek, codecov). Both axes ran as independent agents in fresh context and are not merged or reranked. Both claimed fixes were verified against the code at HEAD, not the commit messages.

Standards

Prior blocking finding (unreachable assertion): FIXED. test_repo_manager_real.py:382-388 now holds each call in its own pytest.raises(RuntimeError), and both halves are genuinely exercisable for distinct reasons: ensure_branch fails in create_local_branch (empty cache, no start point), leaving the cache unchanged; ensure_workspace then clones the empty bare successfully and raises the intended Cannot create branch 'nosuch': neither 'origin/nosuch' nor 'origin/main' at workspace_clone.py:524 — not a repeat of the first failure, not tautological. The docstring correction is accurate: on the launch path ensure_branch runs first and its branch creation is the first cache-contents consultation that raises; the unit-test docstring at test_workspace_clone.py:320-324 matches.

Prior non-blocking finding (discarded base-fetch outcome): FIXED. workspace_clone.py:382-399 captures base_outcome and warns on FetchFailed with the same shape as the neighboring arm (Could not fetch {ref} for {owner}/{repo}: {reason}). The ValueError guard survives intact, and the new behavior-level test (test_workspace_clone.py:264-287, caplog asserts the reason text and that the launch proceeds) pins it properly.

New-code findings, none blocking:

  1. workspace_clone.py:390-399 — the inner base_outcome dispatch names only FetchFailed, silently absorbing Updated, RefMissingOnRemote, and any future fourth arm — two lines below a comment explaining that this file's idiom is "each arm named, so a fourth one cannot be silently read as one of these three." A RefMissingOnRemote on the recorded default branch is arguably also worth a warning. Note.
  2. branch_manager.py:83 — "already exists" in e.stderr is the same translated-stderr hazard c7fe46a just fixed, at the repo's other textual-classification site; under a non-English locale a benign already-exists becomes a RuntimeError. Pre-existing and outside this diff — suggest a follow-up ticket alongside the auto_fetch one. Non-blocking.
  3. test_worktree_repo_manager.py:428-459 — the locale-pin test asserts the env handed to a mocked subprocess.run; implementation-level, but the behavioral version needs git translations installed in CI, so acceptable. The name ..._survives_a_translated_git slightly overstates what is exercised. The SSH_AUTH_SOCK-preserved assertion is a good touch. Note.
  4. Minor: repo_manager.py:316 builds the env with {**os.environ, ...} where workspace_clone.py:477 uses os.environ.copy() — two idioms for one job, not worth a helper at two sites. Note.

Prior judgement-call items (ValueError caught only for the default-branch call, the Updated: pass arm, push=True-only _commit_on) are unchanged and remain accepted. The auto_fetch deferral to its own ticket stands and is not re-raised.

Spec

Spec is #150, implementing the #144 resolution.

Prior Spec 1 (locale-dependent classification): FIXED — HOLDS. Spec: "Model the outcome as the three-way result from #144 (updated / ref-missing / network-failed …), not a bool." fetch_ref (repo_manager.py:304-317) now runs the fetch with env={**os.environ, "LC_ALL": "C", "LANGUAGE": "C"}. Coverage is complete: the "couldn't find remote ref" match at :321 is the only textual stderr classification in the codebase, and the default-branch fetch in ensure_branch goes through this same method, so both classifying fetches get the pin. Env construction preserves the rest of the environment (GIT_*/SSH vars — asserted in the new test). The both-variables approach is sound: glibc gettext consults LANGUAGE only when the locale is not C/POSIX, so LC_ALL=C alone suffices there, but pinning LANGUAGE too removes dependence on that one rule and covers non-glibc gettext. The new test fails if the env pin is removed.

Prior Spec 2 (ref-nowhere pin weaker than it reads): FIXED — HOLDS. Spec: "staleness contract integration: … ref nowhere → existing error." Each call now sits in its own pytest.raises, both raise the existing errors for the spec's reason (details under Standards), and no state leaks between the blocks — the failed first call creates no refs. The corrected ensure_branch docstring now names the actual discovery site on the launch path.

No regression of the previously verified criteria. The fix commits touch only the env kwarg, the base_outcome warning, docstrings, and tests. ensure_repo stays pure clone-if-missing, the targeted refspec and exactly-one-extra-fetch pins stand, last_fetched stays unwritten, the lock pin and re-registration zero-fetch pins are untouched, offline-from-cache still passes. 95 unit + 15 integration tests pass locally.

New fix-pass code against the spec: the now-warned default-branch FetchFailed arm fits the three-way model — create-from-default is licensed only by RefMissingOnRemote on the requested ref; the base fetch failing is network-failed → warn-and-use-cache-or-error, matching the README's documented "Offline: a warning, and the launch proceeds from whatever the cache holds."

Prior Spec 3 (stale origin/* on relaunch) remains a note for #150, not a change to this PR; the fix pass did not touch that path.

Verdict

Approve (posted as a comment — GitHub refuses --approve on a same-account PR; this written verdict is the gate, not the GitHub state).

Both prior blocking findings are verified fixed at HEAD, the non-blocking Standards 2 was fixed as claimed, the auto_fetch deferral is accepted, and the fix pass introduced no blocking problems. The four new items above are notes for follow-up, none of which should hold this PR. Not merging — merge stays human.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cold launch fetches exactly the requested ref; delete the workspace-clone fetch

1 participant