Skip to content
Open
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
1 change: 1 addition & 0 deletions cli/src/commit_ref_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ mod tests {
author,
committer,
secure_sig: None,
metadata: HashMap::new(),
})
}

Expand Down
8 changes: 8 additions & 0 deletions lib/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
//! trait for reading and writing commits, trees, files, etc.

use std::any::Any;
use std::collections::HashMap;
use std::fmt::Debug;
use std::pin::Pin;
use std::slice;
Expand Down Expand Up @@ -215,6 +216,12 @@ pub struct Commit {
/// A cryptographic signature of this commit.
#[serde(skip)] // raw data wouldn't be useful
pub secure_sig: Option<SecureSig>,
/// Additional metadata about this commit. This allows servers and custom
/// clients to attach arbitrary data to a commit. It is not preserved on
/// rewrite. It is part of the commit's identity; two commits with
/// different metadata but otherwise identical should have different IDs.
#[serde(skip)] // raw data wouldn't be useful
pub metadata: HashMap<String, Vec<u8>>,

@OlshaMB OlshaMB Aug 1, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe there is a point in using BTreeMap, since it's already should be sorted. Also a maybe a unnecessary memory question, is there a point in maybe doing Arc<str>, since we never mutate string.

Suggested change
pub metadata: HashMap<String, Vec<u8>>,
pub metadata: BTreeMap</* Arc<str> */, Vec<u8>>,

}

/// An individual copy event, from file A -> B.
Expand Down Expand Up @@ -530,6 +537,7 @@ pub fn make_root_commit(root_change_id: ChangeId, empty_tree_id: TreeId) -> Comm
author: signature.clone(),
committer: signature,
secure_sig: None,
metadata: HashMap::new(),
}
}

Expand Down
5 changes: 5 additions & 0 deletions lib/src/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
#![expect(missing_docs)]

use std::cmp::Ordering;
use std::collections::HashMap;
use std::fmt::Debug;
use std::fmt::Error;
use std::fmt::Formatter;
Expand Down Expand Up @@ -188,6 +189,10 @@ impl Commit {
&self.data.committer
}

pub fn metadata(&self) -> &HashMap<String, Vec<u8>> {
&self.data.metadata
}

/// A commit is hidden if its commit id is not in the change id index.
pub fn is_hidden(&self, repo: &dyn Repo) -> IndexResult<bool> {
let maybe_targets = repo.resolve_change_id(self.change_id())?;
Expand Down
20 changes: 20 additions & 0 deletions lib/src/commit_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

#![expect(missing_docs)]

use std::collections::HashMap;
use std::sync::Arc;

use pollster::FutureExt as _;
Expand Down Expand Up @@ -136,6 +137,15 @@ impl CommitBuilder<'_> {
self
}

pub fn metadata(&self) -> &HashMap<String, Vec<u8>> {
self.inner.metadata()
}

pub fn set_metadata(mut self, metadata: HashMap<String, Vec<u8>>) -> Self {
self.inner.set_metadata(metadata);
self
}

/// [`Commit::is_discardable()`] for the new commit.
pub async fn is_discardable(&self) -> BackendResult<bool> {
self.inner.is_discardable(self.mut_repo).await
Expand Down Expand Up @@ -207,6 +217,7 @@ impl DetachedCommitBuilder {
author: signature.clone(),
committer: signature,
secure_sig: None,
metadata: HashMap::new(),
};
let record_predecessors_in_commit = settings
.get_bool("experimental.record-predecessors-in-commit")
Expand Down Expand Up @@ -370,6 +381,15 @@ impl DetachedCommitBuilder {
self
}

pub fn metadata(&self) -> &HashMap<String, Vec<u8>> {
&self.commit.metadata
}

pub fn set_metadata(&mut self, metadata: HashMap<String, Vec<u8>>) -> &mut Self {
self.commit.metadata = metadata;
self
}

/// [`Commit::is_discardable()`] for the new commit.
pub async fn is_discardable(&self, repo: &dyn Repo) -> BackendResult<bool> {
Ok(self.description().is_empty() && self.is_empty(repo).await?)
Expand Down
48 changes: 48 additions & 0 deletions lib/src/git_backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

#![expect(missing_docs)]

use std::collections::HashMap;
use std::collections::HashSet;
use std::ffi::OsStr;
use std::fmt::Debug;
Expand Down Expand Up @@ -701,6 +702,7 @@ fn commit_from_git_without_root_parent(
author,
committer,
secure_sig,
metadata: HashMap::new(),
})
}

