1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Component, Path, PathBuf};
3use std::process::{Command, Output};
4
5use anyhow::{Context, Result, bail};
6use qk_config::Workspace;
7use qk_taskgraph::Task;
8
9pub fn git(root: &Path, args: &[&str]) -> std::io::Result<Output> {
10 Command::new("git")
11 .arg("-C")
12 .arg(root)
13 .args(args)
14 .env_remove("GIT_DIR")
15 .env_remove("GIT_WORK_TREE")
16 .env_remove("GIT_INDEX_FILE")
17 .env_remove("GIT_COMMON_DIR")
18 .output()
19}
20
21pub fn git_path(root: &Path, flag: &str) -> Option<PathBuf> {
22 let result = git(root, &["rev-parse", "--path-format=absolute", flag]).ok()?;
23 if !result.status.success() {
24 return None;
25 }
26 let path = String::from_utf8(result.stdout).ok()?;
27 Some(PathBuf::from(path.trim_end_matches(['\r', '\n'])))
28}
29
30fn dot_git(root: &Path) -> Option<(PathBuf, PathBuf)> {
36 let (parent, entry) = root
37 .ancestors()
38 .map(|dir| (dir, dir.join(".git")))
39 .find(|(_, entry)| entry.exists())?;
40 let git_dir = if entry.is_dir() {
41 entry
42 } else {
43 let content = std::fs::read_to_string(&entry).ok()?;
44 let named = content.strip_prefix("gitdir:")?.trim();
45 parent.join(named)
46 };
47 let common = match std::fs::read_to_string(git_dir.join("commondir")) {
48 Ok(named) => git_dir.join(named.trim()),
49 Err(_) => git_dir.clone(),
50 };
51 let absolute = |path: PathBuf| std::fs::canonicalize(&path).unwrap_or(path);
52 Some((absolute(git_dir), absolute(common)))
53}
54
55pub fn worktree_state(root: &Path) -> PathBuf {
60 static KNOWN: std::sync::OnceLock<
61 std::sync::Mutex<std::collections::HashMap<PathBuf, PathBuf>>,
62 > = std::sync::OnceLock::new();
63 let known = KNOWN.get_or_init(Default::default);
64 if let Some(state) = known.lock().unwrap().get(root) {
65 return state.clone();
66 }
67 let state = git_path(root, "--git-dir")
68 .or_else(|| dot_git(root).map(|(git_dir, _)| git_dir))
69 .map(|dir| dir.join("qk"))
70 .unwrap_or_else(|| root.join(".qk"));
71 known.lock().unwrap().insert(root.to_owned(), state.clone());
72 state
73}
74
75const WORKTREE_ENTRIES: &[&str] = &["digests.json", "outputs", "restore", "sandbox", "warm"];
79
80pub fn clear_worktree_state(root: &Path) -> Result<()> {
83 let state = worktree_state(root);
84 for entry in WORKTREE_ENTRIES {
85 let path = state.join(entry);
86 let removed = if path.is_dir() {
87 std::fs::remove_dir_all(&path)
88 } else {
89 std::fs::remove_file(&path)
90 };
91 match removed {
92 Err(error) if error.kind() != std::io::ErrorKind::NotFound => {
93 return Err(error).with_context(|| format!("cannot remove {}", path.display()));
94 }
95 _ => {}
96 }
97 }
98 Ok(())
99}
100
101pub fn cache_location(workspace: &Workspace) -> PathBuf {
106 let configured = std::env::var_os("NX_CACHE_DIRECTORY")
107 .filter(|value| !value.is_empty())
108 .map(PathBuf::from)
109 .or_else(|| {
110 workspace
111 .config
112 .extra
113 .get("cacheDirectory")
114 .and_then(serde_json::Value::as_str)
115 .map(PathBuf::from)
116 });
117 match configured {
118 Some(directory) => workspace.root.join(directory).join("qk/v1"),
119 None => cache_directory(&workspace.root),
120 }
121}
122
123pub fn cache_directory(root: &Path) -> PathBuf {
125 git_path(root, "--git-common-dir")
126 .or_else(|| dot_git(root).map(|(_, common)| common))
127 .map(|dir| dir.join("qk/cache/v1"))
128 .unwrap_or_else(|| root.join(".qk/cache/v1"))
129}
130
131pub fn relative(root: &Path, path: &Path) -> Result<String> {
132 let parts = path
133 .strip_prefix(root)?
134 .components()
135 .map(|part| {
136 part.as_os_str()
137 .to_str()
138 .map(str::to_owned)
139 .context("cache paths must be UTF-8")
140 })
141 .collect::<Result<Vec<_>>>()?;
142 Ok(parts.join("/"))
143}
144
145pub fn validate_path(path: &str) -> Result<()> {
146 if path.is_empty()
147 || path.contains(['\\', '\0'])
148 || (cfg!(windows) && path.contains(':'))
149 || Path::new(path).is_absolute()
150 || Path::new(path)
151 .components()
152 .any(|part| !matches!(part, Component::Normal(_)))
153 || path
154 .split('/')
155 .any(|part| matches!(part, ".git" | ".qk" | ".." | "." | ""))
156 {
157 bail!("unsafe cache artifact path {path:?}");
158 }
159 Ok(())
160}
161
162pub fn safe_parents(root: &Path, path: &str) -> Result<()> {
163 validate_path(path)?;
164 let mut current = root.to_owned();
165 for part in Path::new(path)
166 .parent()
167 .into_iter()
168 .flat_map(Path::components)
169 {
170 current.push(part);
171 match std::fs::symlink_metadata(¤t) {
172 Ok(metadata) if metadata.file_type().is_symlink() => {
173 bail!("cache artifact parent is a symlink: {}", current.display())
174 }
175 Ok(metadata) if !metadata.is_dir() => bail!(
176 "cache artifact parent is not a directory: {}",
177 current.display()
178 ),
179 Ok(_) => {}
180 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
181 Err(error) => return Err(error.into()),
182 }
183 }
184 Ok(())
185}
186
187pub use crate::glob::Pattern;
188
189pub fn expand(workspace: &Workspace, project: &str, text: &str) -> Result<String> {
190 let project_root = &workspace.projects[project].root;
191 let prefix = if project_root == "." {
192 String::new()
193 } else {
194 format!("{project_root}/")
195 };
196 let result = text
197 .replace("{workspaceRoot}/", "")
198 .replace("{projectRoot}/", &prefix)
199 .replace("{projectRoot}", project_root)
200 .replace("{workspaceRoot}", ".");
201 if result.contains("{options.") || result.contains("{args.") {
202 bail!("dynamic cache paths are not supported yet");
203 }
204 normalize(&result)
205}
206
207fn normalize(path: &str) -> Result<String> {
209 let mut parts: Vec<&str> = Vec::new();
210 for part in path.split('/') {
211 match part {
212 "." => {}
213 ".." => match parts.pop() {
214 Some(parent) if crate::glob::is_literal(parent) => {}
215 _ => bail!("cache path {path:?} leaves the workspace or follows a glob with `..`"),
216 },
217 part => parts.push(part),
218 }
219 }
220 Ok(if parts.is_empty() {
221 ".".to_owned()
222 } else {
223 parts.join("/")
224 })
225}
226
227type OutputEntries<'a> = Box<dyn Iterator<Item = Result<(String, std::fs::Metadata)>> + 'a>;
228
229#[derive(Clone)]
230pub struct Outputs {
231 patterns: Vec<Pattern>,
232 globs: Vec<String>,
234 negations: Vec<Pattern>,
236 anchors: BTreeSet<String>,
237 complete: BTreeSet<String>,
239 explicit: bool,
242}
243
244impl Outputs {
245 pub fn from_paths(patterns: &[String]) -> Result<Self> {
249 let mut compiled = Vec::new();
250 let mut globs = Vec::new();
251 let mut negations = Vec::new();
252 let mut anchors = BTreeSet::new();
253 let mut complete = BTreeSet::new();
254 for pattern in patterns {
255 if let Some(excluded) = pattern.strip_prefix('!') {
256 validate_path(excluded)?;
257 negations.push(Pattern::new(excluded, false)?);
258 continue;
259 }
260 validate_path(pattern)?;
261 let anchor = pattern
262 .split('/')
263 .take_while(|part| crate::glob::is_literal(part))
264 .collect::<Vec<_>>()
265 .join("/");
266 if anchor.is_empty() {
267 bail!("{pattern:?} needs a fixed directory prefix");
268 }
269 if pattern.split('/').all(crate::glob::is_literal) {
270 complete.insert(pattern.clone());
271 }
272 compiled.push(Pattern::new(pattern, false)?);
273 globs.push(pattern.clone());
274 anchors.insert(anchor);
275 }
276 Ok(Self {
277 patterns: compiled,
278 globs,
279 negations,
280 anchors,
281 complete,
282 explicit: true,
283 })
284 }
285
286 #[cfg(test)]
288 pub fn from_patterns(patterns: &[&str]) -> Self {
289 Self {
290 patterns: patterns
291 .iter()
292 .map(|pattern| Pattern::new(pattern, false).unwrap())
293 .collect(),
294 globs: patterns
295 .iter()
296 .map(|pattern| (*pattern).to_owned())
297 .collect(),
298 anchors: patterns
299 .iter()
300 .map(|pattern| (*pattern).to_owned())
301 .collect(),
302 complete: patterns
303 .iter()
304 .filter(|pattern| pattern.split('/').all(crate::glob::is_literal))
305 .map(|pattern| (*pattern).to_owned())
306 .collect(),
307 negations: Vec::new(),
308 explicit: true,
309 }
310 }
311
312 pub fn new(workspace: &Workspace, task: &Task) -> Result<Self> {
314 let (resolved, explicit) = resolved_outputs(workspace, task)?;
315 let mut patterns = Vec::new();
316 let mut globs = Vec::new();
317 let mut negations = Vec::new();
318 let mut anchors = BTreeSet::new();
319 let mut complete = BTreeSet::new();
320 for output in resolved {
321 let (negated, pattern) = match output.strip_prefix('!') {
322 Some(rest) => (true, rest.to_owned()),
323 None => (false, output),
324 };
325 validate_path(&pattern)?;
326 if negated {
327 negations.push(Pattern::new(&pattern, false)?);
328 continue;
329 }
330 let anchor = pattern
331 .split('/')
332 .take_while(|part| crate::glob::is_literal(part))
333 .collect::<Vec<_>>()
334 .join("/");
335 if anchor.is_empty()
336 || workspace
337 .projects
338 .values()
339 .any(|project| project.root == pattern)
340 {
341 bail!(
342 "cache outputs must have a fixed directory prefix and cannot replace a project root"
343 );
344 }
345 if pattern.split('/').all(crate::glob::is_literal) {
346 complete.insert(pattern.clone());
347 }
348 patterns.push(Pattern::new(&pattern, false)?);
349 globs.push(pattern);
350 anchors.insert(anchor);
351 }
352 Ok(Self {
353 patterns,
354 globs,
355 negations,
356 anchors,
357 complete,
358 explicit,
359 })
360 }
361
362 pub fn is_explicit(&self) -> bool {
364 self.explicit
365 }
366
367 pub fn declared(&self) -> bool {
371 self.explicit && !self.patterns.is_empty()
372 }
373
374 pub fn anchors(&self) -> impl Iterator<Item = &str> {
376 self.anchors.iter().map(String::as_str)
377 }
378
379 pub(crate) fn complete_roots(&self) -> Vec<&str> {
382 if !self.explicit || !self.negations.is_empty() {
383 return Vec::new();
384 }
385 let mut roots: Vec<&str> = Vec::new();
386 for root in &self.complete {
387 if !roots.iter().any(|parent| {
388 root.strip_prefix(parent)
389 .is_some_and(|suffix| suffix.starts_with('/'))
390 }) {
391 roots.push(root);
392 }
393 }
394 roots
395 }
396
397 pub fn matches(&self, path: &str) -> bool {
398 let any = |patterns: &[Pattern]| {
399 Path::new(path)
400 .ancestors()
401 .any(|ancestor| patterns.iter().any(|pattern| pattern.is_match(ancestor)))
402 };
403 (self.complete.iter().any(|root| {
404 path == root
405 || path
406 .strip_prefix(root)
407 .is_some_and(|suffix| suffix.starts_with('/'))
408 }) || any(&self.patterns))
409 && !any(&self.negations)
410 }
411
412 pub fn paths(&self, root: &Path) -> Result<BTreeSet<String>> {
413 self.listing(root).map(|(paths, _)| paths)
414 }
415
416 pub(crate) fn entries<'a>(&self, root: &'a Path) -> Result<OutputEntries<'a>> {
417 if self.explicit && self.negations.is_empty() && self.complete.len() == self.globs.len() {
418 let mut walks = Vec::new();
419 let mut symlink = false;
420 for anchor in &self.anchors {
421 safe_parents(root, anchor)?;
422 match std::fs::symlink_metadata(root.join(anchor)) {
423 Ok(metadata) => symlink |= metadata.file_type().is_symlink(),
424 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
425 Err(error) => return Err(error.into()),
426 }
427 }
428 for anchor in self.complete_roots() {
429 let absolute = root.join(anchor);
430 match std::fs::symlink_metadata(&absolute) {
431 Ok(_) => walks.push(walkdir::WalkDir::new(absolute).follow_links(false)),
432 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
433 Err(error) => return Err(error.into()),
434 }
435 }
436 if !symlink {
437 return Ok(Box::new(walks.into_iter().flatten().map(move |entry| {
438 let entry = entry?;
439 let path = relative(root, entry.path())?;
440 validate_path(&path)?;
441 Ok((path, entry.metadata()?))
442 })));
443 }
444 }
445 let (paths, mut traversal) = self.listing(root)?;
446 Ok(Box::new(paths.into_iter().map(move |path| {
447 let absolute = root.join(&path);
448 let metadata = match traversal.metadata.remove(&absolute) {
449 Some(metadata) => metadata,
450 None => std::fs::symlink_metadata(&absolute)?,
451 };
452 Ok((path, metadata))
453 })))
454 }
455
456 fn listing(&self, root: &Path) -> Result<(BTreeSet<String>, Traversal)> {
457 if let Some(segments) = self.simple_segments()? {
458 for anchor in &self.anchors {
459 safe_parents(root, anchor)?;
460 match std::fs::symlink_metadata(root.join(anchor)) {
461 Ok(metadata) if metadata.file_type().is_symlink() => {
462 return self
463 .walk_paths(root)
464 .map(|paths| (paths, Traversal::default()));
465 }
466 Ok(_) => {}
467 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
468 Err(error) => return Err(error.into()),
469 }
470 }
471 let mut traversal = Traversal::default();
472 let mut candidates = BTreeSet::new();
473 for (glob, pattern) in self.globs.iter().zip(&segments) {
474 let fixed = pattern
475 .iter()
476 .take_while(|segment| matches!(segment, Segment::Literal(_)))
477 .count();
478 let anchor = glob.split('/').take(fixed).collect::<Vec<_>>().join("/");
479 traversal.expand(root, &root.join(anchor), &pattern[fixed..], &mut candidates)?;
480 }
481 return candidates
482 .into_iter()
483 .filter(|path| self.matches(path))
484 .map(|path| {
485 validate_path(&path)?;
486 Ok(path)
487 })
488 .collect::<Result<_>>()
489 .map(|paths| (paths, traversal));
490 }
491 self.walk_paths(root)
492 .map(|paths| (paths, Traversal::default()))
493 }
494
495 fn simple_segments(&self) -> Result<Option<Vec<Vec<Segment>>>> {
496 if self.globs.iter().any(|glob| {
497 glob.contains("**")
498 || glob.contains(['!', '@', '+', '|', ',', '{', '}', '[', ']', '(', ')'])
499 }) {
500 return Ok(None);
501 }
502 let mut matchers: BTreeMap<String, Pattern> = BTreeMap::new();
503 let mut patterns = Vec::new();
504 for glob in &self.globs {
505 let mut segments = Vec::new();
506 for part in glob.split('/') {
507 segments.push(if crate::glob::is_literal(part) {
508 Segment::Literal(part.to_owned())
509 } else {
510 let matcher = match matchers.entry(part.to_owned()) {
511 std::collections::btree_map::Entry::Occupied(entry) => entry.get().clone(),
512 std::collections::btree_map::Entry::Vacant(entry) => {
513 entry.insert(Pattern::new(part, false)?).clone()
514 }
515 };
516 Segment::Glob(matcher)
517 });
518 }
519 patterns.push(segments);
520 }
521 Ok(Some(patterns))
522 }
523
524 fn walk_paths(&self, root: &Path) -> Result<BTreeSet<String>> {
525 let mut paths = BTreeSet::new();
526 for anchor in &self.anchors {
527 safe_parents(root, anchor)?;
528 let path = root.join(anchor);
529 match std::fs::symlink_metadata(&path) {
530 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
531 Err(error) => return Err(error.into()),
532 Ok(_) => {}
533 }
534 let walk = walkdir::WalkDir::new(path)
535 .follow_links(false)
536 .into_iter()
537 .filter_entry(|entry| {
538 entry.depth() == 0
539 || !entry.file_type().is_dir()
540 || relative(root, entry.path()).map_or(true, |directory| {
541 self.globs
542 .iter()
543 .any(|glob| may_match_below(glob, &directory))
544 })
545 });
546 for entry in walk {
547 let entry = entry?;
548 let path = relative(root, entry.path())?;
549 if self.matches(&path) {
550 validate_path(&path)?;
551 paths.insert(path);
552 }
553 }
554 }
555 Ok(paths)
556 }
557}
558
559#[derive(Clone)]
560enum Segment {
561 Literal(String),
562 Glob(Pattern),
563}
564
565#[derive(Default)]
566struct Traversal {
567 metadata: BTreeMap<PathBuf, std::fs::Metadata>,
568 directories: BTreeMap<PathBuf, Vec<PathBuf>>,
569}
570
571impl Traversal {
572 fn metadata(&mut self, path: &Path) -> Result<Option<std::fs::Metadata>> {
573 if let Some(metadata) = self.metadata.get(path) {
574 return Ok(Some(metadata.clone()));
575 }
576 match std::fs::symlink_metadata(path) {
577 Ok(metadata) => {
578 self.metadata.insert(path.to_owned(), metadata.clone());
579 Ok(Some(metadata))
580 }
581 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
582 Err(error) => Err(error.into()),
583 }
584 }
585
586 fn children(&mut self, path: &Path) -> Result<&Vec<PathBuf>> {
587 if !self.directories.contains_key(path) {
588 let children = std::fs::read_dir(path)?
589 .map(|entry| entry.map(|entry| entry.path()))
590 .collect::<std::io::Result<Vec<_>>>()?;
591 self.directories.insert(path.to_owned(), children);
592 }
593 Ok(&self.directories[path])
594 }
595
596 fn expand(
597 &mut self,
598 root: &Path,
599 path: &Path,
600 segments: &[Segment],
601 paths: &mut BTreeSet<String>,
602 ) -> Result<()> {
603 let Some(metadata) = self.metadata(path)? else {
604 return Ok(());
605 };
606 let Some((segment, rest)) = segments.split_first() else {
607 for entry in walkdir::WalkDir::new(path)
608 .follow_links(false)
609 .follow_root_links(false)
610 {
611 paths.insert(relative(root, entry?.path())?);
612 }
613 return Ok(());
614 };
615 if !metadata.is_dir() {
616 return Ok(());
617 }
618 match segment {
619 Segment::Literal(name) => {
620 let candidate = path.join(name);
621 if self.metadata(&candidate)?.is_none() {
622 return Ok(());
623 }
624 let child = self
626 .children(path)?
627 .iter()
628 .find(|child| child.file_name() == Some(std::ffi::OsStr::new(name)))
629 .cloned();
630 if let Some(child) = child {
631 self.expand(root, &child, rest, paths)?;
632 }
633 Ok(())
634 }
635 Segment::Glob(matcher) => {
636 let children: Vec<_> = self
637 .children(path)?
638 .iter()
639 .filter(|child| matcher.is_match(child.file_name().unwrap()))
640 .cloned()
641 .collect();
642 for child in children {
643 self.expand(root, &child, rest, paths)?;
644 }
645 Ok(())
646 }
647 }
648 }
649}
650
651fn may_match_below(glob: &str, directory: &str) -> bool {
656 if glob.contains(['{', '}', '(', ')', '[', ']']) {
657 return true;
658 }
659 for (part, name) in glob.split('/').zip(directory.split('/')) {
660 if part == "**" {
661 return true;
662 }
663 if crate::glob::is_literal(part) && part != name {
664 return false;
665 }
666 }
667 true
668}
669
670pub fn resolved_outputs(workspace: &Workspace, task: &Task) -> Result<(Vec<String>, bool)> {
677 let root = &workspace.projects[&task.project].root;
678 let options = task_options(task);
679 let (templates, explicit) = match &task.definition.outputs {
680 Some(outputs) => (outputs.clone(), true),
681 None => match options.get("outputPath") {
682 Some(serde_json::Value::String(path)) => (vec![path.clone()], false),
683 Some(serde_json::Value::Array(paths)) => (
684 paths
685 .iter()
686 .filter_map(|path| path.as_str().map(str::to_owned))
687 .collect(),
688 false,
689 ),
690 _ if matches!(task.target.as_str(), "build" | "prepare") => (
691 [
692 format!("dist/{root}"),
693 format!("{root}/dist"),
694 format!("{root}/build"),
695 format!("{root}/public"),
696 ]
697 .into(),
698 false,
699 ),
700 _ => (Vec::new(), false),
701 },
702 };
703 let mut resolved = Vec::new();
704 for template in templates {
705 let (negated, template) = match template.strip_prefix('!') {
706 Some(rest) => (true, rest.to_owned()),
707 None => (false, template),
708 };
709 let Some(output) = resolve_output(task, &options, &template) else {
710 continue;
711 };
712 let path = expand(workspace, &task.project, &output)?
713 .trim_end_matches('/')
714 .to_owned();
715 resolved.push(if negated { format!("!{path}") } else { path });
716 }
717 Ok((resolved, explicit))
718}
719
720fn task_options(task: &Task) -> serde_json::Map<String, serde_json::Value> {
723 let mut options = task.definition.options.clone();
724 let mut args = task.args.iter().peekable();
725 while let Some(arg) = args.next() {
726 let Some(flag) = arg.strip_prefix("--") else {
727 continue;
728 };
729 let (key, value) = match flag.split_once('=') {
730 Some((key, value)) => (key.to_owned(), value.to_owned()),
731 None if args.peek().is_some_and(|next| !next.starts_with('-')) => {
732 (flag.to_owned(), args.next().unwrap().clone())
733 }
734 None => (flag.to_owned(), "true".to_owned()),
735 };
736 options.insert(key, serde_json::Value::String(value));
737 }
738 options
739}
740
741fn resolve_output(
745 task: &Task,
746 options: &serde_json::Map<String, serde_json::Value>,
747 template: &str,
748) -> Option<String> {
749 let mut result = String::new();
750 let mut rest = template;
751 while let Some(start) = rest.find('{') {
752 result.push_str(&rest[..start]);
753 let end = rest[start..].find('}')? + start;
754 let token = rest[start + 1..end].trim();
755 let value = if let Some(path) = token.strip_prefix("options.") {
756 let mut value = options.get(path.split('.').next()?)?;
757 for part in path.split('.').skip(1) {
758 value = value.get(part)?;
759 }
760 match value {
761 serde_json::Value::String(text) if !text.is_empty() => text.clone(),
762 serde_json::Value::Number(number) => number.to_string(),
763 serde_json::Value::Bool(true) => "true".into(),
764 _ => return None,
765 }
766 } else {
767 match token {
768 "projectName" | "project.name" => task.project.clone(),
769 "project.root" => "{projectRoot}".into(),
770 "projectRoot" | "workspaceRoot" => format!("{{{token}}}"),
771 _ => return None,
772 }
773 };
774 result.push_str(&value);
775 rest = &rest[end + 1..];
776 }
777 result.push_str(rest);
778 Some(result)
779}
780
781#[cfg(test)]
782mod tests {
783 use super::{Outputs, Pattern, dot_git, normalize};
784
785 #[test]
786 fn dot_git_finds_a_linked_worktrees_directories_without_git() {
787 let scratch = tempfile::tempdir().unwrap();
788 let base = std::fs::canonicalize(scratch.path()).unwrap();
789 let common = base.join("main/.git");
790 let git_dir = common.join("worktrees/linked");
791 std::fs::create_dir_all(&git_dir).unwrap();
792 std::fs::write(git_dir.join("commondir"), "../..\n").unwrap();
793 std::fs::create_dir_all(base.join("linked/apps/web")).unwrap();
794 std::fs::write(
795 base.join("linked/.git"),
796 "gitdir: ../main/.git/worktrees/linked\n",
797 )
798 .unwrap();
799 assert_eq!(
800 dot_git(&base.join("linked/apps/web")),
801 Some((git_dir, common.clone()))
802 );
803 assert_eq!(dot_git(&base.join("main")), Some((common.clone(), common)));
804 assert_eq!(dot_git(&base.join("elsewhere")), None);
805 }
806
807 #[test]
808 fn literal_entries_match_listing_for_nested_and_missing_roots() {
809 let workspace = tempfile::tempdir().unwrap();
810 let root = workspace.path();
811 std::fs::create_dir_all(root.join("dist/nested/empty")).unwrap();
812 std::fs::write(root.join("dist/nested/value"), "contents").unwrap();
813 std::fs::write(root.join("single"), "one").unwrap();
814 #[cfg(unix)]
815 std::os::unix::fs::symlink(root.join("single"), root.join("dist/link")).unwrap();
816 let outputs = Outputs::from_paths(&[
817 "dist".into(),
818 "dist/nested".into(),
819 "missing".into(),
820 "single".into(),
821 ])
822 .unwrap();
823 let entries = outputs
824 .entries(root)
825 .unwrap()
826 .collect::<anyhow::Result<Vec<_>>>()
827 .unwrap();
828 let paths: std::collections::BTreeSet<_> =
829 entries.iter().map(|(path, _)| path.clone()).collect();
830 assert_eq!(entries.len(), paths.len());
831 assert_eq!(paths, outputs.paths(root).unwrap());
832 for (path, metadata) in entries {
833 let expected = std::fs::symlink_metadata(root.join(path)).unwrap();
834 assert_eq!(metadata.file_type(), expected.file_type());
835 assert_eq!(metadata.len(), expected.len());
836 assert_eq!(metadata.modified().unwrap(), expected.modified().unwrap());
837 }
838 }
839
840 #[cfg(unix)]
841 #[test]
842 fn literal_entries_reject_nested_roots_below_a_symlink() {
843 let workspace = tempfile::tempdir().unwrap();
844 let root = workspace.path();
845 std::fs::create_dir_all(root.join("dist")).unwrap();
846 std::fs::create_dir_all(root.join("outside/nested")).unwrap();
847 std::os::unix::fs::symlink(root.join("outside"), root.join("dist/link")).unwrap();
848 let outputs = Outputs::from_paths(&["dist".into(), "dist/link/nested".into()]).unwrap();
849 assert!(outputs.entries(root).is_err());
850 }
851
852 #[test]
853 fn literal_roots_preserve_path_boundaries_and_exclusions() {
854 let outputs =
855 Outputs::from_paths(&["dist".into(), "build/*.js".into(), "!dist/private".into()])
856 .unwrap();
857 for path in ["dist", "dist/nested/file.txt", "build/app.js"] {
858 assert!(outputs.matches(path), "{path}");
859 }
860 for path in [
861 "dist2/file.txt",
862 "dist/private",
863 "dist/private/file.txt",
864 "build/app.ts",
865 ] {
866 assert!(!outputs.matches(path), "{path}");
867 }
868 }
869
870 #[test]
872 fn listing_skips_only_directories_no_glob_reaches() {
873 let workspace = tempfile::tempdir().unwrap();
874 let root = workspace.path();
875 for file in [
876 "apps/web/graphql/manifest.json",
877 "apps/web/src/graphql/manifest.json",
878 "apps/web/node_modules/pkg/graphql/manifest.json",
879 "apps/client/app/graphql/manifest.json",
880 "apps/client/app/graphql/nested/extra.json",
881 "packages/a/deep/x/gen/out.ts",
882 "packages/b/types/index.d.ts",
883 "packages/c/lib/one.js",
884 ] {
885 let path = root.join(file);
886 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
887 std::fs::write(path, "").unwrap();
888 }
889 let outputs = Outputs::from_paths(&[
890 "apps/*/graphql/manifest.json".into(),
891 "apps/*/app/graphql".into(),
892 "packages/a/**/gen/*.ts".into(),
893 "packages/b/types".into(),
894 "packages/{c,d}/lib/*.js".into(),
895 ])
896 .unwrap();
897 assert_eq!(
898 outputs.paths(root).unwrap().into_iter().collect::<Vec<_>>(),
899 [
900 "apps/client/app/graphql",
901 "apps/client/app/graphql/manifest.json",
902 "apps/client/app/graphql/nested",
903 "apps/client/app/graphql/nested/extra.json",
904 "apps/web/graphql/manifest.json",
905 "packages/a/deep/x/gen/out.ts",
906 "packages/b/types",
907 "packages/b/types/index.d.ts",
908 "packages/c/lib/one.js",
909 ]
910 );
911 }
912
913 #[test]
915 fn character_classes_with_slashes_keep_nested_artifacts() {
916 let workspace = tempfile::tempdir().unwrap();
917 for name in ["a", "b", "c"] {
918 let directory = workspace.path().join(format!("apps/{name}/graphql/nested"));
919 std::fs::create_dir_all(&directory).unwrap();
920 std::fs::write(directory.join("output.txt"), "value").unwrap();
921 }
922 let outputs = Outputs::from_paths(&["apps/[a/b]/graphql".into()]).unwrap();
923 assert_eq!(
924 outputs
925 .paths(workspace.path())
926 .unwrap()
927 .into_iter()
928 .collect::<Vec<_>>(),
929 [
930 "apps/a/graphql",
931 "apps/a/graphql/nested",
932 "apps/a/graphql/nested/output.txt",
933 "apps/b/graphql",
934 "apps/b/graphql/nested",
935 "apps/b/graphql/nested/output.txt",
936 ]
937 );
938 }
939
940 #[test]
941 fn segment_traversal_preserves_output_sets() {
942 let workspace = tempfile::tempdir().unwrap();
943 for file in [
944 "apps/a/graphql/manifest.json",
945 "apps/a/graphql/nested/out.ts",
946 "apps/a/src/other.ts",
947 "apps/b/src/graphql/manifest.json",
948 "apps/.hidden/graphql/manifest.json",
949 "apps/a/file",
950 "dist/a.ts",
951 "dist/deep/b.ts",
952 ] {
953 let path = workspace.path().join(file);
954 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
955 std::fs::write(path, "value").unwrap();
956 }
957 for patterns in [
958 vec![
959 "apps/*/graphql/manifest.json",
960 "apps/*/*/graphql/manifest.json",
961 ],
962 vec!["apps/?/graphql", "!apps/a/graphql/nested"],
963 vec!["apps/a/graphql", "apps/*/graphql/*"],
964 vec!["apps/a/file/nested", "missing/*/file", "dist/*.ts"],
965 vec!["dist"],
966 vec!["apps/**/manifest.json"],
967 vec!["apps/[a/b]/graphql"],
968 ] {
969 let outputs =
970 Outputs::from_paths(&patterns.iter().map(|p| (*p).to_owned()).collect::<Vec<_>>())
971 .unwrap();
972 match (
973 outputs.paths(workspace.path()),
974 outputs.walk_paths(workspace.path()),
975 ) {
976 (Ok(actual), Ok(expected)) => assert_eq!(actual, expected, "{patterns:?}"),
977 (Err(actual), Err(expected)) => {
978 assert_eq!(actual.to_string(), expected.to_string())
979 }
980 results => panic!("different results for {patterns:?}: {results:?}"),
981 }
982 }
983 }
984
985 #[cfg(unix)]
986 #[test]
987 fn segment_traversal_preserves_symlink_boundaries() {
988 let workspace = tempfile::tempdir().unwrap();
989 let root = workspace.path();
990 std::fs::create_dir_all(root.join("apps/a/graphql")).unwrap();
991 std::fs::write(root.join("apps/a/graphql/manifest.json"), "value").unwrap();
992 std::os::unix::fs::symlink("a", root.join("apps/link")).unwrap();
993 std::os::unix::fs::symlink("absent", root.join("apps/dangling")).unwrap();
994 for patterns in [
995 vec!["apps/*/graphql/manifest.json"],
996 vec!["apps/*"],
997 vec!["apps/link"],
998 vec!["apps/dangling"],
999 ] {
1000 let outputs =
1001 Outputs::from_paths(&patterns.iter().map(|p| (*p).to_owned()).collect::<Vec<_>>())
1002 .unwrap();
1003 match (outputs.paths(root), outputs.walk_paths(root)) {
1004 (Ok(actual), Ok(expected)) => assert_eq!(actual, expected, "{patterns:?}"),
1005 (Err(actual), Err(expected)) => {
1006 assert_eq!(actual.to_string(), expected.to_string())
1007 }
1008 results => panic!("different results for {patterns:?}: {results:?}"),
1009 }
1010 }
1011 }
1012
1013 #[test]
1014 fn listings_recheck_metadata_and_directory_contents() {
1015 let workspace = tempfile::tempdir().unwrap();
1016 let root = workspace.path();
1017 std::fs::create_dir_all(root.join("apps/a/graphql")).unwrap();
1018 std::fs::write(root.join("apps/a/graphql/manifest.json"), "one").unwrap();
1019 let outputs = Outputs::from_paths(&["apps/*/graphql/manifest.json".into()]).unwrap();
1020 assert_eq!(
1021 outputs
1022 .entries(root)
1023 .unwrap()
1024 .collect::<anyhow::Result<std::collections::BTreeMap<_, _>>>()
1025 .unwrap()["apps/a/graphql/manifest.json"]
1026 .len(),
1027 3
1028 );
1029 std::fs::write(root.join("apps/a/graphql/manifest.json"), "different").unwrap();
1030 std::fs::create_dir_all(root.join("apps/b/graphql")).unwrap();
1031 std::fs::write(root.join("apps/b/graphql/manifest.json"), "two").unwrap();
1032 let entries = outputs
1033 .entries(root)
1034 .unwrap()
1035 .collect::<anyhow::Result<std::collections::BTreeMap<_, _>>>()
1036 .unwrap();
1037 assert_eq!(entries.len(), 2);
1038 assert_eq!(entries["apps/a/graphql/manifest.json"].len(), 9);
1039 std::fs::remove_dir_all(root.join("apps/a")).unwrap();
1040 assert_eq!(
1041 outputs
1042 .entries(root)
1043 .unwrap()
1044 .collect::<anyhow::Result<std::collections::BTreeMap<_, _>>>()
1045 .unwrap()
1046 .len(),
1047 1
1048 );
1049 }
1050
1051 #[test]
1052 fn literal_segments_after_wildcards_remain_case_sensitive() {
1053 let workspace = tempfile::tempdir().unwrap();
1054 let root = workspace.path();
1055 std::fs::create_dir_all(root.join("apps/a/GraphQL")).unwrap();
1056 std::fs::write(root.join("apps/a/GraphQL/Manifest.json"), "one").unwrap();
1057 for glob in [
1058 "apps/*/graphql/Manifest.json",
1059 "apps/*/GraphQL/manifest.json",
1060 "apps/*/GraphQL/Manifest.json",
1061 "apps/a/GraphQL",
1062 ] {
1063 let outputs = Outputs::from_paths(&[glob.into()]).unwrap();
1064 assert_eq!(
1065 outputs.paths(root).unwrap(),
1066 outputs.walk_paths(root).unwrap(),
1067 "{glob}"
1068 );
1069 }
1070 }
1071
1072 #[test]
1073 fn simple_glob_combinations_match_the_walker() {
1074 let workspace = tempfile::tempdir().unwrap();
1075 let root = workspace.path();
1076 for project in ["a", "aa", "Ab", ".hidden", "café"] {
1077 for directory in ["graphql", "GraphQL", "src"] {
1078 let path = root.join(format!("apps/{project}/{directory}/nested"));
1079 std::fs::create_dir_all(&path).unwrap();
1080 std::fs::write(path.join("manifest.json"), "value").unwrap();
1081 std::fs::write(path.parent().unwrap().join("manifest.json"), "value").unwrap();
1082 }
1083 }
1084 for project in ["*", "?", "a*", "*a", "*?", "A*"] {
1085 for directory in ["graphql", "GraphQL", "src", "missing"] {
1086 for suffix in ["", "/manifest.json", "/*.json", "/?anifest.json", "/*"] {
1087 let glob = format!("apps/{project}/{directory}{suffix}");
1088 let outputs =
1089 Outputs::from_paths(&[glob.clone(), "!apps/aa/graphql/nested".into()])
1090 .unwrap();
1091 assert_eq!(
1092 outputs.paths(root).unwrap(),
1093 outputs.walk_paths(root).unwrap(),
1094 "{glob}"
1095 );
1096 }
1097 }
1098 }
1099 }
1100
1101 #[test]
1103 fn complete_roots_do_not_widen_globs_exclusions_or_defaults() {
1104 let mut outputs = Outputs::from_paths(&[
1105 "dist".into(),
1106 "dist/nested".into(),
1107 "dist2".into(),
1108 "partial/**/*.js".into(),
1109 ])
1110 .unwrap();
1111 assert_eq!(outputs.complete_roots(), ["dist", "dist2"]);
1112 outputs
1113 .negations
1114 .push(Pattern::new("dist/private", false).unwrap());
1115 assert!(outputs.complete_roots().is_empty());
1116 outputs.negations.clear();
1117 outputs.explicit = false;
1118 assert!(outputs.complete_roots().is_empty());
1119 }
1120
1121 #[test]
1122 fn normalizes_relative_segments() {
1123 assert_eq!(
1124 normalize("apps/client/tests/../build/*/bundle.bin").unwrap(),
1125 "apps/client/build/*/bundle.bin"
1126 );
1127 assert_eq!(normalize("./dist/./a").unwrap(), "dist/a");
1128 assert_eq!(normalize(".").unwrap(), ".");
1129 assert!(normalize("app/../../outside").is_err());
1130 assert!(normalize("app/**/../x").is_err());
1131 }
1132}