@@ -12,6 +12,7 @@ use std::{
1212} ;
1313
1414use futures_util:: FutureExt ;
15+ use petgraph:: Direction ;
1516use rustc_hash:: FxHashMap ;
1617use vite_path:: { AbsolutePath , AbsolutePathBuf , RelativePathBuf , relative:: InvalidPathDataError } ;
1718use vite_shell:: try_parse_as_and_list;
@@ -22,6 +23,7 @@ use vite_task_graph::{
2223 CacheConfig , ResolvedGlobalCacheConfig , ResolvedTaskOptions ,
2324 user:: { UserCacheConfig , UserTaskOptions } ,
2425 } ,
26+ query:: TaskQuery ,
2527} ;
2628
2729use crate :: {
@@ -34,7 +36,8 @@ use crate::{
3436 in_process:: InProcessExecution ,
3537 path_env:: get_path_env,
3638 plan_request:: {
37- CacheOverride , PlanRequest , QueryPlanRequest , ScriptCommand , SyntheticPlanRequest ,
39+ CacheOverride , PlanOptions , PlanRequest , QueryPlanRequest , ScriptCommand ,
40+ SyntheticPlanRequest ,
3841 } ,
3942 resolve_cache_with_override,
4043} ;
@@ -197,17 +200,27 @@ async fn plan_task_as_execution_node(
197200 let execution_item_kind: ExecutionItemKind = match plan_request {
198201 // Expand task query like `vp run -r build`
199202 Some ( PlanRequest :: Query ( query_plan_request) ) => {
203+ // Rule 1: skip if this nested query is the same as the parent expansion.
204+ // This handles workspace root tasks like `"build": "vp run -r build"` —
205+ // re-entering the same query would just re-expand the same tasks.
206+ if query_plan_request. query == * context. parent_query ( ) {
207+ continue ;
208+ }
209+
200210 // Save task name before consuming the request
201211 let task_name = query_plan_request. query . task_name . clone ( ) ;
202212 // Add prefix envs to the context
203213 context. add_envs ( and_item. envs . iter ( ) ) ;
204- let execution_graph = plan_query_request ( query_plan_request, context)
205- . await
206- . map_err ( |error| Error :: NestPlan {
207- task_display : task_node. task_display . clone ( ) ,
208- command : Str :: from ( & command_str[ add_item_span. clone ( ) ] ) ,
209- error : Box :: new ( error) ,
210- } ) ?;
214+ let QueryPlanRequest { query, plan_options } = query_plan_request;
215+ let query = Arc :: new ( query) ;
216+ let execution_graph =
217+ plan_query_request ( Arc :: clone ( & query) , plan_options, context)
218+ . await
219+ . map_err ( |error| Error :: NestPlan {
220+ task_display : task_node. task_display . clone ( ) ,
221+ command : Str :: from ( & command_str[ add_item_span. clone ( ) ] ) ,
222+ error : Box :: new ( error) ,
223+ } ) ?;
211224 // An empty execution graph means no tasks matched the query.
212225 // At the top level the session shows the task selector UI,
213226 // but in a nested context there is no UI — propagate as an error.
@@ -552,17 +565,24 @@ fn plan_spawn_execution(
552565///
553566/// Builds a `DiGraph` of task executions, then validates it is acyclic via
554567/// `ExecutionGraph::try_from_graph`. Returns `CycleDependencyDetected` if a cycle is found.
568+ ///
569+ /// **Rule 2 (prune self):** If the expanding task (the task whose command triggered
570+ /// this nested query) appears in the expansion result, it is pruned from the graph
571+ /// and its predecessors are wired directly to its successors. This prevents
572+ /// workspace root tasks like `"build": "vp run build"` from infinitely re-expanding
573+ /// themselves when a different query reaches them.
555574#[ expect( clippy:: future_not_send, reason = "PlanContext contains !Send dyn PlanRequestParser" ) ]
556575pub async fn plan_query_request (
557- query_plan_request : QueryPlanRequest ,
576+ query : Arc < TaskQuery > ,
577+ plan_options : PlanOptions ,
558578 mut context : PlanContext < ' _ > ,
559579) -> Result < ExecutionGraph , Error > {
560580 // Apply cache override from `--cache` / `--no-cache` flags on this request.
561581 //
562582 // When `None`, we skip the update so the context keeps whatever the parent
563583 // resolved — this is how `vp run --cache outer` propagates to a nested
564584 // `vp run inner` that has no flags of its own.
565- let cache_override = query_plan_request . plan_options . cache_override ;
585+ let cache_override = plan_options. cache_override ;
566586 if cache_override != CacheOverride :: None {
567587 // Override is relative to the *workspace* config, not the parent's
568588 // resolved config. This means `vp run --no-cache outer` where outer
@@ -574,11 +594,13 @@ pub async fn plan_query_request(
574594 ) ;
575595 context. set_resolved_global_cache ( final_cache) ;
576596 }
577- context. set_extra_args ( Arc :: clone ( & query_plan_request. plan_options . extra_args ) ) ;
597+ context. set_extra_args ( plan_options. extra_args ) ;
598+ context. set_parent_query ( Arc :: clone ( & query) ) ;
599+
578600 // Query matching tasks from the task graph.
579601 // An empty graph means no tasks matched; the caller (session) handles
580602 // empty graphs by showing the task selector.
581- let task_query_result = context. indexed_task_graph ( ) . query_tasks ( & query_plan_request . query ) ?;
603+ let task_query_result = context. indexed_task_graph ( ) . query_tasks ( & query) ?;
582604
583605 #[ expect( clippy:: print_stderr, reason = "user-facing warning for typos in --filter" ) ]
584606 for selector in & task_query_result. unmatched_selectors {
@@ -587,6 +609,12 @@ pub async fn plan_query_request(
587609
588610 let task_node_index_graph = task_query_result. execution_graph ;
589611
612+ // Rule 2: if the expanding task appears in the expansion, prune it.
613+ // This handles cases like root `"build": "vp run build"` — the root's build
614+ // task is in the result but expanding it would recurse, so we remove it and
615+ // reconnect its predecessors directly to its successors.
616+ let pruned_task = context. expanding_task ( ) . filter ( |t| task_node_index_graph. contains_node ( * t) ) ;
617+
590618 let mut execution_node_indices_by_task_index =
591619 FxHashMap :: < TaskNodeIndex , ExecutionNodeIndex > :: with_capacity_and_hasher (
592620 task_node_index_graph. node_count ( ) ,
@@ -599,23 +627,48 @@ pub async fn plan_query_request(
599627 task_node_index_graph. edge_count ( ) ,
600628 ) ;
601629
602- // Plan each task node as execution nodes
630+ // Plan each task node as execution nodes, skipping the pruned task
603631 for task_index in task_node_index_graph. nodes ( ) {
632+ if Some ( task_index) == pruned_task {
633+ continue ;
634+ }
604635 let task_execution =
605636 plan_task_as_execution_node ( task_index, context. duplicate ( ) ) . boxed_local ( ) . await ?;
606637 execution_node_indices_by_task_index
607638 . insert ( task_index, inner_graph. add_node ( task_execution) ) ;
608639 }
609640
610- // Add edges between execution nodes according to task dependencies
641+ // Add edges between execution nodes according to task dependencies,
642+ // skipping edges involving the pruned task.
611643 for ( from_task_index, to_task_index, ( ) ) in task_node_index_graph. all_edges ( ) {
644+ if Some ( from_task_index) == pruned_task || Some ( to_task_index) == pruned_task {
645+ continue ;
646+ }
612647 inner_graph. add_edge (
613648 execution_node_indices_by_task_index[ & from_task_index] ,
614649 execution_node_indices_by_task_index[ & to_task_index] ,
615650 ( ) ,
616651 ) ;
617652 }
618653
654+ // Reconnect through the pruned node: wire each predecessor directly to each successor.
655+ if let Some ( pruned) = pruned_task {
656+ let preds: Vec < _ > =
657+ task_node_index_graph. neighbors_directed ( pruned, Direction :: Incoming ) . collect ( ) ;
658+ let succs: Vec < _ > =
659+ task_node_index_graph. neighbors_directed ( pruned, Direction :: Outgoing ) . collect ( ) ;
660+ for & pred in & preds {
661+ for & succ in & succs {
662+ if let ( Some ( & pe) , Some ( & se) ) = (
663+ execution_node_indices_by_task_index. get ( & pred) ,
664+ execution_node_indices_by_task_index. get ( & succ) ,
665+ ) {
666+ inner_graph. add_edge ( pe, se, ( ) ) ;
667+ }
668+ }
669+ }
670+ }
671+
619672 // Validate the graph is acyclic.
620673 // `try_from_graph` performs a DFS; if a cycle is found, it returns
621674 // `CycleError` containing the full cycle path as node indices.
0 commit comments