Skip to content

feat(workspaces): make workspaces list --since usable for CI change detection - #375

Merged
arcanis merged 4 commits into
mainfrom
mael/workspaces-since-parity
Oct 12, 2026
Merged

arcanis merged 4 commits into
mainfrom
mael/workspaces-since-parity

Conversation

@arcanis

@arcanis arcanis commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Issue: yarn workspaces list --since wasn't enough for CI change detection: changes to repository-wide files outside workspaces were ignored, it couldn't compare two refs, and any lockfile change looked like a change to everything.

Fix:

  • New changesetGlobalFiles setting: a change to a matching file marks every workspace as changed.
  • --head <ref> compares two refs. YARN_CHANGESET_BASE / YARN_CHANGESET_HEAD (or TURBO_SCM_BASE / TURBO_SCM_HEAD) fill unset bounds.
  • --since uses the lockfile-aware detection, and -R follows manifests without needing an install state.
  • yarn tasks run --affected/--since shares the same code.

Covered by acceptance tests. Stacked on #374.


Note

Medium Risk
Changes how --since selects workspaces across list/foreach/tasks run, which can shift CI task scope; behavior is heavily tested but regressions would affect monorepo pipelines.

Overview
Makes workspace change detection CI-friendly by centralizing git range logic and aligning behavior with Turbo-style SCM.

Adds changesetGlobalFiles (like Turbo globalDependencies): a matching file change marks every workspace affected. Introduces --head <ref> so --since can diff two commits instead of the working tree; unset bounds fall back to YARN_CHANGESET_* or TURBO_SCM_*. Lockfile diffs now only flag workspaces whose dependency trees actually changed; .pnp / .yarn install artifacts are ignored.

workspaces list --since drops its ad-hoc git/install-state logic and uses shared ChangesetRange helpers; --recursive (turbo ls --affected) walks dependents via manifests without a lazy install. The same path powers workspaces foreach and tasks run --since selection. Acceptance tests cover global files, ref-to-ref compares, env precedence, and lockfile scoping.

Reviewed by Cursor Bugbot for commit af6c4c6. Bugbot is set up for automated code reviews on this repo. Configure here.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 3 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Merge base ignores the head ref
    • Modified fetch_branch_base to accept and use the head ref parameter when computing merge base, ensuring correct base computation when --head is set without explicit --base.
  • ✅ Fixed: Head still uses working tree manifests
    • Added trust_current_lockfile parameter to find_changed_workspaces to skip working tree hash verification when comparing two refs, preventing uncommitted changes from incorrectly affecting change detection.
  • ✅ Fixed: Generated Yarn files mark root changed
    • Added should_ignore_generated_yarn_file filter to skip .pnp.cjs, .pnp.loader.mjs, and .yarn/ files during change detection, preventing zero-install repos from incorrectly marking root as changed.

Create PR

Or push these changes by commenting:

@cursor push 66cea77c2f
Preview (66cea77c2f)
diff --git a/packages/zpm/src/commands/debug/print_branch_base.rs b/packages/zpm/src/commands/debug/print_branch_base.rs
--- a/packages/zpm/src/commands/debug/print_branch_base.rs
+++ b/packages/zpm/src/commands/debug/print_branch_base.rs
@@ -14,7 +14,7 @@
             = project::Project::new(None).await?;
 
         let branch_base
-            = fetch_branch_base(&project).await?;
+            = fetch_branch_base(&project, None).await?;
 
         println!("{}", branch_base);
 

diff --git a/packages/zpm/src/commands/version/check.rs b/packages/zpm/src/commands/version/check.rs
--- a/packages/zpm/src/commands/version/check.rs
+++ b/packages/zpm/src/commands/version/check.rs
@@ -95,7 +95,7 @@
 
     // Only branch-local versioning files count — base-branch entries
     // can't satisfy bumps required by this branch.
-    let base = match fetch_branch_base(project).await {
+    let base = match fetch_branch_base(project, None).await {
         Ok(base) => base,
         Err(_) => return Ok(VersioningState { releases, declined }),
     };

diff --git a/packages/zpm/src/git_utils.rs b/packages/zpm/src/git_utils.rs
--- a/packages/zpm/src/git_utils.rs
+++ b/packages/zpm/src/git_utils.rs
@@ -64,7 +64,7 @@
     Ok(remotes)
 }
 
-pub async fn fetch_branch_base(project: &Project) -> Result<String, Error> {
+pub async fn fetch_branch_base(project: &Project, head: Option<&str>) -> Result<String, Error> {
     let base_refs
         = project.config.settings.changeset_base_refs.iter()
             .map(|s| s.value.to_string())
@@ -88,7 +88,7 @@
         }
 
         let mut args
-            = vec!["merge-base".to_string(), "HEAD".to_string()];
+            = vec!["merge-base".to_string(), head.unwrap_or("HEAD").to_string()];
 
         args.extend(branches.clone());
 
@@ -130,6 +130,28 @@
     None
 }
 
