Skip to content

Name the sources in --purge's leaving list - #515

Merged
blooop merged 3 commits into
mainfrom
wayfinder/devlaunch-461
Aug 29, 2026
Merged

Name the sources in --purge's leaving list#515
blooop merged 3 commits into
mainfrom
wayfinder/devlaunch-461

Conversation

@blooop

@blooop blooop commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Closes #461.

--purge's "Leaving N workspace(s) devlaunch did not create:" list printed ids and nothing else. An id is what devpod addresses a workspace by and carries nothing about where it came from, so pythontemplate reads exactly the same whether it is a dl <git-url> of yours, a dl ./project whose checkout you care about, or something another tool made. That is the one screen where somebody is deciding, and the line gave them nothing to decide on.

What changed

Each survivor is named by its source.

Leaving 2 workspace(s) devlaunch did not create:
  - someones-project: /home/you/projects/thing
  - devlaunch-main-3j1t: /srv/clones/blooop/devlaunch/devlaunch-main-3j1t

The source comes from describe_source, the same reading that fills dl --ls's SOURCE column, so the two surfaces cannot describe one workspace differently. A source dl cannot read is printed as devpod's own object and said to be one, rather than sitting after a colon looking like a path.

The block says what removing the cache costs the workspaces that stay. They keep working, but a clone an older dl placed outside the cache under the retired worktree.repos_dir key is named only by a record inside the cache that is about to go. After the purge, dl <workspace> rm deletes the workspace and leaves that directory standing with nothing on the machine pointing at it. Removing such a workspace first is what takes its clone with it.

--purge now reports a config.toml that still sets that key. #467 left this decision to this ticket and it is answered yes. It reads the config directly rather than opening dl's records, because opening them runs the cache migration, which would write records into the tree this command is about to remove, and into one an aborted purge was asked to leave alone. Where a workspace still opens such a clone the leaving list names the path; where none does, that notice is the only mention the tree will get.

Deliberately not a claim about volumes: the two named volumes a workspace's devcontainer made are read from devpod's own workspace_result.json under DEVPOD_HOME, which a purge does not touch, so a survivor's volumes still go with it whenever it is removed. That changes with #456, and the doc comment on the sentence says where it will be said.

The case from #481's review

The reproduced case is a test rather than an aspiration. lifecycle_scenario.py gains --stranded-clone: the clone at the path worktree.repos_dir named, a devpod workspace sourcing it, the record in dl's cache pointing at it, and the key still in config.toml. a_purge_names_the_clone_a_retired_repos_dir_left_outside_the_cache asserts the path is in the plan, the retired-key notice is on stderr, and then, after -y, that the tree is still there and its record is not, which is exactly what the two new sentences warn about.

Tests

  • the_leaving_list_names_each_survivors_source_beside_its_id
  • a_survivor_whose_source_dl_cannot_read_is_said_to_be_one
  • a_purge_names_the_clone_a_retired_repos_dir_left_outside_the_cache
  • render::tests::the_cleanup_page_quotes_the_sentences_a_purge_really_prints, the diff test the standing second-copies rule asks for: docs/cleanup.md reproduces the block, and a sample that has drifted from the command is worse than no sample.
  • PURGE_PLAN, the existing golden, gains both additions and says in its doc comment that it is now the second deliberate divergence from Python in that file.
  • The e2e purge assertion checks the survivor's line carries a source, asserted as "there is one" rather than against a literal, because what devpod echoes back for a git source is devpod's normalisation of the URL.

cargo test --workspace, clippy -D warnings, cargo fmt --check and pytest test all pass. No core change, so the public API snapshot is untouched.

🤖 Generated with Claude Code

Summary by Sourcery

Improve dl --purge so users can identify surviving workspaces and understand what removing the cache will leave behind.

Enhancements:

  • Make dl --purge identify surviving workspaces by their sources and explain the cache and stranded-clone consequences of leaving them behind.
  • Report retired worktree.repos_dir configuration during purge without modifying cached records.

Documentation:

  • Update cleanup documentation, README command behavior, and the changelog to describe source-labeled survivors and purge consequences.

Tests:

  • Add lifecycle, rendering, scenario, and end-to-end coverage for source labels, unreadable sources, retired repository directories, and interactive versus -y purge output.

@sourcery-ai

sourcery-ai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR makes dl --purge safer to review by naming surviving workspaces by their readable source, explaining what cache removal loses, and warning about legacy clones under worktree.repos_dir; it adds focused rendering, lifecycle, e2e, documentation-drift, and stranded-clone coverage.

