Repository: vercel/turborepo (2026-04-10T17:12:58Z)

Issue:
Filtering by source control changes doesn't detect changes
### Verify canary release

- [x] I verified that the issue exists in the latest Turborepo canary release.

### Link to code that reproduces this issue

https://github.com/robin-crazygames/turbo-git-filter

### Which canary version will you have in your reproduction?

turbo@2.9.6-canary.3

### Environment information

```block
• turbo 2.9.6-canary.3
CLI:
   Version: 2.9.6-canary.3
   Path to executable: /tmp/turbo-git-filter/node_modules/@turbo/linux-64/bin/turbo
   Daemon status: Not running
   Package manager: npm

Platform:
   Architecture: x86_64
   Operating system: linux
   WSL: false
   Available memory (MB): 43720
   Available CPU cores: 22

Environment:
   CI: None
   AI agent: None
   Terminal (TERM): xterm-256color
   Terminal program (TERM_PROGRAM): unknown
   Terminal program version (TERM_PROGRAM_VERSION): unknown
   Shell (SHELL): /bin/bash
   stdin: false
   Node.js version: v22.17.1
```

### Expected behavior

When using `turbo` with a source control filter, it picks up the same changes as when using that filter in `git` directly.
More in particular, when
```
git show origin/main..HEAD
```
detects changes, 
```
turbo run <command> --filter="[origin/main..HEAD]"
```
should also pick up those changes which it currently doesn't.

### Actual behavior

turbo doesn't detect the changes when using `--filter="[origin/main..HEAD]"`

### To Reproduce

```
git clone git@github.com:robin-crazygames/turbo-git-filter.git
cd turbo-git-filter
npm ci

# Switching to a branch which has one additional commit compared to the main branch
git checkout branch_with_changes

# This branch has one additional commit which isn't part of the main branch:
echo "Printing the changes detected by git"
git show --oneline origin/main..HEAD
# Outputs info about that additional commit

# However, when using the same as filter with turbo, turbo doesn't detect it
echo "Printing the directories in which turbo detects changes when using the same filter"
npx turbo run lint --dry=json --filter="[origin/main..HEAD]" | jq -r '.tasks[] | .directory'
# Outputs no directories

# Turbo does see it when using --affected instead
echo "Printing the directories in which turbo detects changes using --affected"
npx turbo run lint --dry=json --affected | jq -r '.tasks[] | .directory'
# Outputs apps/app-a directory
```

### Additional context

_No response_


Discussion:
Hey, Robin, thanks for the issue. This turned out to be lack of support for `..` Have it fixed in the PR above.



Patch:
diff --git a/crates/turborepo-scope/src/target_selector.rs b/crates/turborepo-scope/src/target_selector.rs
index 3462b0123700d..aaecd0333d29a 100644
--- a/crates/turborepo-scope/src/target_selector.rs
+++ b/crates/turborepo-scope/src/target_selector.rs
@@ -190,6 +190,19 @@ impl FromStr for TargetSelector {
                     allow_unknown_objects: false,
                     merge_base: true,
                 }
