Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`git fetch` over ssh; with a connection master now open on `dl`'s own hottest
path it is the common one.

- **A commit that only an unpushed local tag reaches is no longer read as nothing
to lose.** The delete guard asks what a clone holds that exists nowhere else,
and it excluded `refs/tags/*` from the question outright, because a tag your
remote carries on a branch it no longer has would otherwise read as hundreds of
unpushed commits on every clone of that repository, forever. That exclusion was
right about the case it was written for and silently wrong about its neighbour:
tag before a rewrite, move the branch off the tag, and the commit under
`backup-before-rebase` exists in exactly one place on earth while `dl rm`
deletes the clone without asking and `dl --prune` without printing.

Nothing inside a clone can tell those two apart, because no remote-tracking ref
carries a tag: `refs/tags/` holds no mark saying which name arrived in a fetch.
What knows is the bare mirror `dl` keeps under `repos/<owner>/<repo>/.bare`,
which fetches tags forced and pruned and is what every workspace clone is made
from. So the guard compares the two. A tag the mirror holds at the same object
came off the remote and still costs nothing; a tag it has not got, or holds at
another object, was typed here, and the commits only that tag reaches are
counted like any other unpushed work. A local tag pointing at a commit the
remote already has is asked about and counts for nothing, which is the whole
point of asking rather than counting.

No network, one extra local `for-each-ref` per clone, and none at all for a
repository with no tags. Where there is no mirror to ask, every tag counts,
which keeps the clone: a clone kept costs disk, and the other direction costs
the only copy of somebody's work. [docs/cleanup.md](docs/cleanup.md) has the
table.

**And where a tag is the reason, the refusal now names it**, because "push or
commit it" is advice you have already taken for a commit that is under a tag:
`holds 1 unpushed commit(s), 1 reachable only from local tag(s)
(backup-before-rebase)`. Both counts, since the smaller one is how much of the
refusal pushing cannot clear. It matters in the case where the mirror is merely
*behind* the remote, which is otherwise baffling: a release you did push, on a
branch that has since gone, reads as unpushed until the next sweep, and now the
name in the message is the thing that tells you so. A commit on a branch is not
blamed on a tag, and a tag reaching nothing a branch does not reach is named
nowhere.

- **Sixty-six citations that pointed at nothing now point at something, and a
guard keeps it that way.** Comments across `rust/` name the test that pins the
behaviour they describe, which is most of what makes them worth reading.
Expand Down
66 changes: 56 additions & 10 deletions docs/cleanup.md
Original file line number Diff line number Diff line change
Expand Up @@ -630,16 +630,62 @@ the answer covers every local branch, every worktree's HEAD including detached
ones, and the stash, which is one ref per clone and holds work that exists nowhere
else either.

