diff --git a/Cargo.lock b/Cargo.lock index 3d299c9f..6c8c6ee3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,12 @@ dependencies = [ "cc", ] +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -298,6 +304,48 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +[[package]] +name = "cap-fs-ext" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ff379b70af8e08307a8f65e7040c7301cb4a572538ade16b4984f0da77847f" +dependencies = [ + "cap-primitives", + "cap-std", + "io-lifetimes 3.0.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "cap-primitives" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "cap-std" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ec78e242cfa2cfe276807ac2ecc00315a6c97786977414bcd1c3963b6c91b8" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes 3.0.1", + "rustix", +] + [[package]] name = "cast" version = "0.3.0" @@ -637,8 +685,11 @@ name = "codegraph-store" version = "0.53.3" dependencies = [ "anyhow", + "cap-fs-ext", + "cap-std", "codegraph-core", "rusqlite", + "rustix", "serde", "serde_json", "thiserror", @@ -1194,6 +1245,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1769,6 +1831,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.60.2", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + [[package]] name = "ipnet" version = "2.12.0" @@ -1972,6 +2056,12 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "memchr" version = "2.8.2" @@ -2630,6 +2720,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix", +] + [[package]] name = "rustls" version = "0.23.45" @@ -4375,6 +4475,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.52.0", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index d755b14f..e2362ded 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,8 @@ sha1_smol = "1" rayon = "1.8" globset = "0.4" ignore = "0.4" +cap-fs-ext = "=4.0.3" +cap-std = "=4.0.3" # Logging (rust-base-convention) tracing = "0.1" diff --git a/crates/codegraph-cli/tests/install_init.rs b/crates/codegraph-cli/tests/install_init.rs index 558db637..6ccd497f 100644 --- a/crates/codegraph-cli/tests/install_init.rs +++ b/crates/codegraph-cli/tests/install_init.rs @@ -39,7 +39,17 @@ impl Fixture { } fn command(&self, cwd: &std::path::Path, args: &[&str]) -> Output { - Command::new(bin()) + self.command_with_env(cwd, args, &[]) + } + + fn command_with_env( + &self, + cwd: &std::path::Path, + args: &[&str], + env: &[(&str, &str)], + ) -> Output { + let mut command = Command::new(bin()); + command .args(args) .current_dir(cwd) .env("HOME", &self.home) @@ -48,9 +58,11 @@ impl Fixture { .env("HERMES_HOME", self.root.join("hermes")) .env_remove("CLAUDE_CONFIG_DIR") .env_remove("CODEX_HOME") - .env_remove("APPDATA") - .output() - .expect("run codegraph") + .env_remove("APPDATA"); + for (name, value) in env { + command.env(name, value); + } + command.output().expect("run codegraph") } fn run_ok(&self, args: &[&str]) -> Output { @@ -78,6 +90,22 @@ fn now_nanos() -> u128 { .as_nanos() } +fn symlink_file_for_test(target: &std::path::Path, link: &std::path::Path) -> bool { + #[cfg(unix)] + { + std::os::unix::fs::symlink(target, link).is_ok() + } + #[cfg(windows)] + { + std::os::windows::fs::symlink_file(target, link).is_ok() + } + #[cfg(not(any(unix, windows)))] + { + let _ = (target, link); + false + } +} + #[test] fn install_yes_target_none_init_builds_the_current_project() { let fixture = Fixture::new("one-shot"); @@ -161,6 +189,168 @@ fn init_yes_is_behavior_neutral_and_non_interactive() { assert!(fixture.project.join(".codegraph/codegraph.db").is_file()); } +#[test] +fn init_writes_a_gitignore_only_inside_a_fresh_index_root() { + let fixture = Fixture::new("gitignore"); + + fixture.run_ok(&["init", "--yes"]); + + let gitignore = fixture.project.join(".codegraph/.gitignore"); + assert!(gitignore.is_file(), ".codegraph/.gitignore must be created"); + assert_eq!(fs::read_to_string(&gitignore).unwrap(), "*\n"); + // The project's own root .gitignore is never created or touched. + assert!(!fixture.project.join(".gitignore").exists()); +} + +#[test] +fn a_fresh_index_root_stays_out_of_git_status() { + let fixture = Fixture::new("gitignore-status"); + let git_init = Command::new("git") + .args(["init", "-q"]) + .current_dir(&fixture.project) + .output() + .expect("initialize test git repository"); + assert!(git_init.status.success()); + + fixture.run_ok(&["init", "--yes"]); + + let status = Command::new("git") + .args(["status", "--porcelain=v1", "--untracked-files=all"]) + .current_dir(&fixture.project) + .output() + .expect("read test git status"); + assert!(status.status.success()); + let status = String::from_utf8_lossy(&status.stdout); + assert!( + !status.lines().any(|line| line.contains(".codegraph")), + "the fresh index root must be hidden by its nested .gitignore: {status}" + ); + assert!( + status.lines().any(|line| line == "?? main.rs"), + "the assertion must not hide unrelated project files: {status}" + ); +} + +#[test] +fn init_writes_a_gitignore_inside_a_fresh_custom_index_root() { + let fixture = Fixture::new("gitignore-custom-fresh"); + + let output = fixture.command_with_env( + &fixture.project, + &["init", "--yes"], + &[("CODEGRAPH_DIR", "cache")], + ); + + assert!( + output.status.success(), + "custom-root init failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + fs::read_to_string(fixture.project.join("cache/.gitignore")).unwrap(), + "*\n" + ); + assert!(!fixture.project.join(".codegraph").exists()); +} + +#[test] +fn init_never_backfills_an_existing_index_root() { + let fixture = Fixture::new("gitignore-no-backfill"); + + fixture.run_ok(&["init", "--yes"]); + fs::remove_file(fixture.project.join(".codegraph/.gitignore")).unwrap(); + + let output = fixture.run_ok(&["init", "--yes"]); + + assert!(String::from_utf8_lossy(&output.stdout).contains("Already initialized in")); + assert!( + !fixture.project.join(".codegraph/.gitignore").exists(), + "an existing index root must never be backfilled" + ); +} + +#[test] +fn init_never_hides_an_unrelated_file_in_an_existing_custom_root() { + let fixture = Fixture::new("gitignore-custom-existing"); + let cache = fixture.project.join("cache"); + fs::create_dir(&cache).unwrap(); + fs::write(cache.join("user-note.txt"), "belongs to the user\n").unwrap(); + + let git_init = Command::new("git") + .args(["init", "-q"]) + .current_dir(&fixture.project) + .output() + .expect("initialize test git repository"); + assert!(git_init.status.success()); + + let output = fixture.command_with_env( + &fixture.project, + &["init", "--yes"], + &[("CODEGRAPH_DIR", "cache")], + ); + assert!( + output.status.success(), + "custom-root init failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + assert!( + !cache.join(".gitignore").exists(), + "an existing custom root must not receive a whole-directory ignore" + ); + let status = Command::new("git") + .args(["status", "--porcelain=v1", "--untracked-files=all"]) + .current_dir(&fixture.project) + .output() + .expect("read test git status"); + assert!(status.status.success()); + let status = String::from_utf8_lossy(&status.stdout); + assert!( + status.lines().any(|line| line == "?? cache/user-note.txt"), + "the unrelated file must stay visible in git status: {status}" + ); +} + +#[test] +fn init_never_follows_a_dangling_index_gitignore_symlink() { + let fixture = Fixture::new("gitignore-dangling"); + let index_root = fixture.project.join(".codegraph"); + let external = fixture.root.join("outside-target"); + fs::create_dir(&index_root).unwrap(); + if !symlink_file_for_test(&external, &index_root.join(".gitignore")) { + return; + } + + fixture.run_ok(&["init", "--yes"]); + + assert!( + fs::symlink_metadata(index_root.join(".gitignore")) + .unwrap() + .file_type() + .is_symlink(), + "the dangling link must stay untouched" + ); + assert!( + !external.exists(), + "init must never create a dangling link target outside the index root" + ); +} + +#[test] +fn init_never_overwrites_an_existing_index_gitignore() { + let fixture = Fixture::new("gitignore-idempotent"); + + fixture.run_ok(&["init", "--yes"]); + fs::write(fixture.project.join(".codegraph/.gitignore"), "*.db\n").unwrap(); + + fixture.run_ok(&["init", "--yes"]); + + assert_eq!( + fs::read_to_string(fixture.project.join(".codegraph/.gitignore")).unwrap(), + "*.db\n" + ); +} + #[test] fn install_init_keeps_the_init_unsafe_root_guard() { let fixture = Fixture::new("unsafe-home"); diff --git a/crates/codegraph-core/src/index_paths.rs b/crates/codegraph-core/src/index_paths.rs index 8dd63b43..5217b5fd 100644 --- a/crates/codegraph-core/src/index_paths.rs +++ b/crates/codegraph-core/src/index_paths.rs @@ -198,6 +198,11 @@ impl IndexPaths { self.current_root.join("codegraph.db") } + /// `/.gitignore`. + pub fn gitignore(&self) -> PathBuf { + self.current_root.join(".gitignore") + } + /// `/index.lock` — the permanent lock file (never truncated or /// deleted by normal operation). pub fn permanent_lock(&self) -> PathBuf { @@ -656,6 +661,7 @@ mod tests { assert_eq!(paths.current_root(), root); assert_eq!(paths.current_db(), root.join("codegraph.db")); + assert_eq!(paths.gitignore(), root.join(".gitignore")); assert_eq!(paths.permanent_lock(), root.join("index.lock")); assert_eq!( paths.state_slots(), diff --git a/crates/codegraph-store/Cargo.toml b/crates/codegraph-store/Cargo.toml index 6875c0df..5afbf5a8 100644 --- a/crates/codegraph-store/Cargo.toml +++ b/crates/codegraph-store/Cargo.toml @@ -27,3 +27,8 @@ anyhow = { workspace = true } thiserror = { workspace = true } rusqlite = { workspace = true } tracing = { workspace = true } +cap-std = { workspace = true } +cap-fs-ext = { workspace = true } + +[target.'cfg(unix)'.dependencies] +rustix = { workspace = true, features = ["fs"] } diff --git a/crates/codegraph-store/src/index_lease.rs b/crates/codegraph-store/src/index_lease.rs index cb35f42d..6a5c5e38 100644 --- a/crates/codegraph-store/src/index_lease.rs +++ b/crates/codegraph-store/src/index_lease.rs @@ -6,13 +6,13 @@ //! calls in a bounded loop with a monotonic deadline and cancellation checks. use std::fs::{File, OpenOptions}; -use std::io; +use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant}; #[cfg(feature = "test-hooks")] -use std::io::{Read, Write}; +use std::io::Read; use codegraph_core::IndexPaths; use thiserror::Error; @@ -30,11 +30,14 @@ enum LeaseMode { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum AcquireCheckpoint { + ProjectRootOpened, RootCreated, InitialMetadataValidated, HandleOpened, KernelLockAcquired, FinalPathCorroborated, + InitialGitignoreLeaseValidated, + InitialGitignoreRootOpened, } #[derive(Debug)] @@ -253,7 +256,9 @@ impl IndexLease { mut cancelled: impl FnMut() -> bool, mut checkpoint: impl FnMut(AcquireCheckpoint), ) -> Result { - let root = paths.current_root(); + use cap_fs_ext::{DirExt as _, FollowSymlinks, OpenOptionsFollowExt as _}; + + let root_path = paths.current_root(); let lock_path = paths.permanent_lock(); if cancelled() { return Err(IndexLeaseError::Cancelled { path: lock_path }); @@ -261,50 +266,68 @@ impl IndexLease { if Instant::now() >= deadline { return Err(IndexLeaseError::TimedOut { path: lock_path }); } - match std::fs::symlink_metadata(root) { + let project = + cap_std::fs::Dir::from_std_file(open_directory_no_follow(paths.project()).map_err( + |source| IndexLeaseError::CreateRoot { + path: paths.project().to_path_buf(), + source, + }, + )?); + checkpoint(AcquireCheckpoint::ProjectRootOpened); + debug_assert_eq!(root_path.parent(), Some(paths.project())); + let root_name = root_path + .file_name() + .expect("IndexPaths current root always has a file name"); + match project.symlink_metadata(root_name) { Ok(_) => { return Err(IndexLeaseError::NamespaceAlreadyExists { - path: root.to_path_buf(), + path: root_path.to_path_buf(), }); } Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} Err(source) => { return Err(IndexLeaseError::CreateRoot { - path: root.to_path_buf(), + path: root_path.to_path_buf(), source, }); } } - let parent = root - .parent() - .expect("IndexPaths current root always has a parent"); - std::fs::create_dir_all(parent).map_err(|source| IndexLeaseError::CreateRoot { - path: parent.to_path_buf(), - source, - })?; - std::fs::create_dir(root).map_err(|source| { + project.create_dir(root_name).map_err(|source| { if source.kind() == io::ErrorKind::AlreadyExists { IndexLeaseError::NamespaceAlreadyExists { - path: root.to_path_buf(), + path: root_path.to_path_buf(), } } else { IndexLeaseError::CreateRoot { - path: root.to_path_buf(), + path: root_path.to_path_buf(), source, } } })?; + let root = + project + .open_dir_nofollow(root_name) + .map_err(|source| IndexLeaseError::CreateRoot { + path: root_path.to_path_buf(), + source, + })?; checkpoint(AcquireCheckpoint::RootCreated); - let file = OpenOptions::new() + let lock_name = lock_path + .file_name() + .expect("IndexPaths permanent lock always has a file name"); + let mut lock_options = cap_std::fs::OpenOptions::new(); + lock_options .read(true) .write(true) .create_new(true) - .truncate(false) - .open(&lock_path) + .follow(FollowSymlinks::No); + let file = root + .open_with(lock_name, &lock_options) + .map(cap_std::fs::File::into_std) .map_err(|source| classify_create_error(&lock_path, source))?; let opened_identity = opened_identity(&file, &lock_path, None)?; checkpoint(AcquireCheckpoint::HandleOpened); - Self::acquire_file( + let lease = Self::acquire_file( file, PendingAcquisition { lock_path, @@ -314,8 +337,10 @@ impl IndexLease { opened_identity, }, cancelled, - checkpoint, - ) + &mut checkpoint, + )?; + ensure_initial_gitignore(paths, &lease, &root, &mut checkpoint); + Ok(lease) } /// Whether this capability represents a shared reader lock. @@ -557,6 +582,172 @@ fn lease_test_wait(marker: u8, selector_env: &str) { assert_eq!(release, [b'R'], "invalid lease test barrier release byte"); } +const INITIAL_GITIGNORE_BYTES: &[u8] = b"*\n"; + +/// Best-effort Git hygiene for a namespace this process just created. Existing +/// roots never reach this function, so a custom/config-only root is never +/// backfilled and unrelated files cannot become hidden retroactively. +fn ensure_initial_gitignore( + paths: &IndexPaths, + lease: &IndexLease, + root: &cap_std::fs::Dir, + checkpoint: &mut impl FnMut(AcquireCheckpoint), +) { + if let Err(error) = create_initial_gitignore(paths, lease, root, checkpoint) { + tracing::warn!( + path = %paths.gitignore().display(), + %error, + "could not create the new index root's .gitignore; the index is unaffected" + ); + } +} + +/// Create, never replace, the new root's nested ignore file. The root is opened +/// without following aliases and every child operation is relative to that +/// retained directory capability, so replacing the pathname cannot redirect a +/// later create outside the namespace. +fn create_initial_gitignore( + paths: &IndexPaths, + lease: &IndexLease, + root: &cap_std::fs::Dir, + checkpoint: &mut impl FnMut(AcquireCheckpoint), +) -> io::Result<()> { + use cap_fs_ext::{FollowSymlinks, OpenOptionsFollowExt as _}; + + lease + .validate_exclusive(paths) + .map_err(|error| io::Error::other(error.to_string()))?; + checkpoint(AcquireCheckpoint::InitialGitignoreLeaseValidated); + validate_initial_root(paths, lease, root)?; + checkpoint(AcquireCheckpoint::InitialGitignoreRootOpened); + let path = paths.gitignore(); + let name = path + .file_name() + .expect("IndexPaths gitignore always has a file name"); + match root.symlink_metadata(name) { + Ok(metadata) if cap_metadata_is_regular(&metadata) => return Ok(()), + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "refusing to replace a non-regular .gitignore entry", + )); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + + let mut options = cap_std::fs::OpenOptions::new(); + options + .write(true) + .create_new(true) + .follow(FollowSymlinks::No); + let mut file = match root.open_with(name, &options) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + let metadata = root.symlink_metadata(name)?; + if cap_metadata_is_regular(&metadata) { + return Ok(()); + } + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "refusing to follow or replace a raced .gitignore alias", + )); + } + Err(error) => return Err(error), + }; + file.write_all(INITIAL_GITIGNORE_BYTES)?; + file.flush()?; + file.sync_all() +} + +fn validate_initial_root( + paths: &IndexPaths, + lease: &IndexLease, + root: &cap_std::fs::Dir, +) -> io::Result<()> { + use cap_fs_ext::{FollowSymlinks, OpenOptionsFollowExt as _}; + + let lock_path = paths.permanent_lock(); + let lock_name = lock_path + .file_name() + .expect("IndexPaths permanent lock always has a file name"); + let mut options = cap_std::fs::OpenOptions::new(); + options.read(true).follow(FollowSymlinks::No); + let relative_lock = root.open_with(lock_name, &options)?.into_std(); + if !is_regular(&relative_lock.metadata()?) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "opened index root does not contain a regular permanent lock", + )); + } + if identity_for_file(&relative_lock)? != identity_for_file(&lease.inner.file)? { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "opened index root does not contain the leased permanent lock", + )); + } + Ok(()) +} + +#[cfg(unix)] +fn open_directory_no_follow(path: &Path) -> io::Result { + use rustix::fs::{Mode, OFlags}; + + Ok(File::from(rustix::fs::open( + path, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + )?)) +} + +#[cfg(windows)] +fn open_directory_no_follow(path: &Path) -> io::Result { + use std::os::windows::fs::OpenOptionsExt as _; + + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; + + let file = OpenOptions::new() + .read(true) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + // Omitting FILE_SHARE_DELETE pins the opened root while relative child + // operations use it, matching cap-std's Windows directory contract. + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .open(path)?; + let metadata = file.metadata()?; + if !metadata.is_dir() || is_alias(&metadata) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "index root is not a non-aliased directory", + )); + } + Ok(file) +} + +#[cfg(not(any(unix, windows)))] +fn open_directory_no_follow(_path: &Path) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "safe directory-relative creation is unsupported on this platform", + )) +} + +fn cap_metadata_is_regular(metadata: &cap_std::fs::Metadata) -> bool { + if !metadata.is_file() || metadata.is_symlink() { + return false; + } + #[cfg(windows)] + { + use cap_std::fs::MetadataExt as _; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 + } + #[cfg(not(windows))] + true +} + fn validated_path_metadata(lock_path: &Path) -> Result { let metadata = std::fs::symlink_metadata(lock_path).map_err(|source| { if source.kind() == io::ErrorKind::NotFound { @@ -862,6 +1053,200 @@ mod tests { competing_handle.unlock().expect("unlock competing entry"); } + #[test] + fn initial_lock_creation_never_follows_a_replaced_index_root() { + let project = TempProject::new("lock-parent-race"); + let external = TempProject::new("lock-parent-external"); + let paths = project.paths(); + let displaced = project.0.join("displaced-lock-index-root"); + + let mut replaced = false; + let error = IndexLease::create_exclusive_with( + &paths, + deadline(), + || false, + |point| { + if point == AcquireCheckpoint::RootCreated && !replaced { + std::fs::rename(paths.current_root(), &displaced) + .expect("displace the opened root before lock creation"); + symlink(&external.0, paths.current_root()) + .expect("replace the root with an external alias"); + replaced = true; + } + }, + ) + .expect_err("the replacement path must never become lock authority"); + + assert!(matches!( + error, + IndexLeaseError::LockChangedDuringAcquisition { path } + if path == paths.permanent_lock() + )); + assert!( + !external.0.join("index.lock").exists(), + "initial lock creation must stay relative to the opened root" + ); + assert!( + displaced.join("index.lock").is_file(), + "the no-longer-authoritative directory received the relative create" + ); + assert!( + !external.0.join(".gitignore").exists(), + "failed lock corroboration must not reach Git hygiene" + ); + } + + #[test] + fn initial_namespace_creation_never_follows_a_replaced_project_root() { + let project = TempProject::new("project-parent-race"); + let external = TempProject::new("project-parent-external"); + let paths = project.paths(); + let displaced = project.0.with_file_name(format!( + "codegraph-index-lease-displaced-project-{}-{}", + std::process::id(), + NEXT_TEMP.fetch_add(1, Ordering::Relaxed) + )); + + let mut replaced = false; + let error = IndexLease::create_exclusive_with( + &paths, + deadline(), + || false, + |point| { + if point == AcquireCheckpoint::ProjectRootOpened && !replaced { + std::fs::rename(&project.0, &displaced) + .expect("displace the opened project root"); + symlink(&external.0, &project.0) + .expect("replace the project path with an external alias"); + replaced = true; + } + }, + ) + .expect_err("the replacement project path must never become lock authority"); + + assert!(matches!( + error, + IndexLeaseError::LockChangedDuringAcquisition { path } + if path == paths.permanent_lock() + )); + assert!( + !external.0.join(".codegraph").exists(), + "namespace creation must stay relative to the opened project" + ); + assert!( + displaced.join(".codegraph/index.lock").is_file(), + "the relative create stays with the opened project capability" + ); + + std::fs::remove_file(&project.0).expect("remove replacement project alias"); + std::fs::rename(&displaced, &project.0).expect("restore project for cleanup"); + } + + #[test] + fn initial_gitignore_never_follows_an_alias_raced_after_lock_validation() { + let project = TempProject::new("gitignore-alias-race"); + let paths = project.paths(); + let external = project.0.with_file_name(format!( + "codegraph-index-lease-external-{}-{}", + std::process::id(), + NEXT_TEMP.fetch_add(1, Ordering::Relaxed) + )); + + let mut installed = false; + let lease = IndexLease::create_exclusive_with( + &paths, + deadline(), + || false, + |point| { + if point == AcquireCheckpoint::FinalPathCorroborated && !installed { + symlink(&external, paths.gitignore()) + .expect("install dangling gitignore after lock validation"); + installed = true; + } + }, + ) + .expect("the index lease remains valid when optional git hygiene is refused"); + + assert!( + std::fs::symlink_metadata(paths.gitignore()) + .expect("raced alias remains") + .file_type() + .is_symlink() + ); + assert!( + !external.exists(), + "the best-effort gitignore write must not follow the raced alias" + ); + drop(lease); + } + + #[test] + fn initial_gitignore_never_follows_a_replaced_index_root() { + let project = TempProject::new("gitignore-parent-race"); + let external = TempProject::new("gitignore-parent-external"); + let paths = project.paths(); + let displaced = project.0.join("displaced-index-root"); + + let mut replaced = false; + let lease = IndexLease::create_exclusive_with( + &paths, + deadline(), + || false, + |point| { + if point == AcquireCheckpoint::InitialGitignoreLeaseValidated && !replaced { + std::fs::rename(paths.current_root(), &displaced) + .expect("displace the validated index root"); + symlink(&external.0, paths.current_root()) + .expect("replace the index root with an external alias"); + replaced = true; + } + }, + ) + .expect("optional Git hygiene must not invalidate the acquired lease"); + + assert!( + !external.0.join(".gitignore").exists(), + "a replaced index root must never redirect .gitignore creation outside the project" + ); + drop(lease); + } + + #[test] + fn initial_gitignore_uses_the_opened_root_after_its_path_is_replaced() { + let project = TempProject::new("gitignore-open-root-race"); + let external = TempProject::new("gitignore-open-root-external"); + let paths = project.paths(); + let displaced = project.0.join("displaced-open-index-root"); + + let mut replaced = false; + let lease = IndexLease::create_exclusive_with( + &paths, + deadline(), + || false, + |point| { + if point == AcquireCheckpoint::InitialGitignoreRootOpened && !replaced { + std::fs::rename(paths.current_root(), &displaced) + .expect("displace the opened index root"); + symlink(&external.0, paths.current_root()) + .expect("replace the opened root path with an external alias"); + replaced = true; + } + }, + ) + .expect("directory-relative Git hygiene keeps the acquired lease valid"); + + assert!( + !external.0.join(".gitignore").exists(), + "relative creation must never follow the replacement path" + ); + assert_eq!( + std::fs::read(displaced.join(".gitignore")).unwrap(), + INITIAL_GITIGNORE_BYTES, + "the create remains bound to the directory capability that was opened" + ); + drop(lease); + } + #[test] fn lockless_root_recovery_lock_not_found_is_actionable() { let path = PathBuf::from("/tmp/project/.codegraph/index.lock"); diff --git a/docs/cli.md b/docs/cli.md index 3a205fc7..1246da64 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -306,6 +306,8 @@ the same target values as `install` (csv ids such as `kiro,cursor`, plus `auto`, config and its `--path` are written under the project being initialized, even when the `[path]` argument differs from the current directory. It is idempotent. +When CodeGraph creates a previously absent index root, it also creates a nested `.gitignore` containing only `*` (`/.codegraph/.gitignore` by default, or under the `CODEGRAPH_DIR` override). The file is created relative to a no-follow directory capability, without following or replacing an existing entry, and only after the new root's exclusive lease is held. An index root that already existed is never backfilled or modified, so unrelated files in a pre-existing custom root do not become hidden. The project's own root `.gitignore` is never created or changed. Failure to create this convenience file logs a warning but does not abort indexing. + ```bash codegraph init # index only — no MCP config written (default none) codegraph init --yes # same init flow, explicit non-interactive entry point diff --git a/docs/site/en/guide/quick-start.md b/docs/site/en/guide/quick-start.md index 01980536..cf50971c 100644 --- a/docs/site/en/guide/quick-start.md +++ b/docs/site/en/guide/quick-start.md @@ -94,8 +94,9 @@ Indexed 4 files ``` `init` creates `.codegraph/` in the project and indexes every file it recognises. Files ignored by `.gitignore`, and -dependency and build directories such as `node_modules` and `target`, are left out. Add `.codegraph/` to your -`.gitignore`: it is a local cache, rebuilt from the source at any time. +dependency and build directories such as `node_modules` and `target`, are left out. A newly created index directory +contains its own `.gitignore`, so the local cache stays out of `git status`. CodeGraph does not add that file to an +index directory that already existed; add the directory to the project's `.gitignore` yourself in that case. ## 2. Check it diff --git a/docs/site/en/privacy.md b/docs/site/en/privacy.md index c3e776c5..b1bc448a 100644 --- a/docs/site/en/privacy.md +++ b/docs/site/en/privacy.md @@ -11,7 +11,7 @@ The files of the project you index, within the rules described in [Configuration | Where | What | Written by | | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -| `/.codegraph/` | The SQLite index, its settings and the background process's lock, socket and log | `init`, `index`, `sync`, and the background process | +| `/.codegraph/` | The SQLite index, its settings, a nested `.gitignore` when CodeGraph creates the directory, and the background process's lock, socket and log | `init`, `index`, `sync`, and the background process | | `/.codegraph/ui/trails/` | Trails you chose to save in the viewer | the viewer, only when you save one | | `/.codegraph/diagnostics/` | Diagnostic logs | `init`, `index` and `sync`, only with `--debug` | | The file you name | A diagnostic log | `--debug-log ` on `init`, `index` or `sync` | diff --git a/docs/site/en/reference/faq.md b/docs/site/en/reference/faq.md index e479989d..ab71f2d5 100644 --- a/docs/site/en/reference/faq.md +++ b/docs/site/en/reference/faq.md @@ -10,8 +10,10 @@ that go online are the install scripts and `codegraph self-update`, which downlo ## Should I commit `.codegraph/`? -No. It is a local cache that can be rebuilt from the source at any time, and its contents depend on the machine. Add -`.codegraph/` to your `.gitignore`. +No. It is a local cache that can be rebuilt from the source at any time, and its contents depend on the machine. A +new index directory contains a nested `.gitignore`, so it stays out of `git status` without changing the project's +root `.gitignore`. CodeGraph leaves an index directory that already existed untouched; add that directory to the +project's `.gitignore` yourself if it is still visible. ## A call I can see in the code is missing. Why? diff --git a/docs/site/guide/quick-start.md b/docs/site/guide/quick-start.md index 6136d2d7..4fdf5d12 100644 --- a/docs/site/guide/quick-start.md +++ b/docs/site/guide/quick-start.md @@ -91,7 +91,7 @@ Indexed 4 files 22 nodes, 39 edges in 94ms ``` -`init` 在项目中创建 `.codegraph/`,并索引它能识别的所有文件。`.gitignore` 忽略的文件,以及 `node_modules`、`target` 等依赖和构建目录,不会被索引。请把 `.codegraph/` 加入 `.gitignore`:它是本地缓存,随时可以从源码重建。 +`init` 在项目中创建 `.codegraph/`,并索引它能识别的所有文件。`.gitignore` 忽略的文件,以及 `node_modules`、`target` 等依赖和构建目录,不会被索引。新建的索引目录包含自己的 `.gitignore`,因此这个本地缓存不会出现在 `git status` 中。如果索引目录原本已经存在,CodeGraph 不会补写或修改该文件;此时请自行把索引目录加入项目的 `.gitignore`。 ## 2. 检查索引 diff --git a/docs/site/privacy.md b/docs/site/privacy.md index 8fd0e129..719c6e9c 100644 --- a/docs/site/privacy.md +++ b/docs/site/privacy.md @@ -10,7 +10,7 @@ | 位置 | 内容 | 写入者 | | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | -| `<项目>/.codegraph/` | SQLite 索引、索引设置,以及后台进程的锁、socket 和日志 | `init`、`index`、`sync` 和后台进程 | +| `<项目>/.codegraph/` | SQLite 索引、索引设置、CodeGraph 新建该目录时写入的嵌套 `.gitignore`,以及后台进程的锁、socket 和日志 | `init`、`index`、`sync` 和后台进程 | | `<项目>/.codegraph/ui/trails/` | 你在查看器中主动保存的 Trail | 查看器,仅在你保存时 | | `<项目>/.codegraph/diagnostics/` | 诊断日志 | `init`、`index` 和 `sync`,仅在使用 `--debug` 时 | | 你指定的文件 | 诊断日志 | `init`、`index` 或 `sync` 的 `--debug-log <文件>` | diff --git a/docs/site/reference/faq.md b/docs/site/reference/faq.md index a6c18bbe..6a27d6c7 100644 --- a/docs/site/reference/faq.md +++ b/docs/site/reference/faq.md @@ -8,7 +8,7 @@ ## 要把 `.codegraph/` 提交到仓库吗? -不需要。它是本地缓存,随时可以从源码重建,内容也与所在机器有关。请把 `.codegraph/` 加入 `.gitignore`。 +不需要。它是本地缓存,随时可以从源码重建,内容也与所在机器有关。新建的索引目录包含嵌套的 `.gitignore`,无需修改项目根目录的 `.gitignore`,也不会出现在 `git status` 中。如果索引目录原本已经存在,CodeGraph 会保持原样;该目录仍可见时,请自行把它加入项目的 `.gitignore`。 ## 代码里明明有的调用,为什么查不到?