Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ homepage = "https://github.com/ScriptedAlchemy/tracedecay"
readme = "README.md"
keywords = ["code-intelligence", "knowledge-graph", "mcp", "tree-sitter", "claude"]
categories = ["development-tools", "command-line-utilities"]

# Explicit whitelist so `cargo package`/`cargo publish` ship everything the
# build needs — including the PREBUILT dashboard dist bundles, which are
# gitignored (an `exclude`-style package can never pick them up). Run
Expand Down Expand Up @@ -40,6 +41,9 @@ include = [
"/dashboard/hermes-wrapper/src/**",
]

[workspace]
exclude = [".worktrees", ".codex-worktrees"]

[features]
default = ["full", "token-counting"]

Expand Down
19 changes: 19 additions & 0 deletions src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ pub(crate) fn git_capture_at(repo_root: &Path, args: &[&str]) -> GitCaptureAtRes

fn git_command_at(repo_root: &Path, args: &[&str]) -> Command {
let mut command = Command::new(git_program());
command.env_remove("GIT_DIR");
command.env_remove("GIT_WORK_TREE");
command.env_remove("GIT_COMMON_DIR");
command.arg("-C").arg(repo_root).args(args);
command
}
Expand Down Expand Up @@ -266,6 +269,22 @@ mod tests {
);
}

#[test]
fn git_at_command_clears_repository_selection_overrides() {
let command = git_command_at(Path::new("/problematic/project/root"), &["status"]);

for key in ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR"] {
assert_eq!(
command
.get_envs()
.find(|(candidate, _)| *candidate == OsStr::new(key))
.map(|(_, value)| value),
Some(None),
"git -C must resolve the supplied root rather than inherited {key}"
);
}
}

