From 014f5eb15e020a37d286b708b0f2e8fc6916aedd Mon Sep 17 00:00:00 2001 From: wuwang Date: Sun, 20 Sep 2026 18:44:05 +0800 Subject: [PATCH 1/5] feat(init): write a self-scoped .gitignore inside the index root Init now drops a .gitignore containing only '*' in the index root (.codegraph by default, or the CODEGRAPH_DIR override). A nested .gitignore scopes only to the directory it sits in, so the project's own root .gitignore is never created or modified and the index disappears from git status on its own. The path is derived through a new IndexPaths::gitignore() accessor so the CODEGRAPH_DIR override keeps working and no production code reconstructs .codegraph* strings. The write is idempotent (an existing file is left byte-for-byte untouched) and runs on both exit paths of init, including the already-initialized early return, so pre-existing indexes are backfilled. A write failure logs a warning without failing the command: git never reads the index root as source, so it must not undo an index that index_project already completed. --- crates/codegraph-cli/src/main.rs | 28 ++++++++++++++ crates/codegraph-cli/tests/install_init.rs | 44 ++++++++++++++++++++++ crates/codegraph-core/src/index_paths.rs | 6 +++ docs/cli.md | 2 + 4 files changed, 80 insertions(+) diff --git a/crates/codegraph-cli/src/main.rs b/crates/codegraph-cli/src/main.rs index b69a016c..01a46420 100644 --- a/crates/codegraph-cli/src/main.rs +++ b/crates/codegraph-cli/src/main.rs @@ -1391,6 +1391,32 @@ fn cmd_prompt_hook(path: Option, query: Option) -> Result<()> { Ok(()) } +/// `/.gitignore` content: ignore everything in the index root. +/// +/// A nested `.gitignore` scopes only to the directory it sits in, so this never +/// touches the project's root `.gitignore` or its source tree. +const INDEX_GITIGNORE_CONTENT: &str = "*\n"; + +/// Idempotently write the index root's own `.gitignore`. +/// +/// Convenience only. `git` never reads the index root as source, so a write +/// failure (read-only filesystem, quota) must not undo an index that +/// `index_project` already completed — it logs and returns instead of failing +/// the command. An existing file is left byte-for-byte untouched. +fn ensure_index_gitignore(paths: &codegraph_core::IndexPaths) { + let gitignore = paths.gitignore(); + if gitignore.exists() { + return; + } + if let Err(error) = std::fs::write(&gitignore, INDEX_GITIGNORE_CONTENT) { + tracing::warn!( + path = %gitignore.display(), + %error, + "could not write the index root .gitignore; the index is unaffected" + ); + } +} + fn cmd_init( path: Option, target: &str, @@ -1401,6 +1427,7 @@ fn cmd_init( if explicit_init_observes_readable_current(&project)? { println!("Already initialized in {}", project.display()); println!("Use \"codegraph index\" to re-index or \"codegraph sync\" to update"); + ensure_index_gitignore(&index_paths(&project)?); return installer::run_install_local_targets(project, target); } guard_indexable_root(&project)?; @@ -1415,6 +1442,7 @@ fn cmd_init( )?; println!("Initialized in {}", project.display()); print_index_result(&result); + ensure_index_gitignore(&index_paths(&project)?); installer::run_install_local_targets(project, target) } diff --git a/crates/codegraph-cli/tests/install_init.rs b/crates/codegraph-cli/tests/install_init.rs index c089c674..7f870d1c 100644 --- a/crates/codegraph-cli/tests/install_init.rs +++ b/crates/codegraph-cli/tests/install_init.rs @@ -159,6 +159,50 @@ fn init_yes_is_behavior_neutral_and_non_interactive() { assert!(fixture.project.join(".codegraph/codegraph.db").is_file()); } +#[test] +fn init_writes_a_gitignore_inside_the_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 init_backfills_the_gitignore_for_an_already_initialized_project() { + let fixture = Fixture::new("gitignore-backfill"); + + fixture.run_ok(&["init", "--yes"]); + fs::remove_file(fixture.project.join(".codegraph/.gitignore")).unwrap(); + assert!(!fixture.project.join(".codegraph/.gitignore").exists()); + + let output = fixture.run_ok(&["init", "--yes"]); + assert!(String::from_utf8_lossy(&output.stdout).contains("Already initialized in")); + assert_eq!( + fs::read_to_string(fixture.project.join(".codegraph/.gitignore")).unwrap(), + "*\n" + ); +} + +#[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 daa143fb..9c2c1f94 100644 --- a/crates/codegraph-core/src/index_paths.rs +++ b/crates/codegraph-core/src/index_paths.rs @@ -191,6 +191,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 { @@ -623,6 +628,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/docs/cli.md b/docs/cli.md index 64915178..8809da76 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -221,6 +221,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. +`init` also writes a `.gitignore` containing only `*` **inside the index root** (`/.codegraph/.gitignore` by default, or the `CODEGRAPH_DIR` override). A nested `.gitignore` scopes only to the directory it sits in, so the project's own root `.gitignore` is never created or modified — the index is excluded from `git status` on its own. The write is idempotent: an existing file is left byte-for-byte untouched, and a failure (read-only filesystem, quota) logs a warning without failing the command, since the index itself is already complete. + ```bash codegraph init # index only — no MCP config written (default none) codegraph init --yes # same init flow, explicit non-interactive entry point From 05c841dffef06ee973cc85ca51a844bbf740172c Mon Sep 17 00:00:00 2001 From: sunerpy Date: Thu, 8 Oct 2026 15:42:21 +0800 Subject: [PATCH 2/5] fix(init): constrain index-root gitignore creation - create the nested ignore only for a freshly created leased root\n- refuse raced aliases and leave every existing root untouched\n- cover Git visibility, custom roots, and bilingual user documentation --- crates/codegraph-cli/tests/install_init.rs | 168 +++++++++++++++++++-- crates/codegraph-store/src/index_lease.rs | 102 ++++++++++++- docs/cli.md | 2 +- docs/site/en/guide/quick-start.md | 5 +- docs/site/en/privacy.md | 2 +- docs/site/en/reference/faq.md | 6 +- docs/site/guide/quick-start.md | 2 +- docs/site/privacy.md | 2 +- docs/site/reference/faq.md | 2 +- 9 files changed, 267 insertions(+), 24 deletions(-) diff --git a/crates/codegraph-cli/tests/install_init.rs b/crates/codegraph-cli/tests/install_init.rs index 08980323..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"); @@ -162,7 +190,7 @@ fn init_yes_is_behavior_neutral_and_non_interactive() { } #[test] -fn init_writes_a_gitignore_inside_the_index_root() { +fn init_writes_a_gitignore_only_inside_a_fresh_index_root() { let fixture = Fixture::new("gitignore"); fixture.run_ok(&["init", "--yes"]); @@ -175,18 +203,136 @@ fn init_writes_a_gitignore_inside_the_index_root() { } #[test] -fn init_backfills_the_gitignore_for_an_already_initialized_project() { - let fixture = Fixture::new("gitignore-backfill"); +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(); - assert!(!fixture.project.join(".codegraph/.gitignore").exists()); let output = fixture.run_ok(&["init", "--yes"]); + assert!(String::from_utf8_lossy(&output.stdout).contains("Already initialized in")); - assert_eq!( - fs::read_to_string(fixture.project.join(".codegraph/.gitignore")).unwrap(), - "*\n" + 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" ); } diff --git a/crates/codegraph-store/src/index_lease.rs b/crates/codegraph-store/src/index_lease.rs index cb35f42d..003c97b2 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; @@ -304,7 +304,7 @@ impl IndexLease { .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, @@ -315,7 +315,9 @@ impl IndexLease { }, cancelled, checkpoint, - ) + )?; + ensure_initial_gitignore(paths, &lease); + Ok(lease) } /// Whether this capability represents a shared reader lock. @@ -557,6 +559,60 @@ 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) { + if let Err(error) = create_initial_gitignore(paths, lease) { + 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. `create_new` +/// refuses a dangling symlink/reparse point that appears after the metadata +/// check; the retained exclusive lease binds the operation to this index root. +fn create_initial_gitignore(paths: &IndexPaths, lease: &IndexLease) -> io::Result<()> { + lease + .validate_exclusive(paths) + .map_err(|error| io::Error::other(error.to_string()))?; + let path = paths.gitignore(); + match std::fs::symlink_metadata(&path) { + Ok(metadata) if 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 file = match OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + let metadata = std::fs::symlink_metadata(&path)?; + if 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 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 +918,44 @@ mod tests { competing_handle.unlock().expect("unlock competing entry"); } + #[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 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 09647c39..5690f94c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -306,7 +306,7 @@ 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. -`init` also writes a `.gitignore` containing only `*` **inside the index root** (`/.codegraph/.gitignore` by default, or the `CODEGRAPH_DIR` override). A nested `.gitignore` scopes only to the directory it sits in, so the project's own root `.gitignore` is never created or modified — the index is excluded from `git status` on its own. The write is idempotent: an existing file is left byte-for-byte untouched, and a failure (read-only filesystem, quota) logs a warning without failing the command, since the index itself is already complete. +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 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) 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`。 ## 代码里明明有的调用,为什么查不到? From e944b6dffd285de1c70b0f44e7e710f4d4e327e8 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Thu, 8 Oct 2026 15:43:55 +0800 Subject: [PATCH 3/5] fix(init): remove unsafe post-index gitignore writes --- crates/codegraph-cli/src/main.rs | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/crates/codegraph-cli/src/main.rs b/crates/codegraph-cli/src/main.rs index b45bafbc..564bc482 100644 --- a/crates/codegraph-cli/src/main.rs +++ b/crates/codegraph-cli/src/main.rs @@ -1465,32 +1465,6 @@ fn cmd_prompt_hook(path: Option, query: Option) -> Result<()> { Ok(()) } -/// `/.gitignore` content: ignore everything in the index root. -/// -/// A nested `.gitignore` scopes only to the directory it sits in, so this never -/// touches the project's root `.gitignore` or its source tree. -const INDEX_GITIGNORE_CONTENT: &str = "*\n"; - -/// Idempotently write the index root's own `.gitignore`. -/// -/// Convenience only. `git` never reads the index root as source, so a write -/// failure (read-only filesystem, quota) must not undo an index that -/// `index_project` already completed — it logs and returns instead of failing -/// the command. An existing file is left byte-for-byte untouched. -fn ensure_index_gitignore(paths: &codegraph_core::IndexPaths) { - let gitignore = paths.gitignore(); - if gitignore.exists() { - return; - } - if let Err(error) = std::fs::write(&gitignore, INDEX_GITIGNORE_CONTENT) { - tracing::warn!( - path = %gitignore.display(), - %error, - "could not write the index root .gitignore; the index is unaffected" - ); - } -} - fn cmd_init( path: Option, target: &str, @@ -1501,7 +1475,6 @@ fn cmd_init( if explicit_init_observes_readable_current(&project)? { println!("Already initialized in {}", project.display()); println!("Use \"codegraph index\" to re-index or \"codegraph sync\" to update"); - ensure_index_gitignore(&index_paths(&project)?); return installer::run_install_local_targets(project, target); } guard_indexable_root(&project)?; @@ -1532,7 +1505,6 @@ fn cmd_init( ); } print_index_result(&result); - ensure_index_gitignore(&index_paths(&project)?); installer::run_install_local_targets(project, target) } From a5b7bdce1c89db761fd7590d123ffc6892cf4042 Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Thu, 8 Oct 2026 17:11:42 +0800 Subject: [PATCH 4/5] fix(init): bind index setup to directory capabilities - create the initial namespace, lock, and nested ignore through retained directory handles - refuse project, index-root, and leaf alias replacement races without external writes - pin capability filesystem dependencies and document the no-follow boundary --- Cargo.lock | 110 +++++++ Cargo.toml | 2 + crates/codegraph-store/Cargo.toml | 5 + crates/codegraph-store/src/index_lease.rs | 351 ++++++++++++++++++++-- docs/cli.md | 2 +- 5 files changed, 439 insertions(+), 31 deletions(-) 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-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 003c97b2..1d2878d9 100644 --- a/crates/codegraph-store/src/index_lease.rs +++ b/crates/codegraph-store/src/index_lease.rs @@ -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,46 +266,64 @@ 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); @@ -314,9 +337,9 @@ impl IndexLease { opened_identity, }, cancelled, - checkpoint, + &mut checkpoint, )?; - ensure_initial_gitignore(paths, &lease); + ensure_initial_gitignore(paths, &lease, &root, &mut checkpoint); Ok(lease) } @@ -564,8 +587,13 @@ 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) { - if let Err(error) = create_initial_gitignore(paths, lease) { +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, @@ -574,16 +602,30 @@ fn ensure_initial_gitignore(paths: &IndexPaths, lease: &IndexLease) { } } -/// Create, never replace, the new root's nested ignore file. `create_new` -/// refuses a dangling symlink/reparse point that appears after the metadata -/// check; the retained exclusive lease binds the operation to this index root. -fn create_initial_gitignore(paths: &IndexPaths, lease: &IndexLease) -> io::Result<()> { +/// 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(); - match std::fs::symlink_metadata(&path) { - Ok(metadata) if is_regular(&metadata) => return Ok(()), + 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, @@ -594,11 +636,16 @@ fn create_initial_gitignore(paths: &IndexPaths, lease: &IndexLease) -> io::Resul Err(error) => return Err(error), } - let mut file = match OpenOptions::new().write(true).create_new(true).open(&path) { + 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 = std::fs::symlink_metadata(&path)?; - if is_regular(&metadata) { + let metadata = root.symlink_metadata(name)?; + if cap_metadata_is_regular(&metadata) { return Ok(()); } return Err(io::Error::new( @@ -613,6 +660,94 @@ fn create_initial_gitignore(paths: &IndexPaths, lease: &IndexLease) -> io::Resul 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; + return 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 { @@ -918,6 +1053,95 @@ 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"); @@ -956,6 +1180,73 @@ mod tests { 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 5690f94c..1246da64 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -306,7 +306,7 @@ 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 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. +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) From f2fba45c445c12a5751544cfe340f8ad8a702705 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Thu, 8 Oct 2026 17:33:50 +0800 Subject: [PATCH 5/5] fix(init): satisfy Windows capability lint --- crates/codegraph-store/src/index_lease.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/codegraph-store/src/index_lease.rs b/crates/codegraph-store/src/index_lease.rs index 1d2878d9..6a5c5e38 100644 --- a/crates/codegraph-store/src/index_lease.rs +++ b/crates/codegraph-store/src/index_lease.rs @@ -742,7 +742,7 @@ fn cap_metadata_is_regular(metadata: &cap_std::fs::Metadata) -> bool { { use cap_std::fs::MetadataExt as _; const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; - return metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 } #[cfg(not(windows))] true