Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
45a26a1
feat: Add native Go task tables
cursoragent Sep 6, 2026
0b5ac94
style: Format native Go task tests
cursoragent Sep 6, 2026
e320145
test: Assert Go verification aggregation
cursoragent Sep 6, 2026
9523d66
fix: Expose Go vet as lint task
cursoragent Sep 6, 2026
6d1997f
test: Check removed Go vet task key
cursoragent Sep 6, 2026
d469b73
fix: Remove native Go run task
cursoragent Sep 6, 2026
2d92278
test: Match Go dev dry-run command summary
cursoragent Sep 6, 2026
e4da3b3
test: Make Go task paths portable
cursoragent Sep 7, 2026
8c685e0
feat: Add Go task input and output contracts
cursoragent Sep 7, 2026
5567758
Merge remote-tracking branch 'origin/shew/go-native-task-tables-17d1'…
cursoragent Sep 7, 2026
8ad3770
fix: Wire Go commands to derived task contracts
cursoragent Sep 7, 2026
48970a3
test: Cover native Go execution end to end
cursoragent Sep 7, 2026
07d253c
chore: Merge main into Go task contracts
cursoragent Sep 7, 2026
70232e1
test: Isolate Go cache across platforms
cursoragent Sep 7, 2026
cdac578
test: Make Go contract paths portable
cursoragent Sep 7, 2026
f405861
Merge remote-tracking branch 'origin/shew/go-task-contracts-17d1' int…
cursoragent Sep 7, 2026
75d9d69
fix: Preserve Go cache environment
cursoragent Sep 7, 2026
4e83740
test: Check projected Go cache environment
cursoragent Sep 7, 2026
ab34510
test: Cover Windows Go executable contracts
cursoragent Sep 7, 2026
747349e
chore: Merge repaired Go contracts into native e2e
cursoragent Sep 7, 2026
f8faee0
chore: Merge main into native Go e2e
cursoragent Sep 7, 2026
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
6 changes: 6 additions & 0 deletions crates/turborepo-repository/src/go.rs
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,10 @@ pub const HASHED_ENV_VARS: &[&str] = &[
"PKG_CONFIG",
];

/// Machine-local Go variables required by task execution but excluded from
/// task hashes.
pub(crate) const PROJECTED_ONLY_ENV_VARS: &[&str] = &["GOCACHE"];

