1use super::store::{MemoryError, ProjectScope};
29use serde::{Deserialize, Serialize};
30use std::fs;
31use std::path::{Component, Path, PathBuf};
32use std::process::{Command, Stdio};
33use std::time::{Duration, Instant};
34
35const WAIT_TIMEOUT: Duration = Duration::from_secs(5);
36const POLL_INTERVAL: Duration = Duration::from_millis(10);
37const MARKER: &str = "synaps-memory-identity.json";
38const LOCK: &str = "synaps-memory-identity.lock";
39const MAX_BYTES: u64 = 1024 * 1024;
40const MAX_ROOTS: usize = 4096;
41
42#[cfg(test)]
43thread_local! {
44 static GIT_SPAWNS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
46 static FORCE_SINGLE_REV_PARSE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
48 static GIT_WAITS: std::cell::RefCell<Vec<GitWait>> = const { std::cell::RefCell::new(Vec::new()) };
50 static FORCE_WOULD_BLOCK: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
52}
53
54#[cfg(test)]
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58enum GitWait {
59 PollOutput,
60 Sleep { after_eof: bool, duration: Duration },
61}
62
63#[cfg(test)]
64fn record_wait(wait: GitWait) {
65 GIT_WAITS.with(|w| w.borrow_mut().push(wait));
66}
67
68#[cfg(unix)]
71fn read_pipe(pipe: &mut impl std::io::Read, buffer: &mut [u8]) -> std::io::Result<usize> {
72 #[cfg(test)]
73 if FORCE_WOULD_BLOCK.with(|n| n.replace(n.get().saturating_sub(1))) > 0 {
74 return Err(std::io::ErrorKind::WouldBlock.into());
75 }
76 pipe.read(buffer)
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct RepositoryIdentity {
81 pub scope: ProjectScope,
83 pub legacy_scope: ProjectScope,
85 pub aliases: Vec<ProjectScope>,
89 pub repository_root: PathBuf,
91 pub worktree_root: PathBuf,
93 pub common_dir: Option<PathBuf>,
94}
95
96impl ProjectScope {
97 pub fn discover_repository(start: &Path) -> Result<RepositoryIdentity, MemoryError> {
101 let root = std::env::var_os("SYNAPS_PROJECT_ROOT").map(PathBuf::from);
102 Self::discover_repository_with_override(start, root.as_deref())
103 }
104
105 pub fn discover_repository_with_override(
108 start: &Path,
109 override_root: Option<&Path>,
110 ) -> Result<RepositoryIdentity, MemoryError> {
111 let legacy = Self::discover_with_override(start, override_root)?;
112 Self::validate_repository_key(legacy.key())?;
113 let root = legacy.root().to_path_buf();
114 let has_git = match fs::symlink_metadata(root.join(".git")) {
115 Ok(_) => true,
116 Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
117 Err(e) => return Err(e.into()),
118 };
119 if override_root.is_some() || !has_git {
120 return Ok(RepositoryIdentity {
121 scope: legacy.clone(),
122 legacy_scope: legacy.clone(),
123 aliases: vec![legacy],
124 repository_root: root.clone(),
125 worktree_root: root,
126 common_dir: None,
127 });
128 }
129 resolve(legacy)
130 }
131}
132
133fn invalid(message: impl Into<String>) -> MemoryError {
134 MemoryError::InvalidProjectRoot(message.into())
135}
136
137fn git(root: &Path, args: &[&str]) -> Result<Vec<u8>, MemoryError> {
141 git_output(root, args)?.ok_or_else(|| invalid("Git repository verification failed"))
142}
143
144#[cfg(not(unix))]
146fn git_output(_: &Path, _: &[&str]) -> Result<Option<Vec<u8>>, MemoryError> {
147 Err(invalid(
148 "bounded Git discovery requires Unix nonblocking pipes",
149 ))
150}
151
152#[cfg(unix)]
154fn git_output(root: &Path, args: &[&str]) -> Result<Option<Vec<u8>>, MemoryError> {
155 use std::io::ErrorKind;
156 use std::os::fd::AsRawFd;
157 let mut cmd = Command::new("git");
158 for (name, _) in std::env::vars_os() {
159 if name.as_encoded_bytes().starts_with(b"GIT_") {
160 cmd.env_remove(name);
161 }
162 }
163 cmd.env("GIT_CONFIG_NOSYSTEM", "1")
164 .env("GIT_CONFIG_GLOBAL", "/dev/null")
165 .env("GIT_TERMINAL_PROMPT", "0")
166 .arg("--no-optional-locks")
167 .arg("-C")
168 .arg(root)
169 .args(args)
170 .stdin(Stdio::null())
171 .stdout(Stdio::piped())
172 .stderr(Stdio::null());
173 let deadline = Instant::now() + WAIT_TIMEOUT;
174 #[cfg(test)]
175 GIT_SPAWNS.with(|n| n.set(n.get() + 1));
176 let mut child = cmd.spawn()?;
177 let mut stdout = child.stdout.take().unwrap();
178 let timed_out = || invalid("Git discovery timed out after 5 seconds");
179 let result = (|| {
188 let fd = stdout.as_raw_fd();
189 let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
190 if flags < 0 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 {
191 return Err(std::io::Error::last_os_error().into());
192 }
193 let mut bytes = Vec::new();
194 let mut buffer = [0; 8192];
195 loop {
196 if Instant::now() >= deadline {
197 return Err(timed_out());
198 }
199 match read_pipe(&mut stdout, &mut buffer) {
200 Ok(0) => break,
201 Ok(n) => {
202 if bytes.len() + n > MAX_BYTES as usize {
203 return Err(invalid("Git discovery output too large"));
204 }
205 bytes.extend_from_slice(&buffer[..n]);
206 continue;
207 }
208 Err(e) if e.kind() == ErrorKind::WouldBlock => {}
209 Err(e) if e.kind() == ErrorKind::Interrupted => continue,
210 Err(e) => return Err(e.into()),
211 }
212 let slice = POLL_INTERVAL.min(deadline.saturating_duration_since(Instant::now()));
213 let timeout_ms = slice.as_micros().div_ceil(1000) as libc::c_int;
215 #[cfg(test)]
216 record_wait(GitWait::PollOutput);
217 let mut pfd = libc::pollfd {
218 fd,
219 events: libc::POLLIN,
220 revents: 0,
221 };
222 if unsafe { libc::poll(&mut pfd, 1, timeout_ms) } < 0 {
225 let e = std::io::Error::last_os_error();
226 if e.kind() != ErrorKind::Interrupted {
227 return Err(e.into());
228 }
229 continue;
230 }
231 if let Some(status) = child.try_wait()? {
232 if !status.success() {
233 return Ok(None);
234 }
235 }
236 }
237 let mut backoff = Duration::from_micros(20);
240 loop {
241 if let Some(status) = child.try_wait()? {
242 return Ok(status.success().then_some(bytes));
243 }
244 let remaining = deadline.saturating_duration_since(Instant::now());
245 if remaining.is_zero() {
246 return Err(timed_out());
247 }
248 #[cfg(test)]
249 record_wait(GitWait::Sleep {
250 after_eof: true,
251 duration: backoff.min(remaining),
252 });
253 std::thread::sleep(backoff.min(remaining));
254 backoff = (backoff * 2).min(POLL_INTERVAL);
255 }
256 })();
257 if result.is_err() {
258 let _ = child.kill();
261 let _ = child.wait();
262 }
263 result
264}
265
266fn path_bytes(bytes: &[u8]) -> Result<PathBuf, MemoryError> {
267 #[cfg(unix)]
268 {
269 use std::os::unix::ffi::OsStrExt;
270 Ok(PathBuf::from(std::ffi::OsStr::from_bytes(bytes)))
271 }
272 #[cfg(not(unix))]
273 Ok(PathBuf::from(
274 std::str::from_utf8(bytes).map_err(|_| invalid("non-UTF8 Git path"))?,
275 ))
276}
277
278fn absolute_path(bytes: &[u8]) -> Result<PathBuf, MemoryError> {
279 let path = path_bytes(bytes)?;
280 if !path.is_absolute() {
281 return Err(invalid("Git returned a relative path"));
282 }
283 Ok(path.canonicalize()?)
284}
285
286fn git_path(root: &Path, arg: &str) -> Result<PathBuf, MemoryError> {
287 let bytes = git(root, &["rev-parse", "--path-format=absolute", arg])?;
288 let bytes = bytes
289 .strip_suffix(b"\n")
290 .ok_or_else(|| invalid("invalid Git path output"))?;
291 absolute_path(bytes)
292}
293
294const CHECKOUT_PATHS: [&str; 3] = ["--show-toplevel", "--absolute-git-dir", "--git-common-dir"];
295
296fn three_lines(bytes: &[u8]) -> Option<[&[u8]; 3]> {
300 let body = bytes.strip_suffix(b"\n")?;
301 let lines: Vec<&[u8]> = body.split(|b| *b == b'\n').collect();
302 lines.try_into().ok()
303}
304
305fn checkout_paths(root: &Path) -> Result<Option<[Vec<u8>; 3]>, MemoryError> {
309 #[cfg(test)]
310 if FORCE_SINGLE_REV_PARSE.with(|f| f.get()) {
311 return Ok(None);
312 }
313 let mut args = vec!["rev-parse", "--path-format=absolute"];
314 args.extend(CHECKOUT_PATHS);
315 let Some(bytes) = git_output(root, &args)? else {
316 return Ok(None);
317 };
318 Ok(three_lines(&bytes).map(|lines| lines.map(<[u8]>::to_vec)))
319}
320
321#[derive(PartialEq, Eq)]
322struct Checkout {
323 root: PathBuf,
324 git_dir: PathBuf,
325 common: PathBuf,
326}
327
328fn checkout(root: &Path) -> Result<Checkout, MemoryError> {
329 let entry = fs::symlink_metadata(root.join(".git"))?;
330 if entry.file_type().is_symlink() || !(entry.is_file() || entry.is_dir()) {
331 return Err(invalid("unsafe .git entry"));
332 }
333 let batched = checkout_paths(root)?;
334 let path = |i: usize| match &batched {
335 Some(lines) => absolute_path(&lines[i]),
336 None => git_path(root, CHECKOUT_PATHS[i]),
337 };
338 let top = path(0)?;
339 if top != root {
340 return Err(invalid("Git checkout root mismatch"));
341 }
342 let git_dir = path(1)?;
343 let common = path(2)?;
344 if git_dir != common {
345 if git_dir.parent() != Some(common.join("worktrees").as_path()) {
346 return Err(invalid("unregistered Git worktree directory"));
347 }
348 let back = fs::read(git_dir.join("gitdir"))?;
349 let back = back.strip_suffix(b"\n").unwrap_or(&back);
350 let back = path_bytes(back)?;
351 let back = if back.is_absolute() {
352 back
353 } else {
354 git_dir.join(back)
355 };
356 if back.canonicalize()? != root.join(".git").canonicalize()? {
357 return Err(invalid("Git worktree backlink mismatch"));
358 }
359 }
360 Ok(Checkout {
361 root: top,
362 git_dir,
363 common,
364 })
365}
366
367fn registered(current: &Checkout) -> Result<(PathBuf, Vec<PathBuf>), MemoryError> {
371 let bytes = git(¤t.common, &["worktree", "list", "--porcelain", "-z"])?;
372 let mut roots = Vec::new();
373 let mut main = None;
374 for field in bytes.split(|b| *b == 0) {
375 let Some(bytes) = field.strip_prefix(b"worktree ") else {
376 continue;
377 };
378 if roots.len() >= MAX_ROOTS {
379 return Err(invalid("too many registered worktrees"));
380 }
381 let path = path_bytes(bytes)?;
382 let Ok(path) = path.canonicalize() else {
383 continue;
384 };
385 let Ok(candidate) = checkout(&path) else {
386 continue;
387 };
388 if candidate.common != current.common {
389 continue;
390 }
391 if candidate.git_dir == current.common {
392 if main.as_ref().is_some_and(|other| other != &path) {
393 return Err(invalid("ambiguous main checkout"));
394 }
395 main = Some(path.clone());
396 }
397 roots.push(path);
398 }
399 if current.git_dir == current.common {
400 if main.as_ref().is_some_and(|other| other != ¤t.root) {
401 return Err(invalid("main checkout disagrees with registry"));
402 }
403 if main.is_some() {
406 roots.push(current.root.clone());
407 }
408 } else if !roots.contains(¤t.root) {
409 return Err(invalid("current worktree missing from verified registry"));
410 }
411 roots.push(current.root.clone());
412 roots.sort();
413 roots.dedup();
414 Ok((main.unwrap_or_else(|| current.common.clone()), roots))
415}
416
417#[derive(Serialize, Deserialize)]
418#[serde(deny_unknown_fields)]
419struct Marker {
420 version: u32,
421 key: String,
422 initial_root: PathBuf,
423 roots: Vec<PathBuf>,
424}
425
426impl Marker {
427 fn validate(&self) -> Result<(), MemoryError> {
428 ProjectScope::validate_repository_key(&self.key)?;
429 if self.version != 2 || self.roots.len() > MAX_ROOTS || self.roots.is_empty() {
430 return Err(invalid(
431 "unsupported or malformed repository identity marker",
432 ));
433 }
434 for root in std::iter::once(&self.initial_root).chain(&self.roots) {
435 if !root.is_absolute()
436 || root
437 .components()
438 .any(|c| !matches!(c, Component::RootDir | Component::Normal(_)))
439 {
440 return Err(invalid("noncanonical historical repository path"));
441 }
442 ProjectScope::validate_repository_key(
443 ProjectScope::for_canonical_root(root.clone()).key(),
444 )?;
445 }
446 if !self.roots.contains(&self.initial_root) {
447 return Err(invalid("repository marker roots omit its initial root"));
448 }
449 Ok(())
450 }
451}
452
453fn new_repository_key() -> String {
456 loop {
457 let key = format!("p{:016x}", rand::random::<u64>());
458 if ProjectScope::validate_repository_key(&key).is_ok() {
459 return key;
460 }
461 }
462}
463
464#[cfg(not(unix))]
465fn resolve(_: ProjectScope) -> Result<RepositoryIdentity, MemoryError> {
466 Err(invalid(
467 "private repository identity requires Unix filesystem ownership checks",
468 ))
469}
470
471#[cfg(unix)]
472fn acquire_lock(lock: &fs::File) -> Result<(), MemoryError> {
473 let deadline = Instant::now() + WAIT_TIMEOUT;
474 loop {
475 if Instant::now() >= deadline {
476 return Err(invalid("repository marker lock timed out after 5 seconds"));
477 }
478 match fs4::fs_std::FileExt::try_lock_exclusive(lock) {
479 Ok(true) => return Ok(()),
480 Ok(false) => {}
481 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
482 Err(e) => return Err(e.into()),
483 }
484 std::thread::sleep(POLL_INTERVAL.min(deadline.saturating_duration_since(Instant::now())));
485 }
486}
487
488#[cfg(unix)]
489fn resolve(legacy: ProjectScope) -> Result<RepositoryIdentity, MemoryError> {
490 let current = checkout(legacy.root())?;
491 let dir = private::Directory::open(¤t.common)?;
492 let lock = dir.open_file(LOCK, libc::O_RDWR | libc::O_CREAT)?;
493 acquire_lock(&lock)?;
494 dir.check_file(LOCK, &lock)?;
495 let (repository_root, roots) = registered(¤t)?;
496 let old = match dir.open_file(MARKER, libc::O_RDONLY) {
497 Ok(file) => Some(file),
498 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
499 Err(e) => return Err(e.into()),
500 };
501 let mut marker = if let Some(file) = &old {
502 use std::io::Read;
503 let mut data = Vec::new();
504 file.take(MAX_BYTES + 1).read_to_end(&mut data)?;
505 if data.len() as u64 > MAX_BYTES {
506 return Err(invalid("repository identity marker too large"));
507 }
508 serde_json::from_slice::<Marker>(&data)?
509 } else {
510 Marker {
511 version: 2,
512 key: new_repository_key(),
513 initial_root: repository_root.clone(),
514 roots: vec![repository_root.clone()],
515 }
516 };
517 marker.validate()?;
518 let before = marker.roots.clone();
519 marker.roots.extend(roots);
520 marker.roots.sort();
521 marker.roots.dedup();
522 marker.validate()?;
523 if checkout(legacy.root())? != current {
526 return Err(invalid("Git layout changed during discovery"));
527 }
528 dir.check_path(¤t.common)?;
529 dir.check_file(LOCK, &lock)?;
530 if let Some(file) = &old {
531 dir.check_file(MARKER, file)?;
532 }
533 if old.is_none() || before != marker.roots {
534 let bytes = serde_json::to_vec(&marker)?;
535 if bytes.len() as u64 > MAX_BYTES {
536 return Err(invalid("repository identity marker too large"));
537 }
538 dir.publish(&bytes, old.is_some())?;
539 }
540 let published = dir.open_file(MARKER, libc::O_RDONLY)?;
541 {
542 use std::io::Read;
543 let mut bytes = Vec::new();
544 (&published).take(MAX_BYTES + 1).read_to_end(&mut bytes)?;
545 let expected = serde_json::to_value(&marker)?;
546 if bytes.len() as u64 > MAX_BYTES
547 || serde_json::from_slice::<serde_json::Value>(&bytes)? != expected
548 {
549 return Err(invalid("repository marker changed during discovery"));
550 }
551 }
552 published.sync_all()?;
554 dir.sync_all()?;
555 dir.check_file(LOCK, &lock)?;
556 dir.check_file(MARKER, &published)?;
557 dir.check_path(¤t.common)?;
558 let scope = ProjectScope::from_key(&repository_root, &marker.key)?;
559 let mut aliases = vec![scope.clone()];
560 for root in marker.roots {
561 let alias = ProjectScope::for_canonical_root(root);
562 if !aliases.iter().any(|s| s.key() == alias.key()) {
563 aliases.push(alias);
564 }
565 }
566 Ok(RepositoryIdentity {
567 scope,
568 legacy_scope: legacy.clone(),
569 aliases,
570 repository_root,
571 worktree_root: legacy.root().to_path_buf(),
572 common_dir: Some(current.common),
573 })
574}
575
576#[cfg(unix)]
577mod private {
578 use super::{LOCK, MARKER};
579 use std::ffi::CString;
580 use std::fs::{File, Metadata};
581 use std::io::{self, Write};
582 use std::os::unix::ffi::OsStrExt;
583 use std::os::unix::fs::MetadataExt;
584 use std::os::unix::io::{AsRawFd, FromRawFd};
585 use std::path::{Component, Path};
586
587 pub(super) struct Directory(File);
588
589 fn denied() -> io::Error {
590 io::Error::new(
591 io::ErrorKind::PermissionDenied,
592 "unsafe or changed repository identity filesystem state",
593 )
594 }
595 fn cstr(bytes: &[u8]) -> io::Result<CString> {
596 CString::new(bytes).map_err(|_| denied())
597 }
598 fn opened(fd: libc::c_int) -> io::Result<File> {
599 if fd < 0 {
600 Err(io::Error::last_os_error())
601 } else {
602 Ok(unsafe { File::from_raw_fd(fd) })
603 }
604 }
605 fn same(a: &Metadata, b: &Metadata) -> bool {
606 a.dev() == b.dev() && a.ino() == b.ino()
607 }
608 fn file_ok(meta: &Metadata) -> io::Result<()> {
609 if !meta.is_file()
610 || meta.uid() != unsafe { libc::geteuid() }
611 || meta.mode() & 0o7777 != 0o600
612 || meta.nlink() != 1
613 {
614 return Err(denied());
615 }
616 Ok(())
617 }
618
619 impl Directory {
620 pub(super) fn open(path: &Path) -> io::Result<Self> {
621 if !path.is_absolute() {
622 return Err(denied());
623 }
624 let mut file = File::open("/")?;
625 let uid = unsafe { libc::geteuid() };
626 for part in path.components() {
627 let Component::Normal(name) = part else {
628 if part == Component::RootDir {
629 continue;
630 }
631 return Err(denied());
632 };
633 let name = cstr(name.as_bytes())?;
634 file = opened(unsafe {
635 libc::openat(
636 file.as_raw_fd(),
637 name.as_ptr(),
638 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
639 )
640 })?;
641 let meta = file.metadata()?;
642 if (meta.uid() != uid && meta.uid() != 0)
647 || (meta.mode() & 0o002 != 0 && !(meta.uid() == 0 && meta.mode() & 0o1000 != 0))
648 {
649 return Err(denied());
650 }
651 }
652 let meta = file.metadata()?;
653 if meta.uid() != uid || meta.mode() & 0o002 != 0 {
654 return Err(denied());
655 }
656 Ok(Self(file))
657 }
658
659 pub(super) fn open_file(&self, name: &str, flags: i32) -> io::Result<File> {
660 let name = cstr(name.as_bytes())?;
661 let file = opened(unsafe {
662 libc::openat(
663 self.0.as_raw_fd(),
664 name.as_ptr(),
665 flags | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK,
666 0o600 as libc::c_uint,
669 )
670 })?;
671 file_ok(&file.metadata()?)?;
672 Ok(file)
673 }
674
675 pub(super) fn check_file(&self, name: &str, file: &File) -> io::Result<()> {
676 let meta = file.metadata()?;
677 file_ok(&meta)?;
678 let other = self.open_file(name, libc::O_RDONLY)?;
679 if !same(&meta, &other.metadata()?) {
680 return Err(denied());
681 }
682 Ok(())
683 }
684
685 pub(super) fn check_path(&self, path: &Path) -> io::Result<()> {
686 let other = Self::open(path)?;
687 if !same(&self.0.metadata()?, &other.0.metadata()?) {
688 return Err(denied());
689 }
690 Ok(())
691 }
692
693 pub(super) fn sync_all(&self) -> io::Result<()> {
694 self.0.sync_all()
695 }
696
697 pub(super) fn publish(&self, bytes: &[u8], replace: bool) -> io::Result<()> {
698 let temp = format!("{MARKER}.{}.tmp", uuid::Uuid::new_v4().simple());
699 let mut file = self.open_file(&temp, libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL)?;
700 let temp_c = cstr(temp.as_bytes())?;
701 let final_c = cstr(MARKER.as_bytes())?;
702 let fd = self.0.as_raw_fd();
703 let result = (|| {
704 file.write_all(bytes)?;
705 file.sync_all()?;
706 self.check_file(&temp, &file)?;
707 let rc = if replace {
708 self.open_file(MARKER, libc::O_RDONLY)?;
711 unsafe { libc::renameat(fd, temp_c.as_ptr(), fd, final_c.as_ptr()) }
712 } else {
713 unsafe { libc::linkat(fd, temp_c.as_ptr(), fd, final_c.as_ptr(), 0) }
716 };
717 if rc != 0 {
718 return Err(io::Error::last_os_error());
719 }
720 Ok(())
721 })();
722 debug_assert_ne!(temp, LOCK);
724 let unlinked = unsafe { libc::unlinkat(fd, temp_c.as_ptr(), 0) };
725 result?;
726 if unlinked != 0 && !replace {
727 return Err(io::Error::last_os_error());
728 }
729 self.sync_all()
730 }
731 }
732}
733
734#[cfg(all(test, unix))]
735mod tests {
736 use super::*;
737 use serial_test::serial;
742 use tempfile::TempDir;
743
744 fn fixture_git(root: &Path, args: &[&str]) {
745 let mut command = Command::new("git");
746 for (name, _) in std::env::vars_os() {
747 if name.as_encoded_bytes().starts_with(b"GIT_") {
748 command.env_remove(name);
749 }
750 }
751 let output = command
752 .env("GIT_CONFIG_NOSYSTEM", "1")
753 .env("GIT_CONFIG_GLOBAL", "/dev/null")
754 .env("HOME", root)
755 .args([
756 "-c",
757 "init.defaultBranch=main",
758 "-c",
759 "commit.gpgsign=false",
760 ])
761 .args([
762 "-c",
763 "user.name=Synthetic",
764 "-c",
765 "user.email=test@example.invalid",
766 ])
767 .arg("-C")
768 .arg(root)
769 .args(args)
770 .output()
771 .unwrap();
772 assert!(
773 output.status.success(),
774 "{args:?}: {}",
775 String::from_utf8_lossy(&output.stderr)
776 );
777 }
778
779 fn fixture(tmp: &TempDir, extra: usize, named: &[&str]) -> (PathBuf, Vec<PathBuf>) {
782 let main = tmp.path().join("main");
783 fs::create_dir(&main).unwrap();
784 fixture_git(&main, &["init", "-q"]);
785 fixture_git(&main, &["commit", "-q", "--allow-empty", "-m", "synthetic"]);
786 let main = main.canonicalize().unwrap();
787 let names = (0..extra)
788 .map(|i| format!("wt{i}"))
789 .chain(named.iter().map(|s| s.to_string()));
790 let mut roots = Vec::new();
791 for (i, name) in names.enumerate() {
792 let path = tmp.path().join(name);
793 let branch = format!("b{i}");
794 fixture_git(
795 &main,
796 &[
797 "worktree",
798 "add",
799 "-q",
800 "-b",
801 &branch,
802 path.to_str().unwrap(),
803 ],
804 );
805 roots.push(path.canonicalize().unwrap());
806 }
807 (main, roots)
808 }
809
810 fn spawns<T>(f: impl FnOnce() -> T) -> (T, usize) {
811 let before = GIT_SPAWNS.with(|n| n.get());
812 let value = f();
813 (value, GIT_SPAWNS.with(|n| n.get()) - before)
814 }
815
816 fn single<T>(f: impl FnOnce() -> T) -> T {
817 FORCE_SINGLE_REV_PARSE.with(|f| f.set(true));
818 let value = f();
819 FORCE_SINGLE_REV_PARSE.with(|f| f.set(false));
820 value
821 }
822
823 fn discover(root: &Path) -> RepositoryIdentity {
824 ProjectScope::discover_repository_with_override(root, None).unwrap()
825 }
826
827 #[test]
828 #[serial(umask, process_spawn)]
829 fn discovery_spawns_one_git_per_registered_worktree() {
830 let tmp = TempDir::new().unwrap();
831 let (main, roots) = fixture(&tmp, 40, &[]);
832 let budget = roots.len() + 1 + 4;
834 for start in [&main, &roots[7]] {
835 let (identity, count) = spawns(|| discover(start));
836 assert_eq!(identity.aliases.len(), roots.len() + 2);
837 assert!(count <= budget, "{count} Git spawns > {budget}");
838 }
839 }
840
841 #[test]
842 #[serial(umask, process_spawn)]
843 fn checkout_batches_rev_parse_and_newline_paths_fall_back() {
844 let tmp = TempDir::new().unwrap();
845 let (main, roots) = fixture(&tmp, 1, &["worker a", "worker\n\"b"]);
846 for root in [&main, &roots[0], &roots[1]] {
847 let (batched, count) = spawns(|| checkout(root).unwrap());
848 assert_eq!(count, 1, "{root:?}");
849 assert!(batched == single(|| checkout(root).unwrap()));
850 }
851 let newline = &roots[2];
853 let (batched, count) = spawns(|| checkout(newline).unwrap());
854 assert_eq!(count, 4);
855 assert!(batched == single(|| checkout(newline).unwrap()));
856 assert_eq!(&batched.root, newline);
857 }
858
859 #[test]
860 #[serial(umask, process_spawn)]
861 fn batched_and_single_rev_parse_resolve_identical_identity() {
862 let tmp = TempDir::new().unwrap();
863 let (main, roots) = fixture(&tmp, 3, &["worker a", "worker\n\"b"]);
864 for start in std::iter::once(&main).chain(&roots) {
865 let slow = single(|| discover(start));
866 let fast = discover(start);
867 assert_eq!(fast, slow);
868 assert_eq!(fast.aliases.len(), roots.len() + 2);
869 }
870 assert!(single(|| checkout(&main)).unwrap() == checkout(&main).unwrap());
871 }
872
873 #[test]
874 fn rev_parse_output_needs_exactly_three_terminated_lines() {
875 let ok: [&[u8]; 3] = [b"/a", b"/a/.git", b"/a/.git"];
876 assert_eq!(three_lines(b"/a\n/a/.git\n/a/.git\n"), Some(ok));
877 for bad in [
878 &b""[..],
879 b"\n",
880 b"/a\n/a/.git\n/a/.git",
881 b"/a\n/a/.git\n",
882 b"/x\ny\n/x\ny/.git\n/x\ny/.git\n",
883 b"/a\n/a/.git\n/a/.git\n\n",
884 ] {
885 assert_eq!(three_lines(bad), None, "{bad:?}");
886 }
887 }
888
889 #[test]
897 #[serial(umask, process_spawn)]
898 fn git_waits_for_output_by_poll_not_fixed_sleep() {
899 let tmp = TempDir::new().unwrap();
900 let (main, _) = fixture(&tmp, 0, &[]);
901 for _ in 0..10 {
902 GIT_WAITS.with(|w| w.borrow_mut().clear());
903 FORCE_WOULD_BLOCK.with(|n| n.set(1));
904 let output = git(&main, &["rev-parse", "--git-common-dir"]).unwrap();
905 assert_eq!(output, b".git\n");
906 assert_eq!(FORCE_WOULD_BLOCK.with(|n| n.get()), 0);
907 let waits = GIT_WAITS.with(|w| w.take());
908 assert_eq!(waits.first(), Some(&GitWait::PollOutput), "{waits:?}");
910 let reaping = waits
911 .iter()
912 .position(|w| matches!(w, GitWait::Sleep { .. }))
913 .unwrap_or(waits.len());
914 for (i, wait) in waits.iter().enumerate() {
915 match *wait {
916 GitWait::PollOutput => assert!(i < reaping, "{waits:?}"),
917 GitWait::Sleep {
918 after_eof,
919 duration,
920 } => {
921 assert!(after_eof, "fixed sleep before EOF: {waits:?}");
922 if i == reaping {
923 assert!(duration < Duration::from_millis(1), "{waits:?}");
924 }
925 }
926 }
927 }
928 }
929 }
930
931 #[test]
933 #[serial(umask, process_spawn)]
934 #[ignore]
935 fn discovery_bench_132_worktrees() {
936 let tmp = TempDir::new().unwrap();
937 let (main, roots) = fixture(&tmp, 132, &[]);
938 discover(&main);
939 let mut times = Vec::new();
940 let mut count = 0;
941 for _ in 0..5 {
942 let started = Instant::now();
943 count = spawns(|| discover(&roots[50])).1;
944 times.push(started.elapsed());
945 }
946 times.sort();
947 println!(
948 "discovery 132 worktrees: median {:?} min {:?} max {:?}, {count} Git spawns",
949 times[2], times[0], times[4]
950 );
951 }
952}