diff --git a/src/cli/cmd_build.cpp b/src/cli/cmd_build.cpp index 9a21359..4c18148 100644 --- a/src/cli/cmd_build.cpp +++ b/src/cli/cmd_build.cpp @@ -133,22 +133,21 @@ auto is_tupfile(std::string_view path) -> bool || path.ends_with("/tup.config") || path == "tup.config"; } -/// Collect all upstream input files for commands in the given scopes. -/// Walks backwards through the DAG from commands to their transitive inputs. -auto collect_upstream_files( +/// Walk backward through the DAG from commands in scope, returning all +/// reachable nodes (the transitive upstream closure). +auto walk_upstream_from_scope( pup::graph::BuildGraph const& graph, std::vector const& scopes -) -> std::set +) -> std::set { if (scopes.empty()) { return {}; } - auto upstream = std::set {}; auto visited = std::set {}; auto stack = std::vector {}; - // Find commands in scope and seed the stack with their inputs + // Seed with commands whose source_dir is in scope for (auto id : graph.all_nodes()) { if (!pup::node_id::is_command(id)) { continue; @@ -163,6 +162,8 @@ auto collect_upstream_files( continue; } + visited.insert(id); + for (auto input_id : graph.get_inputs(id)) { stack.push_back(input_id); } @@ -171,7 +172,6 @@ auto collect_upstream_files( } } - // Walk upstream iteratively while (!stack.empty()) { auto id = stack.back(); stack.pop_back(); @@ -180,16 +180,6 @@ auto collect_upstream_files( continue; } - if (!pup::node_id::is_command(id)) { - auto const* node = graph.get_file_node(id); - if (node && (node->type == pup::NodeType::File || node->type == pup::NodeType::Generated)) { - auto path = graph.get_full_path(id); - if (!path.empty()) { - upstream.insert(path); - } - } - } - for (auto input_id : graph.get_inputs(id)) { stack.push_back(input_id); } @@ -198,9 +188,46 @@ auto collect_upstream_files( } } + return visited; +} + +/// Collect all upstream input file paths for commands in the given scopes. +auto collect_upstream_files( + pup::graph::BuildGraph const& graph, + std::vector const& scopes +) -> std::set +{ + auto upstream = std::set {}; + for (auto id : walk_upstream_from_scope(graph, scopes)) { + if (pup::node_id::is_command(id)) { + continue; + } + auto const* node = graph.get_file_node(id); + if (node && (node->type == pup::NodeType::File || node->type == pup::NodeType::Generated)) { + auto path = graph.get_full_path(id); + if (!path.empty()) { + upstream.insert(path); + } + } + } return upstream; } +/// Collect commands in scope plus all transitive upstream producer commands. +auto collect_scope_with_upstream_commands( + pup::graph::BuildGraph const& graph, + std::vector const& scopes +) -> std::set +{ + auto commands = std::set {}; + for (auto id : walk_upstream_from_scope(graph, scopes)) { + if (pup::node_id::is_command(id) && graph.get_command_node(id)) { + commands.insert(id); + } + } + return commands; +} + auto find_changed_files_with_implicit( std::filesystem::path const& source_root, pup::index::Index const& old_index, @@ -895,11 +922,13 @@ auto remove_stale_outputs( /// Build mode precedence (highest to lowest): /// 1. Incremental - if old index exists and files changed -/// 2. Targets - if specific output targets requested (and not incremental) -/// 3. Subset - exclude config commands from full build -/// 4. Full - build everything +/// 2. ScopeWithUpstream - fresh build with -a and explicit targets +/// 3. Targets - if specific output targets requested (and not incremental) +/// 4. Subset - exclude config commands from full build +/// 5. Full - build everything enum class BuildMode { Incremental, + ScopeWithUpstream, Targets, Subset, Full, @@ -908,12 +937,16 @@ enum class BuildMode { auto determine_build_mode( bool has_targets, bool use_incremental, - bool has_config_cmds + bool has_config_cmds, + bool scope_with_upstream ) -> BuildMode { if (use_incremental) { return BuildMode::Incremental; } + if (scope_with_upstream) { + return BuildMode::ScopeWithUpstream; + } if (has_targets) { return BuildMode::Targets; } @@ -946,7 +979,11 @@ auto build_single_variant( // Only scope parsing when explicit targets are given. // CWD-derived scoping should still parse all Tupfiles so that // out-of-scope Tupfile changes are detected for incremental builds. - auto parse_scopes = opts.targets.empty() ? std::vector {} : scopes; + // When -a is set, always parse all Tupfiles so that cross-directory + // producers are discovered and ghost nodes get resolved. + auto parse_scopes = (opts.targets.empty() || opts.include_all_deps) + ? std::vector {} + : scopes; auto ctx_opts = BuildContextOptions { .verbose = opts.verbose, @@ -1190,16 +1227,26 @@ auto build_single_variant( auto start = std::chrono::steady_clock::time_point { std::chrono::steady_clock::now() }; auto build_result = pup::Result {}; + auto scope_with_upstream = opts.include_all_deps && !scopes.empty() && !use_incremental; auto mode = determine_build_mode( !target_node_ids.empty(), use_incremental, - !config_cmd_ids.empty() + !config_cmd_ids.empty(), + scope_with_upstream ); switch (mode) { case BuildMode::Incremental: build_result = scheduler.build_incremental(ctx.graph(), changed_files); break; + case BuildMode::ScopeWithUpstream: { + auto scope_cmds = collect_scope_with_upstream_commands(ctx.graph(), scopes); + for (auto cfg_id : config_cmd_ids) { + scope_cmds.erase(cfg_id); + } + build_result = scheduler.build_subset(ctx.graph(), scope_cmds); + break; + } case BuildMode::Targets: build_result = scheduler.build_targets(ctx.graph(), target_node_ids); break; diff --git a/src/exec/scheduler.cpp b/src/exec/scheduler.cpp index 5fc1f0c..37ad295 100644 --- a/src/exec/scheduler.cpp +++ b/src/exec/scheduler.cpp @@ -782,6 +782,7 @@ auto Scheduler::build_job_list( return make_error>( ErrorCode::ParseError, "Missing input file (unresolved ghost): " + path + + "\n Hint: try building with -a to include upstream dependencies" ); } } diff --git a/test/e2e/fixtures/cross_dir_scoped_alldeps/Tupfile.ini b/test/e2e/fixtures/cross_dir_scoped_alldeps/Tupfile.ini new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/test/e2e/fixtures/cross_dir_scoped_alldeps/Tupfile.ini @@ -0,0 +1 @@ + diff --git a/test/e2e/fixtures/cross_dir_scoped_alldeps/consumer/Tupfile.fixture b/test/e2e/fixtures/cross_dir_scoped_alldeps/consumer/Tupfile.fixture new file mode 100644 index 0000000..71968da --- /dev/null +++ b/test/e2e/fixtures/cross_dir_scoped_alldeps/consumer/Tupfile.fixture @@ -0,0 +1 @@ +: main.txt ../shared/lib.dat |> cat %f > %o |> result.txt diff --git a/test/e2e/fixtures/cross_dir_scoped_alldeps/consumer/main.txt b/test/e2e/fixtures/cross_dir_scoped_alldeps/consumer/main.txt new file mode 100644 index 0000000..cc628cc --- /dev/null +++ b/test/e2e/fixtures/cross_dir_scoped_alldeps/consumer/main.txt @@ -0,0 +1 @@ +world diff --git a/test/e2e/fixtures/cross_dir_scoped_alldeps/producer/Tupfile.fixture b/test/e2e/fixtures/cross_dir_scoped_alldeps/producer/Tupfile.fixture new file mode 100644 index 0000000..4421d94 --- /dev/null +++ b/test/e2e/fixtures/cross_dir_scoped_alldeps/producer/Tupfile.fixture @@ -0,0 +1 @@ +: input.txt |> cat %f > %o |> ../shared/lib.dat diff --git a/test/e2e/fixtures/cross_dir_scoped_alldeps/producer/input.txt b/test/e2e/fixtures/cross_dir_scoped_alldeps/producer/input.txt new file mode 100644 index 0000000..ce01362 --- /dev/null +++ b/test/e2e/fixtures/cross_dir_scoped_alldeps/producer/input.txt @@ -0,0 +1 @@ +hello diff --git a/test/e2e/fixtures/cross_dir_scoped_alldeps/shared/.gitkeep b/test/e2e/fixtures/cross_dir_scoped_alldeps/shared/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/e2e/fixtures/cross_dir_scoped_alldeps/unrelated/Tupfile.fixture b/test/e2e/fixtures/cross_dir_scoped_alldeps/unrelated/Tupfile.fixture new file mode 100644 index 0000000..47caa7d --- /dev/null +++ b/test/e2e/fixtures/cross_dir_scoped_alldeps/unrelated/Tupfile.fixture @@ -0,0 +1 @@ +: other.txt |> cat %f > %o |> stuff.txt diff --git a/test/e2e/fixtures/cross_dir_scoped_alldeps/unrelated/other.txt b/test/e2e/fixtures/cross_dir_scoped_alldeps/unrelated/other.txt new file mode 100644 index 0000000..e45c9c2 --- /dev/null +++ b/test/e2e/fixtures/cross_dir_scoped_alldeps/unrelated/other.txt @@ -0,0 +1 @@ +other diff --git a/test/unit/test_e2e.cpp b/test/unit/test_e2e.cpp index 5e0ecc6..39f4931 100644 --- a/test/unit/test_e2e.cpp +++ b/test/unit/test_e2e.cpp @@ -1345,6 +1345,100 @@ SCENARIO("Scoped build with -a checks upstream deps (mma behavior)", "[e2e][incr } } +// ============================================================================= +// Cross-Directory Scoped Build with -a (all-deps) Tests +// ============================================================================= + +SCENARIO("Fresh scoped build with -a succeeds for cross-directory deps", "[e2e][scope]") +{ + GIVEN("a project with producer/consumer cross-directory dependencies") + { + auto f = E2EFixture { "cross_dir_scoped_alldeps" }; + REQUIRE(f.init().success()); + + WHEN("consumer/ is built with -a on a fresh build (no index)") + { + auto result = f.build({ "-a", "consumer/" }); + + THEN("the build succeeds and both producer and consumer outputs exist") + { + INFO("stdout: " << result.stdout_output); + INFO("stderr: " << result.stderr_output); + REQUIRE(result.success()); + REQUIRE(f.exists("shared/lib.dat")); + REQUIRE(f.exists("consumer/result.txt")); + } + } + } +} + +SCENARIO("Fresh scoped build WITHOUT -a fails for cross-directory deps", "[e2e][scope]") +{ + GIVEN("a project with producer/consumer cross-directory dependencies") + { + auto f = E2EFixture { "cross_dir_scoped_alldeps" }; + REQUIRE(f.init().success()); + + WHEN("consumer/ is built without -a on a fresh build") + { + auto result = f.build({ "consumer/" }); + + THEN("the build fails with an unresolved ghost error") + { + INFO("stdout: " << result.stdout_output); + INFO("stderr: " << result.stderr_output); + REQUIRE_FALSE(result.success()); + auto combined = result.stdout_output + result.stderr_output; + REQUIRE(combined.find("unresolved ghost") != std::string::npos); + } + } + } +} + +SCENARIO("Fresh scoped build with -a does NOT build unrelated dirs", "[e2e][scope]") +{ + GIVEN("a project with producer, consumer, and unrelated directories") + { + auto f = E2EFixture { "cross_dir_scoped_alldeps" }; + REQUIRE(f.init().success()); + + WHEN("consumer/ is built with -a") + { + auto result = f.build({ "-a", "consumer/" }); + + THEN("unrelated/stuff.txt is NOT built") + { + REQUIRE(result.success()); + REQUIRE_FALSE(f.exists("unrelated/stuff.txt")); + } + } + } +} + +SCENARIO("Incremental -a scoped build detects upstream changes", "[e2e][incremental][scope]") +{ + GIVEN("a fully built project with shared include directory") + { + auto f = E2EFixture { "scoped_upstream" }; + REQUIRE(f.init().success()); + REQUIRE(f.build().success()); + + WHEN("an upstream header is modified and scoped build runs with -a and explicit target") + { + f.write_file("include/header.h", "#define VALUE 100\n"); + auto result = f.build({ "-a", "lib" }); + + THEN("the scoped module is rebuilt") + { + INFO("stdout: " << result.stdout_output); + INFO("stderr: " << result.stderr_output); + REQUIRE(result.success()); + REQUIRE_FALSE(result.is_noop()); + } + } + } +} + // ============================================================================= // Clean/Distclean Tests // =============================================================================