use sley_config::GitConfig;
use sley_core::{GitError, ObjectFormat, ObjectId, Result};
use sley_object::{
BString, Commit, EncodedObject, ObjectType, Tree, TreeEntries, TreeEntry,
tree_entry_object_type,
};
use sley_odb::{FileObjectDatabase, ObjectReader, ObjectWriter};
use sley_refs::{FileRefStore, RefTarget, RefUpdate, ReflogEntry};
use sley_sequencer::{CommitCreate, create_commit};
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::path::Path;
pub const DEFAULT_NOTES_REF: &str = "refs/notes/commits";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NotesRef(pub String);
impl NotesRef {
pub fn expand(name: &str) -> Self {
Self(expand_notes_ref(name))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&str> for NotesRef {
fn from(value: &str) -> Self {
Self::expand(value)
}
}
impl From<String> for NotesRef {
fn from(value: String) -> Self {
Self::expand(&value)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Note {
pub annotated: ObjectId,
pub blob: ObjectId,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NotesCommitIdentity {
pub author: Vec<u8>,
pub committer: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpsertNoteOutcome {
Updated { notes_commit: ObjectId },
Unchanged,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RemoveNoteOutcome {
Removed { notes_commit: ObjectId },
Unchanged,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotesMergeStrategy {
Manual,
Ours,
Theirs,
Union,
CatSortUniq,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NotesMergeConflict {
pub annotated: ObjectId,
pub base: Option<ObjectId>,
pub local: Option<ObjectId>,
pub remote: Option<ObjectId>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NotesMergeOutcome {
AlreadyUpToDate { result: ObjectId },
FastForward { result: ObjectId },
Merged { result: ObjectId },
Conflicted {
partial: ObjectId,
conflicts: Vec<NotesMergeConflict>,
},
}
pub fn resolve_notes_ref(git_dir: &Path, ref_override: Option<&str>) -> Result<NotesRef> {
resolve_notes_ref_impl(git_dir, ref_override, None)
}
pub fn resolve_notes_ref_with_config(
git_dir: &Path,
ref_override: Option<&str>,
config: &GitConfig,
) -> Result<NotesRef> {
resolve_notes_ref_impl(git_dir, ref_override, Some(config))
}
fn resolve_notes_ref_impl(
git_dir: &Path,
ref_override: Option<&str>,
config: Option<&GitConfig>,
) -> Result<NotesRef> {
if let Some(value) = ref_override {
return Ok(NotesRef::expand(value));
}
if let Ok(value) = std::env::var("GIT_NOTES_REF")
&& !value.is_empty()
{
return Ok(NotesRef::expand(&value));
}
let owned_config;
let config = match config {
Some(config) => Some(config),
None => match read_repo_config(git_dir) {
Ok(config) => {
owned_config = config;
Some(&owned_config)
}
Err(_) => None,
},
};
if let Some(config) = config
&& let Some(value) = config.get("core", None, "notesRef")
&& !value.is_empty()
{
return Ok(NotesRef::expand(value));
}
Ok(NotesRef::expand(DEFAULT_NOTES_REF))
}
pub struct NotesIter {
db: FileObjectDatabase,
format: ObjectFormat,
stack: Vec<(ObjectId, String)>,
pending: Vec<Note>,
}
impl NotesIter {
fn new(
git_dir: &Path,
format: ObjectFormat,
store: &FileRefStore,
notes_ref: &NotesRef,
) -> Result<Self> {
let Some(tree_oid) = notes_tree_oid(git_dir, format, store, notes_ref)? else {
return Ok(Self {
db: FileObjectDatabase::from_git_dir(git_dir, format),
format,
stack: Vec::new(),
pending: Vec::new(),
});
};
Ok(Self {
db: FileObjectDatabase::from_git_dir(git_dir, format),
format,
stack: vec![(tree_oid, String::new())],
pending: Vec::new(),
})
}
}
impl Iterator for NotesIter {
type Item = Result<Note>;
fn next(&mut self) -> Option<Self::Item> {
loop {
if let Some(note) = self.pending.pop() {
return Some(Ok(note));
}
let (tree_oid, prefix) = self.stack.pop()?;
let entries = match load_hex_tree_entries(&self.db, self.format, &tree_oid) {
Ok(entries) => entries,
Err(err) => return Some(Err(err)),
};
for (name, mode, oid) in entries.into_iter().rev() {
if tree_entry_object_type(mode) == ObjectType::Tree {
let mut nested = prefix.clone();
nested.push_str(&name);
self.stack.push((oid, nested));
} else {
let mut hex = prefix.clone();
hex.push_str(&name);
if hex.len() != self.format.hex_len() {
continue;
}
let Ok(annotated) = ObjectId::from_hex(self.format, &hex) else {
continue;
};
self.pending.push(Note {
annotated,
blob: oid,
});
}
}
}
}
}
pub fn iter_notes(
git_dir: &Path,
format: ObjectFormat,
store: &FileRefStore,
notes_ref: &NotesRef,
) -> Result<NotesIter> {
NotesIter::new(git_dir, format, store, notes_ref)
}
pub fn list_notes(
git_dir: &Path,
format: ObjectFormat,
store: &FileRefStore,
notes_ref: &NotesRef,
) -> Result<Vec<Note>> {
let mut notes = iter_notes(git_dir, format, store, notes_ref)?.collect::<Result<Vec<_>>>()?;
notes.sort_by_key(|entry| entry.annotated.to_hex());
Ok(notes)
}
pub fn read_note_for(
git_dir: &Path,
format: ObjectFormat,
store: &FileRefStore,
notes_ref: &NotesRef,
annotated: &ObjectId,
) -> Result<Option<ObjectId>> {
let Some(tree_oid) = notes_tree_oid(git_dir, format, store, notes_ref)? else {
return Ok(None);
};
let db = FileObjectDatabase::from_git_dir(git_dir, format);
lookup_note_for(&db, format, &tree_oid, "", &annotated.to_hex())
}
pub fn read_note(
git_dir: &Path,
format: ObjectFormat,
store: &FileRefStore,
notes_ref: &NotesRef,
annotated: &ObjectId,
) -> Result<Option<ObjectId>> {
read_note_for(git_dir, format, store, notes_ref, annotated)
}
pub fn read_note_bytes(
git_dir: &Path,
format: ObjectFormat,
store: &FileRefStore,
notes_ref: &NotesRef,
annotated: &ObjectId,
) -> Result<Option<Vec<u8>>> {
let Some(blob) = read_note(git_dir, format, store, notes_ref, annotated)? else {
return Ok(None);
};
let db = FileObjectDatabase::from_git_dir(git_dir, format);
let object = db.read_object(&blob)?;
if object.object_type != ObjectType::Blob {
return Err(GitError::InvalidFormat(format!(
"note for {} is not a blob",
annotated.to_hex()
)));
}
Ok(Some(object.body.to_vec()))
}
pub fn notes_ref_expected(store: &FileRefStore, notes_ref: &NotesRef) -> Result<Option<RefTarget>> {
Ok(match store.read_ref(notes_ref.as_str())? {
Some(RefTarget::Direct(oid)) => Some(RefTarget::Direct(oid)),
_ => None,
})
}
#[allow(clippy::too_many_arguments)]
pub fn write_notes(
git_dir: &Path,
format: ObjectFormat,
store: &FileRefStore,
notes_ref: &NotesRef,
notes: &[Note],
message: &str,
identity: &NotesCommitIdentity,
ref_expected: Option<RefTarget>,
) -> Result<()> {
commit_notes_update(
git_dir,
format,
store,
notes_ref,
notes,
message,
identity,
ref_expected,
)?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn merge_notes(
git_dir: &Path,
format: ObjectFormat,
store: &FileRefStore,
local_ref: &NotesRef,
remote_ref: &NotesRef,
strategy: NotesMergeStrategy,
message: &str,
identity: &NotesCommitIdentity,
) -> Result<NotesMergeOutcome> {
let local_oid = notes_head_oid(store, local_ref)?;
let remote_oid = notes_head_oid(store, remote_ref)?;
match (local_oid, remote_oid) {
(None, None) => {
return Err(GitError::InvalidFormat(format!(
"Cannot merge empty notes ref ({}) into empty notes ref ({})",
remote_ref.as_str(),
local_ref.as_str()
)));
}
(None, Some(remote)) => {
update_notes_ref_to_commit(git_dir, format, store, local_ref, None, remote, message, identity)?;
return Ok(NotesMergeOutcome::FastForward { result: remote });
}
(Some(local), None) => {
return Ok(NotesMergeOutcome::AlreadyUpToDate { result: local });
}
(Some(local), Some(remote)) if local == remote => {
return Ok(NotesMergeOutcome::AlreadyUpToDate { result: local });
}
_ => {}
}
let (Some(local_oid), Some(remote_oid)) = (local_oid, remote_oid) else {
return Err(GitError::InvalidFormat("missing notes merge endpoint".into()));
};
let db = FileObjectDatabase::from_git_dir(git_dir, format);
let bases = merge_base_oids(&db, format, &local_oid, &remote_oid)?;
let base_oid = bases.first().copied();
if base_oid == Some(remote_oid) {
return Ok(NotesMergeOutcome::AlreadyUpToDate { result: local_oid });
}
if base_oid == Some(local_oid) {
update_notes_ref_to_commit(
git_dir,
format,
store,
local_ref,
Some(local_oid),
remote_oid,
message,
identity,
)?;
return Ok(NotesMergeOutcome::FastForward { result: remote_oid });
}
let base_tree = match base_oid {
Some(oid) => commit_tree_oid(&db, format, &oid)?,
None => ObjectId::empty_tree(format),
};
let local_tree = commit_tree_oid(&db, format, &local_oid)?;
let remote_tree = commit_tree_oid(&db, format, &remote_oid)?;
let base_notes = notes_map_from_tree(&db, format, base_tree)?;
let local_notes = notes_map_from_tree(&db, format, local_tree)?;
let remote_notes = notes_map_from_tree(&db, format, remote_tree)?;
let mut merged = local_notes.clone();
let mut conflicts = Vec::new();
let mut candidates: Vec<ObjectId> = base_notes
.keys()
.chain(remote_notes.keys())
.copied()
.collect();
candidates.sort_by_key(|oid| oid.to_hex());
candidates.dedup();
for annotated in candidates {
let base = base_notes.get(&annotated).copied();
let local = local_notes.get(&annotated).copied();
let remote = remote_notes.get(&annotated).copied();
if base == remote || local == remote {
continue;
}
if local == base {
set_note_option(&mut merged, annotated, remote);
continue;
}
match strategy {
NotesMergeStrategy::Manual => {
merged.remove(&annotated);
conflicts.push(NotesMergeConflict {
annotated,
base,
local,
remote,
});
}
NotesMergeStrategy::Ours => {}
NotesMergeStrategy::Theirs => set_note_option(&mut merged, annotated, remote),
NotesMergeStrategy::Union => {
if let Some(blob) =
combine_note_blobs(git_dir, &db, format, local, remote, NoteBlobCombine::Union)?
{
merged.insert(annotated, blob);
}
}
NotesMergeStrategy::CatSortUniq => {
if let Some(blob) =
combine_note_blobs(
git_dir,
&db,
format,
local,
remote,
NoteBlobCombine::CatSortUniq,
)?
{
merged.insert(annotated, blob);
}
}
}
}
let notes = notes_vec_from_map(merged);
let parents = vec![local_oid, remote_oid];
let result = commit_notes_update_with_parents(
git_dir,
format,
store,
local_ref,
¬es,
message.as_bytes(),
identity,
&parents,
Some(RefTarget::Direct(local_oid)),
conflicts.is_empty(),
)?;
if conflicts.is_empty() {
Ok(NotesMergeOutcome::Merged { result })
} else {
Ok(NotesMergeOutcome::Conflicted {
partial: result,
conflicts,
})
}
}
#[allow(clippy::too_many_arguments)]
pub fn finalize_notes_merge(
git_dir: &Path,
format: ObjectFormat,
store: &FileRefStore,
notes_ref: &NotesRef,
partial_commit: ObjectId,
resolved: &[(ObjectId, Vec<u8>)],
identity: &NotesCommitIdentity,
) -> Result<ObjectId> {
let db = FileObjectDatabase::from_git_dir(git_dir, format);
let partial = read_commit(&db, format, &partial_commit)?;
let mut notes = notes_map_from_tree(&db, format, partial.tree)?;
let writable = FileObjectDatabase::from_git_dir(git_dir, format);
for (annotated, body) in resolved {
let blob = writable.write_object(EncodedObject::new(ObjectType::Blob, body.clone()))?;
notes.insert(*annotated, blob);
}
let expected = partial
.parents
.first()
.copied()
.map(RefTarget::Direct);
commit_notes_update_with_parents(
git_dir,
format,
store,
notes_ref,
¬es_vec_from_map(notes),
&partial.message,
identity,
&partial.parents,
expected,
true,
)
}
#[allow(clippy::too_many_arguments)]
pub fn upsert_note_for(
git_dir: &Path,
format: ObjectFormat,
store: &FileRefStore,
notes_ref: &NotesRef,
annotated: &ObjectId,
blob: ObjectId,
message: &str,
identity: &NotesCommitIdentity,
ref_expected: Option<RefTarget>,
) -> Result<UpsertNoteOutcome> {
if let Some(existing) = read_note_for(git_dir, format, store, notes_ref, annotated)?
&& existing == blob
{
return Ok(UpsertNoteOutcome::Unchanged);
}
let mut notes = list_notes(git_dir, format, store, notes_ref)?;
upsert_note(&mut notes, annotated, blob);
let notes_commit = commit_notes_update(
git_dir,
format,
store,
notes_ref,
¬es,
message,
identity,
ref_expected,
)?;
Ok(UpsertNoteOutcome::Updated { notes_commit })
}
#[allow(clippy::too_many_arguments)]
pub fn upsert_note_bytes_for(
git_dir: &Path,
format: ObjectFormat,
store: &FileRefStore,
notes_ref: &NotesRef,
annotated: &ObjectId,
body: &[u8],
message: &str,
identity: &NotesCommitIdentity,
ref_expected: Option<RefTarget>,
) -> Result<UpsertNoteOutcome> {
let db = FileObjectDatabase::from_git_dir(git_dir, format);
let blob = db.write_object(EncodedObject::new(ObjectType::Blob, body.to_vec()))?;
upsert_note_for(
git_dir,
format,
store,
notes_ref,
annotated,
blob,
message,
identity,
ref_expected,
)
}
#[allow(clippy::too_many_arguments)]
pub fn remove_note_for(
git_dir: &Path,
format: ObjectFormat,
store: &FileRefStore,
notes_ref: &NotesRef,
annotated: &ObjectId,
message: &str,
identity: &NotesCommitIdentity,
ref_expected: Option<RefTarget>,
) -> Result<RemoveNoteOutcome> {
remove_notes_for(
git_dir,
format,
store,
notes_ref,
std::slice::from_ref(annotated),
message,
identity,
ref_expected,
)
}
#[allow(clippy::too_many_arguments)]
pub fn remove_notes_for(
git_dir: &Path,
format: ObjectFormat,
store: &FileRefStore,
notes_ref: &NotesRef,
annotated: &[ObjectId],
message: &str,
identity: &NotesCommitIdentity,
ref_expected: Option<RefTarget>,
) -> Result<RemoveNoteOutcome> {
if annotated.is_empty() || notes_head_oid(store, notes_ref)?.is_none() {
return Ok(RemoveNoteOutcome::Unchanged);
}
let targets: HashSet<_> = annotated.iter().collect();
let mut notes = list_notes(git_dir, format, store, notes_ref)?;
let before = notes.len();
notes.retain(|note| !targets.contains(¬e.annotated));
if notes.len() == before {
return Ok(RemoveNoteOutcome::Unchanged);
}
let notes_commit = commit_notes_update(
git_dir,
format,
store,
notes_ref,
¬es,
message,
identity,
ref_expected,
)?;
Ok(RemoveNoteOutcome::Removed { notes_commit })
}
pub fn upsert_note(notes: &mut Vec<Note>, annotated: &ObjectId, blob: ObjectId) {
let target_hex = annotated.to_hex();
if let Some(existing) = notes
.iter_mut()
.find(|entry| entry.annotated.to_hex() == target_hex)
{
existing.blob = blob;
} else {
notes.push(Note {
annotated: *annotated,
blob,
});
}
}
pub fn remove_note(notes: &mut Vec<Note>, annotated: &ObjectId) {
let target_hex = annotated.to_hex();
notes.retain(|entry| entry.annotated.to_hex() != target_hex);
}
pub fn notes_tree_oid(
git_dir: &Path,
format: ObjectFormat,
store: &FileRefStore,
notes_ref: &NotesRef,
) -> Result<Option<ObjectId>> {
let Some(target) = store.read_ref(notes_ref.as_str())? else {
return Ok(None);
};
let commit_oid = match target {
RefTarget::Direct(oid) => oid,
RefTarget::Symbolic(name) => match store.read_ref(&name)? {
Some(RefTarget::Direct(oid)) => oid,
_ => return Ok(None),
},
};
let db = FileObjectDatabase::from_git_dir(git_dir, format);
let object = db.read_object(&commit_oid)?;
match object.object_type {
ObjectType::Commit => Ok(Some(Commit::parse_ref(format, &object.body)?.tree)),
ObjectType::Tree => Ok(Some(commit_oid)),
_ => Ok(None),
}
}
fn load_hex_tree_entries(
db: &FileObjectDatabase,
format: ObjectFormat,
tree_oid: &ObjectId,
) -> Result<Vec<(String, u32, ObjectId)>> {
let object = db.read_object(tree_oid)?;
if object.object_type != ObjectType::Tree {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in TreeEntries::new(format, &object.body) {
let entry = entry?;
let Ok(name) = std::str::from_utf8(entry.name) else {
continue;
};
if !is_hex_name(name) {
continue;
}
out.push((name.to_string(), entry.mode, entry.oid));
}
Ok(out)
}
fn lookup_note_for(
db: &FileObjectDatabase,
format: ObjectFormat,
tree_oid: &ObjectId,
prefix: &str,
target_hex: &str,
) -> Result<Option<ObjectId>> {
for (name, mode, oid) in load_hex_tree_entries(db, format, tree_oid)? {
let mut hex = prefix.to_string();
hex.push_str(&name);
if tree_entry_object_type(mode) == ObjectType::Tree {
if !target_hex.starts_with(&hex) {
continue;
}
if let Some(blob) = lookup_note_for(db, format, &oid, &hex, target_hex)? {
return Ok(Some(blob));
}
} else if hex == target_hex {
return Ok(Some(oid));
}
}
Ok(None)
}
fn is_hex_name(name: &str) -> bool {
!name.is_empty() && name.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn expand_notes_ref(name: &str) -> String {
if name.starts_with("refs/notes/") {
name.to_string()
} else {
format!("refs/notes/{name}")
}
}
fn read_repo_config(git_dir: &Path) -> Result<GitConfig> {
sley_config::read_repo_config(git_dir, None)
}
fn read_commit(
db: &FileObjectDatabase,
format: ObjectFormat,
oid: &ObjectId,
) -> Result<Commit> {
let object = db.read_object(oid)?;
if object.object_type != ObjectType::Commit {
return Err(GitError::InvalidFormat(format!(
"{} is not a commit",
oid.to_hex()
)));
}
Commit::parse(format, &object.body)
}
fn commit_tree_oid(db: &FileObjectDatabase, format: ObjectFormat, oid: &ObjectId) -> Result<ObjectId> {
Ok(read_commit(db, format, oid)?.tree)
}
fn merge_base_oids(
db: &FileObjectDatabase,
format: ObjectFormat,
left: &ObjectId,
right: &ObjectId,
) -> Result<Vec<ObjectId>> {
let left_depths = ancestor_depths(db, format, left)?;
let right_depths = ancestor_depths(db, format, right)?;
let candidates: Vec<ObjectId> = left_depths
.keys()
.filter(|oid| right_depths.contains_key(*oid))
.copied()
.collect();
let mut bases: Vec<ObjectId> = candidates
.iter()
.filter(|candidate| {
!candidates.iter().any(|other| {
other != *candidate
&& depth_lt(&left_depths, other, candidate)
&& depth_lt(&right_depths, other, candidate)
})
})
.copied()
.collect();
bases.sort_by_key(|oid| oid.to_hex());
Ok(bases)
}
fn ancestor_depths(
db: &FileObjectDatabase,
format: ObjectFormat,
start: &ObjectId,
) -> Result<HashMap<ObjectId, usize>> {
let mut depths = HashMap::new();
let mut pending = VecDeque::from([(*start, 0usize)]);
while let Some((oid, depth)) = pending.pop_front() {
if depths.get(&oid).is_some_and(|seen| *seen <= depth) {
continue;
}
depths.insert(oid, depth);
for parent in read_commit(db, format, &oid)?.parents {
pending.push_back((parent, depth + 1));
}
}
Ok(depths)
}
fn depth_lt(depths: &HashMap<ObjectId, usize>, left: &ObjectId, right: &ObjectId) -> bool {
match (depths.get(left), depths.get(right)) {
(Some(left), Some(right)) => left < right,
_ => false,
}
}
fn notes_head_oid(store: &FileRefStore, notes_ref: &NotesRef) -> Result<Option<ObjectId>> {
Ok(match store.read_ref(notes_ref.as_str())? {
Some(RefTarget::Direct(oid)) => Some(oid),
_ => None,
})
}
fn notes_map_from_tree(
db: &FileObjectDatabase,
format: ObjectFormat,
tree_oid: ObjectId,
) -> Result<BTreeMap<ObjectId, ObjectId>> {
let mut notes = BTreeMap::new();
if tree_oid == ObjectId::empty_tree(format) {
return Ok(notes);
}
collect_notes_from_tree(db, format, tree_oid, "", &mut notes)?;
Ok(notes)
}
fn collect_notes_from_tree(
db: &FileObjectDatabase,
format: ObjectFormat,
tree_oid: ObjectId,
prefix: &str,
out: &mut BTreeMap<ObjectId, ObjectId>,
) -> Result<()> {
for (name, mode, oid) in load_hex_tree_entries(db, format, &tree_oid)? {
let mut hex = prefix.to_string();
hex.push_str(&name);
if tree_entry_object_type(mode) == ObjectType::Tree {
collect_notes_from_tree(db, format, oid, &hex, out)?;
} else if hex.len() == format.hex_len()
&& let Ok(annotated) = ObjectId::from_hex(format, &hex)
{
out.insert(annotated, oid);
}
}
Ok(())
}
fn notes_vec_from_map(notes: BTreeMap<ObjectId, ObjectId>) -> Vec<Note> {
notes
.into_iter()
.map(|(annotated, blob)| Note { annotated, blob })
.collect()
}
fn set_note_option(
notes: &mut BTreeMap<ObjectId, ObjectId>,
annotated: ObjectId,
blob: Option<ObjectId>,
) {
match blob {
Some(blob) => {
notes.insert(annotated, blob);
}
None => {
notes.remove(&annotated);
}
}
}
enum NoteBlobCombine {
Union,
CatSortUniq,
}
fn combine_note_blobs(
git_dir: &Path,
db: &FileObjectDatabase,
format: ObjectFormat,
local: Option<ObjectId>,
remote: Option<ObjectId>,
mode: NoteBlobCombine,
) -> Result<Option<ObjectId>> {
match mode {
NoteBlobCombine::Union => combine_note_blobs_union(git_dir, db, format, local, remote),
NoteBlobCombine::CatSortUniq => {
combine_note_blobs_cat_sort_uniq(git_dir, db, format, local, remote)
}
}
}
fn read_blob_bytes(db: &FileObjectDatabase, oid: &ObjectId) -> Result<Option<Vec<u8>>> {
let object = db.read_object(oid)?;
if object.object_type != ObjectType::Blob || object.body.is_empty() {
return Ok(None);
}
Ok(Some(object.body.clone()))
}
fn combine_note_blobs_union(
git_dir: &Path,
db: &FileObjectDatabase,
format: ObjectFormat,
local: Option<ObjectId>,
remote: Option<ObjectId>,
) -> Result<Option<ObjectId>> {
let Some(remote_oid) = remote else {
return Ok(local);
};
let Some(remote_body) = read_blob_bytes(db, &remote_oid)? else {
return Ok(local);
};
let Some(local_oid) = local else {
return Ok(Some(remote_oid));
};
let Some(mut local_body) = read_blob_bytes(db, &local_oid)? else {
return Ok(Some(remote_oid));
};
if local_body.last() == Some(&b'\n') {
local_body.pop();
}
local_body.extend_from_slice(b"\n\n");
local_body.extend_from_slice(&remote_body);
let writable = FileObjectDatabase::from_git_dir(git_dir, format);
writable
.write_object(EncodedObject::new(ObjectType::Blob, local_body))
.map(Some)
}
fn combine_note_blobs_cat_sort_uniq(
git_dir: &Path,
db: &FileObjectDatabase,
format: ObjectFormat,
local: Option<ObjectId>,
remote: Option<ObjectId>,
) -> Result<Option<ObjectId>> {
let mut lines: Vec<Vec<u8>> = Vec::new();
for oid in [local, remote].into_iter().flatten() {
if let Some(body) = read_blob_bytes(db, &oid)? {
lines.extend(body.split(|byte| *byte == b'\n').map(|line| line.to_vec()));
}
}
lines.retain(|line| !line.is_empty());
if lines.is_empty() {
return Ok(None);
}
lines.sort();
lines.dedup();
let mut body = Vec::new();
for line in lines {
body.extend_from_slice(&line);
body.push(b'\n');
}
let writable = FileObjectDatabase::from_git_dir(git_dir, format);
writable
.write_object(EncodedObject::new(ObjectType::Blob, body))
.map(Some)
}
#[allow(clippy::too_many_arguments)]
fn commit_notes_update(
git_dir: &Path,
format: ObjectFormat,
store: &FileRefStore,
notes_ref: &NotesRef,
notes: &[Note],
message: &str,
identity: &NotesCommitIdentity,
ref_expected: Option<RefTarget>,
) -> Result<ObjectId> {
let parent = notes_head_oid(store, notes_ref)?;
let parents = parent.iter().cloned().collect::<Vec<_>>();
commit_notes_update_with_parents(
git_dir,
format,
store,
notes_ref,
notes,
format!("{message}\n").as_bytes(),
identity,
&parents,
ref_expected,
true,
)
}
#[allow(clippy::too_many_arguments)]
fn commit_notes_update_with_parents(
git_dir: &Path,
format: ObjectFormat,
store: &FileRefStore,
notes_ref: &NotesRef,
notes: &[Note],
message: &[u8],
identity: &NotesCommitIdentity,
parents: &[ObjectId],
ref_expected: Option<RefTarget>,
update_ref: bool,
) -> Result<ObjectId> {
let mut db = FileObjectDatabase::from_git_dir(git_dir, format);
let tree_oid = write_notes_tree(&mut db, notes)?;
let commit_oid = create_commit(
&mut db,
CommitCreate {
tree: tree_oid,
parents: parents.to_vec(),
author: identity.author.clone(),
committer: identity.committer.clone(),
message: message.to_vec(),
encoding: None,
},
)?;
if !update_ref {
return Ok(commit_oid);
}
let old_oid = parents.first().copied().unwrap_or(zero_oid(format)?);
let mut tx = store.transaction();
let reflog_message = reflog_message_from_commit_message(message);
tx.update(RefUpdate {
name: notes_ref.as_str().to_string(),
expected: ref_expected,
new: RefTarget::Direct(commit_oid),
reflog: Some(ReflogEntry {
old_oid,
new_oid: commit_oid,
committer: identity.committer.clone(),
message: reflog_message,
}),
});
tx.commit()?;
Ok(commit_oid)
}
fn update_notes_ref_to_commit(
git_dir: &Path,
format: ObjectFormat,
store: &FileRefStore,
notes_ref: &NotesRef,
old: Option<ObjectId>,
new: ObjectId,
message: &str,
identity: &NotesCommitIdentity,
) -> Result<()> {
let old_oid = old.unwrap_or(zero_oid(format)?);
let mut tx = store.transaction();
tx.update(RefUpdate {
name: notes_ref.as_str().to_string(),
expected: old.map(RefTarget::Direct),
new: RefTarget::Direct(new),
reflog: Some(ReflogEntry {
old_oid,
new_oid: new,
committer: identity.committer.clone(),
message: format!("notes: {message}").into_bytes(),
}),
});
let _ = git_dir;
tx.commit()
}
fn reflog_message_from_commit_message(message: &[u8]) -> Vec<u8> {
let subject = message
.split(|byte| *byte == b'\n')
.next()
.unwrap_or(message);
let mut out = b"notes: ".to_vec();
out.extend_from_slice(subject);
out
}
fn write_notes_tree(
db: &mut FileObjectDatabase,
notes: &[Note],
) -> Result<ObjectId> {
if notes.len() >= 256 {
write_fanout_notes_tree(db, notes)
} else {
write_flat_notes_tree(db, notes)
}
}
fn write_flat_notes_tree(db: &mut FileObjectDatabase, notes: &[Note]) -> Result<ObjectId> {
let mut entries: Vec<TreeEntry> = notes
.iter()
.map(|note| TreeEntry {
mode: 0o100644,
name: BString::from(note.annotated.to_hex().as_bytes()),
oid: note.blob,
})
.collect();
entries.sort_by(|left, right| left.name.cmp(&right.name));
db.write_object(EncodedObject::new(
ObjectType::Tree,
Tree { entries }.write(),
))
}
fn write_fanout_notes_tree(db: &mut FileObjectDatabase, notes: &[Note]) -> Result<ObjectId> {
let mut groups: BTreeMap<String, Vec<TreeEntry>> = BTreeMap::new();
for note in notes {
let hex = note.annotated.to_hex();
let (prefix, suffix) = hex.split_at(2);
groups
.entry(prefix.to_string())
.or_default()
.push(TreeEntry {
mode: 0o100644,
name: BString::from(suffix.as_bytes()),
oid: note.blob,
});
}
let mut root_entries = Vec::new();
for (prefix, mut entries) in groups {
entries.sort_by(|left, right| left.name.cmp(&right.name));
let subtree_oid = db.write_object(EncodedObject::new(
ObjectType::Tree,
Tree { entries }.write(),
))?;
root_entries.push(TreeEntry {
mode: 0o040000,
name: BString::from(prefix.as_bytes()),
oid: subtree_oid,
});
}
root_entries.sort_by(|left, right| left.name.cmp(&right.name));
db.write_object(EncodedObject::new(
ObjectType::Tree,
Tree {
entries: root_entries,
}
.write(),
))
}
fn zero_oid(format: ObjectFormat) -> Result<ObjectId> {
ObjectId::from_hex(format, &"0".repeat(format.hex_len()))
}
#[cfg(test)]
mod tests {
use super::*;
use sley_sequencer::format_commit_identity;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};
const NAME: &str = "Tester";
const EMAIL: &str = "tester@example.com";
const DATE: &str = "@1790000000 -0500";
fn unique_temp_dir(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time before unix epoch")
.as_nanos();
std::env::temp_dir().join(format!("sley-notes-{name}-{}-{nanos}", std::process::id()))
}
fn git_available() -> bool {
Command::new("git")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}
fn test_identity() -> NotesCommitIdentity {
NotesCommitIdentity {
author: format_commit_identity(NAME, EMAIL, DATE)
.expect("test operation should succeed"),
committer: format_commit_identity(NAME, EMAIL, DATE)
.expect("test operation should succeed"),
}
}
fn git_env(command: &mut Command) -> &mut Command {
command
.env("GIT_AUTHOR_NAME", NAME)
.env("GIT_AUTHOR_EMAIL", EMAIL)
.env("GIT_AUTHOR_DATE", DATE)
.env("GIT_COMMITTER_NAME", NAME)
.env("GIT_COMMITTER_EMAIL", EMAIL)
.env("GIT_COMMITTER_DATE", DATE)
}
fn init_repo_with_commit(root: &Path) -> (PathBuf, ObjectId) {
let mut init = Command::new("git");
git_env(init.current_dir(root).args(["init", "-q"]))
.status()
.expect("git init should succeed");
fs::write(root.join("f.txt"), b"content\n").expect("write worktree file");
let mut add = Command::new("git");
git_env(add.current_dir(root).args(["add", "f.txt"]))
.status()
.expect("git add should succeed");
let mut commit = Command::new("git");
git_env(commit.current_dir(root).args(["commit", "-q", "-m", "c1"]))
.status()
.expect("git commit should succeed");
let git_dir = root.join(".git");
let format = ObjectFormat::Sha1;
let store = FileRefStore::new(&git_dir, format);
let head = store
.read_ref("HEAD")
.expect("read HEAD")
.expect("HEAD should exist");
let oid = match head {
RefTarget::Direct(oid) => oid,
RefTarget::Symbolic(name) => match store.read_ref(&name).expect("read symref") {
Some(RefTarget::Direct(oid)) => oid,
other => panic!("unexpected symref target: {other:?}"),
},
};
(git_dir, oid)
}
fn write_blob(db: &mut FileObjectDatabase, bytes: &[u8]) -> Result<ObjectId> {
db.write_object(EncodedObject::new(ObjectType::Blob, bytes.to_vec()))
}
#[test]
fn notes_ref_expand_qualifies_names() {
assert_eq!(NotesRef::expand("commits").as_str(), "refs/notes/commits");
assert_eq!(
NotesRef::expand("refs/notes/review").as_str(),
"refs/notes/review"
);
}
#[test]
fn read_write_list_round_trip() {
let dir = unique_temp_dir("round-trip");
fs::create_dir_all(&dir).expect("create temp dir");
let (git_dir, target) = init_repo_with_commit(&dir);
let format = ObjectFormat::Sha1;
let store = FileRefStore::new(&git_dir, format);
let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
let identity = test_identity();
let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
let blob = write_blob(&mut db, b"hello note\n").expect("test operation should succeed");
let mut notes = Vec::new();
upsert_note(&mut notes, &target, blob);
write_notes(
&git_dir,
format,
&store,
¬es_ref,
¬es,
"Notes added by test",
&identity,
notes_ref_expected(&store, ¬es_ref).expect("ref expected"),
)
.expect("test operation should succeed");
let listed = list_notes(&git_dir, format, &store, ¬es_ref)
.expect("test operation should succeed");
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].annotated, target);
assert_eq!(listed[0].blob, blob);
let read_back = read_note(&git_dir, format, &store, ¬es_ref, &target)
.expect("test operation should succeed");
assert_eq!(read_back, Some(blob));
let bytes = read_note_bytes(&git_dir, format, &store, ¬es_ref, &target)
.expect("test operation should succeed");
assert_eq!(bytes.as_deref(), Some(b"hello note\n" as &[u8]));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn iter_notes_matches_list_notes() {
let dir = unique_temp_dir("iter-list");
fs::create_dir_all(&dir).expect("create temp dir");
let (git_dir, target) = init_repo_with_commit(&dir);
let format = ObjectFormat::Sha1;
let store = FileRefStore::new(&git_dir, format);
let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
let blob = write_blob(&mut db, b"iter note\n").expect("blob");
let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
write_notes(
&git_dir,
format,
&store,
¬es_ref,
&[Note {
annotated: target,
blob,
}],
"note",
&test_identity(),
notes_ref_expected(&store, ¬es_ref).expect("ref expected"),
)
.expect("write notes");
let listed = list_notes(&git_dir, format, &store, ¬es_ref).expect("list");
let mut iter_collected = iter_notes(&git_dir, format, &store, ¬es_ref)
.expect("iter")
.collect::<Result<Vec<_>>>()
.expect("collect");
iter_collected.sort_by_key(|entry| entry.annotated.to_hex());
assert_eq!(listed, iter_collected);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn iter_notes_yields_every_note_in_flat_tree() {
let dir = unique_temp_dir("iter-flat-multi");
fs::create_dir_all(&dir).expect("create temp dir");
let (git_dir, _) = init_repo_with_commit(&dir);
let format = ObjectFormat::Sha1;
let store = FileRefStore::new(&git_dir, format);
let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
let first =
ObjectId::from_hex(format, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").expect("oid");
let second =
ObjectId::from_hex(format, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").expect("oid");
let blob_a = write_blob(&mut db, b"note a\n").expect("blob");
let blob_b = write_blob(&mut db, b"note b\n").expect("blob");
let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
write_notes(
&git_dir,
format,
&store,
¬es_ref,
&[
Note {
annotated: first,
blob: blob_a,
},
Note {
annotated: second,
blob: blob_b,
},
],
"notes",
&test_identity(),
notes_ref_expected(&store, ¬es_ref).expect("ref expected"),
)
.expect("write notes");
let collected = iter_notes(&git_dir, format, &store, ¬es_ref)
.expect("iter")
.collect::<Result<Vec<_>>>()
.expect("collect");
assert_eq!(collected.len(), 2);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn read_note_for_skips_unrelated_fanout_branches() {
let dir = unique_temp_dir("lookup");
fs::create_dir_all(&dir).expect("create temp dir");
let (git_dir, target) = init_repo_with_commit(&dir);
let format = ObjectFormat::Sha1;
let store = FileRefStore::new(&git_dir, format);
let target_hex = target.to_hex();
let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
let blob = write_blob(&mut db, b"lookup note\n").expect("blob");
let prefix = &target_hex[..2];
let suffix = &target_hex[2..];
let leaf = Tree {
entries: vec![TreeEntry {
mode: 0o100644,
name: BString::from(suffix.as_bytes()),
oid: blob,
}],
};
let leaf_oid = db
.write_object(EncodedObject::new(ObjectType::Tree, leaf.write()))
.expect("leaf");
let fanout = Tree {
entries: vec![TreeEntry {
mode: 0o040000,
name: BString::from(prefix.as_bytes()),
oid: leaf_oid,
}],
};
let fanout_oid = db
.write_object(EncodedObject::new(ObjectType::Tree, fanout.write()))
.expect("fanout");
let identity = test_identity();
let commit_oid = create_commit(
&mut db,
CommitCreate {
tree: fanout_oid,
parents: Vec::new(),
author: identity.author.clone(),
committer: identity.committer.clone(),
message: b"fanout notes\n".to_vec(),
encoding: None,
},
)
.expect("commit");
let mut tx = store.transaction();
tx.update(RefUpdate {
name: DEFAULT_NOTES_REF.to_string(),
expected: None,
new: RefTarget::Direct(commit_oid),
reflog: None,
});
tx.commit().expect("update ref");
let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
let found = read_note_for(&git_dir, format, &store, ¬es_ref, &target).expect("lookup");
assert_eq!(found, Some(blob));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn fanout_tree_is_readable() {
let dir = unique_temp_dir("fanout");
fs::create_dir_all(&dir).expect("create temp dir");
let (git_dir, target) = init_repo_with_commit(&dir);
let format = ObjectFormat::Sha1;
let store = FileRefStore::new(&git_dir, format);
let target_hex = target.to_hex();
let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
let blob = write_blob(&mut db, b"fanout note\n").expect("test operation should succeed");
let prefix = &target_hex[..2];
let suffix = &target_hex[2..];
let leaf = Tree {
entries: vec![TreeEntry {
mode: 0o100644,
name: BString::from(suffix.as_bytes()),
oid: blob,
}],
};
let leaf_oid = db
.write_object(EncodedObject::new(ObjectType::Tree, leaf.write()))
.expect("test operation should succeed");
let fanout = Tree {
entries: vec![TreeEntry {
mode: 0o040000,
name: BString::from(prefix.as_bytes()),
oid: leaf_oid,
}],
};
let fanout_oid = db
.write_object(EncodedObject::new(ObjectType::Tree, fanout.write()))
.expect("test operation should succeed");
let identity = test_identity();
let commit_oid = create_commit(
&mut db,
CommitCreate {
tree: fanout_oid,
parents: Vec::new(),
author: identity.author.clone(),
committer: identity.committer.clone(),
message: b"fanout notes\n".to_vec(),
encoding: None,
},
)
.expect("test operation should succeed");
let mut tx = store.transaction();
tx.update(RefUpdate {
name: DEFAULT_NOTES_REF.to_string(),
expected: None,
new: RefTarget::Direct(commit_oid),
reflog: None,
});
tx.commit().expect("test operation should succeed");
let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
let read_back = read_note(&git_dir, format, &store, ¬es_ref, &target)
.expect("test operation should succeed");
assert_eq!(read_back, Some(blob));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn note_bytes_match_system_git() {
if !git_available() {
return;
}
let dir = unique_temp_dir("git-interop");
fs::create_dir_all(&dir).expect("test operation should succeed");
let result = std::panic::catch_unwind(|| {
let (git_dir, target) = init_repo_with_commit(&dir);
let format = ObjectFormat::Sha1;
let store = FileRefStore::new(&git_dir, format);
let notes_ref = NotesRef::expand(DEFAULT_NOTES_REF);
let mut git_add_cmd = Command::new("git");
let git_add = git_env(git_add_cmd.current_dir(&dir).args([
"notes",
"add",
"-m",
"interop note",
"HEAD",
]))
.output()
.expect("git notes add should run");
assert!(
git_add.status.success(),
"git notes add failed: {}",
String::from_utf8_lossy(&git_add.stderr)
);
let sley_bytes = read_note_bytes(&git_dir, format, &store, ¬es_ref, &target)
.expect("test operation should succeed")
.expect("note should exist");
let mut git_show_cmd = Command::new("git");
let git_output = git_env(
git_show_cmd
.current_dir(&dir)
.args(["notes", "show", "HEAD"]),
)
.output()
.expect("test operation should succeed");
assert!(
git_output.status.success(),
"git notes show failed: {}",
String::from_utf8_lossy(&git_output.stderr)
);
assert_eq!(sley_bytes, git_output.stdout);
});
let _ = fs::remove_dir_all(&dir);
result.expect("note_bytes_match_system_git assertions");
}
fn heddle_notes_ref() -> NotesRef {
NotesRef::expand("refs/notes/heddle")
}
fn read_notes_head(store: &FileRefStore, notes_ref: &NotesRef) -> Option<ObjectId> {
match store.read_ref(notes_ref.as_str()).expect("read ref") {
Some(RefTarget::Direct(oid)) => Some(oid),
_ => None,
}
}
fn install_fanout_note(
git_dir: &Path,
store: &FileRefStore,
notes_ref: &NotesRef,
annotated: &ObjectId,
blob: ObjectId,
identity: &NotesCommitIdentity,
) {
let format = ObjectFormat::Sha1;
let annotated_hex = annotated.to_hex();
let mut db = FileObjectDatabase::from_git_dir(git_dir, format);
let prefix = &annotated_hex[..2];
let suffix = &annotated_hex[2..];
let leaf = Tree {
entries: vec![TreeEntry {
mode: 0o100644,
name: BString::from(suffix.as_bytes()),
oid: blob,
}],
};
let leaf_oid = db
.write_object(EncodedObject::new(ObjectType::Tree, leaf.write()))
.expect("leaf");
let fanout = Tree {
entries: vec![TreeEntry {
mode: 0o040000,
name: BString::from(prefix.as_bytes()),
oid: leaf_oid,
}],
};
let fanout_oid = db
.write_object(EncodedObject::new(ObjectType::Tree, fanout.write()))
.expect("fanout");
let commit_oid = create_commit(
&mut db,
CommitCreate {
tree: fanout_oid,
parents: Vec::new(),
author: identity.author.clone(),
committer: identity.committer.clone(),
message: b"fanout notes\n".to_vec(),
encoding: None,
},
)
.expect("commit");
let mut tx = store.transaction();
tx.update(RefUpdate {
name: notes_ref.as_str().to_string(),
expected: notes_ref_expected(store, notes_ref).expect("ref expected"),
new: RefTarget::Direct(commit_oid),
reflog: None,
});
tx.commit().expect("update ref");
}
#[test]
fn upsert_note_for_unchanged_is_noop() {
let dir = unique_temp_dir("upsert-unchanged");
fs::create_dir_all(&dir).expect("create temp dir");
let (git_dir, target) = init_repo_with_commit(&dir);
let format = ObjectFormat::Sha1;
let store = FileRefStore::new(&git_dir, format);
let notes_ref = heddle_notes_ref();
let identity = test_identity();
let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
let blob = write_blob(&mut db, br#"{"status":"served"}"#).expect("blob");
let first = upsert_note_for(
&git_dir,
format,
&store,
¬es_ref,
&target,
blob.clone(),
"heddle: export",
&identity,
None,
)
.expect("first upsert");
let first_head = read_notes_head(&store, ¬es_ref).expect("head");
assert!(matches!(first, UpsertNoteOutcome::Updated { .. }));
let second = upsert_note_for(
&git_dir,
format,
&store,
¬es_ref,
&target,
blob,
"heddle: export",
&identity,
Some(RefTarget::Direct(first_head)),
)
.expect("second upsert");
assert_eq!(second, UpsertNoteOutcome::Unchanged);
assert_eq!(read_notes_head(&store, ¬es_ref), Some(first_head));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn upsert_note_for_updates_blob() {
let dir = unique_temp_dir("upsert-update");
fs::create_dir_all(&dir).expect("create temp dir");
let (git_dir, target) = init_repo_with_commit(&dir);
let format = ObjectFormat::Sha1;
let store = FileRefStore::new(&git_dir, format);
let notes_ref = heddle_notes_ref();
let identity = test_identity();
let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
let blob_a = write_blob(&mut db, br#"{"v":1}"#).expect("blob a");
let blob_b = write_blob(&mut db, br#"{"v":2}"#).expect("blob b");
let first = upsert_note_for(
&git_dir,
format,
&store,
¬es_ref,
&target,
blob_a,
"heddle: export",
&identity,
None,
)
.expect("first upsert");
let UpsertNoteOutcome::Updated {
notes_commit: first_commit,
} = first
else {
panic!("expected first upsert to update");
};
let second = upsert_note_for(
&git_dir,
format,
&store,
¬es_ref,
&target,
blob_b,
"heddle: export",
&identity,
Some(RefTarget::Direct(first_commit)),
)
.expect("second upsert");
let UpsertNoteOutcome::Updated {
notes_commit: second_commit,
} = second
else {
panic!("expected second upsert to update");
};
assert_ne!(first_commit, second_commit);
assert_eq!(
read_note(&git_dir, format, &store, ¬es_ref, &target).expect("read"),
Some(blob_b)
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn upsert_note_for_creates_ref() {
let dir = unique_temp_dir("upsert-create");
fs::create_dir_all(&dir).expect("create temp dir");
let (git_dir, target) = init_repo_with_commit(&dir);
let format = ObjectFormat::Sha1;
let store = FileRefStore::new(&git_dir, format);
let notes_ref = heddle_notes_ref();
let identity = test_identity();
let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
let blob = write_blob(&mut db, br#"{"status":"served"}"#).expect("blob");
assert_eq!(read_notes_head(&store, ¬es_ref), None);
let outcome = upsert_note_for(
&git_dir,
format,
&store,
¬es_ref,
&target,
blob,
"heddle: export",
&identity,
None,
)
.expect("upsert");
assert!(matches!(outcome, UpsertNoteOutcome::Updated { .. }));
assert!(read_notes_head(&store, ¬es_ref).is_some());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn upsert_note_for_cas_mismatch_fails() {
let dir = unique_temp_dir("upsert-cas");
fs::create_dir_all(&dir).expect("create temp dir");
let (git_dir, target) = init_repo_with_commit(&dir);
let format = ObjectFormat::Sha1;
let store = FileRefStore::new(&git_dir, format);
let notes_ref = heddle_notes_ref();
let identity = test_identity();
let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
let blob_a = write_blob(&mut db, br#"{"v":1}"#).expect("blob a");
let blob_b = write_blob(&mut db, br#"{"v":2}"#).expect("blob b");
upsert_note_for(
&git_dir,
format,
&store,
¬es_ref,
&target,
blob_a,
"heddle: export",
&identity,
None,
)
.expect("seed note");
let head = read_notes_head(&store, ¬es_ref).expect("head");
let wrong =
ObjectId::from_hex(format, "cccccccccccccccccccccccccccccccccccccccc").expect("oid");
let err = upsert_note_for(
&git_dir,
format,
&store,
¬es_ref,
&target,
blob_b,
"heddle: export",
&identity,
Some(RefTarget::Direct(wrong)),
)
.expect_err("cas mismatch");
assert!(matches!(err, GitError::Transaction(_)));
assert_eq!(read_notes_head(&store, ¬es_ref), Some(head));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn remove_notes_for_partial_hit() {
let dir = unique_temp_dir("remove-partial");
fs::create_dir_all(&dir).expect("create temp dir");
let (git_dir, target) = init_repo_with_commit(&dir);
let format = ObjectFormat::Sha1;
let store = FileRefStore::new(&git_dir, format);
let notes_ref = heddle_notes_ref();
let identity = test_identity();
let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
let other =
ObjectId::from_hex(format, "dddddddddddddddddddddddddddddddddddddddd").expect("oid");
let blob_a = write_blob(&mut db, br#"{"a":1}"#).expect("blob a");
let blob_b = write_blob(&mut db, br#"{"b":2}"#).expect("blob b");
write_notes(
&git_dir,
format,
&store,
¬es_ref,
&[
Note {
annotated: target,
blob: blob_a,
},
Note {
annotated: other,
blob: blob_b,
},
],
"seed",
&identity,
None,
)
.expect("seed notes");
let head = read_notes_head(&store, ¬es_ref).expect("head");
let outcome = remove_notes_for(
&git_dir,
format,
&store,
¬es_ref,
&[target],
"heddle: retract",
&identity,
Some(RefTarget::Direct(head)),
)
.expect("remove");
assert!(matches!(outcome, RemoveNoteOutcome::Removed { .. }));
assert_eq!(
read_note(&git_dir, format, &store, ¬es_ref, &target).expect("read"),
None
);
assert_eq!(
read_note(&git_dir, format, &store, ¬es_ref, &other).expect("read"),
Some(blob_b)
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn remove_notes_for_noop_when_missing() {
let dir = unique_temp_dir("remove-noop");
fs::create_dir_all(&dir).expect("create temp dir");
let (git_dir, target) = init_repo_with_commit(&dir);
let format = ObjectFormat::Sha1;
let store = FileRefStore::new(&git_dir, format);
let notes_ref = heddle_notes_ref();
let identity = test_identity();
let missing =
ObjectId::from_hex(format, "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee").expect("oid");
let absent = remove_notes_for(
&git_dir,
format,
&store,
¬es_ref,
&[target],
"heddle: retract",
&identity,
None,
)
.expect("remove absent ref");
assert_eq!(absent, RemoveNoteOutcome::Unchanged);
let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
let blob = write_blob(&mut db, br#"{"x":1}"#).expect("blob");
upsert_note_for(
&git_dir,
format,
&store,
¬es_ref,
&target,
blob,
"heddle: export",
&identity,
None,
)
.expect("seed");
let head = read_notes_head(&store, ¬es_ref).expect("head");
let noop = remove_notes_for(
&git_dir,
format,
&store,
¬es_ref,
&[missing],
"heddle: retract",
&identity,
Some(RefTarget::Direct(head)),
)
.expect("remove missing oid");
assert_eq!(noop, RemoveNoteOutcome::Unchanged);
assert_eq!(read_notes_head(&store, ¬es_ref), Some(head));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn remove_notes_for_batch_single_commit() {
let dir = unique_temp_dir("remove-batch");
fs::create_dir_all(&dir).expect("create temp dir");
let (git_dir, _) = init_repo_with_commit(&dir);
let format = ObjectFormat::Sha1;
let store = FileRefStore::new(&git_dir, format);
let notes_ref = heddle_notes_ref();
let identity = test_identity();
let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
let first =
ObjectId::from_hex(format, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").expect("oid");
let second =
ObjectId::from_hex(format, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").expect("oid");
let third =
ObjectId::from_hex(format, "cccccccccccccccccccccccccccccccccccccccc").expect("oid");
let blob_a = write_blob(&mut db, b"a\n").expect("blob");
let blob_b = write_blob(&mut db, b"b\n").expect("blob");
let blob_c = write_blob(&mut db, b"c\n").expect("blob");
write_notes(
&git_dir,
format,
&store,
¬es_ref,
&[
Note {
annotated: first,
blob: blob_a,
},
Note {
annotated: second,
blob: blob_b,
},
Note {
annotated: third,
blob: blob_c,
},
],
"seed",
&identity,
None,
)
.expect("seed");
let head = read_notes_head(&store, ¬es_ref).expect("head");
let RemoveNoteOutcome::Removed { notes_commit } = remove_notes_for(
&git_dir,
format,
&store,
¬es_ref,
&[first, second],
"heddle: retract",
&identity,
Some(RefTarget::Direct(head)),
)
.expect("batch remove") else {
panic!("expected removal");
};
let db = FileObjectDatabase::from_git_dir(&git_dir, format);
let commit = db.read_object(¬es_commit).expect("read commit");
let commit = Commit::parse_ref(format, &commit.body).expect("parse");
assert_eq!(commit.parents.len(), 1);
assert_eq!(commit.parents[0], head);
assert_eq!(
list_notes(&git_dir, format, &store, ¬es_ref)
.expect("list")
.len(),
1
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn incremental_ops_read_fanout_legacy() {
let dir = unique_temp_dir("incremental-fanout");
fs::create_dir_all(&dir).expect("create temp dir");
let (git_dir, target) = init_repo_with_commit(&dir);
let format = ObjectFormat::Sha1;
let store = FileRefStore::new(&git_dir, format);
let notes_ref = heddle_notes_ref();
let identity = test_identity();
let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
let blob = write_blob(&mut db, br#"{"legacy":true}"#).expect("blob");
install_fanout_note(&git_dir, &store, ¬es_ref, &target, blob, &identity);
let head = read_notes_head(&store, ¬es_ref).expect("head");
let new_blob = write_blob(&mut db, br#"{"legacy":false}"#).expect("new blob");
upsert_note_for(
&git_dir,
format,
&store,
¬es_ref,
&target,
new_blob,
"heddle: export",
&identity,
Some(RefTarget::Direct(head)),
)
.expect("upsert fanout");
assert_eq!(
read_note_for(&git_dir, format, &store, ¬es_ref, &target).expect("read"),
Some(new_blob)
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn incremental_ops_ff_chain() {
let dir = unique_temp_dir("incremental-ff");
fs::create_dir_all(&dir).expect("create temp dir");
let (git_dir, target) = init_repo_with_commit(&dir);
let format = ObjectFormat::Sha1;
let store = FileRefStore::new(&git_dir, format);
let notes_ref = heddle_notes_ref();
let identity = test_identity();
let mut db = FileObjectDatabase::from_git_dir(&git_dir, format);
let other =
ObjectId::from_hex(format, "ffffffffffffffffffffffffffffffffffffffff").expect("oid");
let blob_a = write_blob(&mut db, br#"{"first":true}"#).expect("blob a");
let blob_b = write_blob(&mut db, br#"{"second":true}"#).expect("blob b");
let UpsertNoteOutcome::Updated {
notes_commit: first_commit,
} = upsert_note_for(
&git_dir,
format,
&store,
¬es_ref,
&target,
blob_a,
"heddle: export",
&identity,
None,
)
.expect("first upsert")
else {
panic!("expected update");
};
let UpsertNoteOutcome::Updated {
notes_commit: second_commit,
} = upsert_note_for(
&git_dir,
format,
&store,
¬es_ref,
&other,
blob_b,
"heddle: export",
&identity,
Some(RefTarget::Direct(first_commit)),
)
.expect("second upsert")
else {
panic!("expected update");
};
let db = FileObjectDatabase::from_git_dir(&git_dir, format);
let object = db.read_object(&second_commit).expect("read commit");
let commit = Commit::parse_ref(format, &object.body).expect("parse");
assert_eq!(commit.parents, vec![first_commit]);
let _ = fs::remove_dir_all(&dir);
}
}