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

@@ -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;
}