Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
93 changes: 70 additions & 23 deletions src/cli/cmd_build.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string> const& scopes
) -> std::set<std::string>
) -> std::set<pup::NodeId>
{
if (scopes.empty()) {
return {};
}

auto upstream = std::set<std::string> {};
auto visited = std::set<pup::NodeId> {};
auto stack = std::vector<pup::NodeId> {};

// 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;
Expand All @@ -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);
}
Expand All @@ -171,7 +172,6 @@ auto collect_upstream_files(
}
}

// Walk upstream iteratively
while (!stack.empty()) {
auto id = stack.back();
stack.pop_back();
Expand All @@ -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);
}
Expand All @@ -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<std::string> const& scopes
) -> std::set<std::string>
{
auto upstream = std::set<std::string> {};
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<std::string> const& scopes
) -> std::set<pup::NodeId>
{
auto commands = std::set<pup::NodeId> {};
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,
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
Expand Down Expand Up @@ -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<std::string> {} : 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<std::string> {}
: scopes;

auto ctx_opts = BuildContextOptions {
.verbose = opts.verbose,
Expand Down Expand Up @@ -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<pup::exec::BuildStats> {};

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;
Expand Down
1 change: 1 addition & 0 deletions src/exec/scheduler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,7 @@ auto Scheduler::build_job_list(
return make_error<std::vector<BuildJob>>(
ErrorCode::ParseError,
"Missing input file (unresolved ghost): " + path
+ "\n Hint: try building with -a to include upstream dependencies"
);
}
}
Expand Down
1 change: 1 addition & 0 deletions test/e2e/fixtures/cross_dir_scoped_alldeps/Tupfile.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
: main.txt ../shared/lib.dat |> cat %f > %o |> result.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
world
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
: input.txt |> cat %f > %o |> ../shared/lib.dat
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
hello
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
: other.txt |> cat %f > %o |> stuff.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
other
94 changes: 94 additions & 0 deletions test/unit/test_e2e.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
// =============================================================================
Expand Down
Loading