Sequence diagram for the safer dl purge review flow

sequenceDiagram
    participant User
    participant Purge as dl --purge
    participant Config as config.toml
    participant Plan as PurgePlan
    participant Devpod as Devpod

    User->>Purge: purge_devlaunch_data()
    Purge->>Config: worktree_config()
    Config-->>Purge: retired worktree.repos_dir notice
    Purge->>Plan: purge_plan()
    Plan->>Devpod: inspect workspace ownership and sources
    Devpod-->>Plan: surviving workspaces
    Plan-->>Purge: purge_plan_lines()
    Purge->>Purge: describe_source(workspace.source())
    Purge-->>User: ids with sources and cache-loss warnings
    User->>Purge: confirm or decline
    Purge-->>User: purge or leave survivors unchanged
Loading

Flow diagram for naming surviving workspaces during purge

flowchart TD
    A[dl --purge] --> B[Read worktree_config directly]
    B --> C{Retired worktree.repos_dir configured?}
    C -->|Yes| D[Print retired-key notice]
    C -->|No| E[Build purge plan]
    D --> E
    E --> F{Workspace survives purge?}
    F -->|No| G[Include workspace in removal plan]
    F -->|Yes| H["describe_source workspace.source()"]
    H --> I{Readable source?}
    I -->|Local or Git| J[Print id and source detail]
    I -->|Unknown| K[Print id and devpod-owned source description]
    J --> L[Print cache-record and stranded-clone warnings]
    K --> L
    L --> M[Ask for confirmation]
Loading

File-Level Changes

Change Details Files
Make --purge survivor listings actionable by showing each workspace's source alongside its ID, using the same source-description logic as dl --ls.
  • Render local paths and Git URLs directly, and explicitly label unreadable DevPod-owned sources.
  • Update lifecycle, end-to-end, and rendering tests to verify source-aware output without depending on DevPod URL normalization.
  • Refresh the README, cleanup documentation, changelog, and purge golden output.
rust/dl/src/render.rs
rust/dl/tests/lifecycle.rs
test/e2e/test_full_workflow.py
docs/cleanup.md
README.md
CHANGELOG.md
Warn users about information and stranded-clone consequences of removing the cache while preserving surviving workspaces.
  • Add explanatory purge-plan sentences describing lost cached records and the need to remove legacy external clones before purging.
  • Read retired worktree.repos_dir configuration directly, without opening or migrating cache records, and report configured paths on stderr.
  • Add a stranded-clone lifecycle scenario that verifies the external tree remains while its cache record is removed.
rust/dl/src/commands.rs
rust/dl/src/render.rs
rust/dl/tests/lifecycle.rs
rust/dl/tests/lifecycle_scenario.py
docs/cleanup.md
CHANGELOG.md

Assessment against linked issues

Issue Objective Addressed Explanation
#461 Name each workspace in the --purge leaving list with its source alongside its id, including a clear fallback for sources that cannot be read.
#461 Explain that removing the cache discards devlaunch's records about surviving workspaces, including the potential loss of knowledge about clones outside the cache, and advise users to remove such workspaces before purging if they want their clones removed.
#461 Update the relevant lifecycle golden, cleanup documentation, and tests to reflect the new purge output while preserving the documented scope and formatting constraints.

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

@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 reviewed your changes and they look great!


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.

@codecov

codecov Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.00000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 95.64%. Comparing base (af9f5a8) to head (9d9386c).

Files with missing lines Patch % Lines
rust/dl/src/commands.rs 91.66% 1 Missing ⚠️
Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 95.95% <98.00%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
shipped code (rust) 95.95% <98.00%> (+0.01%) ⬆️
harness and tooling (python) 42.98% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@blooop

blooop commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

APPROVE

Fresh-context adversarial review, no shared context with the author. I built the branch in a scratch worktree and ran the purge tests plus two of my own; every claim in the PR body that I could check held up, and nothing I threw at the leaving list produced a misleading line. Findings below are all minor and none of them blocks a merge.

What I verified rather than took on trust

describe_source is genuinely one function, not a copy. flows/listing.rs:206 is the only definition; the --ls SOURCE column reaches it through TableRow.source at listing.rs:1190, and render::left_standing_source calls the same thing at render.rs:1783. So the second-copies rule does not bite here and the "cannot describe one workspace differently" claim is structural. I confirmed it on a real run too, in the --stranded-clone world:

