1use aube_manifest::{PackageJson, WorkspaceConfig};
20use std::collections::{BTreeMap, BTreeSet, VecDeque};
21use std::path::{Path, PathBuf};
22use std::process::Command;
23
24#[derive(Debug, Clone, Default, PartialEq, Eq)]
29pub struct EffectiveFilter {
30 pub filters: Vec<String>,
33 pub filter_prods: Vec<String>,
36 pub fail_if_no_match: bool,
40 pub include_workspace_root: bool,
42}
43
44impl EffectiveFilter {
45 pub fn is_empty(&self) -> bool {
46 self.filters.is_empty() && self.filter_prods.is_empty()
47 }
48
49 pub fn from_filters<I>(filters: I) -> Self
52 where
53 I: IntoIterator,
54 I::Item: Into<String>,
55 {
56 Self {
57 filters: filters.into_iter().map(Into::into).collect(),
58 filter_prods: Vec::new(),
59 fail_if_no_match: false,
60 include_workspace_root: false,
61 }
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Selector {
68 base: BaseSelector,
69 include_dependencies: bool,
70 include_dependents: bool,
71 exclude_self: bool,
72 exclude: bool,
73 prod_only: bool,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
78enum BaseSelector {
79 Name(String),
81 NameGlob(String),
83 Path(PathBuf),
86 ChangedSince(String),
88}
89
90impl Selector {
91 pub fn parse(raw: &str) -> Result<Self, ParseError> {
93 Self::parse_kind(raw, false)
94 }
95
96 pub fn parse_prod(raw: &str) -> Result<Self, ParseError> {
99 Self::parse_kind(raw, true)
100 }
101
102 fn parse_kind(raw: &str, prod_only: bool) -> Result<Self, ParseError> {
103 if raw.is_empty() {
104 return Err(ParseError::Empty);
105 }
106 let (exclude, raw) = raw
107 .strip_prefix('!')
108 .map(|s| (true, s))
109 .unwrap_or((false, raw));
110 if raw.is_empty() {
111 return Err(ParseError::Empty);
112 }
113
114 let (include_dependents, raw) = raw
115 .strip_prefix("...")
116 .map(|s| (true, s))
117 .unwrap_or((false, raw));
118 let (exclude_self_from_dependents, raw) = raw
119 .strip_prefix('^')
120 .map(|s| (true, s))
121 .unwrap_or((false, raw));
122 let (include_dependencies, raw) = raw
123 .strip_suffix("...")
124 .map(|s| (true, s))
125 .unwrap_or((false, raw));
126 let (exclude_self_from_dependencies, raw) = raw
127 .strip_suffix('^')
128 .map(|s| (true, s))
129 .unwrap_or((false, raw));
130 if raw.is_empty() {
131 return Err(ParseError::Empty);
132 }
133 let exclude_self = exclude_self_from_dependents || exclude_self_from_dependencies;
134
135 let base = if raw.starts_with('[') && raw.ends_with(']') && raw.len() > 2 {
136 BaseSelector::ChangedSince(raw[1..raw.len() - 1].to_string())
137 }
138 else if raw.starts_with("./")
146 || raw.starts_with("../")
147 || raw.starts_with('/')
148 || raw.ends_with('/')
149 {
150 let trimmed = raw.strip_suffix("/**").unwrap_or(raw).trim_end_matches('/');
151 let normalized = trimmed.strip_prefix("./").unwrap_or(trimmed);
156 BaseSelector::Path(PathBuf::from(normalized))
157 } else
158 if raw.contains('*') || raw.contains('?') {
163 BaseSelector::NameGlob(raw.to_string())
164 } else {
165 BaseSelector::Name(raw.to_string())
166 };
167
168 Ok(Selector {
169 base,
170 include_dependencies,
171 include_dependents,
172 exclude_self,
173 exclude,
174 prod_only,
175 })
176 }
177
178 pub fn matches(&self, pkg: &WorkspacePkg<'_>) -> bool {
180 match &self.base {
181 BaseSelector::Name(n) => pkg.name == Some(n.as_str()),
182 BaseSelector::NameGlob(pat) => match pkg.name {
183 Some(name) => glob_match(pat, name),
184 None => false,
185 },
186 BaseSelector::Path(p) => {
187 let target = pkg.workspace_root.join(p);
188 pkg.dir.starts_with(&target)
192 }
193 BaseSelector::ChangedSince(_) => false,
194 }
195 }
196}
197
198pub struct WorkspacePkg<'a> {
200 pub name: Option<&'a str>,
201 pub dir: &'a Path,
202 pub workspace_root: &'a Path,
203}
204
205#[derive(Debug, thiserror::Error, miette::Diagnostic)]
206pub enum ParseError {
207 #[error("empty --filter selector")]
208 #[diagnostic(code(ERR_AUBE_FILTER_EMPTY))]
209 Empty,
210}
211
212pub fn parse_effective(filter: &EffectiveFilter) -> Result<Vec<Selector>, ParseError> {
216 let mut out = Vec::with_capacity(filter.filters.len() + filter.filter_prods.len());
217 for raw in &filter.filters {
218 out.push(Selector::parse(raw)?);
219 }
220 for raw in &filter.filter_prods {
221 out.push(Selector::parse_prod(raw)?);
222 }
223 Ok(out)
224}
225
226#[derive(Debug, Clone)]
227pub struct SelectedPackage {
228 pub name: Option<String>,
229 pub version: Option<String>,
230 pub private: bool,
231 pub dir: PathBuf,
232 pub manifest: PackageJson,
233}
234
235struct IndexedPackage {
236 selected: SelectedPackage,
237 all_deps: BTreeSet<String>,
240 prod_deps: BTreeSet<String>,
243}
244
245pub fn select_workspace_packages(
246 workspace_root: &Path,
247 workspace_pkgs: &[PathBuf],
248 filter: &EffectiveFilter,
249) -> Result<Vec<SelectedPackage>, SelectError> {
250 let selectors = parse_effective(filter).map_err(SelectError::Parse)?;
251 let packages = index_packages(workspace_pkgs);
252 if selectors.is_empty() {
253 let mut selected: Vec<_> = packages.into_iter().map(|p| p.selected).collect();
254 include_workspace_root(workspace_root, filter, &mut selected)?;
255 return Ok(selected);
256 }
257
258 let has_positive = selectors.iter().any(|s| !s.exclude);
259 let mut included: BTreeSet<usize> = if has_positive {
260 BTreeSet::new()
261 } else {
262 (0..packages.len()).collect()
263 };
264 let mut excluded: BTreeSet<usize> = BTreeSet::new();
265 for selector in &selectors {
266 let matches = expand_selector(workspace_root, &packages, selector)?;
267 if selector.exclude {
268 excluded.extend(matches);
269 } else {
270 included.extend(matches);
271 }
272 }
273 for idx in excluded {
274 included.remove(&idx);
275 }
276
277 let mut selected: Vec<_> = packages
278 .into_iter()
279 .enumerate()
280 .filter_map(|(idx, pkg)| included.contains(&idx).then_some(pkg.selected))
281 .collect();
282 include_workspace_root(workspace_root, filter, &mut selected)?;
283 Ok(selected)
284}
285
286fn index_packages(workspace_pkgs: &[PathBuf]) -> Vec<IndexedPackage> {
287 let mut packages = Vec::new();
288 for dir in workspace_pkgs {
289 let Ok(manifest) = PackageJson::from_path(&dir.join("package.json")) else {
290 continue;
291 };
292 let prod_deps: BTreeSet<String> = manifest
293 .dependencies
294 .keys()
295 .chain(manifest.optional_dependencies.keys())
296 .chain(manifest.peer_dependencies.keys())
297 .cloned()
298 .collect();
299 let all_deps: BTreeSet<String> = prod_deps
300 .iter()
301 .cloned()
302 .chain(manifest.dev_dependencies.keys().cloned())
303 .collect();
304 let private = manifest
305 .extra
306 .get("private")
307 .and_then(|v| v.as_bool())
308 .unwrap_or(false);
309 packages.push(IndexedPackage {
310 selected: SelectedPackage {
311 name: manifest.name.clone(),
312 version: manifest.version.clone(),
313 private,
314 dir: dir.clone(),
315 manifest,
316 },
317 all_deps,
318 prod_deps,
319 });
320 }
321 packages
322}
323
324fn include_workspace_root(
325 workspace_root: &Path,
326 filter: &EffectiveFilter,
327 selected: &mut Vec<SelectedPackage>,
328) -> Result<(), SelectError> {
329 let include_workspace_root = filter.include_workspace_root
330 || WorkspaceConfig::load(workspace_root)
331 .map_err(SelectError::WorkspaceConfig)?
332 .include_workspace_root
333 .unwrap_or(false);
334 if !include_workspace_root || !workspace_root.join("package.json").is_file() {
335 return Ok(());
336 }
337 if selected.iter().any(|pkg| pkg.dir == workspace_root) {
338 return Ok(());
339 }
340
341 let manifest = PackageJson::from_path(&workspace_root.join("package.json"))
342 .map_err(SelectError::Manifest)?;
343 selected.push(SelectedPackage {
344 name: manifest.name.clone(),
345 version: manifest.version.clone(),
346 private: manifest
347 .extra
348 .get("private")
349 .and_then(|v| v.as_bool())
350 .unwrap_or(false),
351 dir: workspace_root.to_path_buf(),
352 manifest,
353 });
354 Ok(())
355}
356
357fn expand_selector(
358 workspace_root: &Path,
359 packages: &[IndexedPackage],
360 selector: &Selector,
361) -> Result<BTreeSet<usize>, SelectError> {
362 let mut seeds = match &selector.base {
363 BaseSelector::ChangedSince(rev) => changed_since(workspace_root, packages, rev)?,
364 _ => packages
365 .iter()
366 .enumerate()
367 .filter_map(|(idx, pkg)| {
368 let view = WorkspacePkg {
369 name: pkg.selected.name.as_deref(),
370 dir: &pkg.selected.dir,
371 workspace_root,
372 };
373 selector.matches(&view).then_some(idx)
374 })
375 .collect(),
376 };
377 let original_seeds = seeds.clone();
378
379 if selector.include_dependencies {
380 seeds.extend(walk_dependencies(
381 packages,
382 &original_seeds,
383 selector.prod_only,
384 ));
385 }
386 if selector.include_dependents {
387 seeds.extend(walk_dependents(
388 packages,
389 &original_seeds,
390 selector.prod_only,
391 ));
392 }
393 if selector.exclude_self {
394 for idx in original_seeds {
395 seeds.remove(&idx);
396 }
397 }
398 Ok(seeds)
399}
400
401fn name_index(packages: &[IndexedPackage]) -> BTreeMap<&str, usize> {
402 packages
403 .iter()
404 .enumerate()
405 .filter_map(|(idx, pkg)| pkg.selected.name.as_deref().map(|n| (n, idx)))
406 .collect()
407}
408
409fn outgoing_deps(pkg: &IndexedPackage, prod_only: bool) -> &BTreeSet<String> {
412 if prod_only {
413 &pkg.prod_deps
414 } else {
415 &pkg.all_deps
416 }
417}
418
419fn walk_dependencies(
420 packages: &[IndexedPackage],
421 seeds: &BTreeSet<usize>,
422 prod_only: bool,
423) -> BTreeSet<usize> {
424 let names = name_index(packages);
425 let mut out = BTreeSet::new();
426 let mut q: VecDeque<usize> = seeds.iter().copied().collect();
427 while let Some(idx) = q.pop_front() {
428 for dep_name in outgoing_deps(&packages[idx], prod_only) {
429 let Some(dep_idx) = names.get(dep_name.as_str()).copied() else {
430 continue;
431 };
432 if out.insert(dep_idx) {
433 q.push_back(dep_idx);
434 }
435 }
436 }
437 out
438}
439
440fn walk_dependents(
441 packages: &[IndexedPackage],
442 seeds: &BTreeSet<usize>,
443 prod_only: bool,
444) -> BTreeSet<usize> {
445 let mut rev_index: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
446 for (idx, pkg) in packages.iter().enumerate() {
447 for dep_name in outgoing_deps(pkg, prod_only) {
448 rev_index.entry(dep_name.as_str()).or_default().push(idx);
449 }
450 }
451 let mut out = BTreeSet::new();
452 let mut q: VecDeque<usize> = seeds.iter().copied().collect();
453 while let Some(idx) = q.pop_front() {
454 let Some(name) = packages[idx].selected.name.as_deref() else {
455 continue;
456 };
457 if let Some(dependents) = rev_index.get(name) {
458 for &dep_idx in dependents {
459 if out.insert(dep_idx) {
460 q.push_back(dep_idx);
461 }
462 }
463 }
464 }
465 out
466}
467
468fn changed_since(
469 workspace_root: &Path,
470 packages: &[IndexedPackage],
471 rev: &str,
472) -> Result<BTreeSet<usize>, SelectError> {
473 if rev.starts_with('-') {
479 return Err(SelectError::GitFailed(format!(
480 "refusing to pass revspec starting with `-` to git: {rev:?}"
481 )));
482 }
483 if rev.contains('\0') {
484 return Err(SelectError::GitFailed(
485 "refusing to pass revspec containing NUL byte to git".to_string(),
486 ));
487 }
488 let revspec = format!("{rev}...HEAD");
489 let git_root = git_root(workspace_root)?;
490 let output = Command::new("git")
491 .arg("diff")
492 .arg("--name-only")
493 .arg(&revspec)
494 .arg("--")
495 .current_dir(&git_root)
496 .output()
497 .map_err(SelectError::GitIo)?;
498 if !output.status.success() {
499 return Err(SelectError::GitFailed(
500 String::from_utf8_lossy(&output.stderr).trim().to_string(),
501 ));
502 }
503 let mut out = BTreeSet::new();
504 for line in String::from_utf8_lossy(&output.stdout).lines() {
505 let changed = git_root.join(line);
506 for (idx, pkg) in packages.iter().enumerate() {
507 if changed.starts_with(&pkg.selected.dir) {
508 out.insert(idx);
509 }
510 }
511 }
512 Ok(out)
513}
514
515fn git_root(workspace_root: &Path) -> Result<PathBuf, SelectError> {
516 let output = Command::new("git")
517 .args(["rev-parse", "--show-toplevel"])
518 .current_dir(workspace_root)
519 .output()
520 .map_err(SelectError::GitIo)?;
521 if !output.status.success() {
522 return Err(SelectError::GitFailed(
523 String::from_utf8_lossy(&output.stderr).trim().to_string(),
524 ));
525 }
526 Ok(PathBuf::from(
527 String::from_utf8_lossy(&output.stdout).trim(),
528 ))
529}
530
531#[derive(Debug, thiserror::Error, miette::Diagnostic)]
532pub enum SelectError {
533 #[error("{0}")]
534 #[diagnostic(transparent)]
535 Parse(#[from] ParseError),
536 #[error("failed to load workspace config: {0}")]
537 WorkspaceConfig(#[source] aube_manifest::Error),
538 #[error("failed to read workspace root package.json: {0}")]
539 Manifest(#[source] aube_manifest::Error),
540 #[error("failed to run git for [ref] filter: {0}")]
541 #[diagnostic(code(ERR_AUBE_FILTER_GIT_IO))]
542 GitIo(std::io::Error),
543 #[error("git [ref] filter failed: {0}")]
544 #[diagnostic(code(ERR_AUBE_FILTER_GIT_FAILED))]
545 GitFailed(String),
546}
547
548fn glob_match(pattern: &str, s: &str) -> bool {
553 let pat: Vec<char> = pattern.chars().collect();
554 let text: Vec<char> = s.chars().collect();
555 fn inner(pat: &[char], text: &[char]) -> bool {
556 let mut pi = 0;
557 let mut ti = 0;
558 let mut star: Option<(usize, usize)> = None;
559 while ti < text.len() {
560 if pi < pat.len() && (pat[pi] == '?' || pat[pi] == text[ti]) {
561 pi += 1;
562 ti += 1;
563 } else if pi < pat.len() && pat[pi] == '*' {
564 star = Some((pi, ti));
565 pi += 1;
566 } else if let Some((sp, st)) = star {
567 pi = sp + 1;
568 ti = st + 1;
569 star = Some((sp, ti));
570 } else {
571 return false;
572 }
573 }
574 while pi < pat.len() && pat[pi] == '*' {
575 pi += 1;
576 }
577 pi == pat.len()
578 }
579 inner(&pat, &text)
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585
586 #[test]
587 fn parse_forms() {
588 assert_eq!(
589 Selector::parse("foo").unwrap(),
590 Selector {
591 base: BaseSelector::Name("foo".into()),
592 include_dependencies: false,
593 include_dependents: false,
594 exclude_self: false,
595 exclude: false,
596 prod_only: false,
597 }
598 );
599 assert_eq!(
600 Selector::parse("@babel/core").unwrap(),
601 Selector {
602 base: BaseSelector::Name("@babel/core".into()),
603 include_dependencies: false,
604 include_dependents: false,
605 exclude_self: false,
606 exclude: false,
607 prod_only: false,
608 }
609 );
610 assert_eq!(
611 Selector::parse("@babel/*").unwrap(),
612 Selector {
613 base: BaseSelector::NameGlob("@babel/*".into()),
614 include_dependencies: false,
615 include_dependents: false,
616 exclude_self: false,
617 exclude: false,
618 prod_only: false,
619 }
620 );
621 assert_eq!(
622 Selector::parse("./packages/a").unwrap(),
623 Selector {
624 base: BaseSelector::Path(PathBuf::from("packages/a")),
625 include_dependencies: false,
626 include_dependents: false,
627 exclude_self: false,
628 exclude: false,
629 prod_only: false,
630 }
631 );
632 assert_eq!(
636 Selector::parse("./packages/**").unwrap(),
637 Selector {
638 base: BaseSelector::Path(PathBuf::from("packages")),
639 include_dependencies: false,
640 include_dependents: false,
641 exclude_self: false,
642 exclude: false,
643 prod_only: false,
644 }
645 );
646 assert_eq!(
650 Selector::parse("@scope/**").unwrap(),
651 Selector {
652 base: BaseSelector::NameGlob("@scope/**".into()),
653 include_dependencies: false,
654 include_dependents: false,
655 exclude_self: false,
656 exclude: false,
657 prod_only: false,
658 }
659 );
660 assert!(Selector::parse("").is_err());
661 }
662
663 #[test]
664 fn parse_prod_sets_prod_only() {
665 let sel = Selector::parse_prod("foo...").unwrap();
666 assert!(sel.prod_only);
667 assert!(sel.include_dependencies);
668 assert_eq!(sel.base, BaseSelector::Name("foo".into()));
669 assert!(!Selector::parse("foo...").unwrap().prod_only);
670 }
671
672 #[test]
673 fn prod_graph_walk_skips_dev_deps() {
674 let mk_pkg = |name: &str, prod: &[&str], dev: &[&str]| -> IndexedPackage {
681 let manifest = aube_manifest::PackageJson {
682 name: Some(name.to_string()),
683 ..aube_manifest::PackageJson::default()
684 };
685 IndexedPackage {
686 selected: SelectedPackage {
687 name: Some(name.to_string()),
688 version: None,
689 private: false,
690 dir: PathBuf::from(format!("/ws/{name}")),
691 manifest,
692 },
693 all_deps: prod
694 .iter()
695 .chain(dev.iter())
696 .map(|s| (*s).to_string())
697 .collect(),
698 prod_deps: prod.iter().map(|s| (*s).to_string()).collect(),
699 }
700 };
701 let packages = vec![
702 mk_pkg("api", &["lib"], &["tooling"]),
703 mk_pkg("lib", &[], &[]),
704 mk_pkg("tooling", &[], &[]),
705 ];
706
707 let mut seeds = BTreeSet::new();
708 seeds.insert(0); let full = walk_dependencies(&packages, &seeds, false);
711 assert_eq!(full, BTreeSet::from([1, 2]));
712
713 let prod = walk_dependencies(&packages, &seeds, true);
714 assert_eq!(prod, BTreeSet::from([1]));
715
716 let mut tool_seeds = BTreeSet::new();
720 tool_seeds.insert(2);
721 let tool_all = walk_dependents(&packages, &tool_seeds, false);
722 assert_eq!(tool_all, BTreeSet::from([0]));
723 let tool_prod = walk_dependents(&packages, &tool_seeds, true);
724 assert!(tool_prod.is_empty());
725 }
726
727 #[test]
728 fn parse_graph_forms() {
729 let deps = Selector::parse("foo...").unwrap();
730 assert_eq!(deps.base, BaseSelector::Name("foo".into()));
731 assert!(deps.include_dependencies);
732 assert!(!deps.include_dependents);
733 assert!(!deps.exclude_self);
734
735 let only_deps = Selector::parse("foo^...").unwrap();
736 assert!(only_deps.include_dependencies);
737 assert!(only_deps.exclude_self);
738
739 let dependents = Selector::parse("...foo").unwrap();
740 assert!(dependents.include_dependents);
741
742 let only_dependents = Selector::parse("...^foo").unwrap();
743 assert!(only_dependents.include_dependents);
744 assert!(only_dependents.exclude_self);
745 }
746
747 #[test]
748 fn glob_matches_names() {
749 assert!(glob_match("@babel/*", "@babel/core"));
750 assert!(glob_match("@babel/*", "@babel/preset-env"));
751 assert!(!glob_match("@babel/*", "@babel-x/core"));
752 assert!(glob_match("foo-*", "foo-bar"));
753 assert!(glob_match("*-plugin", "a-plugin"));
754 assert!(glob_match("*", "anything"));
755 assert!(!glob_match("foo", "foobar"));
756 }
757
758 #[test]
759 fn path_selector_matches_nested() {
760 let root = Path::new("/ws");
761 let sel = Selector::parse("./packages").unwrap();
762 let dir = PathBuf::from("/ws/packages/a");
763 assert!(sel.matches(&WorkspacePkg {
764 name: Some("a"),
765 dir: &dir,
766 workspace_root: root,
767 }));
768 }
769
770 #[test]
771 fn include_workspace_root_adds_root_even_when_filter_matches_nothing() {
772 let dir = tempfile::tempdir().unwrap();
773 std::fs::write(
774 dir.path().join("package.json"),
775 r#"{"name":"root","version":"0.0.0","private":true}"#,
776 )
777 .unwrap();
778 std::fs::write(
779 dir.path().join("pnpm-workspace.yaml"),
780 "packages:\n - packages/*\nincludeWorkspaceRoot: true\n",
781 )
782 .unwrap();
783 std::fs::create_dir_all(dir.path().join("packages/app")).unwrap();
784 std::fs::write(
785 dir.path().join("packages/app/package.json"),
786 r#"{"name":"app","version":"1.0.0"}"#,
787 )
788 .unwrap();
789
790 let selected = select_workspace_packages(
791 dir.path(),
792 &[dir.path().join("packages/app")],
793 &EffectiveFilter::from_filters(["missing"]),
794 )
795 .unwrap();
796
797 assert_eq!(selected.len(), 1);
798 assert_eq!(selected[0].name.as_deref(), Some("root"));
799 assert_eq!(selected[0].dir, dir.path());
800 }
801
802 #[test]
803 fn include_workspace_root_yaml_applies_with_no_selectors() {
804 let dir = tempfile::tempdir().unwrap();
808 std::fs::write(
809 dir.path().join("package.json"),
810 r#"{"name":"root","version":"0.0.0","private":true}"#,
811 )
812 .unwrap();
813 std::fs::write(
814 dir.path().join("pnpm-workspace.yaml"),
815 "packages:\n - packages/*\nincludeWorkspaceRoot: true\n",
816 )
817 .unwrap();
818 std::fs::create_dir_all(dir.path().join("packages/app")).unwrap();
819 std::fs::write(
820 dir.path().join("packages/app/package.json"),
821 r#"{"name":"app","version":"1.0.0"}"#,
822 )
823 .unwrap();
824
825 let selected = select_workspace_packages(
826 dir.path(),
827 &[dir.path().join("packages/app")],
828 &EffectiveFilter::default(),
829 )
830 .unwrap();
831
832 let names: Vec<_> = selected
833 .iter()
834 .filter_map(|pkg| pkg.name.as_deref())
835 .collect();
836 assert_eq!(names, vec!["app", "root"]);
837 }
838
839 #[test]
840 fn include_workspace_root_flag_adds_root_to_regular_matches() {
841 let dir = tempfile::tempdir().unwrap();
842 std::fs::write(
843 dir.path().join("package.json"),
844 r#"{"name":"root","version":"0.0.0","private":true}"#,
845 )
846 .unwrap();
847 std::fs::create_dir_all(dir.path().join("packages/app")).unwrap();
848 std::fs::write(
849 dir.path().join("packages/app/package.json"),
850 r#"{"name":"app","version":"1.0.0"}"#,
851 )
852 .unwrap();
853 let mut filter = EffectiveFilter::from_filters(["app"]);
854 filter.include_workspace_root = true;
855
856 let selected =
857 select_workspace_packages(dir.path(), &[dir.path().join("packages/app")], &filter)
858 .unwrap();
859 let names: Vec<_> = selected
860 .iter()
861 .filter_map(|pkg| pkg.name.as_deref())
862 .collect();
863
864 assert_eq!(names, vec!["app", "root"]);
865 }
866
867 #[test]
868 fn changed_since_rejects_dash_prefixed_rev() {
869 let err =
876 changed_since(Path::new("/nonexistent"), &[], "--upload-pack=/tmp/evil").unwrap_err();
877 let msg = match err {
878 SelectError::GitFailed(m) => m,
879 other => panic!("expected GitFailed, got {other:?}"),
880 };
881 assert!(msg.contains("refusing"), "unexpected error: {msg}");
882 }
883
884 #[test]
885 fn changed_since_rejects_nul_in_rev() {
886 let err = changed_since(Path::new("/nonexistent"), &[], "main\0evil").unwrap_err();
887 let msg = match err {
888 SelectError::GitFailed(m) => m,
889 other => panic!("expected GitFailed, got {other:?}"),
890 };
891 assert!(msg.contains("refusing"), "unexpected error: {msg}");
892 }
893}