It does **not** reach local tags, and that is the one exclusion (#485). A tag your
remote carries, but which no remote *branch* reaches any more, would otherwise read
as unpushed, and a repository that tags releases on branches it then deletes has
those by the hundred. One does: 265 commits reachable only from its tags, which is
what stood between six of the eight workspaces on a host and being deleted, at 265
to 269 unpushed commits apiece and none of it real. A guard in that state is not a clone kept for the price of some disk. It is a
guard that has to be `--force`d past to delete anything, until `--force` is what you
type without reading, over the clone that did hold an hour of work as readily as
over this one. What the exclusion gives up is a commit reachable only from a local
tag, with no branch, worktree HEAD or stash in the clone naming it too.
Tags are the one ref kind the answer has to think about, and both directions of
getting it wrong have a ticket. A tag your remote carries, but which no remote
*branch* reaches any more, must not read as unpushed: a repository that tags
releases on branches it then deletes has those by the hundred, and one does, at
265 commits reachable only from its tags. That was what stood between six of the
eight workspaces on a host and being deleted, at 265 to 269 unpushed commits
apiece and none of it real (#485). A guard in that state is not a clone kept for
the price of some disk. It is a guard that has to be `--force`d past to delete
anything, until `--force` is what you type without reading, over the clone that did
hold an hour of work as readily as over this one. But a tag you typed here and
never pushed is the opposite case, and the backup habit reaches it in two commands:
tag before a rewrite, move the branch off the tag, and that commit exists in one
place on earth (#487).

A clone cannot tell the two apart on its own, because no remote-tracking ref
carries a tag: nothing in `refs/tags/` says which name arrived in a fetch. What
knows is the bare mirror under `repos/<owner>/<repo>/.bare`, which `dl` fetches
tags into and clones the workspace from. So the rule is a comparison, and it costs
no network:

| The tag in your clone | Counted as work at risk? |
| --- | --- |
| The mirror has it, at the same object | No. It came off the remote. |
| The mirror has not got it | Yes. Nothing but this clone has ever seen it. |
| The mirror has the name, at another object | Yes. It was moved or retyped here, and what it used to reach may be nowhere else. |
| There is no mirror to ask | Yes, every tag. |

The last row is the same principle the whole guard is built on: a check that
cannot establish safety fails towards keeping the clone. It is reached by a clone
`dl` has no record for and by a cache directory that has been deleted out from
under a workspace, and in both the answer is a clone kept, which costs disk and
nothing else.

**Where a tag is the reason, the refusal says so and names it**, because the
sentence a refusal ends with has to be one you can act on:

```
$ dl blooop/repo@feature rm
error: devlaunch-repo-feature-xyz holds 1 unpushed commit(s), 1 reachable only
from local tag(s) (backup-before-rebase).
Push or commit it, or run: dl blooop/repo@feature rm --force
```

Both counts, because they answer different questions: how much would be lost, and
how much of it pushing cannot clear. A commit under a tag is already committed, so
"push or commit it" is advice you have already taken, and the tag's name is what
tells you which case you are in. `backup-before-rebase` is the work being saved.
`v0.26.0` is a release you did push, whose tag has not reached the mirror yet,
which is what a `dl --refresh` fixes and what nothing else on the machine would
have told you.

The count that is not attributed to a tag is left alone: a commit on a branch
needs no explaining, so a clone holding one of each reads `2 unpushed commit(s), 1
reachable only from local tag(s) (backup)`, and a tag sitting on a commit some
branch also holds is named nowhere, because it explains nothing about why the
clone is being kept.

The changed paths are named, not just counted, and that matters more than it
looks: a devcontainer that runs a package install in its `postCreateCommand` can
Expand Down
16 changes: 15 additions & 1 deletion rust/devlaunch-core/public-api.rest.txt
Original file line number Diff line number Diff line change
Expand Up @@ -782,7 +782,9 @@ pub fn devlaunch_core::domain::workspace_state::CouldNotTell::fmt(&self, &mut co
impl core::marker::StructuralPartialEq for devlaunch_core::domain::workspace_state::CouldNotTell
pub enum devlaunch_core::domain::workspace_state::Loss
pub devlaunch_core::domain::workspace_state::Loss::Uncommitted(devlaunch_core::domain::workspace_state::NonEmpty<alloc::string::String>)
pub devlaunch_core::domain::workspace_state::Loss::Unpushed(devlaunch_core::domain::workspace_state::NonEmpty<alloc::string::String>)
pub devlaunch_core::domain::workspace_state::Loss::Unpushed
pub devlaunch_core::domain::workspace_state::Loss::Unpushed::by_tags: core::option::Option<devlaunch_core::domain::workspace_state::ByLocalTags>
pub devlaunch_core::domain::workspace_state::Loss::Unpushed::commits: devlaunch_core::domain::workspace_state::NonEmpty<alloc::string::String>
impl core::clone::Clone for devlaunch_core::domain::workspace_state::Loss
pub fn devlaunch_core::domain::workspace_state::Loss::clone(&self) -> devlaunch_core::domain::workspace_state::Loss
impl core::cmp::Eq for devlaunch_core::domain::workspace_state::Loss
Expand All @@ -803,6 +805,15 @@ pub fn devlaunch_core::domain::workspace_state::Unsaved::eq(&self, &devlaunch_co
impl core::fmt::Debug for devlaunch_core::domain::workspace_state::Unsaved
pub fn devlaunch_core::domain::workspace_state::Unsaved::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::marker::StructuralPartialEq for devlaunch_core::domain::workspace_state::Unsaved
pub struct devlaunch_core::domain::workspace_state::ByLocalTags
impl core::clone::Clone for devlaunch_core::domain::workspace_state::ByLocalTags
pub fn devlaunch_core::domain::workspace_state::ByLocalTags::clone(&self) -> devlaunch_core::domain::workspace_state::ByLocalTags
impl core::cmp::Eq for devlaunch_core::domain::workspace_state::ByLocalTags
impl core::cmp::PartialEq for devlaunch_core::domain::workspace_state::ByLocalTags
pub fn devlaunch_core::domain::workspace_state::ByLocalTags::eq(&self, &devlaunch_core::domain::workspace_state::ByLocalTags) -> bool
impl core::fmt::Debug for devlaunch_core::domain::workspace_state::ByLocalTags
pub fn devlaunch_core::domain::workspace_state::ByLocalTags::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::marker::StructuralPartialEq for devlaunch_core::domain::workspace_state::ByLocalTags
pub struct devlaunch_core::domain::workspace_state::NonEmpty<T>
impl devlaunch_core::domain::workspace_state::NonEmpty<devlaunch_core::domain::workspace_state::Loss>
pub fn devlaunch_core::domain::workspace_state::NonEmpty<devlaunch_core::domain::workspace_state::Loss>::describe(&self) -> alloc::string::String
Expand Down Expand Up @@ -1826,6 +1837,7 @@ impl<'a, 'r> devlaunch_core::flows::lifecycle::CloneDirectories<'a, 'r>
pub fn devlaunch_core::flows::lifecycle::CloneDirectories<'a, 'r>::of(&'a devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'r>) -> Self
pub fn devlaunch_core::flows::lifecycle::CloneDirectories<'a, 'r>::take_notices(&self) -> alloc::vec::Vec<devlaunch_core::flows::repo_manager::CacheNotice>
impl devlaunch_core::flows::listing::ClonePathResolver for devlaunch_core::flows::lifecycle::CloneDirectories<'_, '_>
pub fn devlaunch_core::flows::lifecycle::CloneDirectories<'_, '_>::bare_path(&self, &devlaunch_core::domain::model::WorktreeInfo) -> core::option::Option<std::path::PathBuf>
pub fn devlaunch_core::flows::lifecycle::CloneDirectories<'_, '_>::clone_path(&self, &devlaunch_core::domain::model::WorktreeInfo) -> core::option::Option<std::path::PathBuf>
pub struct devlaunch_core::flows::lifecycle::ClonePlacement
impl devlaunch_core::flows::lifecycle::ClonePlacement
Expand Down Expand Up @@ -2185,8 +2197,10 @@ impl core::fmt::Debug for devlaunch_core::flows::listing::WorkspaceOwnership
pub fn devlaunch_core::flows::listing::WorkspaceOwnership::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::marker::StructuralPartialEq for devlaunch_core::flows::listing::WorkspaceOwnership
pub trait devlaunch_core::flows::listing::ClonePathResolver
pub fn devlaunch_core::flows::listing::ClonePathResolver::bare_path(&self, &devlaunch_core::domain::model::WorktreeInfo) -> core::option::Option<std::path::PathBuf>
pub fn devlaunch_core::flows::listing::ClonePathResolver::clone_path(&self, &devlaunch_core::domain::model::WorktreeInfo) -> core::option::Option<std::path::PathBuf>
impl devlaunch_core::flows::listing::ClonePathResolver for devlaunch_core::flows::lifecycle::CloneDirectories<'_, '_>
pub fn devlaunch_core::flows::lifecycle::CloneDirectories<'_, '_>::bare_path(&self, &devlaunch_core::domain::model::WorktreeInfo) -> core::option::Option<std::path::PathBuf>
pub fn devlaunch_core::flows::lifecycle::CloneDirectories<'_, '_>::clone_path(&self, &devlaunch_core::domain::model::WorktreeInfo) -> core::option::Option<std::path::PathBuf>
pub fn devlaunch_core::flows::listing::describe_source(&devlaunch_core::clients::devpod::WorkspaceSource) -> devlaunch_core::flows::listing::SourceDescription
pub fn devlaunch_core::flows::listing::discover_repos_from_workspaces(&devlaunch_core::clients::git::Git<'_>, &[devlaunch_core::clients::devpod::Workspace]) -> devlaunch_core::flows::listing::RepoDiscovery
Expand Down
Loading
Loading