Skip to content

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

Open
arcanis wants to merge 2 commits into
mael/tasks-workspace-selectionfrom
mael/workspaces-since-parity
Open

arcanis wants to merge 2 commits into
mael/tasks-workspace-selectionfrom
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 which workspaces CI and --since/--affected consider modified (lockfile semantics, global globs, ref ranges); incorrect detection could skip or over-run jobs, but behavior is covered by new acceptance tests.

Overview
Improves CI-style workspace change detection for yarn workspaces list --since, -R, and yarn tasks run --since/--affected by centralizing logic in git_utils behind a ChangesetRange (base + optional head).

Adds changesetGlobalFiles (turbo-style global deps): matching globs mark every workspace as changed. Adds --head <ref> and env defaults YARN_CHANGESET_BASE/YARN_CHANGESET_HEAD (with TURBO_SCM_* fallbacks) to compare two refs or override bounds without flags.

Lockfile changes now only flag workspaces whose dependency tree actually changed; .pnp.* / .yarn churn is ignored. -R reuses shared affected detection (changed workspaces + transitive dependents from manifests) and no longer requires a lazy install. Acceptance tests cover global files, ref-to-ref diffs, env vars, and lockfile scoping.

Reviewed by Cursor Bugbot for commit 5365125. 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
… 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 tasks run --affected/--since` shares the same code.
…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.
@arcanis
arcanis force-pushed the mael/tasks-workspace-selection branch from 63f285a to a2b4482 Compare October 9, 2026 13:59
@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.

.collect::<BTreeSet<_>>();

return Ok(changed_files);
}

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.

Two-ref diff is tip-to-tip

Medium Severity

When both bounds are set (--since/--head or TURBO_SCM_BASE/TURBO_SCM_HEAD), changed files come from a tip-to-tip git diff of the two refs. That includes files that only moved on the base branch after the fork, so CI reports workspaces the PR never touched. Turbo’s matching env vars use a three-dot / merge-base diff instead; the no-base path already computes a merge base, so the two-ref path disagrees with both turbo and that fallback.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5365125. Configure here.


if touches_global_files(project, &changed_files) {
return Ok(project.workspaces.iter().map(|w| w.name.clone()).collect());
}

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.

Global files miss install artifacts

Medium Severity

Install artifacts are dropped from the changed-file set before changesetGlobalFiles is evaluated. Patterns that match .yarn/** or PnP files therefore never mark every workspace as changed, even when the user listed them as global inputs. Yarn stores patches and other repo-wide files under .yarn/, which is exactly what this setting is for.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5365125. Configure here.

This branch has not been deployed

No deployments
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