#[derive(Debug, Clone, PartialEq, Eq)]
enum GoContractKind {
Module { output_name: Option<String> },
Expand Down Expand Up @@ -1337,6 +1341,7 @@ mod tests {
crate::package_graph::PackageTaskContextKind::Package,
crate::task_contracts::ScopeTaskContract::go(executable_contract.clone()),
);
assert!(package.task_contract().env_vars().contains(&"GOCACHE"));
let dependency = task_context(
&root,
&library.module_path,
Expand All @@ -1363,6 +1368,7 @@ mod tests {
assert!(executable_io.input_globs.iter().any(|glob| glob == input));
}
assert!(executable_io.env.contains(&"GOOS".to_string()));
assert!(!executable_io.env.contains(&"GOCACHE".to_string()));
assert!(!executable_io.env.contains(&"GOPROXY".to_string()));
assert_eq!(
executable_io.outputs,
Expand Down
4 changes: 3 additions & 1 deletion crates/turborepo-repository/src/task_contracts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,14 @@ impl ScopeTaskContract {
/// Go module and workspace scopes with derived native task contracts.
pub(crate) fn go(contract: crate::go::GoTaskContract) -> Self {
let dependency_source_inputs = contract.dependency_source_inputs();
let mut environment_vars = crate::go::HASHED_ENV_VARS.to_vec();
environment_vars.extend(crate::go::PROJECTED_ONLY_ENV_VARS);
Self {
derives_io: true,
defaults: TaskDefaults::default(),
environment: Some(TaskEnvironmentRequirement::new(
TaskEnvironmentDomain(Cow::Borrowed("go-task-io")),
crate::go::HASHED_ENV_VARS.to_vec(),
environment_vars,
)),
toolchain: Some(ToolchainId::GO),
command_map_target: Some(CommandMapTarget::Go),
Expand Down
13 changes: 13 additions & 0 deletions crates/turborepo-task-executor/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ pub struct ToolchainCommandProvider<'a, M = crate::NoMfeConfig> {
cargo_binary: std::sync::OnceLock<Result<std::path::PathBuf, which::Error>>,
/// Lazily resolved uv binary path for Python framing.
uv_binary: std::sync::OnceLock<Result<std::path::PathBuf, which::Error>>,
/// Lazily resolved go binary path for Go framing.
go_binary: std::sync::OnceLock<Result<std::path::PathBuf, which::Error>>,
}

impl<'a, M: MfeConfigProvider> ToolchainCommandProvider<'a, M> {
Expand All @@ -194,6 +196,7 @@ impl<'a, M: MfeConfigProvider> ToolchainCommandProvider<'a, M> {
package_manager_binary: std::sync::OnceLock::new(),
cargo_binary: std::sync::OnceLock::new(),
uv_binary: std::sync::OnceLock::new(),
go_binary: std::sync::OnceLock::new(),
}
}

Expand Down Expand Up @@ -224,6 +227,13 @@ impl<'a, M: MfeConfigProvider> ToolchainCommandProvider<'a, M> {
}
}

fn go_binary(&self) -> Result<Option<&std::path::Path>, CommandProviderError> {
match self.go_binary.get_or_init(|| which::which("go")) {
Ok(path) => Ok(Some(path.as_path())),
Err(_) => Ok(None),
}
}

fn package_context(
&self,
task_id: &TaskId,
Expand Down Expand Up @@ -276,6 +286,9 @@ impl<'a, M: MfeConfigProvider, E: From<CommandProviderError>> CommandProvider<E>
Some(NativeCommandProgram::Tool(tool)) if tool == "uv" => {
(None, self.uv_binary()?)
}
Some(NativeCommandProgram::Tool(tool)) if tool == "go" => {
(None, self.go_binary()?)
}
Some(NativeCommandProgram::Tool(_)) | None => (None, None),
}
};
Expand Down
186 changes: 185 additions & 1 deletion crates/turborepo/tests/go_workspace_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ use std::{fs, path::Path};

use common::setup;

const AMBIENT_GO_ENV: &[&str] = &[
"GO111MODULE",
"GOARCH",
"GOCACHE",
"GOENV",
"GOEXPERIMENT",
"GOFLAGS",
"GOOS",
"GOTOOLCHAIN",
"GOWORK",
];

fn go_available() -> bool {
let available = which::which("go").is_ok();
if !available {
Expand All @@ -30,8 +42,16 @@ fn setup_go_monorepo(dir: &Path) {

fn run_turbo(dir: &Path, args: &[&str]) -> std::process::Output {
let config_dir = tempfile::tempdir().expect("failed to create config tempdir");
let go_cache_dir = tempfile::tempdir().expect("failed to create Go cache tempdir");
let mut command = common::turbo_command(dir);
command.env("TURBO_CONFIG_DIR_PATH", config_dir.path());
for name in AMBIENT_GO_ENV {
command.env_remove(name);
}
command
.env("GOCACHE", go_cache_dir.path())
.env("GOENV", "off")
.env("GOTOOLCHAIN", "local")
.env("TURBO_CONFIG_DIR_PATH", config_dir.path());
command
.args(args)
.output()
Expand Down Expand Up @@ -424,3 +444,167 @@ fn test_go_native_tasks_are_overrideable_and_excludable() {
assert!(!tasks.iter().any(|task| task == "run"), "tasks: {tasks:?}");
assert!(!tasks.iter().any(|task| task == "dev"), "tasks: {tasks:?}");
}

#[test]
fn test_native_go_tasks_execute_cache_restore_and_pass_through_args() {
if !go_available() {
return;
}

let unfiltered = tempfile::tempdir().unwrap();
setup_go_pure_workspace(unfiltered.path());
for task in ["build", "test", "lint"] {
let output = run_turbo(unfiltered.path(), &["run", task, "--log-order=grouped"]);
assert_command_success(&output, &format!("unfiltered Go {task}"));
}
assert!(
unfiltered
.path()
.join("apps/api/dist")
.join(if cfg!(windows) { "api.exe" } else { "api" })
.exists(),
"unfiltered native build must produce the runnable binary"
);

let filtered = tempfile::tempdir().unwrap();
setup_go_pure_workspace(filtered.path());
let build_args = [
"run",
"build",
"--filter=example.com/api",
"--log-order=grouped",
];
let binary = filtered
.path()
.join("apps/api/dist")
.join(if cfg!(windows) { "api.exe" } else { "api" });

let output = run_turbo(filtered.path(), &build_args);
assert_command_success(&output, "cold filtered Go build");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("cache miss"),
"expected cache miss: {stdout}"
);
assert!(binary.exists(), "native build must produce {binary:?}");

let output = run_turbo(filtered.path(), &build_args);
assert_command_success(&output, "warm filtered Go build");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("FULL TURBO"),
"second build must hit cache: {stdout}"
);

fs::remove_dir_all(binary.parent().expect("binary output directory")).unwrap();
let output = run_turbo(filtered.path(), &build_args);
assert_command_success(&output, "restored filtered Go build");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("FULL TURBO"),
"restoration must come from cache: {stdout}"
);
assert!(binary.exists(), "cache hit must restore the binary");

let output = run_turbo(
filtered.path(),
&[
"run",
"dev",
"--filter=example.com/api",
"--",
"passed-to-go",
],
);
assert_command_success(&output, "native Go dev with pass-through argument");
assert!(
String::from_utf8_lossy(&output.stdout).contains("passed-to-go"),
"go run must receive pass-through arguments: {output:?}"
);
}

#[test]
fn test_go_format_override_exclusion_and_failure_propagation() {
if !go_available() {
return;
}

let tempdir = tempfile::tempdir().unwrap();
setup_go_pure_workspace(tempdir.path());
let library = tempdir.path().join("packages/lib/lib.go");
fs::write(&library, "package lib\nfunc Greet( ){ }\n").unwrap();
let output = run_turbo(
tempdir.path(),
&["run", "format", "--filter=example.com/lib"],
);
assert_command_success(&output, "filtered native Go format");
assert_eq!(
fs::read_to_string(&library).unwrap(),
"package lib\n\nfunc Greet() {}\n"
);

fs::write(
tempdir.path().join("turbo.json"),
r#"{
"$schema": "https://turborepo.dev/schema.json",
"futureFlags": {
"experimentalGoWorkspaces": true,
"experimentalTaskCommand": true
},
"tasks": {
"build": { "command": { "go": ["go", "version"] } }
}
}"#,
)
.unwrap();
let output = run_turbo(
tempdir.path(),
&["run", "build", "--filter=example.com/api"],
);
assert_command_success(&output, "authored Go build override");
assert!(
String::from_utf8_lossy(&output.stdout).contains("go version go"),
"the authored command must execute: {output:?}"
);
assert!(
!tempdir.path().join("apps/api/dist").exists(),
"the native build must not shadow the authored command"
);

fs::write(
tempdir.path().join("apps/api/turbo.json"),
r#"{
"extends": ["//"],
"tasks": {
"build": { "extends": false }
}
}"#,
)
.unwrap();
let tasks = package_task_names(tempdir.path(), "example.com/api");
assert!(
!tasks.iter().any(|task| task == "build"),
"package task exclusion must remove the inherited command: {tasks:?}"
);

fs::write(
tempdir.path().join("packages/lib/lib_test.go"),
"package lib\n\nimport \"testing\"\n\nfunc TestFailure(t *testing.T) { \
t.Fatal(\"intentional failure\") }\n",
)
.unwrap();
let output = run_turbo(tempdir.path(), &["run", "test", "--filter=example.com/lib"]);
assert!(
!output.status.success(),
"a failing Go test must fail the Turbo task"
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
combined.contains("intentional failure"),
"Go failure output must propagate: {combined}"
);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
{
"$schema": "https://turborepo.dev/schema.json",
"futureFlags": { "experimentalGoWorkspaces": true },
"futureFlags": {
"experimentalGoWorkspaces": true,
"experimentalTaskCommand": true
},
"tasks": {
"build": {
"outputs": ["dist/**"],
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.turbo
dist
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
package main

import "example.com/lib"
import (
"fmt"
"os"

"example.com/lib"
)

func main() {
lib.Greet()
if len(os.Args) > 1 {
fmt.Println(os.Args[1])
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
{
"$schema": "https://turborepo.dev/schema.json",
"futureFlags": { "experimentalGoWorkspaces": true },
"futureFlags": {
"experimentalGoWorkspaces": true,
"experimentalTaskCommand": true
},
"tasks": {
"build": {
"dependsOn": ["^build"]
Expand Down
Loading