From abb67fb2d39b44d4b53bc956224143a10dc41a7a Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Thu, 20 Aug 2026 18:24:40 +0000 Subject: [PATCH 1/2] Widen the workspace-id budget to 47, one inside devpod's fatal 48 38 was never a limit anything enforced. Two limits are real underneath it: devpod rejects a 49-character `--id` outright (`workspace name cannot be longer than 48 characters`, verified against the pinned v0.26.1), and the container hostname is capped at 64 bytes, which devlaunch shares with downstream tooling that stacks its own prefixes onto the name (kinisi-robotics/kinisi_ros#9766 already sat at 62/64). 38 was devlaunch holding ten characters back for the second one. This spends nine of them on legibility and keeps one against devpod's wall. `kinisi_ros@ags-devcontainer-tooling-support` now reads `kinisi-ros-ags-devcontainer-tooling-su-lenevere` instead of losing the branch's tail, a dependabot ref keeps its whole action name, and ~17 characters are still there for whatever bolts itself onto the container name -- a caller that wants more is now the one that has to shorten. Nothing about uniqueness moves with this: the eight-character suffix is hashed over the full (owner, repo, ref) triple before any truncation, so the readable part is a legibility choice with no effect on the collision rate. That is why the golden tables' suffixes are untouched here while their values are not. The golden tables in both Rust modules are regenerated by `rust/golden_vectors.py` rather than hand-edited, which is the discipline that file exists for. `only_as_many_middle_segments_drop_as_needed` needed a bigger fixture in both languages: its 8-character segments now fit whole under the wider budget, so the test it was became "nothing drops", pinning nothing. It is now sized to the budget -- over by less than one segment, so exactly one middle has to go -- and the old fixture is kept as its opposite, `a_ref_that_fits_whole_keeps_every_segment`. Co-Authored-By: Claude --- CHANGELOG.md | 21 ++++ README.md | 13 ++- devlaunch/workspace_id.py | 25 +++-- rust/devlaunch-core/src/domain/spec.rs | 8 +- .../devlaunch-core/src/domain/workspace_id.rs | 95 +++++++++++++------ rust/golden_vectors.py | 1 + test/test_workspace_id.py | 18 +++- 7 files changed, 134 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7eb9088..8a0bd565 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Workspace ids may now be 47 characters instead of 38, so long branch names + keep their tail.** The budget was never a limit anything enforced: devpod's own + ceiling is 48 and fatal (`workspace name cannot be longer than 48 characters` — + a 49-character id is refused, not truncated), and 38 was devlaunch reserving 10 + characters for downstream tooling that stacks prefixes onto the container name + against a 64-byte limit. 47 spends nine of those characters on legibility and + keeps one against devpod's wall, leaving ~17 for whatever bolts itself on. + `kinisi_ros@ags-devcontainer-tooling-support` reads + `kinisi-ros-ags-devcontainer-tooling-su-lenevere` rather than + `kinisi-ros-ags-devcontainer-t-lenevere`, and a dependabot ref keeps the whole + action name (`devlaunch-dependabot-codecov-action-6-sifivasa`). Truncation + policy has no effect on collisions — the eight-character suffix is hashed over + the full `(owner, repo, ref)` triple before anything is cut — so nothing about + uniqueness moves with this. Existing containers are unaffected: `dl` addresses + a workspace by the devpod id recorded in `metadata.json` (#88), so an id that + was derived under the old budget keeps opening the same container. A clone + directory whose leaf does change is re-cloned under the new name on the next + launch, leaving the old directory for `dl --prune` to report. + ## [0.2.2] - 2026-08-20 ### Fixed diff --git a/README.md b/README.md index 2a1d4ae5..33e9bfab 100644 --- a/README.md +++ b/README.md @@ -593,16 +593,16 @@ them. If that is too often, unset the variable and use `dl dotfiles`. see in `dl --ls`) and the clone directory under `~/.cache/devlaunch/repos/`: ``` --- at most 38 characters +-- at most 47 characters blooop/devlaunch@main -> devlaunch-main-zovomobo blooop/devlaunch@feature/auth -> devlaunch-feature-auth-poliseno blooop/devlaunch@feature-auth -> devlaunch-feature-auth-nesatabe blooop/test_renv@nb4 -> test-renv-nb4-polenita kinisi-robotics/kinisi_ros@ags-devcontainer-tooling-support - -> kinisi-ros-ags-devcontainer-t-lenevere + -> kinisi-ros-ags-devcontainer-tooling-su-lenevere blooop/devlaunch@dependabot/github_actions/codecov/codecov-action-6 - -> devlaunch-dependabot-codecov-sifivasa + -> devlaunch-dependabot-codecov-action-6-sifivasa ``` The eight-character syllable suffix is a hash of the full `(owner, repo, branch)` triple. @@ -620,8 +620,11 @@ Branch names are case-sensitive, because git refs are. URL specs (`dl github.com/owner/repo`) get an id in the same shape, with the suffix hashed over the URL. -The id is also the container hostname, so it stays well inside the 38-character budget -to leave room for tools that add their own prefixes. +The id is also the container hostname. 47 characters is one inside devpod's own hard +ceiling of 48 — a 49-character id is refused outright, not truncated — and well inside +the 64-byte hostname limit on its own, but tools that stack their own prefixes onto the +container name have about 17 characters to work with, so a tool that wants more is the +one that has to shorten. Branch names must be safe as both git refs and directory names — a name with a space or a leading dash is rejected rather than quietly rewritten. diff --git a/devlaunch/workspace_id.py b/devlaunch/workspace_id.py index f42136eb..7e17bd3f 100644 --- a/devlaunch/workspace_id.py +++ b/devlaunch/workspace_id.py @@ -37,12 +37,23 @@ import re from dataclasses import dataclass -# Total id budget. devpod's own ceiling is 48, but the setup pass's hostname stage +# Total id budget, one character inside devpod's own ceiling of 48. +# +# 48 is hard and fatal: ``devpod up --id`` with a 49-character id is rejected outright +# (``workspace name cannot be longer than 48 characters``, verified against the pinned +# v0.26.1), so an id derived here that overshoots is a launch that does not happen. 47 +# keeps one character of margin from that wall. +# +# This was 38, for the reason it is still not 48: the setup pass's hostname stage # (``tools.setup_stages``) names the container after the workspace id, and downstream -# tooling stacks prefixes and -# suffixes on it against a 64-byte limit (kinisi-robotics/kinisi_ros#9766 already sat -# at 62/64). 48 is a ceiling others eat into, not a budget to fill, so aim at 38. -TARGET_LENGTH = 38 +# tooling stacks its own prefixes and suffixes onto that against a 64-byte limit +# (kinisi-robotics/kinisi_ros#9766 already sat at 62/64 with a 38-char id). Widening to +# 47 spends nine characters of that reserve on legibility — branch names that used to +# lose their tail now keep it — and leaves ~17 characters for whatever a downstream +# caller bolts on, so a caller that wants more is the one that has to shorten. Nothing +# here breaks at any value up to 48, and the id alone is a legal hostname at 47, well +# inside 64. +TARGET_LENGTH = 47 # The repo slug is cut to this length when the id would otherwise overflow, and is # never cut below it. It carries no identity — only legibility — so trimming it is @@ -56,7 +67,7 @@ # This was 3 syllables / 18 bits when the scheme was first decided. 18 bits put a # birthday collision inside a single repo's plausible branch count — 500 branches # already produced one in test — so it was widened by one syllable. The extra two -# characters come out of the readable budget; the 38-char cap did not move. +# characters come out of the readable budget; the cap did not move to pay for them. _CONSONANTS = "bdfghjklmnprstvz" _VOWELS = "aeio" _SYLLABLES = 4 @@ -210,7 +221,7 @@ def value(self) -> str: suffix = self.suffix repo_part = slug(self.repo) # Cut the repo slug only when the id would otherwise overflow. Capping it at - # REPO_SLUG_LENGTH leaves at least TARGET_LENGTH - 20 - 1 - 1 - 8 = 8 + # REPO_SLUG_LENGTH leaves at least TARGET_LENGTH - 20 - 1 - 1 - 8 = 17 # characters for the ref, so the ref budget can never go non-positive — the # hole that let a 47-char repo name skip truncation altogether. if len(_join(repo_part, _fit_ref(self.ref, TARGET_LENGTH), suffix)) > TARGET_LENGTH: diff --git a/rust/devlaunch-core/src/domain/spec.rs b/rust/devlaunch-core/src/domain/spec.rs index 81a50e99..bce62428 100644 --- a/rust/devlaunch-core/src/domain/spec.rs +++ b/rust/devlaunch-core/src/domain/spec.rs @@ -457,10 +457,10 @@ mod tests { Case { spec: "blooop/python_template", shape: Shape::OwnerRepo { owner: "blooop", repo: "python_template", branch: None }, expanded: "git@github.com:blooop/python_template.git", identity: Expect::RepoLabel("python-template") }, Case { spec: "blooop/python_template@nb4", shape: Shape::OwnerRepo { owner: "blooop", repo: "python_template", branch: Some("nb4") }, expanded: "git@github.com:blooop/python_template.git@nb4", identity: Expect::Workspace("python-template-nb4-foganeje") }, Case { spec: "loft-sh/devpod", shape: Shape::OwnerRepo { owner: "loft-sh", repo: "devpod", branch: None }, expanded: "git@github.com:loft-sh/devpod.git", identity: Expect::RepoLabel("devpod") }, - Case { spec: "owner/rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr", shape: Shape::OwnerRepo { owner: "owner", repo: "rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr", branch: None }, expanded: "git@github.com:owner/rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr.git", identity: Expect::RepoLabel("rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr") }, + Case { spec: "owner/rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr", shape: Shape::OwnerRepo { owner: "owner", repo: "rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr", branch: None }, expanded: "git@github.com:owner/rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr.git", identity: Expect::RepoLabel("rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr") }, Case { spec: "owner/rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr@main", shape: Shape::OwnerRepo { owner: "owner", repo: "rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr", branch: Some("main") }, expanded: "git@github.com:owner/rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr.git@main", identity: Expect::Workspace("rrrrrrrrrrrrrrrrrrrr-main-rerajezi") }, - Case { spec: "owner/repo@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", shape: Shape::OwnerRepo { owner: "owner", repo: "repo", branch: Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") }, expanded: "git@github.com:owner/repo.git@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", identity: Expect::Workspace("repo-aaaaaaaaaaaaaaaaaaaaaaaa-pokipete") }, - Case { spec: "owner/myrepo@feature/some-very-long-branch-name-here", shape: Shape::OwnerRepo { owner: "owner", repo: "myrepo", branch: Some("feature/some-very-long-branch-name-here") }, expanded: "git@github.com:owner/myrepo.git@feature/some-very-long-branch-name-here", identity: Expect::Workspace("myrepo-feature-some-very-long-lafazota") }, + Case { spec: "owner/repo@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", shape: Shape::OwnerRepo { owner: "owner", repo: "repo", branch: Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") }, expanded: "git@github.com:owner/repo.git@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", identity: Expect::Workspace("repo-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-pokipete") }, + Case { spec: "owner/myrepo@feature/some-very-long-branch-name-here", shape: Shape::OwnerRepo { owner: "owner", repo: "myrepo", branch: Some("feature/some-very-long-branch-name-here") }, expanded: "git@github.com:owner/myrepo.git@feature/some-very-long-branch-name-here", identity: Expect::Workspace("myrepo-feature-some-very-long-branch-n-lafazota") }, Case { spec: "user.name/repo.name", shape: Shape::OwnerRepo { owner: "user.name", repo: "repo.name", branch: None }, expanded: "git@github.com:user.name/repo.name.git", identity: Expect::RepoLabel("repo-name") }, Case { spec: "my_user/my_repo", shape: Shape::OwnerRepo { owner: "my_user", repo: "my_repo", branch: None }, expanded: "git@github.com:my_user/my_repo.git", identity: Expect::RepoLabel("my-repo") }, Case { spec: "owner/repo@main", shape: Shape::OwnerRepo { owner: "owner", repo: "repo", branch: Some("main") }, expanded: "git@github.com:owner/repo.git@main", identity: Expect::Workspace("repo-main-gemetato") }, @@ -491,7 +491,7 @@ mod tests { Case { spec: "github.com/loft-sh/devpod", shape: Shape::HostPath, expanded: "github.com/loft-sh/devpod", identity: Expect::Workspace("github-com-loft-sh-devpod-vatomiha") }, Case { spec: "github.com/Blooop/DevLaunch", shape: Shape::HostPath, expanded: "github.com/Blooop/DevLaunch", identity: Expect::Workspace("github-com-blooop-devlaunch-lakatoje") }, Case { spec: "github.com/blooop/devlaunch", shape: Shape::HostPath, expanded: "github.com/blooop/devlaunch", identity: Expect::Workspace("github-com-blooop-devlaunch-lakatoje") }, - Case { spec: "github.com/oooooooooooooooooooooooooooooooooooooooo/rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr", shape: Shape::HostPath, expanded: "github.com/oooooooooooooooooooooooooooooooooooooooo/rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr", identity: Expect::Workspace("github-com-oooooooooooooooooo-rasijome") }, + Case { spec: "github.com/oooooooooooooooooooooooooooooooooooooooo/rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr", shape: Shape::HostPath, expanded: "github.com/oooooooooooooooooooooooooooooooooooooooo/rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr", identity: Expect::Workspace("github-com-ooooooooooooooooooooooooooo-rasijome") }, Case { spec: "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/owner/repo", shape: Shape::Url, expanded: "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/owner/repo", identity: Expect::Workspace("github-com-owner-repo-lokolede") }, Case { spec: "/owner/repo@main", shape: Shape::Url, expanded: "/owner/repo@main", identity: Expect::Workspace("github-com-owner-repo-lokolede") }, Case { spec: "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/owner/repo.git", shape: Shape::Url, expanded: "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/owner/repo.git", identity: Expect::Workspace("github-com-owner-repo-lokolede") }, diff --git a/rust/devlaunch-core/src/domain/workspace_id.rs b/rust/devlaunch-core/src/domain/workspace_id.rs index 14a04ec8..88b74234 100644 --- a/rust/devlaunch-core/src/domain/workspace_id.rs +++ b/rust/devlaunch-core/src/domain/workspace_id.rs @@ -67,12 +67,23 @@ use std::fmt; -/// Total id budget. devpod's own ceiling is 48, but the setup pass's hostname +/// Total id budget, one character inside devpod's own ceiling of 48. +/// +/// 48 is hard and fatal: `devpod up --id` with a 49-character id is rejected +/// outright (`workspace name cannot be longer than 48 characters`, verified +/// against the pinned v0.26.1), so an id derived here that overshoots is a +/// launch that does not happen. 47 keeps one character of margin from that wall. +/// +/// This was 38, for the reason it is still not 48: the setup pass's hostname /// stage names the container after the workspace id, and downstream tooling -/// stacks prefixes and suffixes on it against a 64-byte limit -/// (kinisi-robotics/kinisi_ros#9766 already sat at 62/64). 48 is a ceiling -/// others eat into, not a budget to fill, so aim at 38. -pub(crate) const TARGET_LENGTH: usize = 38; +/// stacks its own prefixes and suffixes onto that against a 64-byte limit +/// (kinisi-robotics/kinisi_ros#9766 already sat at 62/64 with a 38-char id). +/// Widening to 47 spends nine characters of that reserve on legibility — branch +/// names that used to lose their tail now keep it — and leaves ~17 characters +/// for whatever a downstream caller bolts on, so a caller that wants more is the +/// one that has to shorten. Nothing here breaks at any value up to 48, and the +/// id alone is a legal hostname at 47, well inside 64. +pub(crate) const TARGET_LENGTH: usize = 47; /// The repo slug is cut to this length when the id would otherwise overflow, and /// is never cut below it. It carries no identity — only legibility — so trimming @@ -87,8 +98,8 @@ pub(crate) const REPO_SLUG_LENGTH: usize = 20; // This was 3 syllables / 18 bits when the scheme was first decided. 18 bits put // a birthday collision inside a single repo's plausible branch count — 500 // branches already produced one in test — so it was widened by one syllable. The -// extra two characters come out of the readable budget; the 38-char cap did not -// move. +// extra two characters come out of the readable budget; the cap did not move +// to pay for them. const CONSONANTS: &[u8; 16] = b"bdfghjklmnprstvz"; const VOWELS: &[u8; 4] = b"aeio"; const SYLLABLES: usize = 4; @@ -274,7 +285,7 @@ impl WorkspaceId { let suffix = self.suffix(); let mut repo_part = slug(&self.repo); // Cut the repo slug only when the id would otherwise overflow. Capping it - // at REPO_SLUG_LENGTH leaves at least TARGET_LENGTH - 20 - 1 - 1 - 8 = 8 + // at REPO_SLUG_LENGTH leaves at least TARGET_LENGTH - 20 - 1 - 1 - 8 = 17 // characters for the ref, so the ref budget can never go non-positive — // the hole that let a 47-char repo name skip truncation altogether. let untruncated = join(&[&repo_part, &fit_ref(&self.git_ref, TARGET_LENGTH), &suffix]); @@ -468,42 +479,42 @@ mod tests { "devlaunch", "dependabot/github_actions/codecov/codecov-action-6", "sifivasa", - "devlaunch-dependabot-codecov-sifivasa", + "devlaunch-dependabot-codecov-action-6-sifivasa", ), ( "blooop", "devlaunch", "dependabot/github_actions/blooop/prek-action-2", "zakogozo", - "devlaunch-dependabot-prek-act-zakogozo", + "devlaunch-dependabot-prek-action-2-zakogozo", ), ( "blooop", "python_template", "dependabot/pip/lib/dependencies-1", "hinarami", - "python-template-dependabot-de-hinarami", + "python-template-dependabot-dependencie-hinarami", ), ( "blooop", "lifetime_foc_rig", "dependabot/github_actions/codecov/codecov-action-6", "matagere", - "lifetime-foc-rig-dependabot-c-matagere", + "lifetime-foc-rig-dependabot-codecov-ac-matagere", ), ( "kinisi-robotics", "kinisi_ros", "ags-devcontainer-tooling-support", "lenevere", - "kinisi-ros-ags-devcontainer-t-lenevere", + "kinisi-ros-ags-devcontainer-tooling-su-lenevere", ), ( "blooop", "devlaunch", "fix/gh-auth-in-devcontainer", "pedoveho", - "devlaunch-fix-gh-auth-in-devc-pedoveho", + "devlaunch-fix-gh-auth-in-devcontainer-pedoveho", ), ( "blooop", @@ -517,7 +528,14 @@ mod tests { "dl", "a/bbbbbbbb/cccccccc/dddddddd/zzz", "jobiriti", - "dl-a-cccccccc-dddddddd-zzz-jobiriti", + "dl-a-bbbbbbbb-cccccccc-dddddddd-zzz-jobiriti", + ), + ( + "blooop", + "dl", + "a/bbbbbbbbbbbb/cccccccccccc/dddddddddddd/zzz", + "gigisini", + "dl-a-cccccccccccc-dddddddddddd-zzz-gigisini", ), ( "blooop", @@ -538,7 +556,7 @@ mod tests { "devlaunch", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "zedabezi", - "devlaunch-aaaaaaaaaaaaaaaaaaa-zedabezi", + "devlaunch-aaaaaaaaaaaaaaaaaaaaaaaaaaaa-zedabezi", ), ( "owner", @@ -552,28 +570,28 @@ mod tests { "rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr", "a/very/long/branch/name/that/eats/the/budget", "bijizoli", - "rrrrrrrrrrrrrrrrrrrr-a-budget-bijizoli", + "rrrrrrrrrrrrrrrrrrrr-a-eats-the-budget-bijizoli", ), ( "owner", "rrrrrrrrrrrrrrrrrrrr", "a/very/long/branch/name/that/eats/the/budget", "tafifipo", - "rrrrrrrrrrrrrrrrrrrr-a-budget-tafifipo", + "rrrrrrrrrrrrrrrrrrrr-a-eats-the-budget-tafifipo", ), ( "owner", "repo", "release/9999999999999999999999999176", "sehirani", - "repo-release-9999999999999999-sehirani", + "repo-release-9999999999999999999999999-sehirani", ), ( "owner", "repo", "release/9999999999999999999999999234", "zivalero", - "repo-release-9999999999999999-zivalero", + "repo-release-9999999999999999999999999-zivalero", ), ("a", "bc", "main", "bajovafa", "bc-main-bajovafa"), ("ab", "c", "main", "mofijihe", "c-main-mofijihe"), @@ -589,14 +607,14 @@ mod tests { "a-repo-name-that-is-long", "feature/shared-prefix-1", "lapajiha", - "a-repo-name-that-is-feature-s-lapajiha", + "a-repo-name-that-is-feature-shared-pre-lapajiha", ), ( "blooop", "a-repo-name-that-is-long", "feature/shared-prefix-2", "sopalopi", - "a-repo-name-that-is-feature-s-sopalopi", + "a-repo-name-that-is-feature-shared-pre-sopalopi", ), ( "owner", @@ -617,14 +635,14 @@ mod tests { "r", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "sigivapa", - "r-bbbbbbbbbbbbbbbbbbbbbbbbbbb-sigivapa", + "r-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-sigivapa", ), ( "owner", "rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "bagakino", - "rrrrrrrrrrrrrrrrrrrr-bbbbbbbb-bagakino", + "rrrrrrrrrrrrrrrrrrrr-bbbbbbbbbbbbbbbbb-bagakino", ), ( "owner", @@ -695,11 +713,11 @@ mod tests { ), ( "github.com/oooooooooooooooooooooooooooooooooooooooo/rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr", - "github-com-oooooooooooooooooo-rasijome", + "github-com-ooooooooooooooooooooooooooo-rasijome", ), ( "github.com/oooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo/rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr", - "github-com-oooooooooooooooooo-bepolero", + "github-com-ooooooooooooooooooooooooooo-bepolero", ), ("github.com/o/r", "github-com-o-r-deparori"), ("o", "o-rotebife"), @@ -1247,11 +1265,32 @@ mod tests { assert!(!value.contains("github-actions"), "{value}"); } + /// The segment widths are sized to the budget: this ref is over it by less + /// than one segment, so exactly one middle has to go. A ref that fits whole + /// would pin nothing, which is what the 8-wide version of it became when the + /// cap moved from 38 to 47. #[test] fn only_as_many_middle_segments_drop_as_needed() { + let git_ref = format!( + "a/{}/{}/{}/zzz", + "b".repeat(12), + "c".repeat(12), + "d".repeat(12) + ); + let value = id("blooop", "dl", &git_ref).value(); + let kept = format!("dl-a-{}-{}-zzz-", "c".repeat(12), "d".repeat(12)); + assert!(value.starts_with(&kept), "{value}"); + assert!(!value.contains(&"b".repeat(12)), "{value}"); + } + + /// The other side of the same rule: nothing drops until it has to. + #[test] + fn a_ref_that_fits_whole_keeps_every_segment() { let value = id("blooop", "dl", "a/bbbbbbbb/cccccccc/dddddddd/zzz").value(); - assert!(value.starts_with("dl-a-cccccccc-dddddddd-zzz-"), "{value}"); - assert!(!value.contains("bbbbbbbb"), "{value}"); + assert!( + value.starts_with("dl-a-bbbbbbbb-cccccccc-dddddddd-zzz-"), + "{value}" + ); } #[test] diff --git a/rust/golden_vectors.py b/rust/golden_vectors.py index 8a38c148..20124281 100644 --- a/rust/golden_vectors.py +++ b/rust/golden_vectors.py @@ -65,6 +65,7 @@ def rs(s): ("blooop", "devlaunch", "fix/gh-auth-in-devcontainer"), ("blooop", "test_renv", "nb4"), ("blooop", "dl", "a/bbbbbbbb/cccccccc/dddddddd/zzz"), + ("blooop", "dl", "a/" + "b" * 12 + "/" + "c" * 12 + "/" + "d" * 12 + "/zzz"), ("blooop", "dl", "aa/" + "m" * 30 + "/" + "n" * 30 + "/zz"), ("blooop", "devlaunch", "a//b///c/d"), ("blooop", "devlaunch", "a" * 60), diff --git a/test/test_workspace_id.py b/test/test_workspace_id.py index 11b7963d..0d9942da 100644 --- a/test/test_workspace_id.py +++ b/test/test_workspace_id.py @@ -280,10 +280,22 @@ def test_dependabot_ref_keeps_the_action_name(self): assert "github-actions" not in value def test_only_as_many_middle_segments_drop_as_needed(self): - """Segments go leftmost-middle first, and only until the ref fits.""" + """Segments go leftmost-middle first, and only until the ref fits. + + The segment widths are sized to the budget: this ref is over it by less + than one segment, so exactly one middle has to go. A ref that fits whole + would pin nothing, which is what the 8-wide version of it became when + the cap moved from 38 to 47. + """ + ref = "a/" + "b" * 12 + "/" + "c" * 12 + "/" + "d" * 12 + "/zzz" + value = WorkspaceId("blooop", "dl", ref).value + assert value.startswith("dl-a-" + "c" * 12 + "-" + "d" * 12 + "-zzz-") + assert "b" * 12 not in value + + def test_a_ref_that_fits_whole_keeps_every_segment(self): + """The other side of the same rule: nothing drops until it has to.""" value = WorkspaceId("blooop", "dl", "a/bbbbbbbb/cccccccc/dddddddd/zzz").value - assert value.startswith("dl-a-cccccccc-dddddddd-zzz-") - assert "bbbbbbbb" not in value + assert value.startswith("dl-a-bbbbbbbb-cccccccc-dddddddd-zzz-") def test_first_and_last_segments_survive_heavy_truncation(self): """However many middles have to go, the ends stay.""" From c8a048217850be8a2618ec2af69891f248dc655a Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Thu, 20 Aug 2026 18:35:08 +0000 Subject: [PATCH 2/2] Widen the heavy-truncation fixture, which passed by one character `the_first_and_last_segments_survive_heavy_truncation` claims the ends of a path-shaped ref survive however many middles have to go. With 30-character middles it was making that claim with a single character to spare under the wider budget: `aa-<30 n>-zz` is 36 against a 35-character ref budget, so one more character of cap would have quietly turned it into a test of dropping *one* middle, still green and no longer testing what it says. 40 each puts it far past the budget rather than just over it, in both languages, and the golden corpus moves with it because `rust/golden_vectors.py` is where the Rust table's inputs live. Co-Authored-By: Claude --- rust/devlaunch-core/src/domain/workspace_id.rs | 11 +++++++---- rust/golden_vectors.py | 2 +- test/test_workspace_id.py | 10 ++++++++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/rust/devlaunch-core/src/domain/workspace_id.rs b/rust/devlaunch-core/src/domain/workspace_id.rs index 88b74234..c2aabc3e 100644 --- a/rust/devlaunch-core/src/domain/workspace_id.rs +++ b/rust/devlaunch-core/src/domain/workspace_id.rs @@ -540,9 +540,9 @@ mod tests { ( "blooop", "dl", - "aa/mmmmmmmmmmmmmmmmmmmmmmmmmmmmmm/nnnnnnnnnnnnnnnnnnnnnnnnnnnnnn/zz", - "rakibasi", - "dl-aa-zz-rakibasi", + "aa/mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm/nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn/zz", + "degosare", + "dl-aa-zz-degosare", ), ( "blooop", @@ -1293,9 +1293,12 @@ mod tests { ); } + /// Both middles are far wider than the budget rather than just over it: at 30 + /// each this passed by a single character, so one more character of cap would + /// have turned it into a test of dropping *one* middle without saying so. #[test] fn the_first_and_last_segments_survive_heavy_truncation() { - let git_ref = format!("aa/{}/{}/zz", "m".repeat(30), "n".repeat(30)); + let git_ref = format!("aa/{}/{}/zz", "m".repeat(40), "n".repeat(40)); let value = id("blooop", "dl", &git_ref).value(); assert!(value.starts_with("dl-aa-zz-"), "{value}"); assert!( diff --git a/rust/golden_vectors.py b/rust/golden_vectors.py index 20124281..36f9e65d 100644 --- a/rust/golden_vectors.py +++ b/rust/golden_vectors.py @@ -66,7 +66,7 @@ def rs(s): ("blooop", "test_renv", "nb4"), ("blooop", "dl", "a/bbbbbbbb/cccccccc/dddddddd/zzz"), ("blooop", "dl", "a/" + "b" * 12 + "/" + "c" * 12 + "/" + "d" * 12 + "/zzz"), - ("blooop", "dl", "aa/" + "m" * 30 + "/" + "n" * 30 + "/zz"), + ("blooop", "dl", "aa/" + "m" * 40 + "/" + "n" * 40 + "/zz"), ("blooop", "devlaunch", "a//b///c/d"), ("blooop", "devlaunch", "a" * 60), ("owner", "r" * 47, "main"), diff --git a/test/test_workspace_id.py b/test/test_workspace_id.py index 0d9942da..9147bc12 100644 --- a/test/test_workspace_id.py +++ b/test/test_workspace_id.py @@ -298,8 +298,14 @@ def test_a_ref_that_fits_whole_keeps_every_segment(self): assert value.startswith("dl-a-bbbbbbbb-cccccccc-dddddddd-zzz-") def test_first_and_last_segments_survive_heavy_truncation(self): - """However many middles have to go, the ends stay.""" - ref = "aa/" + "m" * 30 + "/" + "n" * 30 + "/zz" + """However many middles have to go, the ends stay. + + Both middles are far wider than the budget rather than just over it: at + 30 each this passed by a single character, so one more character of cap + would have turned it into a test of dropping *one* middle without saying + so. + """ + ref = "aa/" + "m" * 40 + "/" + "n" * 40 + "/zz" value = WorkspaceId("blooop", "dl", ref).value assert value.startswith("dl-aa-zz-") assert "mmmm" not in value and "nnnn" not in value