devlaunch-main-3j1t    local  {ROOT}/old-repos/blooop/devlaunch/devlaunch-main-3j1t   2026-07-30 09:08:07
...
  - devlaunch-main-3j1t: {ROOT}/old-repos/blooop/devlaunch/devlaunch-main-3j1t

Byte-identical, and the SOURCE column pads rather than truncates ({:<source_width$}), so there is no width at which the two diverge.

The unreadable arm is total, including the case nobody usually thinks about. WorkspaceSource has four arms and describe_source covers all four; UnreadableLocalFolder and Unrecognised share the Unknown arm. A missing source key is not a fifth case: read_workspace (clients/devpod.rs:776) substitutes an empty map, so the line reads - id: a source dl cannot read, {}. Ugly, honest, and the same thing --ls shows in that row. A source that is not an object at all is refused one level up as NotAListing::SourceNotAnObject, so it never reaches this renderer.

The side-effect argument for reading the config directly holds, and the read is not a second parse. dispatch sends Command::Purge straight to render_purge, which never calls session::open_records; lifecycle::purge_plan reads only context.workspaces(), and purge_all_data touches devpod, DEVPOD_HOME and the cache tree but no MetadataStorage. So "opening the records would run the migration" is a real cost this path otherwise does not pay. And say_retired_keys calls session::worktree_config(), which is the same config::worktree_config() that open_records uses to produce retired_keys, rendered through the same render::retired_keys. No hand-rolled TOML read, no second RetiredKey producer.

The stranded-clone test pins disk state, not the absence of an error. After -y it asserts the tree at old-repos/.../devlaunch-main-3j1t is still there and that cache/devlaunch/metadata.json is not, which is exactly the sentence the block prints. That is the right shape.

Findings

1. In a -y run the second sentence advises something the same run has already made impossible. purge_plan_lines is printed before the if !yes branch, so dl --purge -y prints

...so remove such a workspace now if the clone should go with it.

and then immediately deletes the records that made that possible. Interactively it is exactly right ("a reason to answer n", as the doc comment on SURVIVORS_LOSE_THE_RECORDS says); unattended it is future-tense advice arriving after the door shut. Worth a phrasing that survives both modes, or gating the second line on the question being asked. Not blocking: the whole plan block is written for the interactive reader and -y opts out of being read.

2. The advice is true today and nothing pins it. "Remove such a workspace now if the clone should go with it" works only because resolve_clone_path prefers the record's absolute local_path when it is present, over the cache-derived path (workspace_clone.rs:455). Every unit test around that function uses a path under repos_dir (a_usable_recorded_path_wins_however_it_is_spelled and friends), and the new lifecycle test pins only the negative half. Since #467 moved the derived root inwards, a later "only remove trees under the cache" hardening would turn a printed sentence into bad advice with nothing failing. I wrote the positive and it passes on this branch:

#[test]
fn removing_a_stranded_workspace_before_the_purge_takes_its_clone() {
    let world = World::with(&["--stranded-clone"]);
    let run = world.dl(&["devlaunch-main-3j1t", "rm"]);
    assert!(
        !world.exists("old-repos/blooop/devlaunch/devlaunch-main-3j1t"),
        "the clone stayed: exit {:?}\nout:{}\nerr:{}", run.code, run.out, run.err
    );
}

Four lines beside the test that already exists, and it makes the printed advice a checked fact.

3. The diff test is narrower than the PR body says it is. the_cleanup_page_quotes_the_sentences_a_purge_really_prints diffs the two sentences in SURVIVORS_LOSE_THE_RECORDS. The PR body describes it as guarding the block; the sample's new - id: source line, which is what this PR is actually about, is still hand-maintained with no guard. Either narrow the claim or render one survivor line into the assertion.

4. The e2e assertion accepts the degraded output it is meant to catch. left[0].split(": ", 1)[1].strip() passes just as happily on - e2e-purge-hand-made: a source dl cannot read, {...}. Staying loose about devpod's URL normalisation is right; one extra assert "a source dl cannot read" not in left[0] keeps that while refusing the fallback arm.

5. Observation, probably a ticket rather than a change here. The purge now names every workspace it leaves, with its source, and still names none of the ones it deletes ("- 2 DevPod workspace(s)"). is_devlaunch_clone is a purely lexical starts_with(cache_dir), so a directory of somebody's own that happens to sit under the cache reads as dl's and goes unnamed. The ticket's own premise ("the one screen where somebody is deciding") argues at least as strongly for the destroyed half.

What I tried and could not break