+            } else if let Some((a, b)) = commits_str.split_once("..") {
+                if a.is_empty() || b.is_empty() {
+                    return Err(InvalidSelectorError::InvalidGitRange(
+                        commits_str.to_string(),
+                    ));
+                }
+                GitRange {
+                    from_ref: Some(a.to_string()),
+                    to_ref: Some(b.to_string()),
+                    include_uncommitted: false,
+                    allow_unknown_objects: false,
+                    merge_base: false,
+                }
             } else {
                 // If only the start of the range is specified, we assume that
                 // we want to include uncommitted changes
@@ -285,7 +298,9 @@ mod test {
     #[test_case(".", TargetSelector { raw: ".".to_string(), parent_dir: Some(AnchoredSystemPathBuf::try_from(".").unwrap()), ..Default::default() }; "parent dir dot")]
     #[test_case("..", TargetSelector { raw: "..".to_string(), parent_dir: Some(AnchoredSystemPathBuf::try_from("..").unwrap()), ..Default::default() }; "parent dir dot dot")]
     #[test_case("[master]", TargetSelector { raw: "[master]".to_string(), git_range: Some(GitRange { from_ref: Some("master".to_string()), to_ref: None, include_uncommitted: true, ..Default::default() }), ..Default::default() }; "square brackets master")]
-    #[test_case("[from...to]", TargetSelector { raw: "[from...to]".to_string(), git_range: Some(GitRange { from_ref: Some("from".to_string()), to_ref: Some("to".to_string()), merge_base: true, ..Default::default() }), ..Default::default() }; "[from...to]")]
+    #[test_case("[from..to]", TargetSelector { raw: "[from..to]".to_string(), git_range: Some(GitRange { from_ref: Some("from".to_string()), to_ref: Some("to".to_string()), ..Default::default() }), ..Default::default() }; "from two dot to")]
+    #[test_case("[from...to]", TargetSelector { raw: "[from...to]".to_string(), git_range: Some(GitRange { from_ref: Some("from".to_string()), to_ref: Some("to".to_string()), merge_base: true, ..Default::default() }), ..Default::default() }; "from three dot to")]
+    #[test_case("[origin/main..HEAD]", TargetSelector { raw: "[origin/main..HEAD]".to_string(), git_range: Some(GitRange { from_ref: Some("origin/main".to_string()), to_ref: Some("HEAD".to_string()), ..Default::default() }), ..Default::default() }; "[origin/main..HEAD]")]
     #[test_case("{foo}[master]", TargetSelector { raw: "{foo}[master]".to_string(), git_range: Some(GitRange { from_ref: Some("master".to_string()), to_ref: None, include_uncommitted: true, ..Default::default() }), parent_dir: Some(AnchoredSystemPathBuf::try_from("foo").unwrap()), ..Default::default() }; "{foo}[master]")]
     #[test_case("pattern{foo}[master]", TargetSelector { raw: "pattern{foo}[master]".to_string(), git_range: Some(GitRange { from_ref: Some("master".to_string()), to_ref: None, include_uncommitted: true, ..Default::default() }), parent_dir: Some(AnchoredSystemPathBuf::try_from("foo").unwrap()), name_pattern: "pattern".to_string(), ..Default::default() }; "pattern{foo}[master]")]
     #[test_case("[master]...", TargetSelector { raw: "[master]...".to_string(), git_range: Some(GitRange { from_ref: Some("master".to_string()), to_ref: None, include_uncommitted: true, ..Default::default() }), include_dependencies: true, ..Default::default() }; "square brackets master dot dot dot")]
@@ -316,7 +331,10 @@ mod test {
     #[test_case("{}" ; "curly brackets")]
     #[test_case("......[master]" ; "......[master]")]
     #[test_case("[]" ; "empty git range")]
+    #[test_case("[..some-ref]" ; "missing two-dot git range start")]
+    #[test_case("[some-ref..]" ; "missing two-dot git range end")]
     #[test_case("[...some-ref]" ; "missing git range start")]
+    #[test_case("[..]" ; "missing entire two-dot git range")]
     #[test_case("[some-ref...]" ; "missing git range end")]
     #[test_case("[...]" ; "missing entire git range")]
     fn parse_target_selector_invalid(raw_selector: &str) {


Test patch:
diff --git a/crates/turborepo/tests/filter_run_test.rs b/crates/turborepo/tests/filter_run_test.rs
index 195f7839c7951..0440d8d19854f 100644
--- a/crates/turborepo/tests/filter_run_test.rs
+++ b/crates/turborepo/tests/filter_run_test.rs
@@ -77,6 +77,38 @@ fn test_filter_git_range_committed_change() {
     );
 }
 
+#[test]
+fn test_filter_git_range_two_dot_committed_change() {
+    let tempdir = tempfile::tempdir().unwrap();
+    setup::setup_integration_test(tempdir.path(), "basic_monorepo", "npm@10.5.0", true).unwrap();
+
+    let foo_path = tempdir.path().join("foo.txt");
+    let mut contents = fs::read_to_string(&foo_path).unwrap_or_default();
+    contents.push_str("\nglobal dependency");
+    fs::write(&foo_path, contents).unwrap();
+    git(
+        tempdir.path(),
+        &["commit", "-am", "global dependency change", "--quiet"],
+    );
+
+    let output = run_turbo(
+        tempdir.path(),
+        &[
+            "run",
+            "build",
+            "--filter=[HEAD^..HEAD]",
+            "--output-logs",
+            "none",
+        ],
+    );
+    assert!(output.status.success());
+    let stdout = String::from_utf8_lossy(&output.stdout);
+    assert!(
+        stdout.contains("2 successful, 2 total"),
+        "all packages should rebuild after global dep change: {stdout}"
+    );
+}
+
 #[test]
 fn test_filter_nonexistent_package_errors() {
     let tempdir = tempfile::tempdir().unwrap();