Expand Down Expand Up @@ -1275,6 +1277,14 @@ impl Backend for GitBackend {
) -> BackendResult<(CommitId, Commit)> {
assert!(contents.secure_sig.is_none(), "commit.secure_sig was set");

// TODO: Support writing metadata as custom Git commit headers (e.g.
// "jj:metadata:<key>: <value>")?
if !contents.metadata.is_empty() {
return Err(BackendError::Unsupported(
"The Git backend doesn't support writing commit metadata".to_owned(),
));
}

let locked_repo = self.lock_git_repo();
let tree_ids = &contents.root_tree;
let git_tree_id = match tree_ids.as_resolved() {
Expand Down Expand Up @@ -2014,6 +2024,7 @@ mod tests {
author: create_signature(),
committer: create_signature(),
secure_sig: None,
metadata: HashMap::new(),
};

let (initial_commit_id, _init_commit) = backend.write_commit(commit, None).block_on()?;
Expand Down Expand Up @@ -2106,6 +2117,7 @@ mod tests {
author: create_signature(),
committer: create_signature(),
secure_sig: None,
metadata: HashMap::new(),
};

let write_commit = |commit: Commit| -> BackendResult<(CommitId, Commit)> {
Expand Down Expand Up @@ -2195,6 +2207,7 @@ mod tests {
author: create_signature(),
committer: create_signature(),
secure_sig: None,
metadata: HashMap::new(),
};

let write_commit = |commit: Commit| -> BackendResult<(CommitId, Commit)> {
Expand Down Expand Up @@ -2276,6 +2289,38 @@ mod tests {
Ok(())
}

#[test]
fn write_commit_with_metadata_fails() -> TestResult {
let settings = user_settings();
let temp_dir = new_temp_dir();
let backend = GitBackend::init_internal(&settings, temp_dir.path(), gix::hash::Kind::Sha1)?;
let signature = Signature {
name: "Someone".to_string(),
email: "someone@example.com".to_string(),
timestamp: Timestamp {
timestamp: MillisSinceEpoch(0),
tz_offset: 0,
},
};
let commit = Commit {
parents: vec![backend.root_commit_id().clone()],
predecessors: vec![],
root_tree: Merge::resolved(backend.empty_tree_id().clone()),
conflict_labels: Merge::resolved(String::new()),
change_id: ChangeId::new(vec![42; 16]),
description: "initial".to_string(),
author: signature.clone(),
committer: signature,
secure_sig: None,
metadata: HashMap::from([("foo".to_string(), b"bar".to_vec())]),
};
assert_matches!(
backend.write_commit(commit, None).block_on(),
Err(BackendError::Unsupported(_))
);
Ok(())
}

#[test_case(gix::hash::Kind::Sha1 ; "sha1")]
#[test_case(gix::hash::Kind::Sha256; "sha256")]
fn commit_has_ref(object_hash: gix::hash::Kind) -> TestResult {
Expand All @@ -2301,6 +2346,7 @@ mod tests {
author: signature.clone(),
committer: signature,
secure_sig: None,
metadata: HashMap::new(),
};
let commit_id = backend.write_commit(commit, None).block_on()?.0;
let git_refs = git_repo.references()?;
Expand Down Expand Up @@ -2378,6 +2424,7 @@ mod tests {
author: create_signature(),
committer: create_signature(),
secure_sig: None,
metadata: HashMap::new(),
};

let write_commit = |commit: Commit| -> BackendResult<(CommitId, Commit)> {
Expand Down Expand Up @@ -2474,6 +2521,7 @@ mod tests {
author: create_signature(),
committer: create_signature(),
secure_sig: None,
metadata: HashMap::new(),
};

let mut signer = |data: &_| {
Expand Down
7 changes: 7 additions & 0 deletions lib/src/protos/simple_store.proto
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,11 @@ message Commit {
Signature author = 6;
Signature committer = 7;
optional bytes secure_sig = 9;

message MetadataEntry {
string key = 1;
bytes value = 2;
}
// Sorted by key so the serialized form is deterministic
repeated MetadataEntry metadata = 11;
}
12 changes: 11 additions & 1 deletion lib/src/protos/simple_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ pub mod tree {
pub value: ::core::option::Option<super::TreeValue>,
}
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct Commit {
#[prost(bytes = "vec", repeated, tag = "1")]
pub parents: ::prost::alloc::vec::Vec<::prost::alloc::vec::Vec<u8>>,
Expand All @@ -62,6 +62,9 @@ pub struct Commit {
pub committer: ::core::option::Option<commit::Signature>,
#[prost(bytes = "vec", optional, tag = "9")]
pub secure_sig: ::core::option::Option<::prost::alloc::vec::Vec<u8>>,
/// Sorted by key so the serialized form is deterministic
#[prost(message, repeated, tag = "11")]
pub metadata: ::prost::alloc::vec::Vec<commit::MetadataEntry>,
}
/// Nested message and enum types in `Commit`.
pub mod commit {
Expand All @@ -81,4 +84,11 @@ pub mod commit {
#[prost(message, optional, tag = "3")]
pub timestamp: ::core::option::Option<Timestamp>,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct MetadataEntry {
#[prost(string, tag = "1")]
pub key: ::prost::alloc::string::String,
#[prost(bytes = "vec", tag = "2")]
pub value: ::prost::alloc::vec::Vec<u8>,
}
}
52 changes: 52 additions & 0 deletions lib/src/simple_backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ use futures::StreamExt as _;
use futures::io::Cursor;
use futures::stream;
use futures::stream::BoxStream;
use itertools::Itertools as _;
use pollster::FutureExt as _;
use prost::Message as _;
use tempfile::NamedTempFile;
Expand Down Expand Up @@ -369,6 +370,18 @@ pub fn commit_to_proto(commit: &Commit) -> crate::protos::simple_store::Commit {
proto.description = commit.description.clone();
proto.author = Some(signature_to_proto(&commit.author));
proto.committer = Some(signature_to_proto(&commit.committer));
// Sort by key to make the serialized form deterministic
proto.metadata = commit
.metadata
.iter()
.sorted_by_key(|&(key, _)| key)
.map(
|(key, value)| crate::protos::simple_store::commit::MetadataEntry {
key: key.clone(),
value: value.clone(),
},
)
.collect();
proto
}

Expand All @@ -382,6 +395,11 @@ fn commit_from_proto(mut proto: crate::protos::simple_store::Commit) -> Commit {

let parents = proto.parents.into_iter().map(CommitId::new).collect();
let predecessors = proto.predecessors.into_iter().map(CommitId::new).collect();
let metadata = proto
.metadata
.into_iter()
.map(|entry| (entry.key, entry.value))
.collect();
let merge_builder: MergeBuilder<_> = proto.root_tree.into_iter().map(TreeId::new).collect();
let root_tree = merge_builder.build();
let conflict_labels = ConflictLabels::from_vec(proto.conflict_labels);
Expand All @@ -396,6 +414,7 @@ fn commit_from_proto(mut proto: crate::protos::simple_store::Commit) -> Commit {
author: signature_from_proto(proto.author.unwrap_or_default()),
committer: signature_from_proto(proto.committer.unwrap_or_default()),
secure_sig,
metadata,
}
}

Expand Down Expand Up @@ -505,6 +524,8 @@ fn signature_from_proto(proto: crate::protos::simple_store::commit::Signature) -

#[cfg(test)]
mod tests {
use std::collections::HashMap;

use assert_matches::assert_matches;
use pollster::FutureExt as _;

Expand All @@ -530,6 +551,7 @@ mod tests {
author: create_signature(),
committer: create_signature(),
secure_sig: None,
metadata: HashMap::new(),
};

let write_commit = |commit: Commit| -> BackendResult<(CommitId, Commit)> {
Expand Down Expand Up @@ -569,6 +591,36 @@ mod tests {
Ok(())
}

/// Test that commit metadata gets written and read back
#[test]
fn write_commit_metadata() -> TestResult {
let temp_dir = new_temp_dir();
let store_path = temp_dir.path();

let backend = SimpleBackend::init(store_path);
let commit = Commit {
parents: vec![backend.root_commit_id().clone()],
predecessors: vec![],
root_tree: Merge::resolved(backend.empty_tree_id().clone()),
conflict_labels: Merge::resolved(String::new()),
change_id: ChangeId::from_hex("abc123"),
description: "".to_string(),
author: create_signature(),
committer: create_signature(),
secure_sig: None,
metadata: HashMap::from([
("foo".to_string(), b"bar".to_vec()),
("binary".to_string(), vec![0x00, 0xff]),
]),
};

let (id, returned_commit) = backend.write_commit(commit.clone(), None).block_on()?;
assert_eq!(returned_commit, commit);
let read_commit = backend.read_commit(&id).block_on()?;
assert_eq!(read_commit, commit);
Ok(())
}

fn create_signature() -> Signature {
Signature {
name: "Someone".to_string(),
Expand Down
2 changes: 1 addition & 1 deletion lib/tests/test_id_prefix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,7 @@ fn test_id_prefix_shadowed_by_ref() {

let commit_id_sym = commit.id().to_string();
let change_id_sym = commit.change_id().to_string();
insta::assert_snapshot!(commit_id_sym, @"b06a01f026da65ac5821");
insta::assert_snapshot!(commit_id_sym, @"d62c40b45ee40858ea2e");
insta::assert_snapshot!(change_id_sym, @"sryyqqkqmuumyrlruupspprvnulvovzm");

let context = IdPrefixContext::default();
Expand Down
Loading