I went looking for a printed source that could make a hand-made workspace read as dl's, which is the confusion the ticket exists to end, and could not construct one. The interesting direction turns out to be the opposite: dl's own clone under a previous XDG_CACHE_HOME, or under a retired repos_dir, appears in the leaving list with a path that is dl's layout, and printing it is what makes that diagnosable instead of baffling. A gitRepository arm carrying a local path (devpod records those, per the arm's own doc) prints as a bare path exactly like LocalFolder, so the purge list drops the TYPE column's disambiguation, but neither spelling says anything about ownership and the header already does. I also checked whether the third describe_source consumer (lifecycle::sources_by_workspace) could disagree: it uses .detail bare, but only as a lookup key against clone paths, never printed.

Docs read honestly, no em or en dashes in anything added, and the volumes paragraph is true as written today.

blooop added a commit that referenced this pull request Aug 29, 2026
Four findings from #515's fresh-eyes review.

The advice under the leaving list ("remove such a workspace now if the
clone should go with it") was printed above the question and under -y
alike, and a -y run deletes the records that make it possible three
lines later. The plan renderer is now told which of the two it is
(render::Confirmation), and the sentence has two spellings: the same
subject clause, with the action for a reader who can still take it and
the consequence for a run that has already committed. Only that one line
moves, so PURGE_PLAN and PURGE_PLAN_YES differ by it and nothing else.

The reviewer's positive test goes in as written: the printed advice is
true only because resolve_clone_path prefers the record's absolute
local_path over the cache-derived one, every unit test around that
function uses a path under the clone root, and the lifecycle test pinned
only the negative half. Now `dl <stranded-ws> rm` really taking a tree
outside the cache is a checked fact.

The docs diff test covers the sample's survivor lines as well as the two
sentences, which is what the PR body claimed of it: the line format is
what this change is about, and `- pythontemplate` on its own would have
gone on reading correctly with nothing failing. It also asserts the page
does *not* quote the -y spelling, since that one is described in prose
instead and a second copy of it would drift unguarded.

And the e2e assertion refuses the degraded arm. "Something after the
colon" passed just as happily on `a source dl cannot read, {...}`, which
is exactly what a broken reading prints.
@blooop
blooop force-pushed the wayfinder/devlaunch-461 branch from 3a2949f to 9c56f2c Compare August 29, 2026 19:36
blooop added a commit that referenced this pull request Aug 29, 2026
Four findings from #515's fresh-eyes review.

The advice under the leaving list ("remove such a workspace now if the
clone should go with it") was printed above the question and under -y
alike, and a -y run deletes the records that make it possible three
lines later. The plan renderer is now told which of the two it is
(render::Confirmation), and the sentence has two spellings: the same
subject clause, with the action for a reader who can still take it and
the consequence for a run that has already committed. Only that one line
moves, so PURGE_PLAN and PURGE_PLAN_YES differ by it and nothing else.

The reviewer's positive test goes in as written: the printed advice is
true only because resolve_clone_path prefers the record's absolute
local_path over the cache-derived one, every unit test around that
function uses a path under the clone root, and the lifecycle test pinned
only the negative half. Now `dl <stranded-ws> rm` really taking a tree
outside the cache is a checked fact.

The docs diff test covers the sample's survivor lines as well as the two
sentences, which is what the PR body claimed of it: the line format is
what this change is about, and `- pythontemplate` on its own would have
gone on reading correctly with nothing failing. It also asserts the page
does *not* quote the -y spelling, since that one is described in prose
instead and a second copy of it would drift unguarded.

And the e2e assertion refuses the degraded arm. "Something after the
colon" passed just as happily on `a source dl cannot read, {...}`, which
is exactly what a broken reading prints.
@blooop
blooop force-pushed the wayfinder/devlaunch-461 branch from 9c56f2c to f406cac Compare August 29, 2026 19:51
blooop added a commit that referenced this pull request Aug 29, 2026
Four findings from #515's fresh-eyes review.

The advice under the leaving list ("remove such a workspace now if the
clone should go with it") was printed above the question and under -y
alike, and a -y run deletes the records that make it possible three
lines later. The plan renderer is now told which of the two it is
(render::Confirmation), and the sentence has two spellings: the same
subject clause, with the action for a reader who can still take it and
the consequence for a run that has already committed. Only that one line
moves, so PURGE_PLAN and PURGE_PLAN_YES differ by it and nothing else.

The reviewer's positive test goes in as written: the printed advice is
true only because resolve_clone_path prefers the record's absolute
local_path over the cache-derived one, every unit test around that
function uses a path under the clone root, and the lifecycle test pinned
only the negative half. Now `dl <stranded-ws> rm` really taking a tree
outside the cache is a checked fact.

The docs diff test covers the sample's survivor lines as well as the two
sentences, which is what the PR body claimed of it: the line format is
what this change is about, and `- pythontemplate` on its own would have
gone on reading correctly with nothing failing. It also asserts the page
does *not* quote the -y spelling, since that one is described in prose
instead and a second copy of it would drift unguarded.

And the e2e assertion refuses the degraded arm. "Something after the
colon" passed just as happily on `a source dl cannot read, {...}`, which
is exactly what a broken reading prints.
@blooop
blooop force-pushed the wayfinder/devlaunch-461 branch from f406cac to 27c6899 Compare August 29, 2026 19:52
blooop added 3 commits August 29, 2026 20:54
The list of workspaces a purge leaves standing printed ids and nothing
else, and an id is the one thing a user cannot decide on: devpod
addresses a workspace by it and it carries nothing about where the
workspace came from, so `pythontemplate` reads the same whether it is a
`dl <git-url>` of yours, a `dl ./project` whose checkout you care about,
or something another tool made. Each line now carries the source beside
the id, from the same `describe_source` reading that fills `dl --ls`'s
SOURCE column, so the two surfaces cannot describe one workspace
differently. A source dl cannot read is printed as devpod's own object
and said to be one, rather than sitting after a colon looking like a
path.

The block also says what removing the cache costs the survivors. They
keep working, but a clone an older dl placed outside the cache under the
retired worktree.repos_dir key is named only by a record inside the cache
that is about to go: afterwards `dl <ws> rm` deletes the workspace and
leaves that directory standing with nothing on the machine pointing at
it. Removing such a workspace first is what takes its clone with it.

now reports a config.toml that still sets that key. It reads the config
directly rather than opening dl's records, because opening them runs the
cache migration, which would write records into the tree this command is
about to remove and into one an aborted purge was asked to leave alone.
Where a workspace still opens such a clone the leaving list names the
path; where none does, that notice is the only mention the tree gets.

Deliberately not a claim about volumes: the two named volumes a
devcontainer made are read from devpod's own record under DEVPOD_HOME,
which a purge does not touch.
Four findings from #515's fresh-eyes review.

The advice under the leaving list ("remove such a workspace now if the
clone should go with it") was printed above the question and under -y
alike, and a -y run deletes the records that make it possible three
lines later. The plan renderer is now told which of the two it is
(render::Confirmation), and the sentence has two spellings: the same
subject clause, with the action for a reader who can still take it and
the consequence for a run that has already committed. Only that one line
moves, so PURGE_PLAN and PURGE_PLAN_YES differ by it and nothing else.

The reviewer's positive test goes in as written: the printed advice is
true only because resolve_clone_path prefers the record's absolute
local_path over the cache-derived one, every unit test around that
function uses a path under the clone root, and the lifecycle test pinned
only the negative half. Now `dl <stranded-ws> rm` really taking a tree
outside the cache is a checked fact.

The docs diff test covers the sample's survivor lines as well as the two
sentences, which is what the PR body claimed of it: the line format is
what this change is about, and `- pythontemplate` on its own would have
gone on reading correctly with nothing failing. It also asserts the page
does *not* quote the -y spelling, since that one is described in prose
instead and a second copy of it would drift unguarded.

And the e2e assertion refuses the degraded arm. "Something after the
colon" passed just as happily on `a source dl cannot read, {...}`, which
is exactly what a broken reading prints.
This branch said "volumes are not part of that loss" and gave the
reason: the two names are read from devpod's own workspace_result.json
under DEVPOD_HOME, which a purge does not touch. That was true when it
was written and stopped being true underneath it when #516 merged, which
puts dl's own copy of those names under the cache the purge removes.

It is the case #452 predicted the sentence would have to cover: --purge
never deletes the foreign workspaces, so it destroys the copies naming
their volumes while leaving the volumes standing. What is unchanged is
the ordinary route, and the sentence now separates the two: `dl <ws> rm`
still takes a survivor's volumes with it for as long as devpod lists the
workspace, because that read is devpod's record and not the copy. The
reach a purge costs is --prune's, which reclaims from the copy after
devpod has forgotten a workspace, so a survivor deleted with a bare
devpod delete afterwards leaves volumes nothing can name.

The doc comment, the docs page and the CHANGELOG entry say the same
thing, and the two goldens move with the line.
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.

Name the sources in --purge's leaving list

1 participant