0.0.3-alpha.2
This commit is contained in:
319
crates/knowledge-git-api/src/write.rs
Normal file
319
crates/knowledge-git-api/src/write.rs
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user