0.0.3-alpha.2

This commit is contained in:
2026-09-29 11:31:59 +02:00
parent 3004e4b581
commit 88a63b06cc
11 changed files with 881 additions and 9 deletions

View File

@@ -1,5 +1,5 @@
// file: crates/knowledge-git-api/src/lib.rs
// version: 4
// version: 5
//! Provider-neutral contracts for Git-backed Knowledge sources.
//!
@@ -10,6 +10,7 @@ mod reference;
mod repository;
mod revision;
mod snapshot;
mod write;
/// Opaque Git reference requested by a caller, such as a branch, tag or object expression.
pub use self::reference::GitReference;
@@ -19,6 +20,11 @@ pub use self::repository::GitRepository;
pub use self::revision::GitRevision;
/// Read-only local materialization of one authoritative Git revision.
pub use self::snapshot::GitSnapshot;
/// Provider-neutral types and operations for controlled Git writes.
pub use self::write::{
GitBranch, GitCommitIdentity, GitFileMutation, GitMutationPath, GitPushOutcome, GitPushRequest, GitRemoteState, GitRepositoryWriter, GitWriteInputError,
GitWriteRequest,
};
#[cfg(test)]
#[path = "../unit_tests/lib.rs"]

View File

@@ -0,0 +1,319 @@
// file: crates/knowledge-git-api/src/write.rs
// version: 1
/// Invalid value supplied to the provider-neutral Git write contract.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GitWriteInputError {
/// A mutation path is empty or contains only whitespace.
EmptyMutationPath,
/// A mutation path is absolute or uses a platform-specific separator.
AbsoluteMutationPath,
/// A mutation path contains an empty, current-directory or parent-directory component.
UnnormalizedMutationPath,
/// A mutation path addresses Git's private `.git` area.
ProtectedGitPath,
/// A commit identity name is empty or contains a forbidden line break or NUL byte.
InvalidCommitIdentityName,
/// A commit identity email is empty or contains a forbidden line break or NUL byte.
InvalidCommitIdentityEmail,
/// A commit message is empty or contains a NUL byte.
InvalidCommitMessage,
/// A write request does not contain any file mutation.
NoMutations,
/// More than one mutation targets the same normalized path.
DuplicateMutationPath,
/// A target branch is not a valid full `refs/heads/...` Git reference.
InvalidBranch,
}
impl std::fmt::Display for crate::GitWriteInputError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
crate::GitWriteInputError::EmptyMutationPath => return formatter.write_str("Git mutation path must not be empty"),
crate::GitWriteInputError::AbsoluteMutationPath => return formatter.write_str("Git mutation path must be relative and use '/' separators"),
crate::GitWriteInputError::UnnormalizedMutationPath => return formatter.write_str("Git mutation path must be normalized"),
crate::GitWriteInputError::ProtectedGitPath => return formatter.write_str("Git mutation path must not address .git"),
crate::GitWriteInputError::InvalidCommitIdentityName => return formatter.write_str("Git commit identity name is invalid"),
crate::GitWriteInputError::InvalidCommitIdentityEmail => return formatter.write_str("Git commit identity email is invalid"),
crate::GitWriteInputError::InvalidCommitMessage => return formatter.write_str("Git commit message is invalid"),
crate::GitWriteInputError::NoMutations => return formatter.write_str("Git write request must contain at least one mutation"),
crate::GitWriteInputError::DuplicateMutationPath => return formatter.write_str("Git write request must not mutate one path more than once"),
crate::GitWriteInputError::InvalidBranch => return formatter.write_str("Git target branch must be a valid refs/heads reference"),
}
}
}
impl std::error::Error for crate::GitWriteInputError {}
/// Normalized relative path that may be mutated inside an isolated Git write worktree.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct GitMutationPath(String);
impl crate::GitMutationPath {
/// Validate and build a provider-neutral repository-relative mutation path.
pub fn new(value: &str) -> Result<crate::GitMutationPath, crate::GitWriteInputError> {
if value.trim().is_empty() {
return Err(crate::GitWriteInputError::EmptyMutationPath);
}
if value.starts_with('/') || value.contains('\\') || crate::write::has_windows_drive_prefix(value) {
return Err(crate::GitWriteInputError::AbsoluteMutationPath);
}
if value.contains('\0') {
return Err(crate::GitWriteInputError::UnnormalizedMutationPath);
}
for component in value.split('/') {
if component.is_empty() || component == "." || component == ".." {
return Err(crate::GitWriteInputError::UnnormalizedMutationPath);
}
if component.eq_ignore_ascii_case(".git") {
return Err(crate::GitWriteInputError::ProtectedGitPath);
}
}
return Ok(Self(value.to_owned()));
}
/// Return the normalized repository-relative path.
pub fn as_str(&self) -> &str {
return self.0.as_str();
}
}
/// One file mutation requested for a candidate Git commit.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum GitFileMutation {
/// Create or replace one file with the provided bytes.
Write {
/// Validated repository-relative file path.
path: crate::GitMutationPath,
/// Complete bytes that must become the file content.
content: Vec<u8>,
},
/// Delete one existing path from the candidate tree.
Delete {
/// Validated repository-relative file path.
path: crate::GitMutationPath,
},
}
impl crate::GitFileMutation {
/// Return the validated path targeted by this mutation.
pub fn path(&self) -> &crate::GitMutationPath {
match self {
crate::GitFileMutation::Write { path, .. } => return path,
crate::GitFileMutation::Delete { path } => return path,
}
}
}
/// Explicit author and committer identity used to create a candidate commit.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitCommitIdentity {
name: String,
email: String,
}
impl crate::GitCommitIdentity {
/// Validate and build an explicit Git commit identity.
pub fn new(name: &str, email: &str) -> Result<crate::GitCommitIdentity, crate::GitWriteInputError> {
if crate::write::invalid_identity_component(name) {
return Err(crate::GitWriteInputError::InvalidCommitIdentityName);
}
if crate::write::invalid_identity_component(email) {
return Err(crate::GitWriteInputError::InvalidCommitIdentityEmail);
}
return Ok(Self { name: name.to_owned(), email: email.to_owned() });
}
/// Return the explicit identity name.
pub fn name(&self) -> &str {
return self.name.as_str();
}
/// Return the explicit identity email.
pub fn email(&self) -> &str {
return self.email.as_str();
}
}
/// Validated full Git branch reference accepted as a remote write target.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct GitBranch(String);
impl crate::GitBranch {
/// Validate a full `refs/heads/...` branch reference.
pub fn new(value: &str) -> Result<crate::GitBranch, crate::GitWriteInputError> {
if !crate::write::valid_branch_reference(value) {
return Err(crate::GitWriteInputError::InvalidBranch);
}
return Ok(Self(value.to_owned()));
}
/// Return the validated full branch reference.
pub fn as_str(&self) -> &str {
return self.0.as_str();
}
}
/// Expected or observed state of one remote branch.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum GitRemoteState {
/// The branch does not exist.
Absent,
/// The branch resolves to the provided revision.
Revision(crate::GitRevision),
}
/// Request to build one candidate commit from an explicit base revision.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitWriteRequest {
base_revision: crate::GitRevision,
mutations: Vec<crate::GitFileMutation>,
identity: crate::GitCommitIdentity,
message: String,
}
impl crate::GitWriteRequest {
/// Validate and build a candidate-commit request.
pub fn new(
base_revision: crate::GitRevision,
mutations: Vec<crate::GitFileMutation>,
identity: crate::GitCommitIdentity,
message: &str,
) -> Result<crate::GitWriteRequest, crate::GitWriteInputError> {
if mutations.is_empty() {
return Err(crate::GitWriteInputError::NoMutations);
}
if message.trim().is_empty() || message.contains('\0') {
return Err(crate::GitWriteInputError::InvalidCommitMessage);
}
let mut paths = std::collections::HashSet::new();
for mutation in &mutations {
if !paths.insert(mutation.path().as_str()) {
return Err(crate::GitWriteInputError::DuplicateMutationPath);
}
}
return Ok(Self { base_revision, mutations, identity, message: message.to_owned() });
}
/// Return the resolved base revision from which the candidate must be created.
pub fn base_revision(&self) -> &crate::GitRevision {
return &self.base_revision;
}
/// Return the ordered mutations to apply in the isolated write worktree.
pub fn mutations(&self) -> &[crate::GitFileMutation] {
return self.mutations.as_slice();
}
/// Return the explicit author/committer identity.
pub fn identity(&self) -> &crate::GitCommitIdentity {
return &self.identity;
}
/// Return the candidate commit message.
pub fn message(&self) -> &str {
return self.message.as_str();
}
}
/// Request to compare-and-exchange one remote branch to a candidate revision.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitPushRequest {
target_branch: crate::GitBranch,
expected_remote: crate::GitRemoteState,
candidate_revision: crate::GitRevision,
}
impl crate::GitPushRequest {
/// Build a guarded remote update request from already validated values.
pub fn new(target_branch: crate::GitBranch, expected_remote: crate::GitRemoteState, candidate_revision: crate::GitRevision) -> crate::GitPushRequest {
return Self { target_branch, expected_remote, candidate_revision };
}
/// Return the target remote branch.
pub fn target_branch(&self) -> &crate::GitBranch {
return &self.target_branch;
}
/// Return the remote state that must still hold for the update to succeed.
pub fn expected_remote(&self) -> &crate::GitRemoteState {
return &self.expected_remote;
}
/// Return the candidate revision to publish.
pub fn candidate_revision(&self) -> &crate::GitRevision {
return &self.candidate_revision;
}
}
/// Result of one guarded remote branch update.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum GitPushOutcome {
/// The target branch was updated to the candidate revision.
Updated(
/// Revision now expected at the updated target branch.
crate::GitRevision,
),
/// The target branch no longer matched the expected state.
Conflict {
/// Actual remote state observed when the guarded update was rejected.
actual: crate::GitRemoteState,
},
}
/// Provider-neutral write operations required from a Git repository backend.
///
/// This capability is intentionally separate from [`crate::GitRepository`] so read-only consumers
/// and fakes do not need to implement write methods. A concrete backend may implement this trait
/// independently from the read contract.
pub trait GitRepositoryWriter {
/// Implementation-specific recoverable error.
type Error;
/// Create a candidate commit without updating a remote branch.
fn create_candidate(&mut self, source_id: &knowledge_domain_lib::SourceId, request: &crate::GitWriteRequest) -> Result<crate::GitRevision, Self::Error>;
/// Attempt one guarded remote branch update for a previously created candidate revision.
fn push_candidate(&mut self, source_id: &knowledge_domain_lib::SourceId, request: &crate::GitPushRequest) -> Result<crate::GitPushOutcome, Self::Error>;
}
fn has_windows_drive_prefix(value: &str) -> bool {
let bytes = value.as_bytes();
if bytes.len() < 2 {
return false;
}
return bytes[0].is_ascii_alphabetic() && bytes[1] == b':';
}
fn invalid_identity_component(value: &str) -> bool {
return value.trim().is_empty() || value.contains('\0') || value.contains('\n') || value.contains('\r');
}
fn valid_branch_reference(value: &str) -> bool {
const PREFIX: &str = "refs/heads/";
let branch = match value.strip_prefix(PREFIX) {
Some(value) => value,
None => return false,
};
if branch.is_empty()
|| branch.starts_with('/')
|| branch.ends_with('/')
|| branch.ends_with('.')
|| branch.contains("..")
|| branch.contains("@{")
|| branch.contains("//")
{
return false;
}
for byte in branch.bytes() {
if byte <= 0x20 || byte == 0x7f || matches!(byte, b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\') {
return false;
}
}
for component in branch.split('/') {
if component.is_empty() || component.starts_with('.') || component.ends_with(".lock") {
return false;
}
}
return true;
}

