diff --git a/Cargo.lock b/Cargo.lock index 3e6048c..e9736fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -415,7 +415,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "herdr-context" -version = "0.19.1" +version = "0.19.2" dependencies = [ "cap-fs-ext", "cap-primitives", diff --git a/Cargo.toml b/Cargo.toml index 4d724bf..2ce604b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "herdr-context" -version = "0.19.1" +version = "0.19.2" edition = "2024" rust-version = "1.97.1" autobenches = false diff --git a/README.md b/README.md index 4ed4f01..630775e 100644 --- a/README.md +++ b/README.md @@ -335,7 +335,7 @@ test suite. ## Release status -Version `0.19.1` is the current release line. Tag `v0.19.1`, Cargo +Version `0.19.2` is the current release line. Tag `v0.19.2`, Cargo metadata, both manifests, and the minimum Herdr version are validated together; pushing a `v*` tag runs formatting, Clippy, the full test suite, and the contract checks, then publishes generated GitHub release notes. diff --git a/herdr-plugin.toml b/herdr-plugin.toml index 7c18e91..20ba2f3 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -1,6 +1,6 @@ id = "herdr-context" name = "herdr-context" -version = "0.19.1" +version = "0.19.2" min_herdr_version = "0.8.0" description = "Per-tab project context dock" platforms = ["linux", "macos"] diff --git a/src/conversations/sources/claude_code.rs b/src/conversations/sources/claude_code.rs index a5ff9b8..adbda71 100644 --- a/src/conversations/sources/claude_code.rs +++ b/src/conversations/sources/claude_code.rs @@ -113,7 +113,9 @@ impl KnownFormat for ClaudeCodeFormat { } fn adapter_revision(&self) -> u32 { - 1 + // 2: compact boundaries with a null parent are now accepted, so cached + // rejections from revision 1 must be revisited. + 2 } fn list_candidates( @@ -205,28 +207,34 @@ impl KnownFormat for ClaudeCodeFormat { } visited_entries += 1; let relative = directory.join(&name); - if kind == EntryKind::File - && Path::new(&name) - .extension() - .is_some_and(|value| value == "jsonl") - { - if files.len() == MAX_CANDIDATE_PATHS { - push_inventory_error( + match kind { + EntryKind::File + if Path::new(&name) + .extension() + .is_some_and(|value| value == "jsonl") => + { + if files.len() == MAX_CANDIDATE_PATHS { + push_inventory_error( + errors, + SOURCE_ID, + store.absolute(Path::new(".")), + "Claude projects store exceeds the bounded session inventory", + ); + return files; + } + files.push(relative); + } + // Claude Code writes per-session `/tool-results` and + // per-project `memory` directories next to the transcripts. + EntryKind::Directory => {} + _ => { + push_shape_error( errors, SOURCE_ID, - store.absolute(Path::new(".")), - "Claude projects store exceeds the bounded session inventory", + store.absolute(&relative), + "Claude store entry is outside the verified flat JSONL layout", ); - return files; } - files.push(relative); - } else { - push_shape_error( - errors, - SOURCE_ID, - store.absolute(&relative), - "Claude store entry is outside the verified flat JSONL layout", - ); } } } @@ -358,11 +366,24 @@ impl KnownFormat for ClaudeCodeFormat { ) })?; validate_uuid(uuid, 4)?; + let compact_boundary = record.kind == "system" + && record.subtype.as_deref() == Some("compact_boundary"); match (previous_uuid.is_some(), record.parent_uuid.as_deref()) { (false, None) => {} (true, Some(parent_uuid)) if parent_uuid != uuid => { validate_uuid(parent_uuid, 4)?; } + // A compaction boundary deliberately restarts the transcript + // chain: the pre-compact tail moves into `logicalParentUuid`. + (true, None) if compact_boundary => { + let logical_parent = + record.logical_parent_uuid.as_deref().ok_or_else(|| { + FormatFailure::unsupported( + "Claude compact boundary is missing its logical parent identifier", + ) + })?; + validate_uuid(logical_parent, 4)?; + } _ => { return Err(FormatFailure::unsupported( "Claude root/parent shape does not match the current transcript tree", @@ -522,6 +543,8 @@ struct ClaudeRecord { subtype: Option, #[serde(rename = "leafUuid")] leaf_uuid: Option, + #[serde(rename = "logicalParentUuid")] + logical_parent_uuid: Option, #[serde(rename = "permissionMode")] permission_mode: Option, #[serde(rename = "aiTitle")] diff --git a/src/files/tree.rs b/src/files/tree.rs index 1409d1c..1c846dd 100644 --- a/src/files/tree.rs +++ b/src/files/tree.rs @@ -544,10 +544,10 @@ fn insert_status_with_ancestors( if let Some(entry) = affected.next() { insert_preferred_status(statuses, entry.to_path_buf(), status); } - let ancestor_status = if status == VcsStatusKind::Deleted { - VcsStatusKind::Modified + let ancestor_status = if status == VcsStatusKind::Conflicted { + VcsStatusKind::Conflicted } else { - status + VcsStatusKind::Modified }; for ancestor in affected { insert_preferred_status(statuses, ancestor.to_path_buf(), ancestor_status); @@ -743,6 +743,120 @@ mod tests { ); } + #[test] + fn renders_added_descendant_as_modified_present_directory() { + let temp = TempDir::new().expect("tempdir"); + fs::create_dir_all(temp.path().join("src/nested")).expect("directories"); + fs::write(temp.path().join("src/nested/added.rs"), []).expect("added file"); + let mut tree = FilesTree::new(temp.path().to_path_buf()).expect("tree"); + tree.load_directory(Path::new("")).expect("root"); + + tree.merge_status(&VcsStatusSnapshot::new( + vec![status("src/nested/added.rs", None, VcsStatusKind::Added)], + false, + )) + .expect("merge status"); + + assert_eq!( + tree.node(Path::new("src")).expect("src").status(), + Some(VcsStatusKind::Modified) + ); + tree.load_directory(Path::new("src")).expect("src"); + tree.load_directory(Path::new("src/nested")) + .expect("nested"); + assert_eq!( + tree.node(Path::new("src/nested/added.rs")) + .expect("added row") + .status(), + Some(VcsStatusKind::Added) + ); + } + + #[test] + fn aggregates_every_non_conflicted_descendant_kind_as_modified() { + let temp = TempDir::new().expect("tempdir"); + fs::create_dir_all(temp.path().join("renamed/copied/untracked")).expect("directories"); + fs::write(temp.path().join("renamed/target.rs"), []).expect("rename target"); + fs::write(temp.path().join("renamed/copied/copy.rs"), []).expect("copy target"); + fs::write(temp.path().join("renamed/copied/origin.rs"), []).expect("copy origin"); + fs::write(temp.path().join("renamed/copied/untracked/new.rs"), []).expect("untracked"); + let mut tree = FilesTree::new(temp.path().to_path_buf()).expect("tree"); + tree.load_directory(Path::new("")).expect("root"); + + tree.merge_status(&VcsStatusSnapshot::new( + vec![ + status("renamed/target.rs", Some("old.rs"), VcsStatusKind::Renamed), + status( + "renamed/copied/copy.rs", + Some("renamed/copied/origin.rs"), + VcsStatusKind::Copied, + ), + status( + "renamed/copied/untracked/new.rs", + None, + VcsStatusKind::Untracked, + ), + ], + false, + )) + .expect("merge status"); + tree.load_directory(Path::new("renamed")).expect("renamed"); + tree.load_directory(Path::new("renamed/copied")) + .expect("copied"); + tree.load_directory(Path::new("renamed/copied/untracked")) + .expect("untracked directory"); + for directory in ["renamed", "renamed/copied", "renamed/copied/untracked"] { + assert_eq!( + tree.node(Path::new(directory)) + .unwrap_or_else(|| panic!("{directory} node")) + .status(), + Some(VcsStatusKind::Modified), + "{directory} aggregates as Modified" + ); + } + assert_eq!( + tree.node(Path::new("renamed/target.rs")) + .expect("renamed row") + .status(), + Some(VcsStatusKind::Renamed) + ); + assert_eq!( + tree.node(Path::new("renamed/copied/copy.rs")) + .expect("copied row") + .status(), + Some(VcsStatusKind::Copied) + ); + assert_eq!( + tree.node(Path::new("renamed/copied/untracked/new.rs")) + .expect("untracked row") + .status(), + Some(VcsStatusKind::Untracked) + ); + } + + #[test] + fn keeps_backend_reported_directory_status_over_descendant_modified() { + let temp = TempDir::new().expect("tempdir"); + fs::create_dir_all(temp.path().join("fresh")).expect("directory"); + fs::write(temp.path().join("fresh/child.rs"), []).expect("child"); + let mut tree = FilesTree::new(temp.path().to_path_buf()).expect("tree"); + tree.load_directory(Path::new("")).expect("root"); + + tree.merge_status(&VcsStatusSnapshot::new( + vec![ + status("fresh", None, VcsStatusKind::Added), + status("fresh/child.rs", None, VcsStatusKind::Modified), + ], + false, + )) + .expect("merge status"); + + assert_eq!( + tree.node(Path::new("fresh")).expect("fresh").status(), + Some(VcsStatusKind::Added) + ); + } + #[test] fn keeps_selection_by_stable_path_and_orders_directories_first() { let temp = TempDir::new().expect("tempdir"); @@ -917,7 +1031,7 @@ mod tests { } #[test] - fn aggregates_missing_rename_source_status_onto_its_directory() { + fn aggregates_missing_rename_source_and_target_as_modified_directories() { let temp = TempDir::new().expect("tempdir"); fs::create_dir(temp.path().join("old")).expect("old directory"); fs::create_dir(temp.path().join("new")).expect("new directory"); @@ -937,11 +1051,11 @@ mod tests { assert_eq!( tree.node(Path::new("old")).expect("old directory").status(), - Some(VcsStatusKind::Renamed) + Some(VcsStatusKind::Modified) ); assert_eq!( tree.node(Path::new("new")).expect("new directory").status(), - Some(VcsStatusKind::Renamed) + Some(VcsStatusKind::Modified) ); } diff --git a/tests/conversation_external_discovery.rs b/tests/conversation_external_discovery.rs index 6ab78b7..ea0a5dd 100644 --- a/tests/conversation_external_discovery.rs +++ b/tests/conversation_external_discovery.rs @@ -885,6 +885,168 @@ fn claude_accepts_current_auxiliary_record_shapes() { ); } +#[test] +fn claude_accepts_compact_boundaries_that_restart_the_chain() { + let (_project_dir, project) = project(); + let home = TempDir::new().expect("home"); + let session_id = "11111111-1111-4111-8111-111111111111"; + let destination = home + .path() + .join(".claude/projects/custom-project-key") + .join(format!("{session_id}.jsonl")); + fs::create_dir_all(destination.parent().expect("fixture parent")).expect("store"); + let cwd = project.root(); + let records = [ + serde_json::json!({ + "parentUuid": null, + "isSidechain": false, + "cwd": cwd, + "sessionId": session_id, + "version": "2.1.140", + "type": "user", + "message": {"role": "user", "content": "before compaction"}, + "uuid": "22222222-2222-4222-8222-222222222222", + "timestamp": "2026-05-13T17:13:35.288Z", + }), + serde_json::json!({ + "parentUuid": null, + "logicalParentUuid": "22222222-2222-4222-8222-222222222222", + "isSidechain": false, + "subtype": "compact_boundary", + "content": "Conversation compacted", + "cwd": cwd, + "sessionId": session_id, + "version": "2.1.140", + "type": "system", + "uuid": "44444444-4444-4444-8444-444444444444", + "timestamp": "2026-05-13T17:13:36.288Z", + }), + serde_json::json!({ + "parentUuid": "44444444-4444-4444-8444-444444444444", + "isSidechain": false, + "cwd": cwd, + "sessionId": session_id, + "version": "2.1.140", + "type": "user", + "message": {"role": "user", "content": "after compaction"}, + "uuid": "55555555-5555-4555-8555-555555555555", + "timestamp": "2026-05-13T17:13:37.288Z", + }), + ]; + let transcript = records + .iter() + .map(serde_json::Value::to_string) + .collect::>() + .join("\n"); + fs::write(destination, format!("{transcript}\n")).expect("Claude fixture"); + let source = ClaudeCodeSource::new(project.clone(), home.path().join(".claude/projects")) + .expect("Claude source"); + let batch = source + .discover( + &project, + None, + DiscoveryLimit::new(8).expect("discovery limit"), + ) + .expect("discovery"); + assert!(batch.errors().is_empty(), "{:?}", batch.errors()); + assert_eq!(batch.candidates().len(), 1, "boundary session detected"); +} + +#[test] +fn claude_compact_boundary_without_logical_parent_is_rejected() { + let (_project_dir, project) = project(); + let home = TempDir::new().expect("home"); + let session_id = "11111111-1111-4111-8111-111111111111"; + let destination = home + .path() + .join(".claude/projects/custom-project-key") + .join(format!("{session_id}.jsonl")); + fs::create_dir_all(destination.parent().expect("fixture parent")).expect("store"); + let cwd = project.root(); + let records = [ + serde_json::json!({ + "parentUuid": null, + "isSidechain": false, + "cwd": cwd, + "sessionId": session_id, + "version": "2.1.140", + "type": "user", + "message": {"role": "user", "content": "root"}, + "uuid": "22222222-2222-4222-8222-222222222222", + "timestamp": "2026-05-13T17:13:35.288Z", + }), + serde_json::json!({ + "parentUuid": null, + "isSidechain": false, + "subtype": "compact_boundary", + "content": "Conversation compacted", + "cwd": cwd, + "sessionId": session_id, + "version": "2.1.140", + "type": "system", + "uuid": "44444444-4444-4444-8444-444444444444", + "timestamp": "2026-05-13T17:13:36.288Z", + }), + ]; + let transcript = records + .iter() + .map(serde_json::Value::to_string) + .collect::>() + .join("\n"); + fs::write(destination, format!("{transcript}\n")).expect("Claude fixture"); + let source = ClaudeCodeSource::new(project.clone(), home.path().join(".claude/projects")) + .expect("Claude source"); + let batch = source + .discover( + &project, + None, + DiscoveryLimit::new(8).expect("discovery limit"), + ) + .expect("discovery"); + assert!( + batch.candidates().is_empty(), + "unanchored boundary must not be detected" + ); +} + +#[test] +fn claude_store_directories_are_benign() { + let (_project_dir, project) = project(); + let home = TempDir::new().expect("home"); + let store = home.path().join(".claude/projects/custom-project-key"); + let destination = store.join("11111111-1111-4111-8111-111111111111.jsonl"); + fs::create_dir_all(destination.parent().expect("store parent")).expect("store"); + fs::write( + destination, + fixture_text( + "claude-code/-workspace-project/11111111-1111-4111-8111-111111111111.jsonl", + &project, + ), + ) + .expect("Claude fixture"); + fs::create_dir_all(store.join("11111111-1111-4111-8111-111111111111/tool-results")) + .expect("session tool results directory"); + fs::write( + store.join("11111111-1111-4111-8111-111111111111/tool-results/share.jsonl"), + [], + ) + .expect("nested artifact"); + fs::create_dir_all(store.join("memory")).expect("memory directory"); + fs::write(store.join("memory/MEMORY.md"), "# memory").expect("memory file"); + + let source = ClaudeCodeSource::new(project.clone(), home.path().join(".claude/projects")) + .expect("Claude source"); + let batch = source + .discover( + &project, + None, + DiscoveryLimit::new(8).expect("discovery limit"), + ) + .expect("discovery"); + assert_eq!(batch.candidates().len(), 1, "session still discovered"); + assert!(batch.errors().is_empty(), "{:?}", batch.errors()); +} + #[test] fn codex_retains_distinct_canonical_origin_directories() { let (project_dir, project) = project(); diff --git a/tests/git_status.rs b/tests/git_status.rs index 7654ade..5753253 100644 --- a/tests/git_status.rs +++ b/tests/git_status.rs @@ -111,6 +111,110 @@ fn deleted_descendant_marks_present_directories_modified() { ); } +#[test] +fn added_descendant_marks_present_directories_modified_not_added() { + let repository = repository(); + fs::create_dir_all(repository.path().join("src/nested")).expect("directories"); + fs::write(repository.path().join("src/nested/tracked.rs"), "before").expect("fixture"); + git(repository.path(), &["add", "."]); + git(repository.path(), &["commit", "--quiet", "-m", "fixture"]); + fs::write(repository.path().join("src/nested/added.rs"), "new").expect("added file"); + git(repository.path(), &["add", "src/nested/added.rs"]); + + let mut service = GitService::new(Duration::from_secs(5)); + let workspace = service + .detect(repository.path()) + .expect("detect") + .expect("Git workspace"); + let snapshot = service.refresh_status(&workspace).expect("status"); + let mut tree = FilesTree::new(repository.path().to_path_buf()).expect("tree"); + tree.load_directory(Path::new("")).expect("root"); + tree.merge_status(&snapshot).expect("status overlay"); + + assert_eq!( + tree.node(Path::new("src")).expect("src").status(), + Some(VcsStatusKind::Modified) + ); + tree.load_directory(Path::new("src")).expect("src"); + tree.load_directory(Path::new("src/nested")) + .expect("nested"); + assert_eq!( + tree.node(Path::new("src/nested")).expect("nested").status(), + Some(VcsStatusKind::Modified) + ); + assert_eq!( + tree.node(Path::new("src/nested/added.rs")) + .expect("added row") + .status(), + Some(VcsStatusKind::Added) + ); +} + +#[test] +fn mixed_non_conflicted_descendants_aggregate_as_modified() { + let repository = repository(); + fs::create_dir_all(repository.path().join("src/nested/untracked-dir")).expect("directories"); + for name in ["src/modified.rs", "src/nested/moved-source.rs"] { + fs::write(repository.path().join(name), "before").expect("tracked fixture"); + } + git(repository.path(), &["add", "."]); + git(repository.path(), &["commit", "--quiet", "-m", "fixture"]); + + fs::write(repository.path().join("src/modified.rs"), "after").expect("modify"); + git( + repository.path(), + &["mv", "src/nested/moved-source.rs", "src/nested/renamed.rs"], + ); + fs::write( + repository.path().join("src/nested/untracked-dir/fresh.rs"), + "new", + ) + .expect("untracked file"); + + let mut service = GitService::new(Duration::from_secs(5)); + let workspace = service + .detect(repository.path()) + .expect("detect") + .expect("Git workspace"); + let snapshot = service.refresh_status(&workspace).expect("status"); + let mut tree = FilesTree::new(repository.path().to_path_buf()).expect("tree"); + tree.load_directory(Path::new("")).expect("root"); + tree.merge_status(&snapshot).expect("status overlay"); + + tree.load_directory(Path::new("src")).expect("src"); + tree.load_directory(Path::new("src/nested")) + .expect("nested"); + tree.load_directory(Path::new("src/nested/untracked-dir")) + .expect("untracked directory"); + for directory in ["src", "src/nested", "src/nested/untracked-dir"] { + assert_eq!( + tree.node(Path::new(directory)) + .unwrap_or_else(|| panic!("{directory} node")) + .status(), + Some(VcsStatusKind::Modified), + "{directory} aggregates as Modified" + ); + } + assert_eq!( + tree.node(Path::new("src/modified.rs")) + .expect("modified row") + .status(), + Some(VcsStatusKind::Modified) + ); + assert_eq!( + tree.node(Path::new("src/nested/renamed.rs")) + .expect("renamed row") + .status(), + Some(VcsStatusKind::Renamed) + ); + assert_eq!( + tree.node(Path::new("src/nested/untracked-dir/fresh.rs")) + .expect("untracked row") + .status(), + Some(VcsStatusKind::Untracked) + ); +} + #[test] fn jujutsu_marker_prevents_git_from_becoming_authoritative() { let repository = repository(); diff --git a/tests/jj_status.rs b/tests/jj_status.rs index 8a984e2..0771784 100644 --- a/tests/jj_status.rs +++ b/tests/jj_status.rs @@ -134,6 +134,67 @@ fn deleted_descendant_marks_present_directories_modified() { ); } +#[test] +fn mixed_non_conflicted_descendants_aggregate_as_modified() { + let temp = TempDir::new().expect("tempdir"); + fs::create_dir_all(temp.path().join("src/nested")).expect("directories"); + for name in [ + "src/old.rs", + "src/base.rs", + "src/nested/renamed.rs", + "src/nested/copied.rs", + "src/nested/added.rs", + ] { + fs::write(temp.path().join(name), []).expect("fixture file"); + } + let script = temp.path().join("fake-jj"); + executable( + &script, + "#!/bin/sh\nprintf 'A\\000\\000src/nested/added.rs\\000false\\000false\\000\\000file\\000R\\000src/old.rs\\000src/nested/renamed.rs\\000false\\000false\\000file\\000file\\000C\\000src/base.rs\\000src/nested/copied.rs\\000false\\000false\\000file\\000file\\000'\n", + ); + let mut service = + JjService::with_executable(script, JujutsuMode::Fresh, Duration::from_secs(1)); + let workspace = herdr_context::vcs::VcsWorkspace::new( + temp.path().to_path_buf(), + herdr_context::vcs::VcsBackendMetadata::new("jj", "Jujutsu", true).expect("metadata"), + ) + .expect("workspace"); + let snapshot = service.refresh_status(&workspace).expect("status"); + let mut tree = FilesTree::new(temp.path().to_path_buf()).expect("tree"); + tree.load_directory(Path::new("")).expect("root"); + tree.load_directory(Path::new("src")).expect("src"); + tree.load_directory(Path::new("src/nested")) + .expect("nested"); + tree.merge_status(&snapshot).expect("status overlay"); + + assert_eq!( + tree.node(Path::new("src")).expect("src").status(), + Some(VcsStatusKind::Modified) + ); + assert_eq!( + tree.node(Path::new("src/nested")).expect("nested").status(), + Some(VcsStatusKind::Modified) + ); + assert_eq!( + tree.node(Path::new("src/nested/added.rs")) + .expect("added row") + .status(), + Some(VcsStatusKind::Added) + ); + assert_eq!( + tree.node(Path::new("src/nested/renamed.rs")) + .expect("renamed row") + .status(), + Some(VcsStatusKind::Renamed) + ); + assert_eq!( + tree.node(Path::new("src/nested/copied.rs")) + .expect("copied row") + .status(), + Some(VcsStatusKind::Copied) + ); +} + #[test] fn malformed_templated_output_is_rejected() { let temp = TempDir::new().expect("tempdir"); diff --git a/tests/test_release.py b/tests/test_release.py index 8c260f8..a403941 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -19,15 +19,15 @@ class ReleaseContractTests(unittest.TestCase): def test_repository_contract_is_release_ready(self) -> None: contract = release.validate_repository(ROOT) - self.assertEqual(contract.version, "0.19.1") + self.assertEqual(contract.version, "0.19.2") self.assertEqual(contract.min_herdr_version, "0.8.0") self.assertEqual(len(contract.performance_metrics), 12) def test_trigger_tag_must_exactly_match_cargo_version(self) -> None: contract = release.validate_repository(ROOT) - release.validate_trigger_tag(contract, "v0.19.1") - with self.assertRaisesRegex(release.ReleaseError, "exactly v0.19.1"): + release.validate_trigger_tag(contract, "v0.19.2") + with self.assertRaisesRegex(release.ReleaseError, "exactly v0.19.2"): release.validate_trigger_tag(contract, "vtest") def test_failed_budget_requires_complete_risk_acceptance(self) -> None: