docs(comments): rewrite comments across all crates to the guidelines

Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
This commit is contained in:
2026-07-23 16:55:39 -04:00
parent ff0fb56c67
commit a02b555e66
1458 changed files with 10729 additions and 21750 deletions
@@ -1244,7 +1244,7 @@ mod tests {
let old = now_ms() - SESSION_IDLE_PRUNE_MS - 1000;
session.idle_since_ms.store(old, Ordering::Relaxed);
}
// Prune should remove the session and its call_to_session entries.
// Prune should drop the session and its call_to_session entries.
t.known_sessions();
assert!(!t.sessions.contains_key("stale"));
// Verify call_to_session was cleaned (no dangling entries).
@@ -1449,7 +1449,7 @@ mod tests {
);
}
// ── events.jsonl emission ─────────────────────────────────
// events.jsonl emission
/// Build a tracker whose event sink points at a fresh tempdir, with the
/// `events.jsonl` writer for `session` pre-opened (as turn-start would do).
@@ -111,7 +111,7 @@ const _: () = assert!(
/// Maps `(CapabilityMode, ToolKind)` -> kept-or-dropped.
///
/// This `match` is intentionally exhaustive: when `ToolKind` gains a
/// This `match` is deliberately exhaustive: when `ToolKind` gains a
/// new variant the compiler errors here, forcing a triage decision.
pub(crate) fn kind_allowed(mode: CapabilityMode, kind: ToolKind) -> bool {
use CapabilityMode as M;
@@ -157,9 +157,7 @@ pub(crate) fn kind_allowed(mode: CapabilityMode, kind: ToolKind) -> bool {
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
@@ -285,9 +283,7 @@ mod tests {
}
}
// -----------------------------------------------------------------------
// is_subset_of partial order
// -----------------------------------------------------------------------
#[test]
fn capability_mode_is_subset_of_reflexive() {
+1 -1
View File
@@ -1,7 +1,7 @@
//! Shared transport types used by workspace communication layers.
//!
//! The `WorkspaceChannel` trait and `MpscChannel` in-process implementation
//! have been removed. Sessions now use `WorkspaceHandle` directly (local mode)
//! are gone. Sessions now use `WorkspaceHandle` directly (local mode)
//! or `ToolHarness` RPC calls (proxy mode). These shared types remain for
//! backward compatibility with code that references them.
+1 -1
View File
@@ -176,7 +176,7 @@ impl AgentSessionConfig {
}
}
}
/// WARNING: `tool_config` is intentionally redacted from `Debug` output
/// WARNING: `tool_config` is deliberately redacted from `Debug` output
/// because `ToolServerConfig.tools[*].params` may contain credentials.
impl std::fmt::Debug for AgentSessionConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+6 -16
View File
@@ -22,9 +22,7 @@ pub use kigi_agent::plugins::discovery::DiscoveryConfig as PluginDiscoveryConfig
pub use kigi_agent::plugins::trust::TrustStore as PluginTrustStore;
pub use kigi_agent::prompt::skills::SkillsConfig;
// ---------------------------------------------------------------------------
// Skill discovery
// ---------------------------------------------------------------------------
/// Discover skills visible from the workspace root.
///
@@ -62,9 +60,7 @@ pub async fn discover_skills(root_cwd: &Path, config: &SkillsConfig) -> Vec<Valu
.collect()
}
// ---------------------------------------------------------------------------
// AGENTS.md discovery
// ---------------------------------------------------------------------------
/// Discover project-instruction files (AGENTS.md, Claude.md, rules) from the workspace root up to the git root.
pub async fn discover_agents_md(root_cwd: &Path) -> Vec<Value> {
@@ -101,9 +97,7 @@ pub async fn discover_agents_md(root_cwd: &Path) -> Vec<Value> {
.collect()
}
// ---------------------------------------------------------------------------
// Plugin discovery
// ---------------------------------------------------------------------------
/// Discover plugins visible from the workspace root.
///
@@ -149,9 +143,7 @@ pub fn discover_plugins(
.collect()
}
// ---------------------------------------------------------------------------
// Project config
// ---------------------------------------------------------------------------
/// Load the project config from `<root_cwd>/.kigi/config.toml`.
///
@@ -199,9 +191,7 @@ fn toml_to_json(v: &toml::Value) -> Value {
}
}
// ---------------------------------------------------------------------------
// Permissions
// ---------------------------------------------------------------------------
/// Load the effective permission configuration for the workspace.
///
@@ -250,7 +240,7 @@ mod tests {
use super::*;
use std::fs;
// ---- Skill discovery tests ----
// Skill discovery tests
// Note: `list_skills` also discovers user-scoped skills from
// `~/.kigi/skills/`, so on a developer machine the result may be
@@ -328,7 +318,7 @@ mod tests {
assert!(found["scope"].is_string(), "scope should be serialized");
}
// ---- AGENTS.md discovery tests ----
// AGENTS.md discovery tests
#[test]
fn agent_config_file_wire_matches_workspace_types_mirror() {
@@ -412,7 +402,7 @@ mod tests {
);
}
// ---- Plugin discovery tests ----
// Plugin discovery tests
// Note: `discover_plugins` also discovers user-scoped plugins
// from `~/.kigi/plugins/`, so tests check for specific plugins.
@@ -472,7 +462,7 @@ mod tests {
assert!(p["has_skills"].is_boolean());
}
// ---- Project config tests ----
// Project config tests
#[test]
fn load_project_config_missing_file_returns_null() {
@@ -505,7 +495,7 @@ mod tests {
);
}
// ---- toml_to_json tests ----
// toml_to_json tests
#[test]
fn toml_to_json_basic_types() {
@@ -532,7 +522,7 @@ mod tests {
assert_eq!(json["items"][2], 3);
}
// ---- Permissions tests ----
// Permissions tests
// Note: `resolve_permissions_with_provenance` checks system-managed
// settings and requirements.toml from the global config, so on a
+2 -1
View File
@@ -98,7 +98,8 @@ fn try_direnv_export(dir: &Path) -> Option<HashMap<String, String>> {
if let serde_json::Value::String(s) = v {
Some((k, s))
} else {
None // Skip null values (unset)
// Skip null values (unset)
None
}
})
.collect();
+1 -1
View File
@@ -4,7 +4,7 @@ use crate::capability::CapabilityMode;
/// Errors surfaced by the workspace public API.
///
/// `#[non_exhaustive]` so adding new variants is a non-breaking change.
/// `#[non_exhaustive]` so introducing new variants is a non-breaking extension.
/// Tests should match on variants rather than scrape the `Display` text.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
@@ -7,12 +7,10 @@ pub struct AcpSessionFs {
root: PathBuf,
gateway: GatewaySender,
session_id: acp::SessionId,
/// When set, any path under `display_cwd` is rewritten to `root` before
/// being sent to the extension. This is the defense-in-depth guard for
/// AB overlay isolation: if a tool accidentally passes the display path
/// (e.g., `/testbed/project/foo.rs`) instead of the overlay path
/// (`~/.kigi/worktrees/.../b-overlay/foo.rs`), the adapter rewrites it
/// so the extension reads/writes to the correct overlay location.
/// Defense-in-depth guard for AB overlay isolation: the model sees
/// `display_cwd` (e.g. `/testbed/project`) but the extension must act on
/// `root` (`~/.kigi/worktrees/.../b-overlay`), so any path under
/// `display_cwd` is rebased onto `root` before the request goes out.
display_cwd: Option<PathBuf>,
}
@@ -26,18 +24,11 @@ impl AcpSessionFs {
}
}
/// Set the display CWD for path rewriting.
///
/// When AB FS isolation is active, the model sees `display_cwd`
/// (e.g., `/testbed/project`) but writes should go to `root`
/// (the overlay path). Any path under `display_cwd` is rewritten
/// to the equivalent path under `root`.
pub fn with_display_cwd(mut self, display_cwd: PathBuf) -> Self {
self.display_cwd = Some(display_cwd);
self
}
/// Rewrite a display path to the overlay path if needed.
fn resolve_path(&self, path: &Path) -> PathBuf {
if let Some(ref display) = self.display_cwd
&& let Ok(suffix) = path.strip_prefix(display)
@@ -106,8 +97,7 @@ impl AsyncFileSystem for AcpSessionFs {
}
async fn delete_file(&self, path: &Path) -> Result<(), FsError> {
// ACP protocol doesn't support file deletion yet
// For now, we'll log a warning and return Ok (no-op)
// ACP has no deletion request, so the best we can do is surface it loudly.
tracing::warn!(?path, "ACP filesystem does not support file deletion");
Err(FsError::Other(
"File deletion not supported via ACP".to_string(),
@@ -119,12 +109,11 @@ impl AsyncFileSystem for AcpSessionFs {
mod tests {
use super::*;
// resolve_path only uses self.root and self.display_cwd — extract
// the logic into a standalone test helper that doesn't need a gateway.
// resolve_path only reads root and display_cwd, but constructing an
// AcpSessionFs needs a live gateway; this mirrors its logic instead.
fn test_resolve(root: &str, display_cwd: Option<&str>, input: &str) -> PathBuf {
let root = PathBuf::from(root);
let display = display_cwd.map(PathBuf::from);
// Inline the same logic as resolve_path
if let Some(ref display) = display
&& let Ok(suffix) = Path::new(input).strip_prefix(display)
{
@@ -1,11 +1,3 @@
//! AcpFsAdapter: implements `kigi-tools::AsyncFileSystem` using ACP gateway calls.
//!
//! This adapter enables file tool execution over ACP (remote filesystem).
//! It translates kigi-tools' `AsyncFileSystem` trait into ACP protocol calls:
//! `read_file()` → read_text_file
//! `write_file()` → write_text_file
//! `delete_file()` → not supported by ACP (returns error)
//!
//! Mirrors the pattern of `AcpTerminalAdapter` for terminal execution.
use std::path::Path;
@@ -14,11 +6,9 @@ use agent_client_protocol as acp;
use kigi_acp_lib::AcpAgentGatewaySender as GatewaySender;
use kigi_tools::computer::types::{AsyncFileSystem, ComputerError};
/// Wraps kigi-shell's ACP gateway to satisfy kigi-tools' AsyncFileSystem.
///
/// When a client advertises `clientCapabilities.fs.readTextFile` and `writeTextFile`,
/// file operations from tools (read_file, search_replace, etc.) are routed through
/// the ACP gateway back to the client instead of hitting the local disk directly.
/// Used when a client advertises `clientCapabilities.fs.readTextFile` and
/// `writeTextFile`: tool file operations are then routed back to the client over
/// the gateway instead of hitting the local disk.
pub struct AcpFsAdapter {
gateway: GatewaySender,
session_id: acp::SessionId,
@@ -63,7 +53,7 @@ impl AsyncFileSystem for AcpFsAdapter {
}
async fn delete_file(&self, path: &Path) -> Result<(), ComputerError> {
// ACP protocol doesn't support file deletion yet
// ACP has no deletion request, so the best we can do is surface it loudly.
tracing::warn!(?path, "ACP filesystem does not support file deletion");
Err(ComputerError::io("File deletion not supported via ACP"))
}
@@ -168,7 +168,7 @@ async fn render_text_resource(
/// Render a diff citation as `<diff_contents>`.
///
/// The text is passed through verbatim (no line-number rewriting) since it
/// represents a change, not a file snapshot.
/// represents a delta, not a file snapshot.
async fn render_diff_resource(
text_resource: &agent_client_protocol::TextResourceContents,
) -> Option<String> {
@@ -120,16 +120,13 @@ mod tests {
assert!(cache_path.to_string_lossy().ends_with("goto_index.bin"));
}
// =========================================================================
// Lazy-start mechanic tests
//
// These tests verify the core lazy-start behavior:
// `CodebaseIndexManager::get()` returns None before the index is created,
// which maps to `kigi/code/status` reporting `reason: notStarted`.
// `get_or_create()` is the lazy-start entry point called by
// `MvpAgent::start_codebase_index_for_code_nav` on the first code-nav
// request for an eligible session.
// =========================================================================
/// An empty CodebaseIndexManager returns None for any path.
///
@@ -21,7 +21,6 @@ pub struct ContentSearchParams {
pub respect_gitignore: bool,
}
/// Batch of results sent during streaming search.
#[derive(Debug, Clone, Default)]
pub struct ContentSearchBatch {
pub files: Vec<ContentMatchFile>,
@@ -127,8 +126,9 @@ fn parse_file_path_from_json(root: &Path, json: &serde_json::Value) -> Option<St
Some(root.join(normalized).to_string_lossy().to_string())
}
/// Streaming content search with batched status notifications.
/// Set `cancel` to true to abort the search early.
/// `on_status` fires at most every [`BATCH_INTERVAL_MS`] with the files matched
/// since the last call, then once more with `done`. Set `cancel` to true to
/// abort the search early.
pub async fn content_search_streaming<F>(
root: &Path,
params: &ContentSearchParams,
@@ -1,7 +1,7 @@
//! Filesystem extension ops (`workspace.fs_*`) — the server-proxied backing
//! for the shell's `kigi/fs/*` ACP extension methods.
//!
//! These mirror the pure functions that previously lived only in the
//! These mirror the pure functions that earlier lived only in the
//! shell (`kigi-shell/src/session/file_system.rs`) so that, in proxy
//! mode, a `kigi/fs/*` request executes on the *remote* workspace server
//! instead of the agent host. Each request type implements
@@ -165,9 +165,7 @@ impl WorkspaceOp for FsDeleteFileReq {
}
}
// =========================================================================
// Pure helpers — ported verbatim from the shell so output is identical.
// =========================================================================
fn list(
abs_path: &Path,
@@ -258,9 +256,7 @@ fn build_file_entry(bytes: &[u8]) -> FsReadFileData {
}
}
// =========================================================================
// Tests for the pure helpers (no `WorkspaceHandle` required).
// =========================================================================
#[cfg(test)]
mod tests {
@@ -103,7 +103,8 @@ fn collect_all_contents(
max_depth: usize,
max_dirs: usize,
) -> Result<HashMap<PathBuf, DirContents>, FsError> {
let _timer = (); // instrumentation_timer noop (dev infra)
// instrumentation_timer noop (dev infra)
let _timer = ();
let contents_map: DashMap<PathBuf, DirContents> = DashMap::new();
let files_count = std::sync::atomic::AtomicUsize::new(0);
@@ -120,7 +121,8 @@ fn collect_all_contents(
);
let walker = WalkBuilder::new(root)
.max_depth(Some(max_depth + 1)) // +1 because depth 0 is root itself
// +1 because depth 0 is root itself
.max_depth(Some(max_depth + 1))
.follow_links(false)
.same_file_system(true)
.ignore(true)
@@ -140,7 +142,8 @@ fn collect_all_contents(
);
{
let _timer = (); // instrumentation_timer noop (dev infra)
// instrumentation_timer noop (dev infra)
let _timer = ();
walker.run(|| {
let contents_map = &contents_map;
let files_count = &files_count;
@@ -209,16 +212,18 @@ fn collect_all_contents(
}
let _entries_total = entries_visited.load(std::sync::atomic::Ordering::Relaxed);
// (instrumentation_timer.with_field calls removed — dev-only, not part of Phase 1 move)
// (instrumentation_timer.with_field calls omitted — dev-only, not part of Phase 1 move)
let mut contents_map: HashMap<PathBuf, DirContents> = {
let _timer = (); // instrumentation_timer noop (dev infra)
// instrumentation_timer noop (dev infra)
let _timer = ();
contents_map.into_iter().collect()
};
// Sort all entries for stable output
{
let _timer = (); // instrumentation_timer noop (dev infra)
// instrumentation_timer noop (dev infra)
let _timer = ();
for contents in contents_map.values_mut() {
contents.files.sort_by_cached_key(|n| n.to_lowercase());
contents.dirs.sort_by_cached_key(|n| n.to_lowercase());
@@ -342,7 +347,8 @@ pub async fn list_contents(
path: impl Into<PathBuf>,
limits: ListContentsLimits,
) -> Result<String, FsError> {
let _timer = (); // instrumentation_timer noop (dev infra)
// instrumentation_timer noop (dev infra)
let _timer = ();
let t_total = Instant::now();
let path: PathBuf = path.into();
@@ -363,7 +369,8 @@ pub async fn list_contents(
let max_depth = limits.max_depth;
let max_dirs = limits.max_dirs_visited;
let contents = {
let _timer = (); // instrumentation_timer noop (dev infra)
// instrumentation_timer noop (dev infra)
let _timer = ();
tokio::task::spawn_blocking(move || collect_all_contents(&path_clone, max_depth, max_dirs))
.await
.map_err(|e| FsError::Other(format!("walk join error: {e}")))?
@@ -380,7 +387,8 @@ pub async fn list_contents(
depth_limit_hit,
dirs_limit_hit,
) = {
let _timer = (); // instrumentation_timer noop (dev infra)
// instrumentation_timer noop (dev infra)
let _timer = ();
let mut root_node = DirectoryNode::new(path, 0, &contents);
@@ -394,7 +402,8 @@ pub async fn list_contents(
let mut remaining_chars = lim_characters - min_chars;
let mut to_fit_files = true;
let mut dirs_visited: usize = 1; // Count root as visited
// Count root as visited
let mut dirs_visited: usize = 1;
let mut max_depth_reached: usize = 0;
let mut depth_limit_hit = false;
let mut dirs_limit_hit = false;
@@ -448,7 +457,8 @@ pub async fn list_contents(
};
{
let _timer = (); // instrumentation_timer noop (dev infra)
// instrumentation_timer noop (dev infra)
let _timer = ();
let mut q: VecDeque<&mut DirectoryNode> = VecDeque::new();
if to_fit_files {
q.push_back(&mut root_node);
@@ -481,7 +491,8 @@ pub async fn list_contents(
}
let output = {
let _timer = (); // instrumentation_timer noop (dev infra)
// instrumentation_timer noop (dev infra)
let _timer = ();
let mut output = format!("{path_head}\n");
if root_node.is_expanded() {
output.push_str(&root_node.get_complete_str());
@@ -46,9 +46,7 @@ pub fn bytes_to_string(file_bytes: Vec<u8>) -> Result<String, FsError> {
String::from_utf8(file_bytes).map_err(|e| FsError::Other(e.to_string()))
}
// ============================================================================
// AsyncFsWrapper - Generic wrapper that accepts any path type
// ============================================================================
/// A wrapper around `AsyncFileSystem` that accepts any path type implementing `ToAbsPath`.
///
@@ -129,14 +127,12 @@ impl AsyncFsWrapper {
}
}
/// Write data to a file.
pub async fn write_file<P: ToAbsPath>(&self, path: P, data: &[u8]) -> Result<(), FsError> {
self.inner
.write_file(&path.to_abs_path(self.root()), data)
.await
}
/// Delete a file.
pub async fn delete_file<P: ToAbsPath>(&self, path: P) -> Result<(), FsError> {
self.inner.delete_file(&path.to_abs_path(self.root())).await
}
@@ -265,7 +265,8 @@ impl FuzzyFileMatcher {
.filter(|e| !self.dirs || e.is_dir)
.take(k)
.cloned()
.collect(); // should be already sorted
// should be already sorted
.collect();
}
// https://github.com/helix-editor/helix/blob/d79cce4e4bfc24dd204f1b294c899ed73f7e9453/helix-term/src/ui/completion.rs#L369
@@ -280,8 +281,7 @@ impl FuzzyFileMatcher {
while items.len() < k
&& let Some(m) = iter.next()
// for empty queries, return everything; otherwise, apply heuristic min-score limit
&& (self.query.is_empty() || m.score >= min_score)
&& (self.query.is_empty() || m.score >= min_score)
{
fn extract_match(
m: &Match,
@@ -109,13 +109,15 @@ pub async fn git_status_short(working_directory: impl Into<PathBuf>) -> Result<S
}
fn git_status_impl(working_directory: &Path) -> Result<String, FsError> {
let _timer = /* instrumentation_timer */ () ; // dev macro; noop stub ("git_status.impl")
// dev macro; noop stub ("git_status.impl")
let _timer = /* instrumentation_timer */ () ;
let max_status_chars = 1000;
let mut output = String::with_capacity(max_status_chars);
// Get branch name
let branch_name = {
let _timer = /* instrumentation_timer */ () ; // dev macro; noop stub ("git_status.branch_info")
// dev macro; noop stub ("git_status.branch_info")
let _timer = /* instrumentation_timer */ () ;
run_git(working_directory, &["rev-parse", "--abbrev-ref", "HEAD"])
};
@@ -136,7 +138,8 @@ fn git_status_impl(working_directory: &Path) -> Result<String, FsError> {
// Get upstream ahead/behind
{
let _timer = (); // instrumentation_timer noop stub
// instrumentation_timer noop stub
let _timer = ();
if let Some(upstream_name) = run_git(
working_directory,
&["rev-parse", "--abbrev-ref", "@{upstream}"],
@@ -177,7 +180,8 @@ fn git_status_impl(working_directory: &Path) -> Result<String, FsError> {
// Get staged changes (index vs HEAD) — fast, no workdir scan
let staged_output = {
let _timer = /* instrumentation_timer */ () ; // dev macro; noop stub ("git_status.staged")
// dev macro; noop stub ("git_status.staged")
let _timer = /* instrumentation_timer */ () ;
run_git(
working_directory,
&["diff", "--cached", "--name-status", "HEAD"],
@@ -148,12 +148,11 @@ type FxBuildHasher = std::hash::BuildHasherDefault<FxHasher>;
/// Type alias for HashMap with FxHash (fast, non-cryptographic).
type FxHashMap<K, V> = HashMap<K, V, FxBuildHasher>;
// ============================================================================
// Constants
// ============================================================================
const MAGIC_INDEX: &[u8; 4] = b"FIDX";
#[allow(dead_code)] // Reserved for delta wire format
// Reserved for delta wire format
#[allow(dead_code)]
const MAGIC_DELTA: &[u8; 4] = b"FDLT";
const VERSION: u16 = 1;
@@ -161,7 +160,8 @@ const FLAG_COMPRESSED: u16 = 0x0001;
const ENTRY_FLAG_IS_DIR: u8 = 0x01;
#[allow(dead_code)] // Documentation constant
// Documentation constant
#[allow(dead_code)]
const MAX_DEPTH: usize = 255;
/// Number of threads to use for parallel directory walking.
@@ -169,12 +169,11 @@ fn num_cpus() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
.min(8) // Cap at 8 to avoid excessive parallelism
// Cap at 8 to avoid excessive parallelism
.min(8)
}
// ============================================================================
// WalkOptions - configuration for directory walking
// ============================================================================
/// Options for building a FileIndex from a directory walk.
#[derive(Debug, Clone)]
@@ -234,9 +233,7 @@ impl WalkOptions {
}
}
// ============================================================================
// SegmentId - a handle into the string interner
// ============================================================================
/// A compact identifier for an interned path segment.
/// Using u32 allows up to 4 billion unique segments (plenty).
@@ -253,9 +250,7 @@ impl SegmentId {
}
}
// ============================================================================
// StringInterner - deduplicates path segments
// ============================================================================
/// Arena-based string interner for path segments.
///
@@ -435,7 +430,6 @@ impl StringInterner {
self.offsets.is_empty()
}
/// Total bytes used by the arena.
pub fn arena_bytes(&self) -> usize {
self.arena.len()
}
@@ -474,9 +468,7 @@ impl StringInterner {
}
}
// ============================================================================
// FileEntry - a single file/directory in the index
// ============================================================================
/// Inline storage for short paths (covers 99% of cases).
/// Paths deeper than 6 segments will heap-allocate.
@@ -519,9 +511,7 @@ impl FileEntry {
}
}
// ============================================================================
// PathKey - for O(1) lookup by path
// ============================================================================
/// A key for looking up entries by path.
/// Uses the segment IDs directly for fast comparison.
@@ -534,9 +524,7 @@ impl From<&[SegmentId]> for PathKey {
}
}
// ============================================================================
// FileIndex - the main index structure
// ============================================================================
/// A compact, serializable file index.
#[derive(Debug, Clone)]
@@ -547,7 +535,7 @@ pub struct FileIndex {
entries: Vec<FileEntry>,
/// Path to entry index lookup (for O(1) removal)
path_to_idx: FxHashMap<PathKey, usize>,
/// Tracks removed indices for potential compaction
/// Tracks dropped indices for potential compaction
removed_count: usize,
}
@@ -683,14 +671,14 @@ impl FileIndex {
self.path_to_idx.insert(key, idx);
}
/// Remove a path from the index.
/// Returns true if the path was found and removed.
/// Drop a path from the index.
/// Returns true if the path was found and cleared.
pub fn remove(&mut self, path: impl AsRef<Path>) -> bool {
let segments = self.intern_path(path.as_ref());
let key = PathKey::from(segments.as_slice());
if let Some(idx) = self.path_to_idx.remove(&key) {
// Mark as removed by clearing segments (tombstone)
// Mark as cleared by clearing segments (tombstone)
self.entries[idx].segments.clear();
self.removed_count += 1;
@@ -787,9 +775,7 @@ impl FileIndex {
self.interner.get(id)
}
// ------------------------------------------------------------------------
// Internal helpers
// ------------------------------------------------------------------------
fn intern_path(&mut self, path: &Path) -> smallvec::SmallVec<[SegmentId; INLINE_SEGMENTS]> {
path.components()
@@ -829,9 +815,7 @@ impl FileIndex {
self.removed_count = 0;
}
// ------------------------------------------------------------------------
// Serialization
// ------------------------------------------------------------------------
/// Serialize to binary format.
pub fn to_bytes(&self) -> Vec<u8> {
@@ -865,7 +849,8 @@ impl FileIndex {
// Header
w.write_all(MAGIC_INDEX)?;
w.write_all(&VERSION.to_le_bytes())?;
w.write_all(&0u16.to_le_bytes())?; // flags (no compression)
// flags (no compression)
w.write_all(&0u16.to_le_bytes())?;
w.write_all(&(self.interner.len() as u32).to_le_bytes())?;
w.write_all(&(self.len() as u32).to_le_bytes())?;
@@ -873,7 +858,8 @@ impl FileIndex {
for (_, seg) in self.interner.iter() {
let len = seg.len() as u16;
w.write_all(&len.to_le_bytes())?;
w.write_all(seg)?; // seg is &BStr which derefs to &[u8]
// seg is &BStr which derefs to &[u8]
w.write_all(seg)?;
}
// Entry table
@@ -997,33 +983,31 @@ impl FileIndex {
}
}
// ============================================================================
// FileIndexDelta - incremental updates
// ============================================================================
/// An incremental update to the file index.
/// An incremental delta to the file index.
#[derive(Debug, Clone)]
pub enum FileIndexDelta {
/// Add new entries
/// Append new entries
Add(Vec<(String, bool)>),
/// Remove entries by path
/// Drop entries by path
Remove(Vec<String>),
/// Multiple operations batched
Batch(Vec<FileIndexDelta>),
}
impl FileIndexDelta {
/// Create an add delta.
/// Create an insert delta.
pub fn add(entries: Vec<(String, bool)>) -> Self {
Self::Add(entries)
}
/// Create a remove delta.
/// Create a delete delta.
pub fn remove(paths: Vec<String>) -> Self {
Self::Remove(paths)
}
/// Check if the delta is empty (no actual changes).
/// Check if the delta is empty (no real delta).
pub fn is_empty(&self) -> bool {
match self {
Self::Add(entries) => entries.is_empty(),
@@ -1113,9 +1097,7 @@ impl FileIndexDelta {
}
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
@@ -1127,7 +1109,7 @@ mod tests {
let id1 = interner.intern("src");
let id2 = interner.intern("lib");
let id3 = interner.intern("src"); // duplicate
let id3 = interner.intern("src");
assert_eq!(id1, id3);
assert_ne!(id1, id2);
@@ -1151,7 +1133,8 @@ mod tests {
assert!(!index.contains("src/foo.rs"));
// Check interning worked
assert!(index.num_segments() < 6); // "src" should be shared
// "src" should be shared
assert!(index.num_segments() < 6);
}
#[test]
@@ -1164,7 +1147,8 @@ mod tests {
assert_eq!(index.len(), 2);
assert!(index.remove("src/main.rs"));
assert!(!index.remove("src/main.rs")); // already removed
// already cleared
assert!(!index.remove("src/main.rs"));
assert_eq!(index.len(), 1);
assert!(!index.contains("src/main.rs"));
@@ -1379,16 +1363,16 @@ mod tests {
#[test]
fn test_delta_is_empty() {
// Empty add
// Empty insert
assert!(FileIndexDelta::Add(vec![]).is_empty());
// Non-empty add
// Non-Empty insert
assert!(!FileIndexDelta::Add(vec![("src/main.rs".to_string(), false)]).is_empty());
// Empty remove
// Empty delete
assert!(FileIndexDelta::Remove(vec![]).is_empty());
// Non-empty remove
// Non-Empty delete
assert!(!FileIndexDelta::Remove(vec!["src/main.rs".to_string()]).is_empty());
// Empty batch
@@ -1464,7 +1448,8 @@ mod tests {
let segment = format!("segment_{}", i);
assert_eq!(interner.intern(&segment), id);
}
assert_eq!(interner.len(), segment_count); // No new segments added
// No new segments appended
assert_eq!(interner.len(), segment_count);
}
#[test]
@@ -1503,7 +1488,7 @@ mod tests {
assert!(index.contains("lib/core/main.rs"));
assert!(!index.contains("nonexistent/path/file.rs"));
// Removal should work correctly
// Drop should work correctly
assert!(index.remove("src/utils/mod.rs"));
assert!(!index.contains("src/utils/mod.rs"));
assert_eq!(index.len(), 154);
@@ -1525,7 +1510,7 @@ mod tests {
assert!(!index.contains("foo/bar/baz.rs"));
assert!(!index.contains("nonexistent.txt"));
// Segment count should be unchanged
// Segment count should be `unchanged`
assert_eq!(index.num_segments(), initial_segments);
}
@@ -1540,7 +1525,8 @@ mod tests {
assert_eq!(interner.get_bytes(id_valid), Some(b"hello".as_slice()));
// Intern raw bytes that are not valid UTF-8
let invalid_utf8: &[u8] = &[0x80, 0x81, 0x82]; // Invalid UTF-8 sequence
// Invalid UTF-8 sequence
let invalid_utf8: &[u8] = &[0x80, 0x81, 0x82];
let id_invalid = interner.intern_bytes(invalid_utf8);
// get() should return None for non-UTF-8
@@ -6,7 +6,6 @@ use std::process::Command;
use crate::file_system::FsError;
/// jj template: change ID, commit ID, description, bookmarks.
const JJ_LOG_TEMPLATE: &str = r#"separate("\n",
"Change: " ++ change_id.shortest(8),
"Commit: " ++ commit_id.shortest(8),
@@ -18,7 +17,7 @@ const JJ_LOG_TEMPLATE: &str = r#"separate("\n",
"")
)"#;
/// Compact jj status for the system prompt (~1k chars max).
/// Truncated to roughly 1k chars so the prompt budget stays bounded.
pub async fn jj_status(working_directory: impl Into<PathBuf>) -> Result<String, FsError> {
let working_directory = working_directory.into();
tokio::task::spawn_blocking(move || jj_status_impl(&working_directory))
@@ -64,7 +63,8 @@ fn jj_status_impl(cwd: &Path) -> Result<String, FsError> {
Ok(out)
}
/// Run a jj command synchronously, returning trimmed stdout or `None` on failure.
/// `--ignore-working-copy` keeps this read-only: without it jj snapshots the
/// working copy into a new commit as a side effect of the query.
fn run_jj(cwd: &Path, args: &[&str]) -> Option<String> {
let mut cmd = Command::new("jj");
cmd.arg("--ignore-working-copy")
@@ -67,7 +67,6 @@ use std::{
};
use uuid::Uuid;
// Canonical in kigi-workspace-types; re-exported for existing paths.
pub use kigi_workspace_types::rpc::search::{ClientId, ContentSearchRequest, TargetClientId};
impl From<ContentSearchRequest> for ContentSearchParams {
@@ -164,13 +163,12 @@ pub struct FuzzySearchContext {
pub min_generation: usize,
pub has_query: bool,
pub query_version: usize,
/// The root path for this search (used to convert relative paths to absolute).
/// Daemon results are relative to this; callers absolutize against it.
pub root: PathBuf,
/// Session ID for routing notifications.
/// Used by the relay to route notifications to session subscribers.
/// Routes notifications to the session's subscribers.
pub session_id: Option<String>,
/// Target client ID for routing notifications.
/// Extracted from `_meta.clientId` in the open request.
/// Taken from `_meta.clientId` on the open request; routes notifications
/// through the relay to that one client.
pub target_client_id: TargetClientId,
}
@@ -233,16 +231,12 @@ impl FuzzySearchManager {
search_id
}
/// Get the session ID for a search, if one was set.
/// Used for routing notifications to session subscribers.
pub fn get_session_id(&self, search_id: &str) -> Option<String> {
self.searches
.get(search_id)
.and_then(|ctx| ctx.session_id.clone())
}
/// Get the target client ID for a search, if one was set.
/// Used for routing notifications to the correct client via relay.
pub fn get_target_client_id(&self, search_id: &str) -> TargetClientId {
self.searches
.get(search_id)
@@ -250,8 +244,6 @@ impl FuzzySearchManager {
.unwrap_or_default()
}
/// Get the root path for a search.
/// Used to convert relative paths to absolute paths in results.
pub fn get_root(&self, search_id: &str) -> Option<PathBuf> {
self.searches.get(search_id).map(|ctx| ctx.root.clone())
}
@@ -158,9 +158,7 @@ pub(super) fn build_glob_overrides(
})
}
// =========================================================================
// Paginated listing
// =========================================================================
/// One listed node in neutral form (no wire serialization). Consumers map
/// this to their own node shape.
@@ -265,9 +263,7 @@ pub fn list_directory_paged(abs_dir: &Path, opts: ListOptions<'_>, max_collect:
}
}
// =========================================================================
// Ranged, binary-safe reads
// =========================================================================
/// A read chunk in the requested transfer encoding.
pub enum ChunkPayload {
@@ -105,7 +105,7 @@ pub fn decide_inputs_with_interactive(
// (its own git2 discover), and `repo_configs_present` → `RepoDirChain::resolve`
// discovers the same repo again. Collapsing the two would mean threading the
// resolved root into key derivation (rippling `workspace_key` repo-wide) — out
// of scope; NOT the redundant discovers this change already removed.
// of scope; NOT the redundant discovers the shared dir-chain already removed.
repo_configs_present: repo_configs_present(cwd),
is_interactive,
// An over-broad key (home / fs-root / non-absolute) can never be recorded
@@ -260,7 +260,7 @@ pub fn repo_config_kinds(cwd: &Path) -> Vec<&'static str> {
fn collect_repo_config_kinds(cwd: &Path, first_only: bool) -> Vec<&'static str> {
// Resolve the git root + cwd→root dir chain ONCE and reuse it across the
// git2-based marker checks below: this gate does 1 git2 discover + 1 git2
// walk (+ the settings-compat path's own cheap `.git`-existence walk, intentionally separate —
// walk (+ the settings-compat path's own cheap `.git`-existence walk, deliberately separate —
// see its check). Each walker used to run its own git2 discover + walk (~5
// discovers), and on a non-git dir each discover walks to the filesystem root
// — wasteful anywhere, and Windows taxes every such syscall 10-100x.
@@ -680,7 +680,7 @@ mod tests {
fn repo_config_kinds_matches_gate_and_reports_all_kinds() {
// SSOT guard: `repo_config_kinds` (full scan) must agree with the gate
// (`repo_configs_present == !repo_config_kinds(..).is_empty()`) AND report
// the kinds the single-source refactor added — `plugins` via
// the kinds the single-source scan reports — `plugins` via
// `[plugins].paths`, `claude` via `.claude/settings.json`, `agents` via
// `.kigi/agents` — even when launched from a SUBDIR (the cwd→git-root walk
// that `first_only` shares). Guards against silent drift between the two.
@@ -841,7 +841,8 @@ mod tests {
let _home = EnvVarGuard::set("KIGI_SHARE_DIR", home.path());
let _unset = EnvVarGuard::unset(kigi_version::TEST_VERSION_ENV);
if option_env!("KIGI_VERSION").is_some() {
return; // a release-stamped test binary is not a local build
// a release-stamped test binary is not a local build
return;
}
let tmp = repo_tmp();
let key = workspace_key(tmp.path());
@@ -880,7 +881,7 @@ mod tests {
#[test]
fn revoke_folder_trust_store_persists_untrust_for_trusted_folder() {
// The store half of revoke, tested directly (not just via the shell
// wrapper): a previously-trusted folder reports was_trusted=true AND gets
// wrapper): an already-trusted folder reports was_trusted=true AND gets
// an explicit `set_untrusted` persisted, so it is untrusted on reload.
// KIGI_SHARE_DIR-isolated so the seed/deny hit a temp store, not the real file.
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
@@ -259,7 +259,7 @@ fn open_sqlite_transaction_with_journal_mode(
// non-symlink final file are validated above; adversarial swap-and-restore
// races are outside this scanner's local-user threat model. Only local WAL
// reaches this direct read-only/query-only open; its native coordination may
// still update SHM read marks despite scanner SQL making no logical writes.
// still touch SHM read marks despite scanner SQL making no logical writes.
let connection = Connection::open_with_flags(
&opened.path,
OpenFlags::SQLITE_OPEN_READ_ONLY
@@ -485,6 +485,8 @@ fn is_generated_prompt(value: &str) -> bool {
return true;
}
let value = value.trim_start();
// Tool-injected wrappers open with a lowercase tag (`<command-message>`,
// `<local-command-stdout>`); prose a user types rarely does.
value
.strip_prefix('<')
.and_then(|rest| rest.chars().next())
@@ -9,6 +9,8 @@ pub(super) fn scoped_project_dirs(config_dir: &Path, cwd: &Path) -> Vec<PathBuf>
if let Ok(repository) = git2::Repository::discover(cwd) {
if let Some(workdir) = repository.workdir() {
paths.push(dunce::canonicalize(workdir).unwrap_or_else(|_| workdir.to_path_buf()));
// A linked worktree has its own gitdir distinct from the shared
// commondir, whose parent is the main worktree.
if repository.path() != repository.commondir()
&& let Some(main_workdir) = repository.commondir().parent()
{
@@ -78,6 +78,8 @@ fn highest_named_state_database(root: &ApprovedRoot) -> Option<PathBuf> {
match std::fs::symlink_metadata(&path) {
Ok(_) => Some(path),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
// An unreadable entry still claims its generation, so the caller
// falls back to rollout files instead of trusting an older database.
Err(_) => Some(path),
}
})
@@ -1,7 +1,7 @@
//! Bounded, metadata-only listing of foreign coding-agent sessions.
//! Foreign SQLite stores are opened only when `kigi_sqlite_journal::JournalMode`
//! selects local WAL. The direct read-only/query-only transaction makes no
//! logical writes, though WAL coordination may update shared-memory read marks.
//! logical writes, though WAL coordination may touch shared-memory read marks.
//! Network filesystems fail soft before SQLite open, conversion, or writes.
use std::cmp::Ordering;
use std::collections::HashSet;
@@ -1,4 +1,5 @@
#![allow(dead_code)] // Functions consumed by handle.rs event forwarder wiring
// Functions consumed by handle.rs event forwarder wiring
#![allow(dead_code)]
//! FsNotify adapter functions bridging [`kigi_fsnotify`] events to
//! workspace subsystems (hunk tracker, codebase graph, workspace
//! event broadcast).
+1 -2
View File
@@ -58,8 +58,7 @@ pub fn init_metrics() {
#[cfg(test)]
pub(crate) static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Crate-shared RAII guard for a single process env var in tests: sets (or
/// unsets) it on construction and restores the prior value on drop. The ONE
/// generic env-var guard for the whole crate (replaces the per-module copies).
/// unsets) it on construction and restores the prior value on drop.
///
/// Hold it together with [`ENV_TEST_LOCK`] for the test's lifetime, acquiring
/// the lock FIRST so it drops LAST — the env restore (this guard) then runs
@@ -2,7 +2,7 @@
//!
//! Port of common agent auto-permission classifier semantics adapted to Kigi's
//! `AccessKind` permission gate (classifier blocks prompt the user; upstream
//! denial-limit tracking is intentionally not ported).
//! denial-limit tracking is deliberately not ported).
use std::future::Future;
use std::pin::Pin;
@@ -115,7 +115,7 @@ impl ClassifierContext {
/// Flat transcript text feeding the heuristic substring pre-check. Renders all
/// turns including assistant tool_use args (`{tool} {args}`), so the
/// dangerous-pattern / hostile-intent blob now also scans tool-call args — a
/// conservative broadening (only adds matches), not a strict-parity claim.
/// conservative broadening (only expands matches), not a strict-parity claim.
fn transcript_text(&self) -> String {
self.turns
.iter()
@@ -368,7 +368,8 @@ const ROUTINE_PREFIXES: &[&str] = &[
"kubectl get",
"kubectl logs",
"kubectl describe",
"set", // shell options affect only the spawned shell
// shell options affect only the spawned shell
"set",
];
/// Env var KEYs safe to set for a routine command: cosmetic / logging only, with
@@ -405,7 +406,7 @@ fn classify_bash(cmd: &str) -> ClassifierVerdict {
return ClassifierVerdict::Block;
};
// Default-deny env: an assigned env KEY outside the cosmetic-safe allowlist
// (or any `env` option) can change which binary runs / how code resolves.
// (or any `env` option) can differ which binary runs / how code resolves.
// Read from the PARSED, quote-stripped tree so `env "LD_PRELOAD=..."` can't
// hide the key.
if sets_unsafe_env(tree.root_node(), cmd, &cmds) {
@@ -771,10 +772,12 @@ fn command_env_is_unsafe(words: &[String]) -> bool {
if current.first().and_then(|w| w.rsplit(['/', '\\']).next()) == Some("env") {
for arg in &current[1..] {
if arg == "--" {
break; // end of env options; the rest is the command
// end of env options; the rest is the command
break;
}
if arg.starts_with('-') {
return true; // env option alters/clears the exec environment
// env option alters/clears the exec environment
return true;
}
match arg.split_once('=') {
Some((key, _)) => {
@@ -782,7 +785,8 @@ fn command_env_is_unsafe(words: &[String]) -> bool {
return true;
}
}
None => break, // first plain word is the inner command
// first plain word is the inner command
None => break,
}
}
}
@@ -982,7 +986,7 @@ Decisions the user has already made in this conversation are part of their inten
/// `{thinking, shouldBlock, reason}` shape the prompt requests and that
/// [`parse_classifier_model_text`] parses. Sent as the request `json_schema` so the
/// model is constrained to emit conforming JSON — parity with a forced
/// `classify_result` tool schema, and removes reliance on best-effort text parsing.
/// `classify_result` tool schema, and drops reliance on best-effort text parsing.
pub fn classifier_output_json_schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
@@ -2230,7 +2234,7 @@ mod tests {
Vec<ClassifierMessage>,
tokio::sync::oneshot::Sender<Result<String, String>>,
)>();
drop(rx); // closed channel
drop(rx);
let clf = LlmPermissionClassifier::with_channel(tx, ClassifierPromptType::Full);
assert!(clf.has_side_query());
assert_eq!(
@@ -2500,7 +2504,7 @@ mod tests {
// The read-only forms stay routine, incl. `--o*` options that are NOT
// abbreviations of the pager flag. (`git grep -o` stays Block via the
// pre-existing write model, which treats `git ... -o <path>` as an
// output flag — format-patch conservatism, unchanged here.)
// output flag — format-patch conservatism, `unchanged` here.)
assert_eq!(v("git grep -n TODO src"), ClassifierVerdict::Allow);
assert_eq!(v("git grep -o TODO src"), ClassifierVerdict::Block);
assert_eq!(
@@ -336,7 +336,7 @@ pub(crate) fn wrapper_has_chdir(words: &[String]) -> bool {
/// Simple shell-like splitter that:
/// - splits on whitespace (outside of quotes)
/// - handles single and double quotes, removing the quotes
/// - handles single and double quotes, stripping the quotes
/// - handles backslash escapes in a basic way
fn sh_split_simple(s: &str) -> Vec<String> {
let mut result = Vec::new();
@@ -525,7 +525,7 @@ fn parse_plain_command_from_node(cmd: Node, src: &str) -> Option<PlainCommand> {
})
}
// ── Display soft-breaks (permission UI / formatting) ─────────────────
// Display soft-breaks (permission UI / formatting)
/// Node kinds whose descendants are payload text, not shell control flow.
/// Operators that appear only as *characters* inside these are not real
@@ -679,7 +679,7 @@ pub fn range_fully_inside(start: usize, end: usize, ranges: &[(usize, usize)]) -
/// breaks that fall strictly inside the line.
///
/// When there are no applicable breaks, returns a single-element vec with
/// `line` unchanged.
/// `line` `unchanged`.
pub fn split_physical_line_at_soft_breaks<'a>(
line: &'a str,
line_start: usize,
@@ -975,7 +975,7 @@ mod tests {
assert_eq!(primary_command_from_script(sleep_only), None);
}
// ── soft_break_offsets_after_operators ────────────────────────────
// soft_break_offsets_after_operators
/// Helper: operators present at soft-break points (suffix of each prefix).
fn break_operator_suffixes(script: &str) -> Vec<String> {
@@ -1149,7 +1149,7 @@ mod tests {
#[test]
fn soft_break_nested_command_sub_still_sees_outer_list() {
// Operators inside $() may or may not be soft-breaks depending on
// whether we treat command_substitution as payload. We intentionally
// whether we treat command_substitution as payload. We deliberately
// still allow breaks inside $() (they're real shell ops for that
// subshell) — only string/heredoc/comment are payload. Assert outer
// list op is present either way.
@@ -9,9 +9,7 @@ use tracing::{debug, warn};
use crate::permission::rules::parse_permission_rule;
use crate::permission::types::{PermissionConfig, RuleAction};
// ═════════════════════════════════════════════════════════════════════════════
// Settings Types (Claude JSON subset)
// ═════════════════════════════════════════════════════════════════════════════
/// Subset of `.claude/settings.json` we care about.
#[derive(Debug, Default, Deserialize)]
@@ -257,7 +255,7 @@ fn extract_string_array(value: Option<&serde_json::Value>) -> (Vec<String>, Vec<
/// become their string representation. Null, array, and object values are
/// skipped with warnings.
///
/// Note: nulls are intentionally skipped rather than coerced to the literal
/// Note: nulls are deliberately skipped rather than coerced to the literal
/// `"null"` — setting an env var to `"null"` is rarely useful and more likely a
/// user mistake.
fn extract_string_map(
@@ -324,9 +322,7 @@ impl JsonTypeName for serde_json::Value {
}
}
// ═════════════════════════════════════════════════════════════════════════════
// Discovery
// ═════════════════════════════════════════════════════════════════════════════
// TODO(follow-up): The discovery logic here (find_claude_settings_paths,
// collect_project_claude_paths, find_repo_root) is local to this module.
@@ -443,9 +439,7 @@ fn find_repo_root(start: &Path) -> Option<PathBuf> {
}
}
// ═════════════════════════════════════════════════════════════════════════════
// Environment Variables
// ═════════════════════════════════════════════════════════════════════════════
/// Load merged environment variables from Claude settings files, gating the
/// repo-tree `.claude/settings.json` `env` on `project_trusted`.
@@ -498,13 +492,11 @@ pub fn load_claude_env_with_project(cwd: &Path, project_trusted: bool) -> HashMa
merged
}
// =============================================================================
// Phase 2 cutoff marker
// =============================================================================
//
// `kigi-shell::claude_import` writes the marker. We re-implement a small
// reader here because the gate consumers live in this crate and can't depend
// on shell (it would create a cycle). Caching is intentionally omitted; if
// on shell (it would create a cycle). Caching is deliberately omitted; if
// this becomes a hotspot we can lift it into a shared crate.
/// True when the user marked Claude settings imported (`[claude_compat].imported`
@@ -105,7 +105,7 @@ fn mcp_server_prefix_allowed(name: &str, servers: &HashSet<String>) -> bool {
}
/// Pre-decision lookup for an MCP tool. Returns `Some(Decision::Allow)`
/// when the user has previously granted "always allow" for this exact
/// when the user has already granted "always allow" for this exact
/// tool name or for the tool's server prefix. Returns `None` (i.e. fall
/// through to the prompt) when no grant exists.
///
@@ -207,7 +207,7 @@ fn is_safe_command_words_str(cmd: &str) -> bool {
|| matches_command_prefix(cmd, "uniq")
|| matches_command_prefix(cmd, "tr")
|| matches_command_prefix(cmd, "cut")
// CWE-863: `tee` removed from safe-command list — it writes stdin
// CWE-863: `tee` is not in the safe-command list — it writes stdin
// to arbitrary files, enabling pipelines like `cat data | tee /target` to
// bypass edit permissions.
//
@@ -906,11 +906,11 @@ fn spawn_permission_manager_with_pin(
let client_id_ref = client_identifier.as_deref();
let mut state = load_state_from_disk(&cwd, client_id_ref).await;
// One-time migration for users who previously selected
// One-time migration for users who selected
// "Yes, allow all edits during this session".
//
// Prior to this change, that choice would set edit_policy=Allow and
// persist it to ~/.kigi/sessions/<cwd>/permission.toml. This caused
// That choice used to set edit_policy=Allow and persist it to
// ~/.kigi/sessions/<cwd>/permission.toml. This caused
// the allow to survive full restarts (new kigi process, new agent
// session in the same directory), which did not match the label or
// user expectation (and did not match upstream session-scoped
@@ -1356,7 +1356,7 @@ fn spawn_permission_manager_with_pin(
//
// The session allowlist (`allowed_mcp_tools` /
// `allowed_mcp_servers`) short-circuits the prompt
// when the user has previously granted "always allow"
// when the user has already granted "always allow"
// for the tool or its server prefix. A policy `Ask`
// rule overrides the allowlist unless
// `remember_tool_approvals` is on, in which case an
@@ -1760,7 +1760,7 @@ mod tests {
use super::*;
use crate::permission::bash_command_splitting::primary_command_from_script;
// ── Managed-policy pin: yolo clamp + persisted bash clamp ──
// Managed-policy pin: yolo clamp + persisted bash clamp
const PIN: &str = crate::permission::resolution::YOLO_PIN_REASON_REQUIREMENTS;
@@ -1806,7 +1806,8 @@ mod tests {
cwd.clone(),
ClientType::Generic,
None,
vec![], // deny_read_globs
// deny_read_globs
vec![],
vec![],
initial_yolo,
None,
@@ -1827,7 +1828,8 @@ mod tests {
cwd.clone(),
ClientType::Generic,
Some(config),
vec![], // deny_read_globs
// deny_read_globs
vec![],
vec![],
initial_yolo,
None,
@@ -2427,7 +2429,8 @@ mod tests {
cwd.clone(),
client_type,
config,
vec![], // deny_read_globs
// deny_read_globs
vec![],
vec![],
false,
None,
@@ -2987,8 +2990,6 @@ mod tests {
let (mgr, _e) =
manager_with_recording_client(&cwd, None, client, ClientType::Generic);
// Two non-safe segments (`curl`, `sh`) — previously each opened
// its own permission UI with only that segment as the command.
let cmd = "curl http://example.com && sh -c 'echo hi'";
let d = tokio::time::timeout(
std::time::Duration::from_secs(5),
@@ -3586,7 +3587,7 @@ mod tests {
.await;
}
// ── Test-only bridging helpers ─────────────────────────────────
// Test-only bridging helpers
//
// The production helpers operate on parsed segment word lists. These
// shims preserve the previous string-based test signatures so existing
@@ -3952,7 +3953,7 @@ mod tests {
}
}
// ── pipe-aware is_safe_command tests (tree-sitter based) ────────
// pipe-aware is_safe_command tests (tree-sitter based)
#[test]
fn test_safe_command_pipe_all_safe() {
@@ -4052,7 +4053,7 @@ mod tests {
#[test]
fn test_v020_safe_command_rejects_prefix_collisions() {
// "truncate" must NOT be considered safe (previously matched "tr")
// "truncate" must NOT be considered safe (it shares the "tr" prefix)
assert!(!is_safe_command("truncate --size=0 /etc/passwd"));
assert!(!is_safe_command("truncate -s 0 important.db"));
// "traceroute" must NOT be considered safe
@@ -4091,7 +4092,7 @@ mod tests {
]));
}
// ── evaluate_bash_segments: per-segment scrutiny tests ─────────
// evaluate_bash_segments: per-segment scrutiny tests
//
// These cover the security bypasses that the previous primary-only
// check allowed (`ls && rm -rf`, `cargo test && git push --force`, ...)
@@ -4165,7 +4166,7 @@ mod tests {
#[test]
fn evaluate_chained_dangerous_with_whitelisted_primary_still_prompts() {
// Bypass class 2: a previously approved `cargo test` whitelist
// Bypass class 2: an approved `cargo test` whitelist
// entry must NOT cause `cargo test && git push --force` to skip
// the dangerous-segment prompt.
let mut state = PermissionState::default();
@@ -4218,7 +4219,7 @@ mod tests {
#[test]
fn evaluate_all_whitelisted_chain_auto_allows() {
// A user who previously approved `cargo` should have any
// A user who approved `cargo` should have any
// chain of `cargo *` commands auto-allow, since each segment
// matches the whitelist prefix.
let mut state = PermissionState::default();
@@ -167,7 +167,7 @@ impl From<PermissionConfig> for CompiledPolicy {
/// The inner script string of a `bash -c "<script>"` invocation (also `sh`,
/// `dash`, `zsh`, `ksh`); `None` if the words are not such an invocation.
/// Known residuals: option arguments (`-o pipefail`) and `+`-option words can
/// mis-take the operand — escalation-only so a miss never allows; skipping `+…` would add a dodge.
/// mis-take the operand — escalation-only so a miss never allows; skipping `+…` would open a dodge.
fn shell_dash_c_script(words: &[String]) -> Option<&str> {
let program = words.first()?.rsplit(['/', '\\']).next()?;
if !matches!(program, "bash" | "sh" | "dash" | "zsh" | "ksh") {
@@ -336,7 +336,7 @@ mod tests {
use super::*;
use crate::permission::types::PermissionRule;
// ── pattern_matches tests ─────────────────────────────────────────────
// pattern_matches tests
fn rule_for(pattern: &str) -> PermissionRule {
PermissionRule {
@@ -476,7 +476,7 @@ mod tests {
assert!(!matches(&AccessKind::Read(None), &rule_for("src/*")));
}
// ── tool_filter_matches tests ──────────────────────────────────────────
// tool_filter_matches tests
#[test]
fn test_tool_filter_any() {
@@ -557,7 +557,7 @@ mod tests {
));
}
// ── evaluate tests ─────────────────────────────────────────────────────
// evaluate tests
fn evaluate_policy(access: &AccessKind, config: &PermissionConfig) -> Option<Decision> {
CompiledPolicy::new(config.clone()).evaluate(access)
@@ -622,7 +622,7 @@ mod tests {
assert!(evaluate_policy(&AccessKind::Bash("ls".into()), &policy).is_none());
}
// ── CompiledPolicy reuse tests ────────────────────────────────────────
// CompiledPolicy reuse tests
#[test]
fn test_compiled_policy_reuse_across_evaluations() {
@@ -651,7 +651,7 @@ mod tests {
);
}
// ── whitespace prefix bypass regression tests ─────────────────
// whitespace prefix bypass regression tests
#[test]
fn test_bash_deny_not_bypassed_by_whitespace_prefix() {
@@ -684,7 +684,7 @@ mod tests {
assert!(matches(&access, &rule_for("rm*")));
}
// ── Deny bypass via shell operators ──────────────────────────────────
// Deny bypass via shell operators
#[test]
fn bash_deny_enforced_in_non_leading_command_position() {
@@ -739,7 +739,7 @@ mod tests {
);
}
// ── default action tests ──────────────────────────────────────
// default action tests
#[test]
fn test_rule_action_defaults_to_deny() {
@@ -761,7 +761,7 @@ mod tests {
);
}
// ── other tests from main ────────────────────────────────────────────
// other tests from main
#[test]
fn mcp_tool_respects_deny_policy() {
@@ -77,7 +77,7 @@ const ENABLE_ALWAYS_APPROVE_LABEL: &str =
///
/// Note: the pager's `default_selected_permission` + sticky "last used"
/// cursor logic (see `DefaultSelectedPermission` + `enqueue_permission`)
/// deliberately skips this option via `is_enable_always_approve_option`
/// Deliberately skips this option via `is_enable_always_approve_option`
/// when a configured or last-used preselection is in play. When neither is
/// set, the cursor preselects THIS option explicitly (also via
/// `is_enable_always_approve_option`, not by index 0).
@@ -225,7 +225,7 @@ pub fn mcp_tool_display_name(tool_name: &str, server_prefix: Option<&str>) -> St
/// or scrollback blocks that store the wire name verbatim). Splits on
/// the (validated-at-construction) `MCP_TOOL_NAME_DELIMITER`: if the
/// split succeeds the name is formatted as `"(Server) Action"` with
/// each segment title-cased; otherwise the input is returned unchanged
/// each segment title-cased; otherwise the input is returned `unchanged`
/// (no title-casing — the input may be a bash command, file path, or
/// other non-MCP text that the caller mustn't mangle).
pub fn mcp_pretty_name_if_qualified(name: &str) -> String {
@@ -1292,7 +1292,7 @@ mod tests {
"(Linear) List Issues"
);
// Non-qualified input (e.g. a bash command, file path, or any
// string without `__`) is returned UNCHANGED — must not
// string without `__`) is returned `UNCHANGED` — must not
// title-case or mangle non-MCP strings.
assert_eq!(mcp_pretty_name_if_qualified("read_file"), "read_file");
assert_eq!(mcp_pretty_name_if_qualified("cargo test"), "cargo test");
@@ -1314,9 +1314,7 @@ mod tests {
assert_eq!(mcp_titleize_segment(""), "");
}
// ------------------------------------------------------------------
// "Enable always-approve mode" option (prepended for TUI/Pager/Desktop)
// ------------------------------------------------------------------
fn enable_always_approve_id() -> acp::PermissionOptionId {
acp::PermissionOptionId::new(ENABLE_ALWAYS_APPROVE_OPTION_ID)
@@ -1486,7 +1484,7 @@ mod tests {
);
}
// ── events.jsonl emission ─────────────────────────────────
// events.jsonl emission
#[test]
fn tool_name_for_access_pins_canonical_names() {
@@ -1567,7 +1565,8 @@ mod tests {
let writer = EventWriter::open(dir.path());
let (tx, rx) = mpsc::unbounded_channel();
drop(rx); // channel closed → request_permission errors immediately
// channel closed → request_permission errors immediately
drop(rx);
let gateway = GatewaySender::new(tx);
let prompter = AcpPrompter::new(
@@ -299,9 +299,7 @@ fn managed_config_permissions(
.collect()
}
// ═════════════════════════════════════════════════════════════════════════════
// Fallback Resolver
// ═════════════════════════════════════════════════════════════════════════════
/// Resolve permission config, merging native Kigi and Claude sources.
/// Evaluation is order-independent (deny > ask > allow); merge order affects
@@ -707,9 +705,7 @@ fn resolve_claude_settings_inner(
))
}
// ═════════════════════════════════════════════════════════════════════════════
// managed-settings.json
// ═════════════════════════════════════════════════════════════════════════════
use std::sync::OnceLock;
@@ -1384,9 +1380,7 @@ fn normalize_git_url(url: &str) -> String {
url.to_lowercase().trim_end_matches(".git").to_string()
}
// ═════════════════════════════════════════════════════════════════════════════
// Tests
// ═════════════════════════════════════════════════════════════════════════════
#[cfg(test)]
mod tests {
@@ -1399,7 +1393,7 @@ mod tests {
use crate::ENV_TEST_LOCK as ENV_LOCK;
// The crate-shared generic env-var guard (one definition in `lib.rs`),
// aliased here so the existing `EnvVarGuard::set/unset` call sites are unchanged.
// aliased here so the existing `EnvVarGuard::set/unset` call sites are `unchanged`.
use crate::TestEnvGuard as EnvVarGuard;
/// Only `Deny` rules on read-capable tools (Read/Grep/Any) become grep
@@ -1416,9 +1410,11 @@ mod tests {
rule(RuleAction::Deny, ToolFilter::Read, "**/.env"),
rule(RuleAction::Deny, ToolFilter::Any, "**/*.pem"),
rule(RuleAction::Deny, ToolFilter::Grep, "**/secret.txt"),
rule(RuleAction::Deny, ToolFilter::Edit, "**/.env"), // write-only: excluded
rule(RuleAction::Allow, ToolFilter::Read, "src/**"), // allow: excluded
rule(RuleAction::Ask, ToolFilter::Read, "**/secrets/**"), // ask: excluded
// write-only: excluded
rule(RuleAction::Deny, ToolFilter::Edit, "**/.env"),
// allow: excluded
rule(RuleAction::Allow, ToolFilter::Read, "src/**"),
rule(RuleAction::Ask, ToolFilter::Read, "**/secrets/**"),
]);
assert_eq!(
deny_read_globs_from_config(&config),
@@ -1628,9 +1624,7 @@ mod tests {
assert_eq!(settings.default_mode, Some("acceptEdits".to_string()));
}
// ═══════════════════════════════════════════════════════════════════════
// Phase 4: Integration / Precedence Tests
// ═══════════════════════════════════════════════════════════════════════
/// Integration test: end-to-end flow from .claude/settings.json file
/// through load -> into_config -> verify rules are produced.
@@ -1760,9 +1754,7 @@ mod tests {
);
}
// ═══════════════════════════════════════════════════════════════════════
// defaultMode + resolve_claude_permissions tests
// ═══════════════════════════════════════════════════════════════════════
#[test]
fn default_mode_accept_edits_produces_allow_edit_rule() {
@@ -1854,9 +1846,7 @@ mod tests {
assert_eq!(cfg.rules[1].tool, ToolFilter::Edit);
}
// ═══════════════════════════════════════════════════════════════════════
// Environment variable loading tests
// ═══════════════════════════════════════════════════════════════════════
#[test]
fn load_settings_with_env() {
@@ -2018,7 +2008,7 @@ mod tests {
);
}
// ── requirements.toml / managed-settings.json permission tests ────
// requirements.toml / managed-settings.json permission tests
#[test]
fn parse_toml_compact_deny_rules() {
@@ -2114,7 +2104,7 @@ mod tests {
assert!(result.is_none());
}
// ── managed-settings.json tests ──────────────────────────────────
// managed-settings.json tests
#[test]
fn parse_managed_settings_json_end_to_end() {
@@ -2310,12 +2300,13 @@ mod tests {
let ms = parse_managed_settings_json(&json, path);
let al = &ms.mcp_allowlist;
// All four previously fell through the literal glob (fail-open).
// All four earlier fell through the literal glob (fail-open).
for bypass in [
"https://mcp-gateway.example.net:443/mcp", // explicit port
"http://mcp-gateway.example.net/mcp", // scheme swap
"https://mcp-gateway.example.net", // path-less host
"https://mcp-gateway.example.net./mcp", // trailing-dot FQDN
"https://mcp-gateway.example.net:443/mcp",
"http://mcp-gateway.example.net/mcp",
"https://mcp-gateway.example.net",
// trailing-dot FQDN
"https://mcp-gateway.example.net./mcp",
] {
assert!(!al.is_http_allowed(bypass), "must be denied: {bypass}");
}
@@ -2407,7 +2398,7 @@ mod tests {
);
}
// ── serverName MCP policy matching ───────────────────────────────
// serverName MCP policy matching
fn http_named(name: &str, url: &str) -> agent_client_protocol::McpServer {
agent_client_protocol::McpServer::Http(
@@ -2618,9 +2609,7 @@ mod tests {
assert!(!al.is_url_allowed("git@evil.com:ACME/repo.git"));
}
// ═══════════════════════════════════════════════════════════════════════
// Bare tool name parsing tests
// ═══════════════════════════════════════════════════════════════════════
#[test]
fn parse_bare_bash_tool_name() {
@@ -2665,9 +2654,7 @@ mod tests {
assert_eq!(rule.pattern, Some("npm test".to_string()));
}
// ═══════════════════════════════════════════════════════════════════════
// Cross-file permission merging tests
// ═══════════════════════════════════════════════════════════════════════
#[test]
fn merge_permissions_across_project_and_global_settings() {
@@ -2877,9 +2864,7 @@ mod tests {
assert!(path.ends_with(".claude/settings.json"));
}
// ═══════════════════════════════════════════════════════════════════════
// bypassPermissions defaultMode tests
// ═══════════════════════════════════════════════════════════════════════
#[test]
fn bypass_permissions_produces_catch_all_allow() {
@@ -3095,7 +3080,7 @@ mod tests {
}
/// The native `[ui] disable_bypass_permissions_mode` key locks when true
/// (default false). `permission_mode` is intentionally not a lock key.
/// (default false). `permission_mode` is deliberately not a lock key.
#[test]
fn disable_bypass_permissions_mode_locks_when_true() {
let p = Path::new("test-requirements.toml");
@@ -4000,9 +3985,7 @@ mod tests {
}
}
// ═══════════════════════════════════════════════════════════════════════
// Additional tool prefix tests
// ═══════════════════════════════════════════════════════════════════════
#[test]
fn parse_glob_tool_prefix() {
@@ -4030,9 +4013,7 @@ mod tests {
assert_eq!(rule.tool, ToolFilter::Edit);
}
// ═══════════════════════════════════════════════════════════════════════
// Escaped parentheses tests
// ═══════════════════════════════════════════════════════════════════════
#[test]
fn parse_escaped_parens_in_content() {
@@ -4050,9 +4031,7 @@ mod tests {
assert_eq!(rule.pattern, Some(r"echo test\nvalue".to_string()));
}
// ═══════════════════════════════════════════════════════════════════════
// Bash(*) and Bash() normalization tests
// ═══════════════════════════════════════════════════════════════════════
#[test]
fn bash_star_is_tool_wide() {
@@ -69,9 +69,7 @@ pub(crate) struct DefaultModeEffects {
pub(crate) bypass_permissions: bool,
}
// ═════════════════════════════════════════════════════════════════════════════
// Error Type
// ═════════════════════════════════════════════════════════════════════════════
/// Errors from parsing a permission rule string.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -102,9 +100,7 @@ impl std::fmt::Display for RuleParseError {
impl std::error::Error for RuleParseError {}
// ═════════════════════════════════════════════════════════════════════════════
// Rule Parser
// ═════════════════════════════════════════════════════════════════════════════
/// Parse a permission rule string into a native `PermissionRule`.
///
@@ -258,7 +258,8 @@ fn runs_in_current_shell(cmd: Node<'_>) -> bool {
let mut node = cmd;
loop {
if node.next_sibling().is_some_and(|s| s.kind() == "&") {
return false; // backgrounded subshell
// backgrounded subshell
return false;
}
let Some(parent) = node.parent() else {
return true;
@@ -297,7 +298,7 @@ fn cwd_poison_positions(root: Node<'_>, src: &str) -> Vec<usize> {
positions
}
/// Whether an operand at `at` runs after a cwd change, so it can't be pinned.
/// Whether an operand at `at` runs after a cwd shift, so it can't be pinned.
fn cwd_unpinned_before(positions: &[usize], at: usize) -> bool {
positions.iter().any(|&p| p < at)
}
@@ -371,7 +372,7 @@ fn shell_node_arg(node: Node<'_>, src: &str) -> Option<ArgText> {
}
/// Every `command` node (incl. nested) as `(start_byte, words, ambiguous)`, in
/// source order. `start_byte` orders invocations against cwd-change positions.
/// source order. `start_byte` orders invocations against cwd-shift positions.
fn shell_command_invocations(root: Node<'_>, src: &str) -> Vec<(usize, Vec<String>, bool)> {
let mut found: Vec<(usize, Vec<String>, bool)> = Vec::new();
let mut stack = vec![root];
@@ -528,7 +529,7 @@ fn special_file_operands(program: &str, words: &[String]) -> Vec<(String, ShellF
})
.collect(),
// `--output`/`-o` write the output file. (`git`'s `-O` is a READ
// order-file, NOT a write, so it is intentionally excluded.)
// order-file, NOT a write, so it is deliberately excluded.)
"sort" | "go" | "git" => shell_output_flag_values(words)
.map(|output| (output.to_owned(), ShellFileMode::Write))
.collect(),
@@ -1094,18 +1095,25 @@ mod tests {
let policy = compiled(vec![file_rule(
RuleAction::Deny,
ToolFilter::Read,
"/repo-b/.env", // path-scoped: matching would need the untracked cd target
// path-scoped: matching would need the untracked cd target
"/repo-b/.env",
)]);
let session = std::path::Path::new("/repo-a");
for cmd in [
"cd /repo-b && cat .env", // cd in the current shell
"pushd /repo-b; cat .env", // pushd is never folded
"if true; then cd /repo-b; fi; cat .env", // conditional cd
"env -C /repo-b cat .env", // env chdir wrapper
// cd in the current shell
"cd /repo-b && cat .env",
// pushd is never folded
"pushd /repo-b; cat .env",
"if true; then cd /repo-b; fi; cat .env",
// env chdir wrapper
"env -C /repo-b cat .env",
"env --chdir=/repo-b cat .env",
"/usr/bin/env -C /repo-b cat .env", // path-qualified env
"env FOO=1 -C /repo-b cat .env", // chdir after an assignment
"cd /repo-b && echo x > .env", // redirect operand too
// path-qualified env
"/usr/bin/env -C /repo-b cat .env",
// chdir after an assignment
"env FOO=1 -C /repo-b cat .env",
// redirect operand too
"cd /repo-b && echo x > .env",
] {
assert!(
matches!(
@@ -1142,7 +1150,7 @@ mod tests {
}
}
/// A `cd` in a pipeline/subshell/backgrounded `&` doesn't change a sibling's
/// A `cd` in a pipeline/subshell/backgrounded `&` does not alter a sibling's
/// cwd, so their reads resolve against the original cwd, not the `cd` target.
#[test]
fn shell_cd_does_not_scope_across_pipe_subshell_or_background() {
@@ -1153,9 +1161,12 @@ mod tests {
"/work/secret.env",
)]);
for cmd in [
"cd /elsewhere | cat secret.env", // pipeline segment: own subshell
"(cd /elsewhere); cat secret.env", // subshell ended with `;`
"cd /elsewhere & cat secret.env", // backgrounded cd
// pipeline segment: own subshell
"cd /elsewhere | cat secret.env",
// subshell ended with `;`
"(cd /elsewhere); cat secret.env",
// backgrounded cd
"cd /elsewhere & cat secret.env",
] {
assert!(
matches!(
@@ -1328,10 +1339,13 @@ mod tests {
"**/.env",
)]);
for cmd in [
"rg secret", // no path: searches cwd
"ack secret", // no path
"rg secret .", // directory operand
"rg secret src/", // directory operand
// no path: searches cwd
"rg secret",
"ack secret",
// directory operand
"rg secret .",
// directory operand
"rg secret src/",
"ag secret .",
] {
assert!(
@@ -1373,7 +1387,7 @@ mod tests {
/// `[permission]` tier. Tool mapping: `Read`→Read, `Write`/`Edit`→Edit, `Bash`→Bash.
fn enterprise_requirements_policy() -> CompiledPolicy {
compiled(vec![
// ── ask = [...] ──
// ask = [...]
bash_rule(RuleAction::Ask, "kubectl *"),
bash_rule(RuleAction::Ask, "terraform apply *"),
bash_rule(RuleAction::Ask, "aws *"),
@@ -1383,10 +1397,10 @@ mod tests {
bash_rule(RuleAction::Ask, "security *"),
bash_rule(RuleAction::Ask, "op *"),
file_rule(RuleAction::Ask, ToolFilter::Read, "**/secrets/**"),
file_rule(RuleAction::Ask, ToolFilter::Edit, "**/secrets/**"), // Write(..)
file_rule(RuleAction::Ask, ToolFilter::Edit, "**/secrets/**"), // Edit(..)
file_rule(RuleAction::Ask, ToolFilter::Edit, "**/secrets/**"),
file_rule(RuleAction::Ask, ToolFilter::Edit, "**/secrets/**"),
file_rule(RuleAction::Ask, ToolFilter::Read, "**/Library/Mail/**"),
// ── deny = [...] ──
// deny = [...]
bash_rule(RuleAction::Deny, "rm -rf *"),
bash_rule(RuleAction::Deny, "sudo *"),
bash_rule(RuleAction::Deny, "su *"),
@@ -1416,23 +1430,23 @@ mod tests {
ToolFilter::Read,
"**/Library/Keychains/**",
),
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/.env*"), // Write(..)
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/.ssh/**"), // Write(..)
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/*.pem"), // Write(..)
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/*.key"), // Write(..)
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/*.p12"), // Write(..)
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/.internal-deploy/**"), // Write(..)
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/terraform.tfstate"), // Write(..)
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/.env*"),
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/.ssh/**"),
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/*.pem"),
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/*.key"),
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/*.p12"),
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/.internal-deploy/**"),
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/terraform.tfstate"),
file_rule(
RuleAction::Deny,
ToolFilter::Edit,
"**/terraform.tfstate.backup",
), // Write(..)
),
file_rule(
RuleAction::Deny,
ToolFilter::Edit,
"**/Library/Keychains/**",
), // Write(..)
),
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/.env"),
file_rule(RuleAction::Deny, ToolFilter::Edit, "**/.env.*"),
])
@@ -1606,10 +1620,10 @@ mod tests {
let policy = enterprise_requirements_policy();
let matrix: &[(&str, Vector, Expect)] = &[
// ── file-read tool: real sentinel files (setup.sh) ──
// file-read tool: real sentinel files (setup.sh)
("read .env", ReadTool(".env"), Deny),
("read .env.staging", ReadTool(".env.staging"), Deny), // **/.env.*
("read src/server.pem", ReadTool("src/server.pem"), Deny), // **/*.pem
("read .env.staging", ReadTool(".env.staging"), Deny),
("read src/server.pem", ReadTool("src/server.pem"), Deny),
(
"read terraform.tfstate",
ReadTool("terraform.tfstate"),
@@ -1619,10 +1633,10 @@ mod tests {
"read secrets/api_key.txt",
ReadTool("secrets/api_key.txt"),
Ask,
), // **/secrets/**
),
("read README.md (neg)", ReadTool("README.md"), Allowed),
("read src/main.py (neg)", ReadTool("src/main.py"), Allowed),
// ── file-read tool: every remaining deny/ask glob in the policy ──
// file-read tool: every remaining deny/ask glob in the policy
("read *.key", ReadTool("config/id_rsa.key"), Deny),
("read *.p12", ReadTool("cert.p12"), Deny),
("read *.pfx", ReadTool("cert.pfx"), Deny),
@@ -1657,17 +1671,18 @@ mod tests {
ReadTool("Library/Mail/Inbox.mbox"),
Ask,
),
// ── file-read tool: lookalike negatives (must NOT match) ──
// file-read tool: lookalike negatives (must NOT match)
("read key.pem.txt (neg)", ReadTool("key.pem.txt"), Allowed),
(
"read my.env.example (neg)",
ReadTool("my.env.example"),
Allowed,
),
// ── write/edit tool: Write(..)/Edit(..) denies + secrets ask ──
// write/edit tool: Write(..)/Edit(..) denies + secrets ask
("edit .env", EditTool(".env"), Deny),
("edit .env.local", EditTool(".env.local"), Deny), // **/.env* and **/.env.*
("edit src/server.pem", EditTool("src/server.pem"), Deny), // Write(**/*.pem)
("edit .env.local", EditTool(".env.local"), Deny),
// Write(**/*.pem)
("edit src/server.pem", EditTool("src/server.pem"), Deny),
("edit *.key", EditTool("config/id_rsa.key"), Deny),
("edit *.p12", EditTool("cert.p12"), Deny),
(
@@ -1696,13 +1711,13 @@ mod tests {
("edit *.pfx (no write rule)", EditTool("cert.pfx"), Allowed),
("edit README.md (neg)", EditTool("README.md"), Allowed),
("edit src/main.py (neg)", EditTool("src/main.py"), Allowed),
// ── bash command rules: deny set ──
// bash command rules: deny set
("bash rm -rf", Bash("rm -rf /tmp/x"), Deny),
("bash sudo", Bash("sudo apt-get update"), Deny),
("bash su", Bash("su - root"), Deny),
// ssh to *.corp.example is deny even though `ssh *` is ask (deny wins).
("bash ssh corp-example", Bash("ssh prod.corp.example"), Deny),
// ── bash command rules: ask set ──
// bash command rules: ask set
("bash kubectl", Bash("kubectl get pods -A"), Ask),
(
"bash terraform apply",
@@ -1719,10 +1734,10 @@ mod tests {
Ask,
),
("bash op", Bash("op read op://vault/item"), Ask),
// ── bash command rules: negatives ──
// bash command rules: negatives
("bash ls (neg)", Bash("ls -la"), Allowed),
("bash git status (neg)", Bash("git status"), Allowed),
// ── shell file-access gate: readers / redirects / substitutions ──
// shell file-access gate: readers / redirects / substitutions
("sh cat .env", Shell("cat .env"), Deny),
("sh cat .env.staging", Shell("cat .env.staging"), Deny),
("sh cat src/server.pem", Shell("cat src/server.pem"), Deny),
@@ -1733,30 +1748,33 @@ mod tests {
),
("sh grep FAKE .env", Shell("grep FAKE .env"), Deny),
("sh base64 .env", Shell("base64 .env"), Deny),
("sh cat 0<.env", Shell("cat 0<.env"), Deny), // fd-prefixed read redirect
("sh cat<.env", Shell("cat<.env"), Deny), // glued read redirect
("sh cat $(echo .env)", Shell("cat $(echo .env)"), Ask), // unpinnable substitution
// fd-prefixed read redirect
("sh cat 0<.env", Shell("cat 0<.env"), Deny),
// glued read redirect
("sh cat<.env", Shell("cat<.env"), Deny),
// unpinnable substitution
("sh cat $(echo .env)", Shell("cat $(echo .env)"), Ask),
(
"sh diff <(cat .env)",
Shell("diff <(cat .env) /dev/null"),
Deny,
), // process sub
("sh cat subdir/../.env", Shell("cat subdir/../.env"), Deny), // `..` traversal
),
("sh cat subdir/../.env", Shell("cat subdir/../.env"), Deny),
("sh cat .ssh/id_rsa", Shell("cat .ssh/id_rsa"), Deny),
// ── shell file-access gate: writers (file must stay unchanged) ──
("sh echo > .env", Shell("echo HACKED > .env"), Deny), // write redirect
// shell file-access gate: writers (file must stay unchanged)
("sh echo > .env", Shell("echo HACKED > .env"), Deny),
(
"sh sed -ni .env",
Shell("sed -ni s/FAKE/HACKED/ .env"),
Deny,
), // in-place sed
("sh tee .env", Shell("printf HACKED | tee .env"), Deny), // pipe into tee
),
("sh tee .env", Shell("printf HACKED | tee .env"), Deny),
(
"sh echo > tfstate",
Shell("echo x > terraform.tfstate"),
Deny,
),
// ── shell file-access gate: secrets ask + negatives ──
// shell file-access gate: secrets ask + negatives
(
"sh cat secrets/api_key.txt",
Shell("cat secrets/api_key.txt"),
@@ -1,4 +1,5 @@
#![allow(dead_code)] // Phase 1 internal helpers
// Phase 1 internal helpers
#![allow(dead_code)]
use crate::permission::types::EditPolicy;
use kigi_paths::AbsPathBuf;
@@ -151,7 +152,7 @@ pub async fn cleanup_stale_permission_state(max_age: std::time::Duration) {
mod tests {
use super::*;
// ── PermissionState serialization roundtrip tests ─────────────
// PermissionState serialization roundtrip tests
#[test]
fn default_state_serialization() {
@@ -385,7 +386,7 @@ allowed_bash_commands = ["ls"]
assert!(state.disallowed_bash_commands.is_empty());
}
// ── Disk persistence roundtrip tests ─────────────────────────
// Disk persistence roundtrip tests
#[tokio::test]
async fn persist_and_load_roundtrip() {
@@ -441,7 +442,7 @@ allowed_bash_commands = ["ls"]
assert!(state.allowed_bash_commands.is_empty());
}
// ── Per-client state file path tests ──────────────────────────
// Per-client state file path tests
#[test]
fn state_file_path_without_client_id() {
@@ -8,7 +8,6 @@ use tokio::sync::oneshot;
pub struct PermissionEvent {
/// Tool call ID from the model
pub tool_id: String,
/// Name of the tool being executed
pub tool_name: String,
/// Type of access requested (read, edit, bash, mcp)
pub access_kind: String,
@@ -19,7 +18,6 @@ pub struct PermissionEvent {
pub yolo_mode: bool,
/// Whether this was auto-approved (by YOLO mode or policy rules)
pub auto_approved: bool,
/// Whether the user was prompted for this decision
pub user_prompted: bool,
/// The final decision (allow, reject)
pub decision: String,
@@ -27,10 +25,8 @@ pub struct PermissionEvent {
/// etc.); None on auto/non-prompt decisions. The trigger lives in `decision_reason`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_outcome: Option<String>,
/// Rejection reason if rejected
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reject_reason: Option<String>,
/// When this decision was made
pub timestamp: DateTime<Utc>,
/// If this permission was requested by a subagent, the subagent's session ID.
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -318,7 +314,6 @@ pub enum PromptPolicy {
/// Seeded into the permission manager's auto flag at session start.
Auto,
}
/// A single permission rule.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionRule {
pub action: RuleAction,
@@ -335,7 +330,6 @@ pub enum PatternMode {
Glob,
Domain,
}
/// Action to take when rule matches.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum RuleAction {
@@ -344,7 +338,6 @@ pub enum RuleAction {
Deny,
Ask,
}
/// Tool filter for permission rules.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ToolFilter {
@@ -20,12 +20,10 @@ use std::collections::{HashMap, HashSet};
/// - `None` — a turn hook (`on_before_turn`/`on_after_turn`).
/// - `Some(idx)` — a rewind RPC arm (`begin_prompt`/`end_prompt`).
pub(crate) enum TurnBoundary {
/// Turn start.
Start {
prompt_index: Option<usize>,
turn_number: u64,
},
/// Turn end.
End {
prompt_index: Option<usize>,
turn_number: u64,
@@ -82,7 +80,6 @@ impl TurnBoundary {
/// a blob written before a later field existed still deserializes (field `None`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RewindCheckpoint {
/// The prompt this checkpoint belongs to.
pub prompt_index: usize,
/// Filesystem before/after snapshots for the prompt.
pub fs: RewindPoint,
@@ -225,7 +222,7 @@ impl WorkspaceHandle {
/// Keyed on `prompt_index`: turn hooks (`None`) drive activity; rewind RPC
/// arms (`Some`) drive rewind capture (FS, plus git/hunks when their flags
/// are on). `workspace_rewind_all_outcomes` also finalizes the open FS
/// checkpoint on non-`Completed` turn-ends (gap #2).
/// checkpoint on non-`Completed` turn-ends.
pub(crate) async fn on_turn_boundary(&self, session_id: &str, boundary: TurnBoundary) {
match boundary {
TurnBoundary::Start {
@@ -362,7 +362,7 @@ fn load_capped_from_disk(dir: &Path, cap: usize) -> BTreeMap<usize, RewindCheckp
let Some(idx) = parse_checkpoint_index(&file_name) else {
// Sweep orphaned temp files from a crashed `write_checkpoint_file`:
// rehydrate runs once at construction before this instance writes, so
// removing them is safe and bounds clutter. Best-effort.
// dropping them is safe and bounds clutter. Best-effort.
if is_orphan_checkpoint_tmp(&file_name) {
let _ = std::fs::remove_file(entry.path());
}
@@ -691,7 +691,7 @@ mod tests {
"no separators for {raw:?}: {s:?}"
);
assert!(s != "." && s != "..", "not a traversal component: {s:?}");
// Joining must stay within the store root and add exactly one path
// Joining must stay within the store root and join exactly one path
// component (no `..` escape).
let joined = root.join(&s);
assert!(joined.starts_with(root), "stays in root: {joined:?}");
@@ -572,7 +572,8 @@ impl FileStateTracker {
let mut source = self.lazy_source.lock().await;
// Clone the path so we can clear `source` after a successful read.
let Some(path) = source.clone() else {
return; // already loaded, or never lazy
// already loaded, or never lazy
return;
};
let loaded = match read_rewind_points_file(&path) {
Ok(points) => points,
@@ -659,7 +660,7 @@ impl FileStateTracker {
///
/// NOTE: This method is similar to `capture_file_state_with_fs`. They are kept
/// separate due to type system constraints (`AsyncFileSystem` trait vs `AsyncFsWrapper`
/// concrete type). Keep them in sync when making changes.
/// concrete type). Keep them in sync when editing either side.
pub async fn capture_file_state<F: AsyncFileSystem + ?Sized>(
&self,
fs: &F,
@@ -677,7 +678,8 @@ impl FileStateTracker {
// Not currently processing a prompt, skip capture
return Ok(());
};
drop(current); // Release lock before async operations
// Release lock before async operations
drop(current);
// Read current file content (or None if it doesn't exist)
let content = fs
@@ -704,7 +706,7 @@ impl FileStateTracker {
///
/// NOTE: This method is similar to `capture_file_state`. They are kept separate
/// due to type system constraints (`AsyncFsWrapper` concrete type vs generic
/// `AsyncFileSystem` trait). Keep them in sync when making changes.
/// `AsyncFileSystem` trait). Keep them in sync when editing either side.
pub async fn capture_file_state_with_fs(
&self,
fs: &AsyncFsWrapper,
@@ -722,7 +724,8 @@ impl FileStateTracker {
// Not currently processing a prompt, skip capture
return Ok(());
};
drop(current); // Release lock before async operations
// Release lock before async operations
drop(current);
// Read current file content (or None if it doesn't exist)
let content = fs
@@ -826,7 +829,7 @@ impl FileStateTracker {
result
}
/// Get a specific rewind point by prompt index. Intentionally does NOT trigger
/// Get a specific rewind point by prompt index. Deliberately does NOT trigger
/// the historical load: this is the live persistence path (a just-completed
/// prompt's point is always in memory), so resume-then-work stays fast.
pub async fn get_rewind_point(&self, prompt_index: usize) -> Option<RewindPoint> {
@@ -1067,7 +1070,8 @@ impl FileStateHandle {
#[cfg(test)]
mod tests {
use super::ToolContext; // from stub above
// from stub above
use super::ToolContext;
use super::*;
use crate::file_system::MockFs;
use kigi_paths::AbsPathBuf;
@@ -1392,7 +1396,7 @@ mod tests {
assert!(abs_json.contains("\"path\":\"/abs/path/file.txt\""));
}
// ── Lazy historical rewind-point loading ──────────────────────────────────
// Lazy historical rewind-point loading
/// Build a rewind point at `idx` with the given (relative path, content) files.
fn point_with_files(idx: usize, files: &[(&str, &str)]) -> RewindPoint {
@@ -1444,7 +1448,6 @@ mod tests {
assert_eq!(points.len(), 2);
assert_eq!(points[0].prompt_index, 0);
assert_eq!(points[1].prompt_index, 1);
// Now singular lookups see the loaded points.
assert!(tracker.get_rewind_point(0).await.is_some());
}
@@ -1555,9 +1558,9 @@ mod tests {
let metas = tracker.get_rewind_point_metas().await;
assert_eq!(metas.len(), 2);
assert_eq!(metas[0].prompt_index, 0); // from disk
assert_eq!(metas[0].prompt_index, 0);
assert_eq!(metas[0].num_file_snapshots, 1);
assert_eq!(metas[1].prompt_index, 1); // from memory
assert_eq!(metas[1].prompt_index, 1);
assert_eq!(metas[1].num_file_snapshots, 1);
}
@@ -1666,7 +1669,7 @@ mod tests {
assert_eq!(metas[1].num_file_snapshots, 1);
}
// ── pure merge_rewind_points_from branch coverage ────────────────────────
// pure merge_rewind_points_from branch coverage
#[test]
fn merge_pure_target_zero_clears_all() {
@@ -940,7 +940,7 @@ fn change_type_from_porcelain(ch: char, staged: bool) -> ChangeType {
}
/// Parse `git diff --numstat` output into a map of path → (additions, deletions).
///
/// We intentionally omit `-M` from our `git diff --numstat` invocations, so rename
/// We deliberately omit `-M` from our `git diff --numstat` invocations, so rename
/// entries won't appear in practice. The format is simply `ADDS\tDELS\tPATH`
/// (or `-\t-\tPATH` for binary files).
fn parse_numstat(output: &str) -> HashMap<String, (u64, u64)> {
@@ -370,7 +370,7 @@ impl WorkspaceSession {
///
/// TOOL-STATE CAVEAT: the outgoing toolset is not flushed here, so an
/// in-process rebuild can drop up to one debounce window (≤500 ms) of
/// unpersisted state. Intentionally not "fixed" with a flush-before-rebuild:
/// unpersisted state. Deliberately not "fixed" with a flush-before-rebuild:
/// tool `call()` does not hold `update_lock`, so a concurrent call would
/// still race. Restart/snapshot scenarios are unaffected.
pub(crate) fn replace(
@@ -486,7 +486,7 @@ pub struct WorkspaceShared {
pub(crate) session_event_writers:
Arc<dashmap::DashMap<String, kigi_file_utils::events::EventWriter>>,
/// `(path, size, mtime_ms) → sha256` memo for the client-facing
/// `workspace.client_fs_*` ops, so unchanged files hash once per
/// `workspace.client_fs_*` ops, so `unchanged` files hash once per
/// workspace instead of per stat/read.
/// Test-only seam: runs after the toolset re-resolve returns and before
/// the post-resolve turn re-check / install in
@@ -315,8 +315,7 @@ impl SessionContextFactory for WorkspaceSessionContextFactory {
IDS.clone()
}
}
/// Build web fetch config. Enabled with default params unless
/// `KIGI_DISABLE_WEB_FETCH=1` is set.
/// Enabled with default params unless `KIGI_DISABLE_WEB_FETCH=1` is set.
fn build_web_fetch_config() -> kigi_tools::implementations::kigi::web_fetch::WebFetchConfig {
use kigi_tools::implementations::kigi::web_fetch::{WebFetchConfig, WebFetchParams};
if std::env::var("KIGI_DISABLE_WEB_FETCH").is_ok_and(|v| v == "1" || v == "true") {
@@ -8,7 +8,7 @@
use std::str::FromStr;
use std::time::Duration;
// ── Default timing/threshold values ──────────────────────────────────────
// Default timing/threshold values
// Single source of truth for the `StatusConfig::default()` values and the
// documented fallbacks for each `KIGI_WORKSPACE_*` env var.
+14 -10
View File
@@ -218,7 +218,7 @@ impl TrustStore {
.contains_key(canonical.to_string_lossy().as_ref())
}
// ── Internal ──────────────────────────────────────────────────────
// Internal
/// Shared write path for [`Self::set_trusted`] / [`Self::set_untrusted`].
///
@@ -234,7 +234,7 @@ impl TrustStore {
/// rather than clobbered (lost-update fix);
/// 3. insert the record and persist atomically;
/// 4. only on success commit the new document to memory — on any
/// lock/persist error `self.doc` is left unchanged.
/// lock/persist error `self.doc` is left `unchanged`.
fn record_decision(&mut self, workspace_key: &Path, trusted: bool) -> io::Result<()> {
let canonical = canonicalize_or_owned(workspace_key);
if is_unsafe_trust_root(&canonical) {
@@ -281,7 +281,7 @@ impl TrustStore {
);
// Commit to memory only after a successful durable write, so a failure
// leaves the in-memory store unchanged.
// leaves the in-memory store `unchanged`.
Self::persist_doc(path, &doc)?;
self.doc = doc;
Ok(())
@@ -641,7 +641,7 @@ mod tests {
fn migrate_legacy_hook_trust_is_noop_when_file_absent() {
let tmp = tempfile::tempdir().unwrap();
let store_path = tmp.path().join(TRUST_FILE_NAME);
let legacy = tmp.path().join("trusted-hook-projects"); // never created
let legacy = tmp.path().join("trusted-hook-projects");
let mut store = TrustStore::load_from(store_path);
let migrated = migrate_legacy_hook_trust_in(&legacy, &mut store);
@@ -684,7 +684,8 @@ mod tests {
// `persist_failure_leaves_memory_unchanged`, robust even when run as root).
let tmp = tempfile::tempdir().unwrap();
let store_path = tmp.path().join(TRUST_FILE_NAME);
std::fs::create_dir_all(&store_path).unwrap(); // store path is a dir, not a file
// store path is a dir, not a file
std::fs::create_dir_all(&store_path).unwrap();
let project = tmp.path().join("repo");
std::fs::create_dir_all(&project).unwrap();
let project_key = canonicalize_or_owned(&project);
@@ -954,7 +955,8 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let store_path = tmp.path().join(TRUST_FILE_NAME);
let Some(home) = dirs::home_dir() else {
return; // no home dir in this environment; nothing to assert
// no home dir in this environment; nothing to assert
return;
};
let mut store = TrustStore::load_from(store_path.clone());
@@ -1112,7 +1114,8 @@ mod tests {
// A hand-edited / migrated `[folders."<home>"]` record must not trust
// repos under $HOME — the read side ignores it, matching set_trusted.
let Some(home) = dirs::home_dir() else {
return; // no home dir in this environment; nothing to assert
// no home dir in this environment; nothing to assert
return;
};
let canonical_home = canonicalize_or_owned(&home);
let tmp = tempfile::tempdir().unwrap();
@@ -1255,10 +1258,11 @@ mod tests {
// rename in persist fails (renaming a file over a directory). This is
// robust even when tests run as root (a chmod 0o500 dir would be
// bypassed by root), and it exercises the invariant: on a write error
// the in-memory doc is left unchanged (memory-before-persist fix).
// the in-memory doc is left `unchanged` (memory-before-persist fix).
let tmp = tempfile::tempdir().unwrap();
let store_path = tmp.path().join(TRUST_FILE_NAME);
std::fs::create_dir_all(&store_path).unwrap(); // store path is a dir, not a file
// store path is a dir, not a file
std::fs::create_dir_all(&store_path).unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
let key = canonicalize_or_owned(&repo);
@@ -1416,7 +1420,7 @@ mod tests {
);
}
// ── workspace_key registry collapse (kigi-managed worktrees) ─────────
// workspace_key registry collapse (kigi-managed worktrees)
// Crate-shared env lock + env guards bundled as ONE value so the env restores
// before the lock releases by struct field order (see lib.rs), regardless of
@@ -1,4 +1,5 @@
#![allow(unexpected_cfgs)] // bundle_rg is set by the shell build script; harmless warning in the workspace lib
// bundle_rg is set by the shell build script; harmless warning in the workspace lib
#![allow(unexpected_cfgs)]
use std::path::PathBuf;
use std::sync::OnceLock;
@@ -1604,7 +1604,7 @@ mod tests {
/// destructuring below (no `..`) fails to compile when upstream adds or renames
/// a field, and rebuilding `HookSpecWire` from those bindings catches wire-side
/// drift; the assertion pins that both serde shapes stay byte-identical. The
/// compiled `matcher` is `#[serde(skip)]` and is the only field intentionally
/// compiled `matcher` is `#[serde(skip)]` and is the only field deliberately
/// absent from the wire.
#[test]
fn hook_spec_wire_covers_all_upstream_fields() {
@@ -45,10 +45,8 @@ pub(crate) fn to_creation_mode(t: WorktreeType) -> kigi_fast_worktree::CreationM
}
}
// ============================================================================
// Btrfs delegate factory -- injected by binaries that link a concrete
// snapshot helper delegate
// ============================================================================
/// Process-global factory producing the btrfs delegate, if any.
///
@@ -87,9 +85,7 @@ fn get_head_commit(repo: &Repository) -> Result<String> {
Ok(commit.id().to_string())
}
// ============================================================================
// In-progress tracking
// ============================================================================
// Process-local, best-effort dedup of duplicate async spawns within one process —
// NOT a cross-process lock: in proxy mode `prepare` (hub) and creation (shell) are
@@ -121,9 +117,7 @@ pub async fn mark_worktree_complete(session_id: &str) {
worktree_registry().lock().await.remove(session_id);
}
// ============================================================================
// Background Copy Infrastructure
// ============================================================================
/// Default parallelism config for background tasks.
/// This will leave some cores free in case foreground tasks are handled.
@@ -346,9 +340,7 @@ pub async fn run_background_ignored_copy<N: WorktreeNotificationSender>(
}
}
// ============================================================================
// Request / Response types
// ============================================================================
fn default_copy_mode() -> WorktreeCopyMode {
WorktreeCopyMode::Dirty
@@ -362,7 +354,7 @@ pub struct PrepareWorktreeResult {
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "status")]
pub enum WorktreeStatus {
// === EXISTING VARIANTS (unchanged for backward compatibility) ===
// EXISTING VARIANTS (unchanged for backward compatibility)
#[serde(rename = "progress")]
Progress {
#[serde(rename = "sessionId")]
@@ -392,7 +384,7 @@ pub enum WorktreeStatus {
message: String,
},
// === NEW VARIANTS (additive -- old clients ignore unknown status values) ===
// NEW VARIANTS (additive -- old clients ignore unknown status values)
/// Emitted when analyzing the source worktree for dirty state
#[serde(rename = "analyzing")]
Analyzing {
@@ -427,7 +419,7 @@ pub enum WorktreeStatus {
current_file: Option<String>,
},
// === BACKGROUND IGNORED FILE COPY VARIANTS ===
// BACKGROUND IGNORED FILE COPY VARIANTS
/// Background ignored file copy started
#[serde(rename = "copyingIgnored")]
CopyingIgnored {
@@ -476,9 +468,7 @@ pub trait WorktreeNotificationSender {
async fn send_worktree_status(&self, progress: WorktreeStatus);
}
// ============================================================================
// Human-Readable Worktree Naming
// ============================================================================
/// Maximum length for a sanitized label.
pub const MAX_LABEL_LEN: usize = 64;
@@ -614,9 +604,7 @@ pub fn resolve_label_collision(base_dir: &Path, label: &str) -> String {
auto_label()
}
// ============================================================================
// Worktree Base Directory Resolution
// ============================================================================
/// Resolve the kigi home for worktree paths via the **same** resolver used for
/// `worktrees.db` (`kigi_fast_worktree::resolve_kigi_home`), so checkout dirs and
@@ -761,9 +749,7 @@ pub fn touch_worktree_for_cwd(cwd: &str) {
}
}
// ============================================================================
// Worktree Lifecycle: Create
// ============================================================================
pub async fn prepare_worktree_creation(req: &CreateWorktreeRequest) -> PrepareWorktreeResult {
let source_path = Path::new(&req.source_path);
@@ -1113,9 +1099,7 @@ pub async fn create_worktree_streaming<N: WorktreeNotificationSender>(
}
}
// ============================================================================
// Remove Worktree
// ============================================================================
pub async fn remove_worktree(
req: &RemoveWorktreeRequest,
@@ -1346,9 +1330,7 @@ async fn snapshot_and_remove_subagent_worktree(
Ok(snapshot_ref)
}
// ============================================================================
// Create Worktree from Existing Worktree (Fork Flow)
// ============================================================================
/// Request to create a new worktree from an existing worktree.
/// Used during session forking to create a copy of another worktree's state.
@@ -1968,9 +1950,7 @@ pub async fn create_worktree_from_worktree_sync(
})
}
// ============================================================================
// Apply Worktree
// ============================================================================
#[derive(Debug)]
struct ApplyContext {
@@ -2192,9 +2172,7 @@ pub async fn apply_worktree(req: &ApplyWorktreeRequest) -> Result<ApplyWorktreeR
}
}
// ============================================================================
// Jujutsu workspace isolation
// ============================================================================
use crate::session::git::{jj_cli, jj_cli_mut};
@@ -2291,9 +2269,7 @@ pub async fn remove_jj_workspace(workspace_path: &str) -> Result<()> {
Ok(())
}
// ============================================================================
// Resume / Rehydrate types (types only -- impl stays in shell)
// ============================================================================
/// Request to resume an existing session in a fresh worktree.
///
@@ -2376,9 +2352,7 @@ pub struct RehydrateSessionResponse {
pub warnings: Vec<String>,
}
// ============================================================================
// Worktree Management / DB
// ============================================================================
use kigi_fast_worktree::{
DbStats, GcOptions, GcReport, ListFilter, WorktreeDb, WorktreeKind, WorktreeRecord,
@@ -2466,9 +2440,7 @@ pub fn resolve_worktree_by_id_or_path(id_or_path: &str) -> Result<Option<std::pa
if p.exists() { Ok(Some(p)) } else { Ok(None) }
}
// ============================================================================
// Repo-wide candidate enumeration (for worktree resume)
// ============================================================================
/// Build a deduplicated, deterministically-ordered list of candidate cwds
/// for the same repository as `current_cwd`.
@@ -2575,7 +2547,7 @@ mod tests {
assert_eq!(cfg.agent_connect_timeout, Duration::from_secs(5));
}
// ── snapshot_and_remove_subagent_worktree ────────────────────────────
// snapshot_and_remove_subagent_worktree
/// Run a git command in `dir` and return trimmed stdout (test-only helper).
fn git_out(dir: &Path, args: &[&str]) -> String {
@@ -2751,7 +2723,7 @@ mod tests {
);
}
// ── worktree_record_for_cwd / touch_worktree_for_cwd ─────────────────
// worktree_record_for_cwd / touch_worktree_for_cwd
// Crate-shared env lock + env guards bundled as ONE value so the env
// restores before the lock releases by struct field order (see lib.rs),