Skip to content

Commit 4eba094

Browse files
lore-proto, lore-revision, lore-server: opt-in last-commit attribution on RevisionTree
## Summary Adds TreeNode.last_commit to ThinClientService.RevisionTree, so a client rendering a directory listing can show per-entry last-commit message, timestamp, and revision identifier without walking history client-side. Off by default; existing callers pay nothing. Without this, a client needs RevisionDiff per revision back from the tip plus RevisionInfo per touching revision to produce the same rendering. Attribution rides on the per-entry back-pointer that lore-revision already maintains on NodeFileMetadata for file::history. One delta-block read per state, one metadata-block read per entry. - New request field RevisionTreeRequest.include_last_commit gates the attribution work - TreePath gains last_revision and last_revision_repository, populated inside enumerate_children from TreeAttribution built against that call's walk_state / walk_repository - Attribution follows the walker across link boundaries. Each linked-subtree recursion builds its own TreeAttribution from the linked repository's state, so entries inside a link attribute against the linked repository's revisions rather than the parent's. NodeIDs are u32 indices - mixing states would silently return plausible garbage - Directories inherit their descendant's revision through the existing metadata propagation, so folder rows attribute directly without a max-over-descendants pass - Server handler deduplicates by (RepositoryId, Hash) and resolves each unique revision once via load_tree_commit, reusing the walked repository's context when possible and building a linked context only when the tree spans a link - Legacy v0 handler passes include_last_commit: false - the v0 message has no field to carry attribution Test Plan - lore-revision unit tests: raw metadata invariant (per-entry back-pointer vs parent stamp), tree() end-to-end with attribution on and off, and cross-link attribution asserting the linked-subtree carries the linked repository's revision and id - lore-server unit tests: load_tree_commit signature-and-identifier round-trip; include_last_commit gates the field without changing which nodes are emitted; per-entry attribution does not borrow a neighbour's commit and does not collide across repositories - lore-proto shape test extended to destructure TreeCommit and the new fields on TreeNode / RevisionTreeRequest - New end-to-end tests in scripts/test/test_revision_tree_last_commit.py cover the flag gate, per-entry attribution across two revisions, cross-link attribution, and directory propagation on the wire - cargo test --workspace passes (no regressions) Signed-off-by: Graham Plumb <graham@lorelab.io>
1 parent ebb1e08 commit 4eba094

13 files changed

Lines changed: 1734 additions & 19 deletions

File tree

lore-proto/proto/lore/thin_client/v1/model.proto

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,23 @@ message DiffPartition {
117117
bytes link_partition = 2;
118118
}
119119

