@@ -209,19 +209,51 @@ def _mark_transitive_staleness(results: list[dict], target_list: list) -> None:
209209 queue .append (dependent )
210210
211211
212+ STATUS_NAMES = ["fresh" , "stale" , "missing" , "error" , "transitive" ]
213+ GROUP_ORDER = ["stale" , "missing" , "transitive" , "error" , "fresh" ]
214+
215+
216+ def _resolve_status_list (value : str | None ) -> set [str ] | None :
217+ """Resolve a comma-separated list of status names (with prefix matching) to a set.
218+
219+ Returns None if value is None/empty. Raises click.BadParameter on ambiguous or unknown prefixes.
220+ """
221+ if not value :
222+ return None
223+ result = set ()
224+ for raw in value .split ("," ):
225+ token = raw .strip ().lower ()
226+ if not token :
227+ continue
228+ matches = [s for s in STATUS_NAMES if s .startswith (token )]
229+ if not matches :
230+ raise click .BadParameter (f"unknown status { token !r} (expected one of { STATUS_NAMES } )" )
231+ if len (matches ) > 1 :
232+ raise click .BadParameter (f"ambiguous status prefix { token !r} : matches { matches } " )
233+ result .add (matches [0 ])
234+ return result
235+
236+
212237@click .command ()
213238@click .argument ("targets" , nargs = - 1 )
214239@click .option ("-d" , "--with-deps" , is_flag = True , default = True , help = "Check upstream dependencies." )
240+ @click .option ("-G" , "--no-group" , is_flag = True , help = "Don't group output by status." )
215241@click .option ("-j" , "--jobs" , type = int , default = None , help = "Number of parallel workers." )
242+ @click .option ("-N" , "--no-transitive" , is_flag = True , help = "Hide transitively stale stages." )
243+ @click .option ("-s" , "--status" , "status_filter" , default = None , help = "Show only these statuses (comma-sep, prefix-matched, e.g. 's,m')." )
216244@click .option ("-v" , "--verbose" , is_flag = True , help = "Show all files including fresh." )
245+ @click .option ("-x" , "--omit" , default = None , help = "Exclude these statuses (comma-sep, prefix-matched, e.g. 'm')." )
217246@click .option ("--json" , "as_json" , is_flag = True , help = "Output results as JSON." )
218- @click .option ("-N" , "--no-transitive" , is_flag = True , help = "Hide transitively stale stages." )
219247@click .option ("-y" , "--yaml" , "as_yaml" , is_flag = True , help = "Output detailed results as YAML (includes before/after hashes)." )
220- def status (targets , with_deps , jobs , verbose , as_json , no_transitive , as_yaml ):
248+ def status (targets , with_deps , no_group , jobs , no_transitive , status_filter , verbose , omit , as_json , as_yaml ):
221249 """Check freshness status of artifacts.
222250
223- By default, only shows stale/missing files (like git status).
224- Use -v/--verbose to show all files including fresh ones.
251+ By default, only shows stale/missing files (like git status), grouped by status.
252+ Use -v/--verbose to also include fresh files.
253+ Use -s/--status to include only specific statuses (e.g. -s stale,missing).
254+ Use -x/--omit to exclude specific statuses (e.g. -x missing).
255+ Status names support prefix matching: 's' → stale, 'm' → missing, etc.
256+ Use -G/--no-group to flatten output (paths sorted, no per-status sections).
225257 Use -y/--yaml for detailed output with before/after hashes for changed deps.
226258
227259 Examples:
@@ -231,11 +263,16 @@ def status(targets, with_deps, jobs, verbose, as_json, no_transitive, as_yaml):
231263 dvx status -j 4 # Use 4 parallel workers
232264 dvx status --json # Output as JSON
233265 dvx status -y # Detailed YAML with hashes
266+ dvx status -x m # Hide missing files
267+ dvx status -s s,t # Show only stale and transitive
234268 """
235269 import json as json_module
236270 from concurrent .futures import ThreadPoolExecutor , as_completed
237271 from functools import partial
238272
273+ include = _resolve_status_list (status_filter )
274+ exclude = _resolve_status_list (omit ) or set ()
275+
239276 # Find targets - expand directories to .dvc files
240277 if targets :
241278 target_list = _expand_targets (targets )
@@ -270,50 +307,78 @@ def status(targets, with_deps, jobs, verbose, as_json, no_transitive, as_yaml):
270307 _mark_transitive_staleness (results , target_list )
271308
272309 results .sort (key = lambda r : r ["path" ])
273- transitive_count = sum (1 for r in results if r ["status" ] == "transitive" )
274- stale_count = sum (1 for r in results if r ["status" ] == "stale" )
275- missing_count = sum (1 for r in results if r ["status" ] == "missing" )
276- fresh_count = sum (1 for r in results if r ["status" ] == "fresh" )
277- error_count = sum (1 for r in results if r ["status" ] == "error" )
310+
311+ # Counts from the full, unfiltered set (for the summary line)
312+ counts = {s : sum (1 for r in results if r ["status" ] == s ) for s in STATUS_NAMES }
313+
314+ # Compute the visible set. Precedence: -s overrides default; -v adds fresh to default;
315+ # -x always subtracts.
316+ if include is not None :
317+ visible = set (include )
318+ else :
319+ visible = set (STATUS_NAMES ) if verbose else {"stale" , "missing" , "error" , "transitive" }
320+ visible -= exclude
321+
322+ filtered = [r for r in results if r ["status" ] in visible ]
278323
279324 if as_yaml :
280325 import yaml
281- # Filter to non-fresh unless verbose
282- if not verbose :
283- results = [r for r in results if r ["status" ] != "fresh" ]
284- # Convert to dict keyed by path for nicer YAML
285326 yaml_data = {}
286- for r in results :
327+ for r in filtered :
287328 path = r .pop ("path" )
288- # Remove None values for cleaner output
289329 yaml_data [path ] = {k : v for k , v in r .items () if v is not None }
290330 click .echo (yaml .dump (yaml_data , default_flow_style = False , sort_keys = False ))
291- elif as_json :
292- click .echo (json_module .dumps (results , indent = 2 ))
331+ return
332+
333+ if as_json :
334+ click .echo (json_module .dumps (filtered , indent = 2 ))
335+ return
336+
337+ status_style = {
338+ "fresh" : ("✓" , "green" ),
339+ "stale" : ("✗" , "red" ),
340+ "missing" : ("?" , "magenta" ),
341+ "error" : ("!" , "red" ),
342+ "transitive" : ("⚠" , "yellow" ),
343+ }
344+
345+ def _render (r ):
346+ icon , color = status_style .get (r ["status" ], ("?" , "red" ))
347+ styled_icon = click .style (icon , fg = color )
348+ line = f"{ styled_icon } { r ['path' ]} "
349+ if r .get ("reason" ):
350+ line += click .style (f" ({ r ['reason' ]} )" , fg = "bright_black" )
351+ return line
352+
353+ if no_group :
354+ for r in filtered :
355+ click .echo (_render (r ))
293356 else :
294- # By default, only show non-fresh files (like git status)
295- status_style = {
296- "fresh" : ("✓" , "green" ),
297- "stale" : ("✗" , "red" ),
298- "missing" : ("?" , "magenta" ),
299- "error" : ("!" , "red" ),
300- "transitive" : ("⚠" , "yellow" ),
301- }
302- for r in results :
303- if r ["status" ] == "fresh" and not verbose :
357+ first = True
358+ for s in GROUP_ORDER :
359+ if s not in visible :
360+ continue
361+ group = [r for r in filtered if r ["status" ] == s ]
362+ if not group :
304363 continue
305- icon , color = status_style .get (r ["status" ], ("?" , "red" ))
306- styled_icon = click .style (icon , fg = color )
307- line = f"{ styled_icon } { r ['path' ]} "
308- if r .get ("reason" ):
309- line += click .style (f" ({ r ['reason' ]} )" , fg = "bright_black" )
310- click .echo (line )
311-
312- # Summary line
313- parts = [f"Fresh: { fresh_count } " , f"Stale: { stale_count } " ]
314- if transitive_count :
315- parts .append (f"Transitively stale: { transitive_count } " )
316- click .echo (f"\n { ', ' .join (parts )} " )
364+ if not first :
365+ click .echo ()
366+ first = False
367+ _ , color = status_style [s ]
368+ header = click .style (f"{ s .capitalize ()} ({ len (group )} ):" , fg = color , bold = True )
369+ click .echo (header )
370+ for r in group :
371+ click .echo (f" { _render (r )} " )
372+
373+ # Summary line (always reflects the full set, not filtered)
374+ parts = [f"Fresh: { counts ['fresh' ]} " , f"Stale: { counts ['stale' ]} " ]
375+ if counts ["missing" ]:
376+ parts .append (f"Missing: { counts ['missing' ]} " )
377+ if counts ["transitive" ]:
378+ parts .append (f"Transitively stale: { counts ['transitive' ]} " )
379+ if counts ["error" ]:
380+ parts .append (f"Error: { counts ['error' ]} " )
381+ click .echo (f"\n { ', ' .join (parts )} " )
317382
318383
319384# Export the command
0 commit comments