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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion herdr-plugin.toml
Original file line number Diff line number Diff line change
@@ -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"]
Expand Down
61 changes: 42 additions & 19 deletions src/conversations/sources/claude_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 `<uuid>/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",
);
}
}
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -522,6 +543,8 @@ struct ClaudeRecord {
subtype: Option<String>,
#[serde(rename = "leafUuid")]
leaf_uuid: Option<String>,
#[serde(rename = "logicalParentUuid")]
logical_parent_uuid: Option<String>,
#[serde(rename = "permissionMode")]
permission_mode: Option<IgnoredAny>,
#[serde(rename = "aiTitle")]
Expand Down
126 changes: 120 additions & 6 deletions src/files/tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand All @@ -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)
);
}

Expand Down
Loading
Loading