diff --git a/crates/statsai-adapters/src/archive/scan/claude.rs b/crates/statsai-adapters/src/archive/scan/claude.rs index 0a12049..47c81e7 100644 --- a/crates/statsai-adapters/src/archive/scan/claude.rs +++ b/crates/statsai-adapters/src/archive/scan/claude.rs @@ -125,7 +125,13 @@ pub(crate) fn collect_claude_file( .get("cwd") .and_then(Value::as_str) .map(expand_home_path) - .and_then(|path| resolve_project_context(Some(path), None, None)); + .and_then(|path| { + let branch = value + .get("gitBranch") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + resolve_project_context(Some(path), None, branch) + }); } let message = value.get("message").unwrap_or(&value); let role = message diff --git a/crates/statsai-adapters/src/archive/scan/grok.rs b/crates/statsai-adapters/src/archive/scan/grok.rs index c02350b..2a15884 100644 --- a/crates/statsai-adapters/src/archive/scan/grok.rs +++ b/crates/statsai-adapters/src/archive/scan/grok.rs @@ -54,7 +54,21 @@ pub(crate) fn collect_grok( .pointer("/info/cwd") .and_then(Value::as_str) .map(expand_home_path) - .and_then(|path| resolve_project_context(Some(path), None, None)); + .and_then(|path| { + resolve_project_context( + Some(path), + value + .get("git_remotes") + .and_then(Value::as_array) + .and_then(|remotes| remotes.first()) + .and_then(Value::as_str) + .map(ToOwned::to_owned), + value + .get("head_branch") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + ) + }); } } let file = File::open(&chat_path)?; diff --git a/crates/statsai-adapters/src/cache.rs b/crates/statsai-adapters/src/cache.rs index 3666c18..c7ba61c 100644 --- a/crates/statsai-adapters/src/cache.rs +++ b/crates/statsai-adapters/src/cache.rs @@ -17,20 +17,30 @@ pub(crate) const SCAN_CACHE_SIGNATURE_VERSION: &str = "scan-cache.v1"; // so the name is joined when the session is built. // call-context.v38: turn events list their model calls, with request starts, // compaction boundaries, and each call's model, for the prompt-cache report. -pub(crate) const CODEX_SCAN_CACHE_PARSER_REVISION: &str = "call-context.v38"; +// branch-label.v39: turn_context lines keep the branch session_meta recorded +// instead of taking the checkout's HEAD at scan time. +pub(crate) const CODEX_SCAN_CACHE_PARSER_REVISION: &str = "branch-label.v39"; // session-metadata.v30: Claude user-message counts include only typed prompts, // not tool results, meta lines, or compaction summaries. (v29: events carry the // session's custom or AI title and the prompt that started their turn.) // call-context.v31: events record their request start, sub-agent, and // compaction boundary for the prompt-cache report. -pub(crate) const CLAUDE_SCAN_CACHE_PARSER_REVISION: &str = "call-context.v31"; +// branch-label.v32: lines without a gitBranch leave the branch unset instead of +// taking the checkout's HEAD at scan time, and archived conversations carry the +// branch their lines record. +pub(crate) const CLAUDE_SCAN_CACHE_PARSER_REVISION: &str = "branch-label.v32"; // session-metadata.v21: events carry each session's user and assistant message // counts. (v20: session rows report their message count as requests and are // priced per message, so long-context tiers stop being decided session-wide.) -pub(crate) const OPENCODE_SCAN_CACHE_PARSER_REVISION: &str = "session-metadata.v21"; +// branch-label.v22: OpenCode records no branch, so sessions leave it unset +// instead of taking the checkout's HEAD at scan time. +pub(crate) const OPENCODE_SCAN_CACHE_PARSER_REVISION: &str = "branch-label.v22"; // Revisit Grok sessions whose Fast request was left unpriced because modelsUsed // also retained a selected standard model that made no inference. -pub(crate) const GROK_BUILD_SCAN_CACHE_PARSER_REVISION: &str = "grok-fast-pricing.v23"; +// branch-label.v24: sessions without a head_branch leave the branch unset +// instead of taking the checkout's HEAD at scan time, and archived +// conversations carry the branch and remote their summary records. +pub(crate) const GROK_BUILD_SCAN_CACHE_PARSER_REVISION: &str = "branch-label.v24"; pub(crate) fn scan_candidate( path: PathBuf, diff --git a/crates/statsai-adapters/src/claude/tests/parse.rs b/crates/statsai-adapters/src/claude/tests/parse.rs index fdb8400..40cf798 100644 --- a/crates/statsai-adapters/src/claude/tests/parse.rs +++ b/crates/statsai-adapters/src/claude/tests/parse.rs @@ -327,7 +327,10 @@ fn claude_jsonl_project_context_overrides_stale_session_index_path() { project.repo_label.as_deref(), Some("example-org/current-workspace") ); - assert_eq!(project.branch_label.as_deref(), Some("main")); + // The stale index's branch belongs to another directory, and the line + // records none, so the checkout's HEAD must not stand in for it. + assert_eq!(project.branch_label, None); + assert_eq!(project.branch_hash, None); assert_eq!(scan.task_spans.len(), 1); let task = &scan.task_spans[0]; diff --git a/crates/statsai-adapters/src/codex/parse/mod.rs b/crates/statsai-adapters/src/codex/parse/mod.rs index 04ac278..5aff48c 100644 --- a/crates/statsai-adapters/src/codex/parse/mod.rs +++ b/crates/statsai-adapters/src/codex/parse/mod.rs @@ -38,6 +38,7 @@ pub(crate) fn parse_codex_file( let mut current_reasoning = ModelReasoningState::default(); let mut current_model_is_fallback = false; let mut current_project: Option = None; + let mut session_project_inputs = CodexProjectInputs::default(); let mut current_title: Option = None; // Events only: a sub-agent borrows its parent thread's name, but task // titles keep their own prompt-based rules. @@ -399,7 +400,11 @@ pub(crate) fn parse_codex_file( ctx.scan .session_names .extend(codex_subagent_session_name(&value, &session_id)); - current_project = codex_project_context_from_value(&value, &mut project_cache); + session_project_inputs = codex_project_inputs_from_value(&value); + current_project = codex_project_context_from_inputs( + session_project_inputs.clone(), + &mut project_cache, + ); continue; } @@ -411,7 +416,11 @@ pub(crate) fn parse_codex_file( current_model = Some(model_name); current_model_is_fallback = false; } - if let Some(project) = codex_project_context_from_value(&value, &mut project_cache) { + if let Some(project) = codex_turn_context_project_from_value( + &value, + &session_project_inputs, + &mut project_cache, + ) { current_project = Some(project); } continue; diff --git a/crates/statsai-adapters/src/codex/parse/session.rs b/crates/statsai-adapters/src/codex/parse/session.rs index d5f1d74..2760b65 100644 --- a/crates/statsai-adapters/src/codex/parse/session.rs +++ b/crates/statsai-adapters/src/codex/parse/session.rs @@ -89,26 +89,68 @@ pub(crate) fn codex_subagent_session_name( }) } +/// The directory, remote, and branch one Codex line records for its project. +#[derive(Debug, Clone, Default)] +pub(crate) struct CodexProjectInputs { + project_path: Option, + repository_url: Option, + branch: Option, +} + +pub(crate) fn codex_project_inputs_from_value(value: &Value) -> CodexProjectInputs { + let payload = value.get("payload"); + let git = payload.and_then(|payload| payload.get("git")); + let git_text = |key: &str| { + git.and_then(|git| git.get(key)) + .and_then(Value::as_str) + .filter(|text| !text.trim().is_empty()) + .map(ToOwned::to_owned) + }; + CodexProjectInputs { + project_path: payload + .and_then(|payload| payload.get("cwd")) + .and_then(Value::as_str) + .map(expand_home_path), + repository_url: git_text("repository_url"), + branch: git_text("branch"), + } +} + +pub(crate) fn codex_project_context_from_inputs( + inputs: CodexProjectInputs, + cache: &mut ProjectContextCache, +) -> Option { + resolve_project_context_cached( + inputs.project_path, + inputs.repository_url, + inputs.branch, + cache, + ) +} + pub(crate) fn codex_project_context_from_value( value: &Value, cache: &mut ProjectContextCache, ) -> Option { - let payload = value.get("payload"); - let project_path = payload - .and_then(|payload| payload.get("cwd")) - .and_then(Value::as_str) - .map(expand_home_path); - let repository_url = payload - .and_then(|payload| payload.get("git")) - .and_then(|git| git.get("repository_url")) - .and_then(Value::as_str) - .map(ToOwned::to_owned); - let branch = payload - .and_then(|payload| payload.get("git")) - .and_then(|git| git.get("branch")) - .and_then(Value::as_str) - .map(ToOwned::to_owned); - resolve_project_context_cached(project_path, repository_url, branch, cache) + codex_project_context_from_inputs(codex_project_inputs_from_value(value), cache) +} + +/// `turn_context` repeats the session's cwd but never its `git` block, so a +/// turn in the directory `session_meta` named keeps the remote and branch it +/// recorded. A turn in another directory has no recorded branch. +pub(crate) fn codex_turn_context_project_from_value( + value: &Value, + session: &CodexProjectInputs, + cache: &mut ProjectContextCache, +) -> Option { + let mut inputs = codex_project_inputs_from_value(value); + if inputs.project_path.is_some() && inputs.project_path == session.project_path { + inputs.repository_url = inputs + .repository_url + .or_else(|| session.repository_url.clone()); + inputs.branch = inputs.branch.or_else(|| session.branch.clone()); + } + codex_project_context_from_inputs(inputs, cache) } pub(crate) fn codex_headless_usage_value(value: &Value) -> Option<&Value> { diff --git a/crates/statsai-adapters/src/codex/tests/parse/session.rs b/crates/statsai-adapters/src/codex/tests/parse/session.rs index 3850d0a..0480063 100644 --- a/crates/statsai-adapters/src/codex/tests/parse/session.rs +++ b/crates/statsai-adapters/src/codex/tests/parse/session.rs @@ -51,6 +51,167 @@ fn codex_extracts_cwd_and_git_metadata_from_session_meta() { assert_eq!(project.branch_label.as_deref(), Some("main")); } +const BRANCH_TEST_REMOTE: &str = "git@github.com:example-org/example-workspace.git"; + +/// A session whose `session_meta` names `cwd` and, when given, `branch`, +/// followed by the `turn_context` and token count of one turn in `turn_cwd`. +fn write_codex_branch_session(path: &Path, cwd: &Path, turn_cwd: &Path, branch: Option<&str>) { + let git = branch + .map(|branch| { + format!(r#","git":{{"repository_url":"{BRANCH_TEST_REMOTE}","branch":"{branch}"}}"#) + }) + .unwrap_or_default(); + let mut file = File::create(path).expect("session file"); + writeln!( + file, + r#"{{"timestamp":"2026-06-01T08:00:00Z","type":"session_meta","payload":{{"id":"branch-session","cwd":"{}"{git}}}}}"#, + cwd.display() + ) + .expect("write session meta"); + writeln!( + file, + r#"{{"timestamp":"2026-06-01T08:00:01Z","type":"turn_context","payload":{{"cwd":"{}","model":"gpt-5","approval_policy":"never"}}}}"#, + turn_cwd.display() + ) + .expect("write turn context"); + writeln!( + file, + r#"{{"timestamp":"2026-06-01T08:00:05Z","type":"event_msg","payload":{{"type":"token_count","info":{{"last_token_usage":{{"input_tokens":80,"cached_input_tokens":20,"output_tokens":40,"reasoning_output_tokens":10,"total_tokens":120}},"total_token_usage":{{"input_tokens":80,"cached_input_tokens":20,"output_tokens":40,"reasoning_output_tokens":10,"total_tokens":120}}}}}}}}"# + ) + .expect("write tokens"); +} + +/// `(branch_label, branch_hash, repo_label)` of one event's project. +type BranchLabels = (Option, Option, Option); + +/// Scans `codex_root` once per checked-out branch and returns each scan's +/// labels for every event, in order. +fn codex_branches_per_checkout( + codex_root: &Path, + workspace: &Path, + checkouts: &[&str], +) -> Vec> { + let source = SourceLocation::local_adapter( + CODEX_PROVIDER, + "test", + "0", + codex_root, + LocationOrigin::Configured, + ); + checkouts + .iter() + .map(|checkout| { + write_git_fixture(workspace, BRANCH_TEST_REMOTE, checkout); + let scan = scan_codex_source(&CodexAdapter, &source, &options()).expect("scan"); + assert!(!scan.events.is_empty(), "the session must produce events"); + scan.events + .iter() + .map(|event| { + let project = event.project.as_ref().expect("project"); + ( + project.branch_label.clone(), + project.branch_hash.clone(), + project.repo_label.clone(), + ) + }) + .collect() + }) + .collect() +} + +#[test] +fn codex_rescan_on_another_checkout_keeps_the_session_meta_branch() { + let dir = tempfile::tempdir().expect("tempdir"); + let codex_root = dir.path().join("codex"); + let sessions = codex_root.join("sessions"); + let workspace = dir.path().join("workspace").join("ai-stats"); + std::fs::create_dir_all(&sessions).expect("sessions"); + std::fs::create_dir_all(&workspace).expect("workspace"); + // The session ran on `main`; `turn_context` repeats its cwd but, as in + // real rollouts, carries no `git` block. + write_codex_branch_session( + &sessions.join("rollout.jsonl"), + &workspace, + &workspace, + Some("main"), + ); + + let scans = codex_branches_per_checkout( + &codex_root, + &workspace, + &["main", "feature/new-work", "release/next"], + ); + + let expected = ( + Some("main".to_string()), + Some(hash_text("main")), + Some("example-org/example-workspace".to_string()), + ); + for scan in &scans { + assert!(scan.iter().all(|labels| *labels == expected), "{scans:?}"); + } +} + +#[test] +fn codex_session_without_recorded_branch_never_takes_the_checkout_head() { + let dir = tempfile::tempdir().expect("tempdir"); + let codex_root = dir.path().join("codex"); + let sessions = codex_root.join("sessions"); + let workspace = dir.path().join("workspace").join("ai-stats"); + std::fs::create_dir_all(&sessions).expect("sessions"); + std::fs::create_dir_all(&workspace).expect("workspace"); + write_codex_branch_session( + &sessions.join("rollout.jsonl"), + &workspace, + &workspace, + None, + ); + + let scans = codex_branches_per_checkout(&codex_root, &workspace, &["main", "feature/new-work"]); + + // The repository still comes from the checkout's remote; only the branch, + // which the checkout cannot know for a past session, stays unset. + let expected = ( + None, + None, + Some("example-org/example-workspace".to_string()), + ); + for scan in &scans { + assert!(scan.iter().all(|labels| *labels == expected), "{scans:?}"); + } +} + +#[test] +fn codex_turn_in_another_directory_does_not_inherit_the_session_branch() { + let dir = tempfile::tempdir().expect("tempdir"); + let codex_root = dir.path().join("codex"); + let sessions = codex_root.join("sessions"); + let started_in = dir.path().join("workspace").join("started-here"); + let workspace = dir.path().join("workspace").join("ai-stats"); + std::fs::create_dir_all(&sessions).expect("sessions"); + std::fs::create_dir_all(&started_in).expect("start dir"); + std::fs::create_dir_all(&workspace).expect("workspace"); + // `session_meta`'s branch describes the directory it names. A turn that + // moved to another checkout has no recorded branch there. + write_codex_branch_session( + &sessions.join("rollout.jsonl"), + &started_in, + &workspace, + Some("topic/started-here"), + ); + + let scans = codex_branches_per_checkout(&codex_root, &workspace, &["main", "feature/new-work"]); + + let expected = ( + None, + None, + Some("example-org/example-workspace".to_string()), + ); + for scan in &scans { + assert!(scan.iter().all(|labels| *labels == expected), "{scans:?}"); + } +} + #[test] fn codex_line_filter_skips_non_message_response_items() { let reasoning = r#"{"timestamp":"2026-06-03T09:36:21.793Z","type":"response_item","payload":{"type":"reasoning","summary":[],"encrypted_content":"abc"}}"#; diff --git a/crates/statsai-adapters/src/opencode/tests/sessions.rs b/crates/statsai-adapters/src/opencode/tests/sessions.rs index e973e75..3c45cc1 100644 --- a/crates/statsai-adapters/src/opencode/tests/sessions.rs +++ b/crates/statsai-adapters/src/opencode/tests/sessions.rs @@ -475,3 +475,75 @@ fn opencode_session_usage_beyond_its_messages_stays_untiered() { Some(1_180_000) ); } + +#[test] +fn opencode_rescan_on_another_checkout_leaves_the_branch_unset() { + let dir = tempfile::tempdir().expect("tempdir"); + let workspace = dir.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace"); + let db_path = dir.path().join("opencode.db"); + let connection = Connection::open(&db_path).expect("db"); + connection + .execute_batch( + "CREATE TABLE session ( + id TEXT PRIMARY KEY, + title TEXT, + model TEXT, + cost REAL, + tokens_input INTEGER NOT NULL, + tokens_output INTEGER NOT NULL, + tokens_reasoning INTEGER NOT NULL, + tokens_cache_read INTEGER NOT NULL, + tokens_cache_write INTEGER NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + directory TEXT + );", + ) + .expect("schema"); + connection + .execute( + "INSERT INTO session VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + rusqlite::params![ + "ses_branch", + "Past session", + r#"{"id":"grok-build-0.1","providerID":"xai"}"#, + 0.5_f64, + 100_i64, + 20_i64, + 0_i64, + 0_i64, + 0_i64, + 1_767_225_600_000_i64, + 1_767_225_660_000_i64, + workspace.to_string_lossy().to_string(), + ], + ) + .expect("insert"); + let source = SourceLocation::local_adapter( + OPENCODE_PROVIDER, + "test", + "0", + dir.path(), + LocationOrigin::Configured, + ); + + // OpenCode records no branch, and the checkout's HEAD at scan time says + // nothing about where a past session ran. + for checkout in ["main", "feature/new-work"] { + crate::tests::write_git_fixture( + &workspace, + "https://github.com/example-org/example-workspace.git", + checkout, + ); + let scan = scan_opencode_source(&OpenCodeAdapter, &source, &options()).expect("scan"); + assert_eq!(scan.events.len(), 1); + let project = scan.events[0].project.as_ref().expect("project"); + assert_eq!( + project.repo_label.as_deref(), + Some("example-org/example-workspace") + ); + assert_eq!(project.branch_label, None, "checked out on {checkout}"); + assert_eq!(project.branch_hash, None, "checked out on {checkout}"); + } +} diff --git a/crates/statsai-adapters/src/project.rs b/crates/statsai-adapters/src/project.rs index 80d8cd5..75ab983 100644 --- a/crates/statsai-adapters/src/project.rs +++ b/crates/statsai-adapters/src/project.rs @@ -79,10 +79,10 @@ pub(crate) fn resolve_project_context( git.as_ref() .and_then(|metadata| metadata.repo_label.clone()) }); - let branch_label = branch.or_else(|| { - git.as_ref() - .and_then(|metadata| metadata.branch_label.clone()) - }); + // Only the branch the record carries. The checkout's HEAD at scan time says + // nothing about where a past session ran, and a label read from it would + // move every time a re-import happens on another branch. + let branch_label = branch.filter(|branch| !branch.trim().is_empty()); let branch_hash = branch_label.as_ref().map(|branch| hash_text(branch)); let project_label = project_path .as_deref() @@ -122,7 +122,6 @@ pub(crate) fn project_context_from_path_fallback(root: &Path, path: &Path) -> Op pub(crate) struct GitRepositoryMetadata { normalized_remote: Option, repo_label: Option, - branch_label: Option, } pub(crate) fn read_git_repository_metadata(path: &Path) -> Option { @@ -144,7 +143,6 @@ pub(crate) fn read_git_repository_metadata(path: &Path) -> Option Option { origin_remote_url.or(first_remote_url) } -pub(crate) fn read_git_head_branch(git_dir: &Path) -> Option { - let text = std::fs::read_to_string(git_dir.join("HEAD")).ok()?; - let head = text.trim(); - head.strip_prefix("ref: refs/heads/").map(ToOwned::to_owned) -} - pub(crate) fn repo_label_from_normalized_remote(remote: &str) -> String { let parts: Vec<&str> = remote.split('/').filter(|part| !part.is_empty()).collect(); if parts.len() >= 3 {