1use std::{
2 collections::BTreeSet,
3 fmt::{Display, Formatter},
4 path::{Path, PathBuf},
5 sync::{Arc, LazyLock},
6};
7
8use fs::Fs;
9use futures::StreamExt as _;
10use gpui::{App, AppContext as _, Entity, Subscription, Task};
11use postage::watch;
12use project::Worktree;
13use regex::Regex;
14use strum::VariantArray;
15use util::ResultExt as _;
16use worktree::ChildEntriesOptions;
17
18/// Matches the most common license locations, with US and UK English spelling.
19static LICENSE_FILE_NAME_REGEX: LazyLock<regex::bytes::Regex> = LazyLock::new(|| {
20 regex::bytes::RegexBuilder::new(
21 "^ \
22 (?: license | licence)? \
23 (?: [\\-._]? \
24 (?: apache (?: [\\-._] (?: 2.0 | 2 ))? | \
25 0? bsd (?: [\\-._] [0123])? (?: [\\-._] clause)? | \
26 isc | \
27 mit | \
28 upl))? \
29 (?: [\\-._]? (?: license | licence))? \
30 (?: \\.txt | \\.md)? \
31 $",
32 )
33 .ignore_whitespace(true)
34 .case_insensitive(true)
35 .build()
36 .unwrap()
37});
38
39#[derive(Debug, Clone, Copy, Eq, PartialEq, VariantArray)]
40pub enum OpenSourceLicense {
41 Apache2_0,
42 BSD0Clause,
43 BSD1Clause,
44 BSD2Clause,
45 BSD3Clause,
46 ISC,
47 MIT,
48 UPL1_0,
49}
50
51impl Display for OpenSourceLicense {
52 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
53 write!(f, "{}", self.spdx_identifier())
54 }
55}
56
57impl OpenSourceLicense {
58 pub fn spdx_identifier(&self) -> &'static str {
59 match self {
60 OpenSourceLicense::Apache2_0 => "apache-2.0",
61 OpenSourceLicense::BSD0Clause => "0bsd",
62 OpenSourceLicense::BSD1Clause => "bsd-1-clause",
63 OpenSourceLicense::BSD2Clause => "bsd-2-clause",
64 OpenSourceLicense::BSD3Clause => "bsd-3-clause",
65 OpenSourceLicense::ISC => "isc",
66 OpenSourceLicense::MIT => "mit",
67 OpenSourceLicense::UPL1_0 => "upl-1.0",
68 }
69 }
70
71 pub fn regex(&self) -> &'static str {
72 match self {
73 OpenSourceLicense::Apache2_0 => include_str!("license_detection/apache-2.0.regex"),
74 OpenSourceLicense::BSD0Clause => include_str!("license_detection/0bsd.regex"),
75 OpenSourceLicense::BSD1Clause => include_str!("license_detection/bsd-1-clause.regex"),
76 OpenSourceLicense::BSD2Clause => include_str!("license_detection/bsd-2-clause.regex"),
77 OpenSourceLicense::BSD3Clause => include_str!("license_detection/bsd-3-clause.regex"),
78 OpenSourceLicense::ISC => include_str!("license_detection/isc.regex"),
79 OpenSourceLicense::MIT => include_str!("license_detection/mit.regex"),
80 OpenSourceLicense::UPL1_0 => include_str!("license_detection/upl-1.0.regex"),
81 }
82 }
83}
84
85fn detect_license(license: &str) -> Option<OpenSourceLicense> {
86 static LICENSE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
87 let mut regex_string = String::new();
88 let mut is_first = true;
89 for license in OpenSourceLicense::VARIANTS {
90 if is_first {
91 regex_string.push_str("^(?:(");
92 is_first = false;
93 } else {
94 regex_string.push_str(")|(");
95 }
96 regex_string.push_str(&canonicalize_license_text(license.regex()));
97 }
98 regex_string.push_str("))$");
99 let regex = Regex::new(®ex_string).unwrap();
100 assert_eq!(regex.captures_len(), OpenSourceLicense::VARIANTS.len() + 1);
101 regex
102 });
103
104 LICENSE_REGEX
105 .captures(&canonicalize_license_text(license))
106 .and_then(|captures| {
107 let license = OpenSourceLicense::VARIANTS
108 .iter()
109 .enumerate()
110 .find(|(index, _)| captures.get(index + 1).is_some())
111 .map(|(_, license)| *license);
112 if license.is_none() {
113 log::error!("bug: open source license regex matched without any capture groups");
114 }
115 license
116 })
117}
118
119/// Canonicalizes the whitespace of license text and license regexes.
120fn canonicalize_license_text(license: &str) -> String {
121 license
122 .split_ascii_whitespace()
123 .collect::<Vec<_>>()
124 .join(" ")
125 .to_ascii_lowercase()
126}
127
128pub enum LicenseDetectionWatcher {
129 Local {
130 is_open_source_rx: watch::Receiver<bool>,
131 _is_open_source_task: Task<()>,
132 _worktree_subscription: Subscription,
133 },
134 SingleFile,
135 Remote,
136}
137
138impl LicenseDetectionWatcher {
139 pub fn new(worktree: &Entity<Worktree>, cx: &mut App) -> Self {
140 let worktree_ref = worktree.read(cx);
141 if worktree_ref.is_single_file() {
142 return Self::SingleFile;
143 }
144
145 let (files_to_check_tx, mut files_to_check_rx) = futures::channel::mpsc::unbounded();
146
147 let Worktree::Local(local_worktree) = worktree_ref else {
148 return Self::Remote;
149 };
150 let fs = local_worktree.fs().clone();
151 let worktree_abs_path = local_worktree.abs_path().clone();
152
153 let options = ChildEntriesOptions {
154 include_files: true,
155 include_dirs: false,
156 include_ignored: true,
157 };
158 for top_file in local_worktree.child_entries_with_options(Path::new(""), options) {
159 let path_bytes = top_file.path.as_os_str().as_encoded_bytes();
160 if top_file.is_created() && LICENSE_FILE_NAME_REGEX.is_match(path_bytes) {
161 let rel_path = top_file.path.clone();
162 files_to_check_tx.unbounded_send(rel_path).ok();
163 }
164 }
165
166 let _worktree_subscription =
167 cx.subscribe(worktree, move |_worktree, event, _cx| match event {
168 worktree::Event::UpdatedEntries(updated_entries) => {
169 for updated_entry in updated_entries.iter() {
170 let rel_path = &updated_entry.0;
171 let path_bytes = rel_path.as_os_str().as_encoded_bytes();
172 if LICENSE_FILE_NAME_REGEX.is_match(path_bytes) {
173 files_to_check_tx.unbounded_send(rel_path.clone()).ok();
174 }
175 }
176 }
177 worktree::Event::DeletedEntry(_) | worktree::Event::UpdatedGitRepositories(_) => {}
178 });
179
180 let (mut is_open_source_tx, is_open_source_rx) = watch::channel_with::<bool>(false);
181
182 let _is_open_source_task = cx.background_spawn(async move {
183 let mut eligible_licenses = BTreeSet::new();
184 while let Some(rel_path) = files_to_check_rx.next().await {
185 let abs_path = worktree_abs_path.join(&rel_path);
186 let was_open_source = !eligible_licenses.is_empty();
187 if Self::is_path_eligible(&fs, abs_path).await.unwrap_or(false) {
188 eligible_licenses.insert(rel_path);
189 } else {
190 eligible_licenses.remove(&rel_path);
191 }
192 let is_open_source = !eligible_licenses.is_empty();
193 if is_open_source != was_open_source {
194 *is_open_source_tx.borrow_mut() = is_open_source;
195 }
196 }
197 });
198
199 Self::Local {
200 is_open_source_rx,
201 _is_open_source_task,
202 _worktree_subscription,
203 }
204 }
205
206 async fn is_path_eligible(fs: &Arc<dyn Fs>, abs_path: PathBuf) -> Option<bool> {
207 log::debug!("checking if `{abs_path:?}` is an open source license");
208 // Resolve symlinks so that the file size from metadata is correct.
209 let Some(abs_path) = fs.canonicalize(&abs_path).await.ok() else {
210 log::debug!(
211 "`{abs_path:?}` license file probably deleted (error canonicalizing the path)"
212 );
213 return None;
214 };
215 let metadata = fs.metadata(&abs_path).await.log_err()??;
216 // If the license file is >32kb it's unlikely to legitimately match any eligible license.
217 if metadata.len > 32768 {
218 return None;
219 }
220 let text = fs.load(&abs_path).await.log_err()?;
221 let is_eligible = detect_license(&text).is_some();
222 if is_eligible {
223 log::debug!(
224 "`{abs_path:?}` matches a license that is eligible for data collection (if enabled)"
225 );
226 } else {
227 log::debug!(
228 "`{abs_path:?}` does not match a license that is eligible for data collection"
229 );
230 }
231 Some(is_eligible)
232 }
233
234 /// Answers false until we find out it's open source
235 pub fn is_project_open_source(&self) -> bool {
236 match self {
237 Self::Local {
238 is_open_source_rx, ..
239 } => *is_open_source_rx.borrow(),
240 Self::SingleFile | Self::Remote => false,
241 }
242 }
243}
244
245#[cfg(test)]
246mod tests {
247
248 use fs::FakeFs;
249 use gpui::TestAppContext;
250 use serde_json::json;
251 use settings::{Settings as _, SettingsStore};
252 use unindent::unindent;
253 use worktree::WorktreeSettings;
254
255 use super::*;
256
257 const APACHE_2_0_TXT: &str = include_str!("license_detection/apache-2.0.txt");
258 const ISC_TXT: &str = include_str!("license_detection/isc.txt");
259 const MIT_TXT: &str = include_str!("license_detection/mit.txt");
260 const UPL_1_0_TXT: &str = include_str!("license_detection/upl-1.0.txt");
261 const BSD_0_CLAUSE_TXT: &str = include_str!("license_detection/0bsd.txt");
262 const BSD_1_CLAUSE_TXT: &str = include_str!("license_detection/bsd-1-clause.txt");
263 const BSD_2_CLAUSE_TXT: &str = include_str!("license_detection/bsd-2-clause.txt");
264 const BSD_3_CLAUSE_TXT: &str = include_str!("license_detection/bsd-3-clause.txt");
265
266 #[track_caller]
267 fn assert_matches_license(text: &str, license: OpenSourceLicense) {
268 let license_regex =
269 Regex::new(&format!("^{}$", canonicalize_license_text(license.regex()))).unwrap();
270 assert!(license_regex.is_match(&canonicalize_license_text(text)));
271 assert_eq!(detect_license(text), Some(license));
272 }
273
274 #[test]
275 fn test_0bsd_positive_detection() {
276 assert_matches_license(BSD_0_CLAUSE_TXT, OpenSourceLicense::BSD0Clause);
277 }
278
279 #[test]
280 fn test_apache_positive_detection() {
281 assert_matches_license(APACHE_2_0_TXT, OpenSourceLicense::Apache2_0);
282
283 let license_with_appendix = format!(
284 r#"{APACHE_2_0_TXT}
285
286 END OF TERMS AND CONDITIONS
287
288 APPENDIX: How to apply the Apache License to your work.
289
290 To apply the Apache License to your work, attach the following
291 boilerplate notice, with the fields enclosed by brackets "[]"
292 replaced with your own identifying information. (Don't include
293 the brackets!) The text should be enclosed in the appropriate
294 comment syntax for the file format. We also recommend that a
295 file or class name and description of purpose be included on the
296 same "printed page" as the copyright notice for easier
297 identification within third-party archives.
298
299 Copyright [yyyy] [name of copyright owner]
300
301 Licensed under the Apache License, Version 2.0 (the "License");
302 you may not use this file except in compliance with the License.
303 You may obtain a copy of the License at
304
305 http://www.apache.org/licenses/LICENSE-2.0
306
307 Unless required by applicable law or agreed to in writing, software
308 distributed under the License is distributed on an "AS IS" BASIS,
309 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
310 See the License for the specific language governing permissions and
311 limitations under the License."#
312 );
313 assert_matches_license(&license_with_appendix, OpenSourceLicense::Apache2_0);
314
315 // Sometimes people fill in the appendix with copyright info.
316 let license_with_copyright = license_with_appendix.replace(
317 "Copyright [yyyy] [name of copyright owner]",
318 "Copyright 2025 John Doe",
319 );
320 assert!(license_with_copyright != license_with_appendix);
321 assert_matches_license(&license_with_copyright, OpenSourceLicense::Apache2_0);
322 }
323
324 #[test]
325 fn test_apache_negative_detection() {
326 assert!(
327 detect_license(&format!(
328 "{APACHE_2_0_TXT}\n\nThe terms in this license are void if P=NP."
329 ))
330 .is_none()
331 );
332 }
333
334 #[test]
335 fn test_bsd_1_clause_positive_detection() {
336 assert_matches_license(BSD_1_CLAUSE_TXT, OpenSourceLicense::BSD1Clause);
337 }
338
339 #[test]
340 fn test_bsd_2_clause_positive_detection() {
341 assert_matches_license(BSD_2_CLAUSE_TXT, OpenSourceLicense::BSD2Clause);
342 }
343
344 #[test]
345 fn test_bsd_3_clause_positive_detection() {
346 assert_matches_license(BSD_3_CLAUSE_TXT, OpenSourceLicense::BSD3Clause);
347 }
348
349 #[test]
350 fn test_isc_positive_detection() {
351 assert_matches_license(ISC_TXT, OpenSourceLicense::ISC);
352 }
353
354 #[test]
355 fn test_isc_negative_detection() {
356 let license_text = format!(
357 r#"{ISC_TXT}
358
359 This project is dual licensed under the ISC License and the MIT License."#
360 );
361
362 assert!(detect_license(&license_text).is_none());
363 }
364
365 #[test]
366 fn test_mit_positive_detection() {
367 assert_matches_license(MIT_TXT, OpenSourceLicense::MIT);
368 }
369
370 #[test]
371 fn test_mit_negative_detection() {
372 let license_text = format!(
373 r#"{MIT_TXT}
374
375 This project is dual licensed under the MIT License and the Apache License, Version 2.0."#
376 );
377 assert!(detect_license(&license_text).is_none());
378 }
379
380 #[test]
381 fn test_upl_positive_detection() {
382 assert_matches_license(UPL_1_0_TXT, OpenSourceLicense::UPL1_0);
383 }
384
385 #[test]
386 fn test_upl_negative_detection() {
387 let license_text = format!(
388 r#"{UPL_1_0_TXT}
389
390 This project is dual licensed under the UPL License and the MIT License."#
391 );
392
393 assert!(detect_license(&license_text).is_none());
394 }
395
396 #[test]
397 fn test_license_file_name_regex() {
398 // Test basic license file names
399 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE"));
400 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENCE"));
401 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"license"));
402 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"licence"));
403
404 // Test with extensions
405 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE.txt"));
406 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE.md"));
407 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENCE.txt"));
408 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENCE.md"));
409
410 // Test with specific license types
411 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE-APACHE"));
412 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE-MIT"));
413 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE.MIT"));
414 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE_MIT"));
415 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE-ISC"));
416 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE-UPL"));
417
418 // Test with "license" coming after
419 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"APACHE-LICENSE"));
420
421 // Test version numbers
422 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"APACHE-2"));
423 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"APACHE-2.0"));
424 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"BSD-1"));
425 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"BSD-2"));
426 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"BSD-3"));
427 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"BSD-3-CLAUSE"));
428
429 // Test combinations
430 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE-MIT.txt"));
431 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENCE.ISC.md"));
432 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"license_upl"));
433 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE.APACHE.2.0"));
434
435 // Test case insensitive
436 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"License"));
437 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"license-mit.TXT"));
438 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENCE_isc.MD"));
439
440 // Test edge cases that should match
441 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"license.mit"));
442 assert!(LICENSE_FILE_NAME_REGEX.is_match(b"licence-upl.txt"));
443
444 // Test non-matching patterns
445 assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"COPYING"));
446 assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE.html"));
447 assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"MYLICENSE"));
448 assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"src/LICENSE"));
449 assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE.old"));
450 assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE-GPL"));
451 assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"LICENSEABC"));
452 }
453
454 #[test]
455 fn test_canonicalize_license_text() {
456 let input = " Paragraph 1\nwith multiple lines\n\n\n\nParagraph 2\nwith more lines\n ";
457 let expected = "paragraph 1 with multiple lines paragraph 2 with more lines";
458 assert_eq!(canonicalize_license_text(input), expected);
459
460 // Test tabs and mixed whitespace
461 let input = "Word1\t\tWord2\n\n Word3\r\n\r\n\r\nWord4 ";
462 let expected = "word1 word2 word3 word4";
463 assert_eq!(canonicalize_license_text(input), expected);
464 }
465
466 #[test]
467 fn test_license_detection_canonicalizes_whitespace() {
468 let mit_with_weird_spacing = unindent(
469 r#"
470 MIT License
471
472
473 Copyright (c) 2024 John Doe
474
475
476 Permission is hereby granted, free of charge, to any person obtaining a copy
477 of this software and associated documentation files (the "Software"), to deal
478 in the Software without restriction, including without limitation the rights
479 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
480 copies of the Software, and to permit persons to whom the Software is
481 furnished to do so, subject to the following conditions:
482
483
484
485 The above copyright notice and this permission notice shall be included in all
486 copies or substantial portions of the Software.
487
488
489
490 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
491 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
492 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
493 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
494 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
495 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
496 SOFTWARE.
497 "#
498 .trim(),
499 );
500
501 assert_matches_license(&mit_with_weird_spacing, OpenSourceLicense::MIT);
502 }
503
504 fn init_test(cx: &mut TestAppContext) {
505 cx.update(|cx| {
506 let settings_store = SettingsStore::test(cx);
507 cx.set_global(settings_store);
508 WorktreeSettings::register(cx);
509 });
510 }
511
512 #[gpui::test]
513 async fn test_watcher_single_file(cx: &mut TestAppContext) {
514 init_test(cx);
515
516 let fs = FakeFs::new(cx.background_executor.clone());
517 fs.insert_tree("/root", json!({ "main.rs": "fn main() {}" }))
518 .await;
519
520 let worktree = Worktree::local(
521 Path::new("/root/main.rs"),
522 true,
523 fs.clone(),
524 Default::default(),
525 &mut cx.to_async(),
526 )
527 .await
528 .unwrap();
529
530 let watcher = cx.update(|cx| LicenseDetectionWatcher::new(&worktree, cx));
531 assert!(matches!(watcher, LicenseDetectionWatcher::SingleFile));
532 assert!(!watcher.is_project_open_source());
533 }
534
535 #[gpui::test]
536 async fn test_watcher_updates_on_changes(cx: &mut TestAppContext) {
537 init_test(cx);
538
539 let fs = FakeFs::new(cx.background_executor.clone());
540 fs.insert_tree("/root", json!({ "main.rs": "fn main() {}" }))
541 .await;
542
543 let worktree = Worktree::local(
544 Path::new("/root"),
545 true,
546 fs.clone(),
547 Default::default(),
548 &mut cx.to_async(),
549 )
550 .await
551 .unwrap();
552
553 let watcher = cx.update(|cx| LicenseDetectionWatcher::new(&worktree, cx));
554 assert!(matches!(watcher, LicenseDetectionWatcher::Local { .. }));
555 assert!(!watcher.is_project_open_source());
556
557 fs.write(Path::new("/root/LICENSE-MIT"), MIT_TXT.as_bytes())
558 .await
559 .unwrap();
560
561 cx.background_executor.run_until_parked();
562 assert!(watcher.is_project_open_source());
563
564 fs.write(Path::new("/root/LICENSE-APACHE"), APACHE_2_0_TXT.as_bytes())
565 .await
566 .unwrap();
567
568 cx.background_executor.run_until_parked();
569 assert!(watcher.is_project_open_source());
570
571 fs.write(Path::new("/root/LICENSE-MIT"), "Nevermind".as_bytes())
572 .await
573 .unwrap();
574
575 // Still considered open source as LICENSE-APACHE is present
576 cx.background_executor.run_until_parked();
577 assert!(watcher.is_project_open_source());
578
579 fs.write(
580 Path::new("/root/LICENSE-APACHE"),
581 "Also nevermind".as_bytes(),
582 )
583 .await
584 .unwrap();
585
586 cx.background_executor.run_until_parked();
587 assert!(!watcher.is_project_open_source());
588 }
589
590 #[gpui::test]
591 async fn test_watcher_initially_opensource_and_then_deleted(cx: &mut TestAppContext) {
592 init_test(cx);
593
594 let fs = FakeFs::new(cx.background_executor.clone());
595 fs.insert_tree(
596 "/root",
597 json!({ "main.rs": "fn main() {}", "LICENSE-MIT": MIT_TXT }),
598 )
599 .await;
600
601 let worktree = Worktree::local(
602 Path::new("/root"),
603 true,
604 fs.clone(),
605 Default::default(),
606 &mut cx.to_async(),
607 )
608 .await
609 .unwrap();
610
611 let watcher = cx.update(|cx| LicenseDetectionWatcher::new(&worktree, cx));
612 assert!(matches!(watcher, LicenseDetectionWatcher::Local { .. }));
613
614 cx.background_executor.run_until_parked();
615 assert!(watcher.is_project_open_source());
616
617 fs.remove_file(
618 Path::new("/root/LICENSE-MIT"),
619 fs::RemoveOptions {
620 recursive: false,
621 ignore_if_not_exists: false,
622 },
623 )
624 .await
625 .unwrap();
626
627 cx.background_executor.run_until_parked();
628 assert!(!watcher.is_project_open_source());
629 }
630}