+/// Returns true if the file is a generated Yarn file that should be ignored
+/// during change detection (PnP artifacts and zero-installs cache).
+fn should_ignore_generated_yarn_file(project_root: &Path, file: &Path) -> bool {
+    let Some(rel_path) = file.forward_relative_to(project_root) else {
+        return false;
+    };
+
+    let rel_str = rel_path.as_str();
+
+    // Skip PnP files at the root
+    if rel_str == ".pnp.cjs" || rel_str == ".pnp.loader.mjs" {
+        return true;
+    }
+
+    // Skip the .yarn directory (zero-installs cache and other artifacts)
+    if rel_str == ".yarn" || rel_str.starts_with(".yarn/") {
+        return true;
+    }
+
+    false
+}
+
 pub async fn fetch_base(root: &Path, base_refs: &[&str]) -> Result<String, Error> {
     let mut ancestor_bases
         = Vec::new();
@@ -170,7 +192,7 @@
 pub async fn fetch_changed_workspaces(project: &Project, since: Option<&str>) -> Result<BTreeMap<Ident, BTreeSet<Path>>, Error> {
     let since_ref = match since {
         Some(since) => since.to_string(),
-        None => fetch_branch_base(project).await?,
+        None => fetch_branch_base(project, None).await?,
     };
 
     let changed_files
@@ -198,6 +220,11 @@
             continue;
         }
 
+        // Skip generated Yarn files (PnP and zero-installs cache)
+        if should_ignore_generated_yarn_file(&project.project_cwd, file) {
+            continue;
+        }
+
         let workspace
             = project.workspaces.iter()
                 .filter(|w| w.path.contains(file))
@@ -229,7 +256,7 @@
             = fetch_lockfile_at_ref(project, since_ref).await
                 .unwrap_or_else(|_| Lockfile::new());
 
-        for ident in find_changed_workspaces(project, &old_lockfile, &current_lockfile) {
+        for ident in find_changed_workspaces(project, &old_lockfile, &current_lockfile, head.is_some()) {
             changed_workspaces.entry(ident)
                 .or_default()
                 .insert(lockfile_path.clone());
@@ -270,7 +297,7 @@
 pub async fn fetch_changed_files(project: &Project, since: Option<&str>) -> Result<BTreeSet<Path>, Error> {
     let since = match since {
         Some(since) => since.to_string(),
-        None => fetch_branch_base(project).await?,
+        None => fetch_branch_base(project, None).await?,
     };
 
     fetch_changed_files_between(project, &since, None).await
@@ -371,7 +398,7 @@
 pub async fn fetch_changed_workspaces_in_range(project: &Project, range: &ChangesetRange) -> Result<BTreeSet<Ident>, Error> {
     let since_ref = match &range.base {
         Some(base) => base.clone(),
-        None => fetch_branch_base(project).await?,
+        None => fetch_branch_base(project, range.head.as_deref()).await?,
     };
 
     let changed_files

diff --git a/packages/zpm/src/lockfile_tree.rs b/packages/zpm/src/lockfile_tree.rs
--- a/packages/zpm/src/lockfile_tree.rs
+++ b/packages/zpm/src/lockfile_tree.rs
@@ -229,7 +229,7 @@
  * workspaces have the same dependencies on both sides, so we walk the two
  * trees side by side until we find a descriptor that resolves differently.
  */
-pub fn find_changed_workspaces(project: &Project, base: &Lockfile, current: &Lockfile) -> BTreeSet<Ident> {
+pub fn find_changed_workspaces(project: &Project, base: &Lockfile, current: &Lockfile, trust_current_lockfile: bool) -> BTreeSet<Ident> {
     let islands
         = workspace_islands(project);
     let workspace_dependencies
@@ -250,12 +250,14 @@
 
         // The lockfile from the working tree may be stale, so we also check
         // that its hash matches the dependencies we're about to walk.
+        // When comparing two refs (trust_current_lockfile=true), we trust
+        // the current lockfile's hash without verifying against the working tree.
         let dependencies = dependencies.as_ref().ok().filter(|dependencies| {
             let hash
                 = hash_workspace_dependencies(dependencies);
 
             base.project.workspaces.get(ident) == Some(&hash)
-                && current.project.workspaces.get(ident) == Some(&hash)
+                && (trust_current_lockfile || current.project.workspaces.get(ident) == Some(&hash))
         });
 
         let Some(dependencies) = dependencies else {

diff --git a/packages/zpm/src/versioning.rs b/packages/zpm/src/versioning.rs
--- a/packages/zpm/src/versioning.rs
+++ b/packages/zpm/src/versioning.rs
@@ -207,7 +207,7 @@
     }
 
     pub async fn versioning_path(&self) -> Result<Path, Error> {
-        let Some(base) = fetch_branch_base(self.project).await.ok() else {
+        let Some(base) = fetch_branch_base(self.project, None).await.ok() else {
             return self.create_versioning_path();
         };

You can send follow-ups to the cloud agent here.

Comment thread packages/zpm/src/git_utils.rs
Comment thread packages/zpm/src/git_utils.rs
Comment thread packages/zpm/src/commands/workspaces_list.rs
@arcanis
arcanis force-pushed the mael/workspaces-since-parity branch from 40e7e79 to 5365125 Compare October 9, 2026 13:59

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix is ON, but it could not run because the spend limit has been reached. To enable Bugbot Autofix, raise your spend limit in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 5365125. Configure here.

Comment thread packages/zpm/src/git_utils.rs
Comment thread packages/zpm/src/git_utils.rs
@arcanis
arcanis added this pull request to stack #384 October 11, 2026 11:16
@netlify

netlify Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for yarn-v6 ready!

Name Link
🔨 Latest commit af6c4c6
🔍 Latest deploy log https://app.netlify.com/projects/yarn-v6/deploys/6acd00657c3a9638fa87dca7
😎 Deploy Preview https://deploy-preview-375--yarn-v6.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

⏱️ Benchmark Results

gatsby install-full-cold

Metric Base Head Difference
Mean 4.328s 4.367s +0.89% ⚠️
Median 4.314s 4.365s +1.18% ⚠️
Min 4.255s 4.282s
Max 4.451s 4.650s
Std Dev 0.049s 0.071s
📊 Raw benchmark data (gatsby install-full-cold)

Base times: 4.345s, 4.294s, 4.451s, 4.285s, 4.257s, 4.372s, 4.304s, 4.314s, 4.411s, 4.312s, 4.348s, 4.314s, 4.308s, 4.349s, 4.282s, 4.284s, 4.377s, 4.325s, 4.301s, 4.292s, 4.290s, 4.309s, 4.395s, 4.343s, 4.336s, 4.296s, 4.314s, 4.255s, 4.340s, 4.445s

Head times: 4.650s, 4.369s, 4.312s, 4.394s, 4.364s, 4.302s, 4.383s, 4.282s, 4.412s, 4.357s, 4.295s, 4.285s, 4.321s, 4.366s, 4.392s, 4.421s, 4.357s, 4.375s, 4.446s, 4.434s, 4.373s, 4.428s, 4.283s, 4.368s, 4.361s, 4.351s, 4.374s, 4.333s, 4.327s, 4.296s


gatsby install-cache-only

Metric Base Head Difference
Mean 1.320s 1.323s +0.26% ⚠️
Median 1.316s 1.316s -0.05% ✅
Min 1.300s 1.280s
Max 1.389s 1.596s
Std Dev 0.017s 0.053s
📊 Raw benchmark data (gatsby install-cache-only)

Base times: 1.316s, 1.305s, 1.303s, 1.308s, 1.319s, 1.305s, 1.304s, 1.310s, 1.328s, 1.331s, 1.315s, 1.315s, 1.300s, 1.326s, 1.311s, 1.324s, 1.335s, 1.326s, 1.389s, 1.320s, 1.317s, 1.332s, 1.314s, 1.337s, 1.310s, 1.310s, 1.319s, 1.333s, 1.310s, 1.320s

Head times: 1.323s, 1.326s, 1.324s, 1.304s, 1.322s, 1.336s, 1.310s, 1.308s, 1.315s, 1.314s, 1.294s, 1.310s, 1.301s, 1.317s, 1.329s, 1.596s, 1.321s, 1.305s, 1.319s, 1.321s, 1.348s, 1.316s, 1.291s, 1.318s, 1.307s, 1.309s, 1.312s, 1.321s, 1.280s, 1.296s


gatsby install-cache-and-lock (warm, with lockfile)

Metric Base Head Difference
Mean 0.361s 0.361s +0.08% ⚠️
Median 0.361s 0.360s -0.33% ✅
Min 0.357s 0.352s
Max 0.366s 0.395s
Std Dev 0.002s 0.008s
📊 Raw benchmark data (gatsby install-cache-and-lock (warm, with lockfile))

Base times: 0.357s, 0.359s, 0.360s, 0.366s, 0.363s, 0.362s, 0.360s, 0.358s, 0.359s, 0.361s, 0.361s, 0.360s, 0.364s, 0.361s, 0.362s, 0.358s, 0.360s, 0.359s, 0.364s, 0.362s, 0.365s, 0.362s, 0.358s, 0.360s, 0.360s, 0.361s, 0.358s, 0.364s, 0.362s, 0.364s

Head times: 0.364s, 0.363s, 0.364s, 0.395s, 0.361s, 0.360s, 0.356s, 0.360s, 0.360s, 0.359s, 0.356s, 0.361s, 0.361s, 0.367s, 0.359s, 0.359s, 0.357s, 0.356s, 0.383s, 0.355s, 0.358s, 0.361s, 0.354s, 0.352s, 0.359s, 0.359s, 0.356s, 0.357s, 0.366s, 0.362s

Base automatically changed from mael/tasks-workspace-selection to main October 12, 2026 09:48
… detection

CI jobs need the list of workspaces affected by a change, including
changes to files outside any workspace.

- New `changesetGlobalFiles` setting: a change to a matching file
  marks every workspace as changed.
- `--head <ref>` compares two refs instead of the working tree.
  `YARN_CHANGESET_BASE` / `YARN_CHANGESET_HEAD` fill unset bounds,
  falling back to `TURBO_SCM_BASE` / `TURBO_SCM_HEAD`.
- `--since` now uses the lockfile-aware detection, so a lockfile change
  only marks workspaces whose dependency tree changed.
- `-R` follows the manifests and no longer needs an install state.
- `yarn workspaces foreach --since` and `yarn tasks run --since` share
  the same detection.
…acts

- With --head (or YARN_CHANGESET_HEAD / TURBO_SCM_HEAD) and no base, the
  merge base was computed against the current checkout rather than the
  head ref, so the range was wrong whenever the checkout was elsewhere.
- workspaces list --since used to ignore .pnp.cjs, .pnp.loader.mjs and
  .yarn/; going through the shared detection made them mark the root
  workspace as changed. They're skipped again.
`yarn workspaces foreach --since` and `yarn tasks run --since` can now
compare two refs with `--head <ref>`, like `yarn workspaces list`. An
explicit `--since <ref>` / `--head <ref>` takes precedence over the
`YARN_CHANGESET_*` / `TURBO_SCM_*` environment variables.
@arcanis
arcanis force-pushed the mael/workspaces-since-parity branch from 5365125 to 3dba5ef Compare October 12, 2026 10:10
@cursor

cursor Bot commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: 28d746b3-a2c6-44a0-9471-9b5526f2e63a)

@cursor

cursor Bot commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: eaa86a90-1a0a-455d-9684-8ee99b2e8e03)

@arcanis
arcanis merged commit 138d08a into main Oct 12, 2026
25 checks passed
@arcanis
arcanis deleted the mael/workspaces-since-parity branch October 12, 2026 19:21
arcanis added a commit that referenced this pull request Oct 12, 2026
Main already has squashed, revised versions of the earlier commits on
this branch (#373, #374, #375), so main wins for every conflicted file.
The only change kept from the branch is 86a2038. Its acceptance test
now uses main's `@defaultTask` / `*:build` syntax, because main dropped
`@workspaces` and `^name`.
arcanis added a commit that referenced this pull request Oct 12, 2026
…376)

**Issue:** running a `@long-lived` task that depends on `^build`:
- hid the output and status of the dependencies, which ran in the
long-lived context;
- on Ctrl-C in standalone mode, printed "still running in the
background" while the server died with the process;
- ignored SIGTERM.

**Fix:** subscriptions deliver events for every task they pushed,
whatever its context. Standalone runs stop their long-lived targets on
Ctrl-C (exit 130) and SIGTERM (exit 143). Daemon runs still detach on
Ctrl-C.

Covered by acceptance tests. Stacked on #375.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Touches task runner signal handling and daemon notification filtering;
behavior changes for long-lived tasks and CI shutdown paths, mitigated
by new acceptance tests.
> 
> **Overview**
> Fixes `@long-lived` task runs so **dependency output and status**
still stream to the CLI when those deps execute in a separate long-lived
context, by treating every task the subscription pushed (`all_task_ids`)
as in-scope for notifications—not only explicit targets in the run
context.
> 
> **Interrupt handling** on Unix now listens for **SIGTERM**
(CI/`timeout`) as well as SIGINT. Daemon runs can still **detach**
long-lived targets on Ctrl-C; **standalone** runs and any **SIGTERM**
always cancel, explicitly **stop** started long-lived targets, and exit
with the right signal code (**130** vs **143**).
> 
> Adds acceptance tests for workspace dependency build output,
standalone SIGINT teardown, and SIGTERM cleanup.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
a759e06. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

This branch was successfully deployed

1 active deployment
test-reports — af6c4c68 Deployed Oct 12, 2026 by arcanis via Reporting test results #1439
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.

1 participant