View File

@@ -1,5 +1,5 @@
// file: crates/knowledge-git-api/unit_tests/lib.rs
// version: 3
// version: 4
#[derive(Debug, Eq, PartialEq)]
struct FakeGitError;
@@ -22,6 +22,20 @@ impl crate::GitRepository for FakeGitRepository {
}
}
struct FakeGitWriter;
impl crate::GitRepositoryWriter for FakeGitWriter {
type Error = FakeGitError;
fn create_candidate(&mut self, _source_id: &knowledge_domain_lib::SourceId, request: &crate::GitWriteRequest) -> Result<crate::GitRevision, Self::Error> {
return Ok(crate::GitRevision::new(&format!("candidate:{}", request.base_revision().as_str())));
}
fn push_candidate(&mut self, _source_id: &knowledge_domain_lib::SourceId, request: &crate::GitPushRequest) -> Result<crate::GitPushOutcome, Self::Error> {
return Ok(crate::GitPushOutcome::Updated(request.candidate_revision().clone()));
}
}
#[test]
fn generic_repository_contract_does_not_require_a_forge() {
let source_result = knowledge_domain_lib::SourceId::new("local-pgr");
@@ -43,3 +57,140 @@ fn generic_repository_contract_does_not_require_a_forge() {
assert_eq!(snapshot_result, Ok(crate::GitSnapshot::new(revision, std::path::PathBuf::from("/controlled/snapshot"))));
return;
}
#[test]
fn mutation_paths_are_relative_normalized_and_protect_git_metadata() {
let valid = crate::GitMutationPath::new("docs/rules/200-GOVERNANCE.md");
assert_eq!(valid.as_ref().map(crate::GitMutationPath::as_str), Ok("docs/rules/200-GOVERNANCE.md"));
assert_eq!(crate::GitMutationPath::new(""), Err(crate::GitWriteInputError::EmptyMutationPath));
assert_eq!(crate::GitMutationPath::new("/etc/passwd"), Err(crate::GitWriteInputError::AbsoluteMutationPath));
assert_eq!(crate::GitMutationPath::new("C:/outside.txt"), Err(crate::GitWriteInputError::AbsoluteMutationPath));
assert_eq!(crate::GitMutationPath::new("docs\\rules.md"), Err(crate::GitWriteInputError::AbsoluteMutationPath));
assert_eq!(crate::GitMutationPath::new("docs/../RULES.md"), Err(crate::GitWriteInputError::UnnormalizedMutationPath));
assert_eq!(crate::GitMutationPath::new("docs/./RULES.md"), Err(crate::GitWriteInputError::UnnormalizedMutationPath));
assert_eq!(crate::GitMutationPath::new(".git/config"), Err(crate::GitWriteInputError::ProtectedGitPath));
assert_eq!(crate::GitMutationPath::new("nested/.GIT/config"), Err(crate::GitWriteInputError::ProtectedGitPath));
return;
}
#[test]
fn commit_identity_is_explicit_and_rejects_line_breaks() {
let identity = crate::GitCommitIdentity::new("Knowledge Publisher", "knowledge@example.invalid");
assert_eq!(identity.as_ref().map(crate::GitCommitIdentity::name), Ok("Knowledge Publisher"));
assert_eq!(identity.as_ref().map(crate::GitCommitIdentity::email), Ok("knowledge@example.invalid"));
assert_eq!(crate::GitCommitIdentity::new(" ", "knowledge@example.invalid"), Err(crate::GitWriteInputError::InvalidCommitIdentityName));
assert_eq!(
crate::GitCommitIdentity::new("Knowledge\nPublisher", "knowledge@example.invalid"),
Err(crate::GitWriteInputError::InvalidCommitIdentityName)
);
assert_eq!(crate::GitCommitIdentity::new("Knowledge Publisher", " "), Err(crate::GitWriteInputError::InvalidCommitIdentityEmail));
return;
}
#[test]
fn target_branch_is_limited_to_valid_heads_references() {
let branch = crate::GitBranch::new("refs/heads/knowledge/proposal-42");
assert_eq!(branch.as_ref().map(crate::GitBranch::as_str), Ok("refs/heads/knowledge/proposal-42"));
for invalid in [
"main",
"refs/tags/v1",
"refs/heads/",
"refs/heads/.hidden",
"refs/heads/topic..next",
"refs/heads/topic.lock",
"refs/heads/topic@{next",
"refs/heads/topic\\next",
"refs/heads/topic//next",
] {
assert_eq!(crate::GitBranch::new(invalid), Err(crate::GitWriteInputError::InvalidBranch), "{invalid}");
}
return;
}
#[test]
fn write_request_requires_mutations_unique_paths_and_message() {
let identity_result = crate::GitCommitIdentity::new("Knowledge Publisher", "knowledge@example.invalid");
let identity = match identity_result {
Ok(value) => value,
Err(_) => return,
};
let empty = crate::GitWriteRequest::new(crate::GitRevision::new("base"), Vec::new(), identity.clone(), "candidate");
assert_eq!(empty, Err(crate::GitWriteInputError::NoMutations));
let path_result = crate::GitMutationPath::new("content.md");
let path = match path_result {
Ok(value) => value,
Err(_) => return,
};
let duplicates =
vec![crate::GitFileMutation::Write { path: path.clone(), content: b"one".to_vec() }, crate::GitFileMutation::Delete { path: path.clone() }];
let duplicate_request = crate::GitWriteRequest::new(crate::GitRevision::new("base"), duplicates, identity.clone(), "candidate");
assert_eq!(duplicate_request, Err(crate::GitWriteInputError::DuplicateMutationPath));
let mutation = crate::GitFileMutation::Write { path, content: b"content".to_vec() };
let invalid_message = crate::GitWriteRequest::new(crate::GitRevision::new("base"), vec![mutation.clone()], identity.clone(), " ");
assert_eq!(invalid_message, Err(crate::GitWriteInputError::InvalidCommitMessage));
let request = crate::GitWriteRequest::new(crate::GitRevision::new("base"), vec![mutation], identity, "candidate");
assert!(request.is_ok());
return;
}
#[test]
fn push_request_models_absent_or_revision_remote_state() {
let branch_result = crate::GitBranch::new("refs/heads/proposals/example");
let branch = match branch_result {
Ok(value) => value,
Err(_) => return,
};
let candidate = crate::GitRevision::new("candidate");
let create = crate::GitPushRequest::new(branch.clone(), crate::GitRemoteState::Absent, candidate.clone());
assert_eq!(create.target_branch(), &branch);
assert_eq!(create.expected_remote(), &crate::GitRemoteState::Absent);
assert_eq!(create.candidate_revision(), &candidate);
let existing = crate::GitPushRequest::new(branch, crate::GitRemoteState::Revision(crate::GitRevision::new("old")), candidate);
assert_eq!(existing.expected_remote(), &crate::GitRemoteState::Revision(crate::GitRevision::new("old")));
return;
}
#[test]
fn write_contract_is_separate_from_read_contract_and_provider_neutral() {
let source_result = knowledge_domain_lib::SourceId::new("snippets-extra");
let source_id = match source_result {
Ok(value) => value,
Err(_) => return,
};
let path_result = crate::GitMutationPath::new("snippets/example.md");
let path = match path_result {
Ok(value) => value,
Err(_) => return,
};
let identity_result = crate::GitCommitIdentity::new("Knowledge Publisher", "knowledge@example.invalid");
let identity = match identity_result {
Ok(value) => value,
Err(_) => return,
};
let write_result = crate::GitWriteRequest::new(
crate::GitRevision::new("base"),
vec![crate::GitFileMutation::Write { path, content: b"snippet".to_vec() }],
identity,
"Add snippet",
);
let write_request = match write_result {
Ok(value) => value,
Err(_) => return,
};
let mut writer = FakeGitWriter;
let candidate_result = crate::GitRepositoryWriter::create_candidate(&mut writer, &source_id, &write_request);
assert_eq!(candidate_result, Ok(crate::GitRevision::new("candidate:base")));
let candidate = match candidate_result {
Ok(value) => value,
Err(_) => return,
};
let branch_result = crate::GitBranch::new("refs/heads/proposals/snippet");
let branch = match branch_result {
Ok(value) => value,
Err(_) => return,
};
let push_request = crate::GitPushRequest::new(branch, crate::GitRemoteState::Absent, candidate.clone());
let push_result = crate::GitRepositoryWriter::push_candidate(&mut writer, &source_id, &push_request);
assert_eq!(push_result, Ok(crate::GitPushOutcome::Updated(candidate)));
return;
}