#[cfg(unix)]
#[test]
fn git_capture_deadline_kills_and_reaps_child() {
Expand Down
2 changes: 1 addition & 1 deletion src/sessions/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1379,7 +1379,7 @@ mod tests {
.ancestors()
.find(|ancestor| ancestor.file_name().is_some_and(|name| name == "repo"))
.unwrap_or(path);
if UNKNOWN_PATH_ATTEMPTS.fetch_add(1, Ordering::SeqCst) == 0 {
if UNKNOWN_PATH_ATTEMPTS.fetch_add(1, Ordering::SeqCst) == 1 {
return crate::worktree::GitRepoIdentityOutcome::Unknown;
}
crate::worktree::GitRepoIdentityOutcome::Resolved(crate::worktree::GitRepoIdentity {
Expand Down
42 changes: 30 additions & 12 deletions src/sessions/cline_like.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ use serde_json::{Map, Value};

use crate::sessions::SessionMessageRecord;
use crate::sessions::shared::{
StoredCursor, TranscriptLocation, TranscriptLocationMetadataKeys, append_location_metadata,
append_tool_calls_metadata, append_usage_metadata, content_storage_text_and_tools,
path_belongs_to_project, title_from_messages,
ProjectMembership, ProjectRootMatcherCache, StoredCursor, TranscriptLocation,
TranscriptLocationMetadataKeys, append_location_metadata, append_tool_calls_metadata,
append_usage_metadata, content_storage_text_and_tools, title_from_messages,
};
use crate::sessions::source::{
ParsedTranscript, SessionDraft, TranscriptSource, read_changed_with_companion,
Expand All @@ -46,6 +46,7 @@ pub struct ClineLikeSource {
provider: &'static str,
storage_roots: Vec<PathBuf>,
user_registered_roots: Option<Vec<PathBuf>>,
project_matchers: ProjectRootMatcherCache,
}

impl ClineLikeSource {
Expand Down Expand Up @@ -78,6 +79,7 @@ impl ClineLikeSource {
.join("User/globalStorage/saoudrizwan.claude-dev/tasks"),
],
user_registered_roots: None,
project_matchers: ProjectRootMatcherCache::default(),
}
}

Expand All @@ -89,6 +91,7 @@ impl ClineLikeSource {
.join("User/globalStorage/rooveterinaryinc.roo-cline/tasks"),
],
user_registered_roots: None,
project_matchers: ProjectRootMatcherCache::default(),
}
}

Expand All @@ -101,6 +104,7 @@ impl ClineLikeSource {
home.join(".kilocode/cli/global/tasks"),
],
user_registered_roots: None,
project_matchers: ProjectRootMatcherCache::default(),
}
}

Expand Down Expand Up @@ -137,15 +141,15 @@ impl TranscriptSource for ClineLikeSource {
let metadata = read_task_metadata(task_dir)?;
let location_cwd = if let Some(roots) = &self.user_registered_roots {
let paths = metadata_project_paths(&metadata);
if paths
.iter()
.any(|path| roots.iter().any(|root| path_belongs_to_project(path, root)))
{
if paths.iter().any(|path| {
self.project_matchers.membership_against_roots(path, roots)
!= ProjectMembership::NoMatch
}) {
return None;
}
paths.into_iter().next()?
} else {
metadata_project_location(&metadata, project_root)?
metadata_project_location(&metadata, project_root, &self.project_matchers)?
};

let document: Value = match serde_json::from_str(&changed.contents) {
Expand Down Expand Up @@ -310,10 +314,24 @@ fn read_task_metadata(task_dir: &Path) -> Option<Value> {
None
}

fn metadata_project_location(metadata: &Value, project_root: &Path) -> Option<PathBuf> {
metadata_project_paths(metadata)
.into_iter()
.find(|path| path_belongs_to_project(path, project_root))
fn metadata_project_location(
metadata: &Value,
project_root: &Path,
project_matchers: &ProjectRootMatcherCache,
) -> Option<PathBuf> {
let mut matched = None;
for path in metadata_project_paths(metadata) {
match project_matchers.membership(&path, project_root) {
ProjectMembership::Match => {
if matched.is_none() {
matched = Some(path);
}
}
ProjectMembership::NoMatch => {}
ProjectMembership::Unknown => return None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let a definitive Cline path match override unknown paths

When Cline metadata contains both a path that definitively belongs to project_root and another stale or network-backed path whose Git lookup times out, this branch returns None and discards the already-recorded match. Such multi-path metadata previously used any matching path, so a consistently inaccessible auxiliary path can now prevent a valid task from ever being ingested; accumulate Unknown and defer only when no path produced Match.

Useful? React with 👍 / 👎.

}
}
matched
}

fn metadata_project_paths(value: &Value) -> Vec<PathBuf> {
Expand Down
2 changes: 1 addition & 1 deletion src/sessions/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1623,7 +1623,7 @@ mod source_matcher_cache_tests {
.ancestors()
.find(|ancestor| ancestor.file_name().is_some_and(|name| name == "repo"))
.unwrap_or(path);
if UNKNOWN_PATH_ATTEMPTS.fetch_add(1, Ordering::SeqCst) == 0 {
if UNKNOWN_PATH_ATTEMPTS.fetch_add(1, Ordering::SeqCst) == 1 {
return crate::worktree::GitRepoIdentityOutcome::Unknown;
}
crate::worktree::GitRepoIdentityOutcome::Resolved(crate::worktree::GitRepoIdentity {
Expand Down
53 changes: 30 additions & 23 deletions src/sessions/cursor_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ use libsql::{Builder, OpenFlags};
use serde_json::{Value, json};

use crate::global_db::{GlobalDb, ParseOffset};
use crate::sessions::shared::path_belongs_to_project;
use crate::sessions::shared::{ProjectMembership, ProjectRootMatcherCache};
use crate::sessions::{SessionMessageRecord, SessionRecord};

/// `SQLITE_OPEN_URI` — not exposed by libsql's [`OpenFlags`], so we OR the raw
Expand Down Expand Up @@ -84,6 +84,7 @@ impl CursorComposerSweepOutcome {
pub struct CursorComposerSource {
state_db_path: PathBuf,
chats_dir: PathBuf,
project_matchers: ProjectRootMatcherCache,
}

impl CursorComposerSource {
Expand All @@ -104,6 +105,7 @@ impl CursorComposerSource {
.join("globalStorage")
.join("state.vscdb"),
chats_dir: home.join(".cursor").join("chats"),
project_matchers: ProjectRootMatcherCache::default(),
}
}

Expand Down Expand Up @@ -209,20 +211,23 @@ impl CursorComposerSource {
.or_insert_with(|| project.path.clone());
}
let selected_project = match project_root {
Some(root) if path_belongs_to_project(Path::new(&project.path), root) => {
ComposerProject {
Some(root) => match self
.project_matchers
.membership(Path::new(&project.path), root)
{
ProjectMembership::Match => ComposerProject {
path: project.path.clone(),
}
}
Some(_) => continue,
None if registered_roots
.iter()
.any(|root| path_belongs_to_project(Path::new(&project.path), root)) =>
},
ProjectMembership::NoMatch | ProjectMembership::Unknown => continue,
},
None => match self
.project_matchers
.membership_against_roots(Path::new(&project.path), registered_roots)
{
continue;
}
None => ComposerProject {
path: "user".to_string(),
ProjectMembership::NoMatch => ComposerProject {
path: "user".to_string(),
},
ProjectMembership::Match | ProjectMembership::Unknown => continue,
},
};
// Own this session for JSONL dedupe regardless of the per-pass cap.
Expand Down Expand Up @@ -322,18 +327,20 @@ impl CursorComposerSource {
let ws_hash = ws_entry.file_name().to_string_lossy().to_string();
// Scope by ws-hash -> project mapping harvested from the envelopes.
let project_path = match (workspace_paths.get(&ws_hash), project_root) {
(Some(path), Some(root)) if path_belongs_to_project(Path::new(path), root) => {
path.clone()
(Some(path), Some(root)) => {
match self.project_matchers.membership(Path::new(path), root) {
ProjectMembership::Match => path.clone(),
ProjectMembership::NoMatch | ProjectMembership::Unknown => continue,
}
}
(Some(_), Some(_)) | (None, _) => continue,
(Some(path), None)
if registered_roots
.iter()
.any(|root| path_belongs_to_project(Path::new(path), root)) =>
(None, _) => continue,
(Some(path), None) => match self
.project_matchers
.membership_against_roots(Path::new(path), registered_roots)
{
continue;
}
(Some(_), None) => "user".to_string(),
ProjectMembership::NoMatch => "user".to_string(),
ProjectMembership::Match | ProjectMembership::Unknown => continue,
},
};
let Ok(agent_entries) = std::fs::read_dir(ws_entry.path()) else {
continue;
Expand Down
Loading
Loading