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