120+
// The revision that last modified a tree entry. Directories report the
121+
// revision of the most recent change anywhere beneath them.
122+
message TreeCommit {
123+
// Content signature of the revision.
124+
bytes signature = 1;
125+
// Free-form commit message.
126+
string commit_message = 2;
127+
// Commit timestamp (Unix epoch milliseconds).
128+
uint64 timestamp = 3;
129+
// Resolved (branch, number) of the revision. Carried in full rather than as
130+
// a bare number because the number is per-branch and this is not enough to
131+
// describe its' provenance
132+
lore.model.v1.RevisionIdentifier identifier = 4;
133+
// Identity that committed the revision.
134+
string committed_by = 5;
135+
}
136+
120137
// A single entry in a revision tree listing.
121138
message TreeNode {
122139
// Repository-relative path of this entry.
@@ -132,6 +149,10 @@ message TreeNode {
132149
// True when a link entry tracks its parent's branch; false for pinned links
133150
// and non-link entries.
134151
bool tracking = 6;
152+
// Revision that last modified this entry, set only when the request asks
153+
// for it. Absent wherever the server cannot attribute an entry - most
154+
// commonly the repository root.
155+
optional TreeCommit last_commit = 7;
135156
}
136157

137158
// Self-describing revision record. Carries the resolved RevisionIdentifier

lore-proto/proto/lore/thin_client/v1/thin_client.proto

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ message RevisionTreeRequest {
124124
// emits only direct children of the prefix root; 2 emits direct
125125
// children plus grandchildren; etc. 0 or unset means unbounded.
126126
optional uint32 max_depth = 4;
127+
// If true, populate `TreeNode.last_commit`. Off by default.
128+
// Note: Attribution costs one delta-block read per state plus one
129+
// file-metadata-block read per entry.
130+
bool include_last_commit = 5;
127131
}
128132

129133
// Header for a RevisionTree stream. Echoes the resolved revision so

lore-proto/src/grpc/lore.thin_client.v1.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,38 @@ impl ::prost::Name for DiffPartition {
104104
"/lore.thin_client.v1.DiffPartition".into()
105105
}
106106
}
107+
/// The revision that last modified a tree entry. Directories report the
108+
/// revision of the most recent change anywhere beneath them.
109+
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
110+
pub struct TreeCommit {
111+
/// Content signature of the revision.
112+
#[prost(bytes = "bytes", tag = "1")]
113+
pub signature: ::prost::bytes::Bytes,
114+
/// Free-form commit message.
115+
#[prost(string, tag = "2")]
116+
pub commit_message: ::prost::alloc::string::String,
117+
/// Commit timestamp (Unix epoch milliseconds).
118+
#[prost(uint64, tag = "3")]
119+
pub timestamp: u64,
120+
/// Resolved (branch, number) of the revision. Carried in full rather than as
121+
/// a bare number because the number is per-branch and this is not enough to
122+
/// describe its' provenance
123+
#[prost(message, optional, tag = "4")]
124+
pub identifier: ::core::option::Option<crate::lore::model::v1::RevisionIdentifier>,
125+
/// Identity that committed the revision.
126+
#[prost(string, tag = "5")]
127+
pub committed_by: ::prost::alloc::string::String,
128+
}
129+
impl ::prost::Name for TreeCommit {
130+
const NAME: &'static str = "TreeCommit";
131+
const PACKAGE: &'static str = "lore.thin_client.v1";
132+
fn full_name() -> ::prost::alloc::string::String {
133+
"lore.thin_client.v1.TreeCommit".into()
134+
}
135+
fn type_url() -> ::prost::alloc::string::String {
136+
"/lore.thin_client.v1.TreeCommit".into()
137+
}
138+
}
107139
/// A single entry in a revision tree listing.
108140
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
109141
pub struct TreeNode {
@@ -126,6 +158,11 @@ pub struct TreeNode {
126158
/// and non-link entries.
127159
#[prost(bool, tag = "6")]
128160
pub tracking: bool,
161+
/// Revision that last modified this entry, set only when the request asks
162+
/// for it. Absent wherever the server cannot attribute an entry - most
163+
/// commonly the repository root.
164+
#[prost(message, optional, tag = "7")]
165+
pub last_commit: ::core::option::Option<TreeCommit>,
129166
}
130167
impl ::prost::Name for TreeNode {
131168
const NAME: &'static str = "TreeNode";
@@ -714,6 +751,11 @@ pub struct RevisionTreeRequest {
714751
/// children plus grandchildren; etc. 0 or unset means unbounded.
715752
#[prost(uint32, optional, tag = "4")]
716753
pub max_depth: ::core::option::Option<u32>,
754+
/// If true, populate `TreeNode.last_commit`. Off by default.
755+
/// Note: Attribution costs one delta-block read per state plus one
756+
/// file-metadata-block read per entry.
757+
#[prost(bool, tag = "5")]
758+
pub include_last_commit: bool,
717759
/// Revision specifier.
718760
#[prost(oneof = "revision_tree_request::Query", tags = "1, 2")]
719761
pub query: ::core::option::Option<revision_tree_request::Query>,

lore-proto/tests/v1_thin_client.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use lore_proto::lore::thin_client::v1::RevisionInfoResponse;
2323
use lore_proto::lore::thin_client::v1::RevisionTreeHeader;
2424
use lore_proto::lore::thin_client::v1::RevisionTreeRequest;
2525
use lore_proto::lore::thin_client::v1::RevisionTreeResponse;
26+
use lore_proto::lore::thin_client::v1::TreeCommit;
2627
use lore_proto::lore::thin_client::v1::TreeNode;
2728
use lore_proto::lore::thin_client::v1::content_diff_response::Payload as ContentDiffPayload;
2829
use lore_proto::lore::thin_client::v1::revision::Parent as RevisionParent;
@@ -119,7 +120,15 @@ fn v1_thin_client_field_shapes() {
119120
size: _,
120121
mode: _,
121122
tracking: _,
123+
last_commit: _,
122124
} = TreeNode::default();
125+
let TreeCommit {
126+
signature: _,
127+
commit_message: _,
128+
timestamp: _,
129+
identifier: _,
130+
committed_by: _,
131+
} = TreeCommit::default();
123132

124133
// Revision + nested Parent + Metadata
125134
let Revision {
@@ -179,6 +188,7 @@ fn v1_thin_client_field_shapes() {
179188
query: _,
180189
path_prefix: _,
181190
max_depth: _,
191+
include_last_commit: _,
182192
} = RevisionTreeRequest::default();
183193
let _ = RevisionTreeQuery::Identifier(Default::default());
184194
let _ = RevisionTreeQuery::Signature(Default::default());

lore-revision/src/revision.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1125,12 +1125,18 @@ pub struct TreeResult {
11251125
pub paths: Vec<TreePath>,
11261126
}
11271127

1128+
/// Walk the tree at `revision`, optionally attributing each entry with the
1129+
/// revision that last modified it.
1130+
///
1131+
/// `include_last_commit` populates [`TreePath::last_revision`] and
1132+
/// [`TreePath::last_revision_repository`]. Off by default.
11281133
pub async fn tree(
11291134
repository: Arc<RepositoryContext>,
11301135
revision: Hash,
11311136
path: RelativePath,
11321137
max_depth: usize,
11331138
can_read: crate::state::CanReadRepository,
1139+
include_last_commit: bool,
11341140
) -> Result<TreeResult, StateError> {
11351141
lore_debug!(
11361142
"Gathering tree in repository {} revision: {} path: {}",
@@ -1139,7 +1145,15 @@ pub async fn tree(
11391145
path.as_str()
11401146
);
11411147
let state = State::deserialize(repository.clone(), revision).await?;
1142-
let paths = gather_tree_paths(state, repository, path, max_depth, can_read).await?;
1148+
let paths = gather_tree_paths(
1149+
state,
1150+
repository,
1151+
path,
1152+
max_depth,
1153+
can_read,
1154+
include_last_commit,
1155+
)
1156+
.await?;
11431157
Ok(TreeResult { paths })
11441158
}
11451159

0 commit comments

Comments
 (0)