From 7b319413180360684202f1bb7b7f93d37c3d647d Mon Sep 17 00:00:00 2001 From: Dmitry Starkov <21260939+starkdmi@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:39:59 +0300 Subject: [PATCH] Keep recorded branch labels stable across re-imports A project's branch label came from the record when it carried one, and otherwise from the checkout's HEAD at scan time. HEAD at scan time says nothing about where a past session ran, so every re-import made while a repository sat on another branch relabeled its history and moved those sessions' rollups to the new branch. Codex hit this on almost every session. `session_meta` records the branch, but `turn_context` repeats only the cwd, and each one resolved the project again without a branch and fell back to HEAD. A turn in the directory `session_meta` named now keeps the remote and branch it recorded. A turn in another directory has no recorded branch. The HEAD fallback is gone. A branch is labeled only when the record carries one, and is otherwise left unset. OpenCode records no branch, so its sessions are no longer labeled. Claude Code and Grok Build archive scans now pass the branch their records carry. Codex, Claude Code, OpenCode, and Grok Build parser revisions are bumped, so the first scan after upgrading rescans and re-archives them once and drops every branch taken from a checkout's HEAD. --- .../src/archive/scan/claude.rs | 8 +- .../statsai-adapters/src/archive/scan/grok.rs | 16 +- crates/statsai-adapters/src/cache.rs | 18 +- .../src/claude/tests/parse.rs | 5 +- .../statsai-adapters/src/codex/parse/mod.rs | 13 +- .../src/codex/parse/session.rs | 74 ++++++-- .../src/codex/tests/parse/session.rs | 161 ++++++++++++++++++ .../src/opencode/tests/sessions.rs | 72 ++++++++ crates/statsai-adapters/src/project.rs | 16 +- 9 files changed, 346 insertions(+), 37 deletions(-) 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 {