0.0.3-alpha.3
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// file: crates/knowledge-git-lib/src/backend.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
//! Physical source configuration, generated-state layout and controlled Git process runner.
|
||||
|
||||
@@ -51,6 +51,16 @@ impl crate::GitBackendConfig {
|
||||
pub fn snapshots_path(&self) -> std::path::PathBuf {
|
||||
return self.state_root.join("snapshots");
|
||||
}
|
||||
|
||||
/// Return the directory reserved for temporary isolated write worktrees.
|
||||
pub fn write_worktrees_path(&self) -> std::path::PathBuf {
|
||||
return self.state_root.join("write-worktrees");
|
||||
}
|
||||
|
||||
/// Return the controlled empty global Git configuration path.
|
||||
pub fn empty_global_config_path(&self) -> std::path::PathBuf {
|
||||
return self.state_root.join("empty-global.gitconfig");
|
||||
}
|
||||
}
|
||||
|
||||
/// Coarse physical source kind used by the backend.
|
||||
@@ -155,7 +165,9 @@ impl crate::GitMirror {
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct GitCommandSpec {
|
||||
arguments: Vec<std::ffi::OsString>,
|
||||
environment: Vec<(std::ffi::OsString, std::ffi::OsString)>,
|
||||
executable: std::path::PathBuf,
|
||||
global_config_path: std::path::PathBuf,
|
||||
hooks_path: std::path::PathBuf,
|
||||
operation: crate::GitOperation,
|
||||
}
|
||||
@@ -183,9 +195,20 @@ impl GitCommandRunner for SystemGitRunner {
|
||||
command.env_remove("GIT_INDEX_FILE");
|
||||
command.env_remove("GIT_OBJECT_DIRECTORY");
|
||||
command.env_remove("GIT_WORK_TREE");
|
||||
command.env_remove("GIT_AUTHOR_NAME");
|
||||
command.env_remove("GIT_AUTHOR_EMAIL");
|
||||
command.env_remove("GIT_COMMITTER_NAME");
|
||||
command.env_remove("GIT_COMMITTER_EMAIL");
|
||||
command.env_remove("GIT_CONFIG_GLOBAL");
|
||||
command.env_remove("GIT_CONFIG_NOSYSTEM");
|
||||
command.env("GIT_CONFIG_GLOBAL", &specification.global_config_path);
|
||||
command.env("GIT_CONFIG_NOSYSTEM", "1");
|
||||
command.env("GIT_CONFIG_COUNT", "1");
|
||||
command.env("GIT_CONFIG_KEY_0", "core.hooksPath");
|
||||
command.env("GIT_CONFIG_VALUE_0", &specification.hooks_path);
|
||||
for (name, value) in &specification.environment {
|
||||
command.env(name, value);
|
||||
}
|
||||
let output_result = command.output();
|
||||
let output = match output_result {
|
||||
Ok(value) => value,
|
||||
@@ -246,7 +269,7 @@ impl crate::GitBackend {
|
||||
source_id: &knowledge_domain_lib::SourceId,
|
||||
revision: &knowledge_git_api::GitRevision,
|
||||
) -> Result<std::path::PathBuf, crate::GitBackendError> {
|
||||
if !is_safe_revision(revision.as_str()) {
|
||||
if !crate::is_safe_revision(revision.as_str()) {
|
||||
return Err(crate::GitBackendError::InvalidRevision);
|
||||
}
|
||||
let source_key = crate::GitSourceKey::from_source_id(source_id);
|
||||
@@ -301,7 +324,7 @@ impl crate::GitBackend {
|
||||
}
|
||||
}
|
||||
|
||||
fn configured_source(&self, source_id: &knowledge_domain_lib::SourceId) -> Result<&crate::GitSourceConfig, crate::GitBackendError> {
|
||||
pub(crate) fn configured_source(&self, source_id: &knowledge_domain_lib::SourceId) -> Result<&crate::GitSourceConfig, crate::GitBackendError> {
|
||||
for source in &self.sources {
|
||||
if source.source_id() == source_id {
|
||||
return Ok(source);
|
||||
@@ -310,16 +333,44 @@ impl crate::GitBackend {
|
||||
return Err(crate::GitBackendError::SourceNotConfigured);
|
||||
}
|
||||
|
||||
fn prepare_state_directories(&self) -> Result<(), crate::GitBackendError> {
|
||||
for path in [self.config.hooks_path(), self.config.repositories_path(), self.config.snapshots_path()] {
|
||||
pub(crate) fn prepare_state_directories(&self) -> Result<(), crate::GitBackendError> {
|
||||
for path in [self.config.hooks_path(), self.config.repositories_path(), self.config.snapshots_path(), self.config.write_worktrees_path()] {
|
||||
let create_result = std::fs::create_dir_all(path);
|
||||
if create_result.is_err() {
|
||||
return Err(crate::GitBackendError::StatePreparationFailed);
|
||||
}
|
||||
}
|
||||
let config_path = self.config.empty_global_config_path();
|
||||
if !config_path.exists() {
|
||||
let write_result = std::fs::write(config_path, b"");
|
||||
if write_result.is_err() {
|
||||
return Err(crate::GitBackendError::StatePreparationFailed);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
pub(crate) fn run_controlled_git(
|
||||
&self,
|
||||
operation: crate::GitOperation,
|
||||
arguments: Vec<std::ffi::OsString>,
|
||||
environment: Vec<(std::ffi::OsString, std::ffi::OsString)>,
|
||||
) -> Result<Vec<u8>, crate::GitBackendError> {
|
||||
let specification = GitCommandSpec {
|
||||
arguments,
|
||||
environment,
|
||||
executable: self.config.git_executable().to_path_buf(),
|
||||
global_config_path: self.config.empty_global_config_path(),
|
||||
hooks_path: self.config.hooks_path(),
|
||||
operation,
|
||||
};
|
||||
let result = self.runner.run(&specification);
|
||||
match result {
|
||||
Ok(output) => return Ok(output.stdout),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn clone_mirror(&self, source: &crate::GitSourceConfig, repository_path: &std::path::Path) -> Result<(), crate::GitBackendError> {
|
||||
let mut arguments = vec![std::ffi::OsString::from("clone"), std::ffi::OsString::from("--mirror")];
|
||||
if source.source_kind == crate::GitSourceKind::Local {
|
||||
@@ -330,7 +381,9 @@ impl crate::GitBackend {
|
||||
arguments.push(repository_path.as_os_str().to_os_string());
|
||||
let specification = GitCommandSpec {
|
||||
arguments,
|
||||
environment: Vec::new(),
|
||||
executable: self.config.git_executable().to_path_buf(),
|
||||
global_config_path: self.config.empty_global_config_path(),
|
||||
hooks_path: self.config.hooks_path(),
|
||||
operation: crate::GitOperation::CloneMirror,
|
||||
};
|
||||
@@ -349,7 +402,9 @@ impl crate::GitBackend {
|
||||
std::ffi::OsString::from("rev-parse"),
|
||||
std::ffi::OsString::from("--is-bare-repository"),
|
||||
],
|
||||
environment: Vec::new(),
|
||||
executable: self.config.git_executable().to_path_buf(),
|
||||
global_config_path: self.config.empty_global_config_path(),
|
||||
hooks_path: self.config.hooks_path(),
|
||||
operation: crate::GitOperation::InspectMirror,
|
||||
};
|
||||
@@ -390,7 +445,9 @@ impl crate::GitBackend {
|
||||
std::ffi::OsString::from("update"),
|
||||
std::ffi::OsString::from("--prune"),
|
||||
],
|
||||
environment: Vec::new(),
|
||||
executable: self.config.git_executable().to_path_buf(),
|
||||
global_config_path: self.config.empty_global_config_path(),
|
||||
hooks_path: self.config.hooks_path(),
|
||||
operation: crate::GitOperation::RefreshMirror,
|
||||
};
|
||||
@@ -427,7 +484,9 @@ impl crate::GitBackend {
|
||||
std::ffi::OsString::from("--end-of-options"),
|
||||
std::ffi::OsString::from(revision_expression),
|
||||
],
|
||||
environment: Vec::new(),
|
||||
executable: self.config.git_executable().to_path_buf(),
|
||||
global_config_path: self.config.empty_global_config_path(),
|
||||
hooks_path: self.config.hooks_path(),
|
||||
operation: crate::GitOperation::ResolveRevision,
|
||||
};
|
||||
@@ -443,7 +502,7 @@ impl crate::GitBackend {
|
||||
return Err(crate::GitBackendError::UnexpectedGitOutput { operation: crate::GitOperation::ResolveRevision });
|
||||
},
|
||||
};
|
||||
if !is_safe_revision(stdout) {
|
||||
if !crate::is_safe_revision(stdout) {
|
||||
return Err(crate::GitBackendError::UnexpectedGitOutput { operation: crate::GitOperation::ResolveRevision });
|
||||
}
|
||||
return Ok(knowledge_git_api::GitRevision::new(stdout));
|
||||
@@ -459,7 +518,7 @@ impl crate::GitBackend {
|
||||
Ok(_) => (),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
if !is_safe_revision(revision.as_str()) {
|
||||
if !crate::is_safe_revision(revision.as_str()) {
|
||||
return Err(crate::GitBackendError::InvalidRevision);
|
||||
}
|
||||
let mirror_result = self.inspect_mirror(source_id);
|
||||
@@ -499,7 +558,9 @@ impl crate::GitBackend {
|
||||
snapshot_path.as_os_str().to_os_string(),
|
||||
std::ffi::OsString::from(revision.as_str()),
|
||||
],
|
||||
environment: Vec::new(),
|
||||
executable: self.config.git_executable().to_path_buf(),
|
||||
global_config_path: self.config.empty_global_config_path(),
|
||||
hooks_path: self.config.hooks_path(),
|
||||
operation: crate::GitOperation::CreateSnapshot,
|
||||
};
|
||||
@@ -527,7 +588,9 @@ impl crate::GitBackend {
|
||||
std::ffi::OsString::from("--verify"),
|
||||
std::ffi::OsString::from("HEAD"),
|
||||
],
|
||||
environment: Vec::new(),
|
||||
executable: self.config.git_executable().to_path_buf(),
|
||||
global_config_path: self.config.empty_global_config_path(),
|
||||
hooks_path: self.config.hooks_path(),
|
||||
operation: crate::GitOperation::InspectSnapshot,
|
||||
};
|
||||
@@ -552,7 +615,9 @@ impl crate::GitBackend {
|
||||
std::ffi::OsString::from("--porcelain=v1"),
|
||||
std::ffi::OsString::from("--untracked-files=all"),
|
||||
],
|
||||
environment: Vec::new(),
|
||||
executable: self.config.git_executable().to_path_buf(),
|
||||
global_config_path: self.config.empty_global_config_path(),
|
||||
hooks_path: self.config.hooks_path(),
|
||||
operation: crate::GitOperation::InspectSnapshot,
|
||||
};
|
||||
@@ -592,7 +657,7 @@ impl knowledge_git_api::GitRepository for crate::GitBackend {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_safe_revision(value: &str) -> bool {
|
||||
pub(crate) fn is_safe_revision(value: &str) -> bool {
|
||||
if value.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/knowledge-git-lib/src/error.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
//! Recoverable errors for controlled Git process execution.
|
||||
|
||||
@@ -18,6 +18,18 @@ pub enum GitOperation {
|
||||
CreateSnapshot,
|
||||
/// Validate an existing detached snapshot before reuse.
|
||||
InspectSnapshot,
|
||||
/// Create one isolated detached worktree for candidate construction.
|
||||
CreateWriteWorktree,
|
||||
/// Stage the requested candidate mutations.
|
||||
StageCandidate,
|
||||
/// Inspect the candidate index, commit or parent relationship.
|
||||
InspectCandidate,
|
||||
/// Create the candidate commit with explicit identity.
|
||||
CommitCandidate,
|
||||
/// Retain the detached candidate commit under an internal backend reference.
|
||||
PreserveCandidate,
|
||||
/// Remove one backend-owned candidate worktree.
|
||||
RemoveWriteWorktree,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for crate::GitOperation {
|
||||
@@ -29,6 +41,12 @@ impl std::fmt::Display for crate::GitOperation {
|
||||
crate::GitOperation::ResolveRevision => return formatter.write_str("resolve revision"),
|
||||
crate::GitOperation::CreateSnapshot => return formatter.write_str("create snapshot"),
|
||||
crate::GitOperation::InspectSnapshot => return formatter.write_str("inspect snapshot"),
|
||||
crate::GitOperation::CreateWriteWorktree => return formatter.write_str("create write worktree"),
|
||||
crate::GitOperation::StageCandidate => return formatter.write_str("stage candidate"),
|
||||
crate::GitOperation::InspectCandidate => return formatter.write_str("inspect candidate"),
|
||||
crate::GitOperation::CommitCandidate => return formatter.write_str("commit candidate"),
|
||||
crate::GitOperation::PreserveCandidate => return formatter.write_str("preserve candidate"),
|
||||
crate::GitOperation::RemoveWriteWorktree => return formatter.write_str("remove write worktree"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +89,20 @@ pub enum GitBackendError {
|
||||
InvalidRevision,
|
||||
/// An existing snapshot does not match the requested revision or is not clean.
|
||||
InvalidSnapshot,
|
||||
/// The deterministic backend-owned write worktree path already exists unexpectedly.
|
||||
WriteWorktreeExists,
|
||||
/// A mutation would traverse or replace a symbolic link.
|
||||
UnsafeMutationTarget,
|
||||
/// A requested deletion target does not exist.
|
||||
MutationTargetMissing,
|
||||
/// A filesystem mutation or parent-directory preparation failed.
|
||||
MutationFilesystemFailed,
|
||||
/// The staged candidate paths do not exactly match the requested mutation set.
|
||||
CandidateMutationMismatch,
|
||||
/// The resulting candidate commit does not have the requested base as its sole first parent.
|
||||
InvalidCandidate,
|
||||
/// A backend-owned write worktree could not be removed deterministically.
|
||||
WriteCleanupFailed,
|
||||
/// Git returned output that does not satisfy the backend contract.
|
||||
UnexpectedGitOutput {
|
||||
/// Operation whose output could not be validated.
|
||||
@@ -95,6 +127,13 @@ impl std::fmt::Display for crate::GitBackendError {
|
||||
crate::GitBackendError::MirrorMissing => return formatter.write_str("controlled git mirror does not exist"),
|
||||
crate::GitBackendError::InvalidRevision => return formatter.write_str("git revision is not a safe object identity"),
|
||||
crate::GitBackendError::InvalidSnapshot => return formatter.write_str("controlled git snapshot is invalid or dirty"),
|
||||
crate::GitBackendError::WriteWorktreeExists => return formatter.write_str("controlled git write worktree already exists"),
|
||||
crate::GitBackendError::UnsafeMutationTarget => return formatter.write_str("git mutation target traverses a symbolic link"),
|
||||
crate::GitBackendError::MutationTargetMissing => return formatter.write_str("git deletion target does not exist"),
|
||||
crate::GitBackendError::MutationFilesystemFailed => return formatter.write_str("git candidate filesystem mutation failed"),
|
||||
crate::GitBackendError::CandidateMutationMismatch => return formatter.write_str("git candidate staged paths do not match requested mutations"),
|
||||
crate::GitBackendError::InvalidCandidate => return formatter.write_str("git candidate commit does not match the requested base"),
|
||||
crate::GitBackendError::WriteCleanupFailed => return formatter.write_str("controlled git write worktree cleanup failed"),
|
||||
crate::GitBackendError::UnexpectedGitOutput { operation } => {
|
||||
return write!(formatter, "git returned unexpected output while attempting to {operation}");
|
||||
},
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
// file: crates/knowledge-git-lib/src/lib.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
//! Controlled Git process backend for Knowledge content sources.
|
||||
//!
|
||||
//! This crate owns physical Git source configuration, generated-state layout, provider-neutral read
|
||||
//! materialization and process execution. It deliberately contains no forge API, persistence,
|
||||
//! scheduler or application orchestration.
|
||||
//! materialization, isolated candidate construction and process execution. It deliberately contains no
|
||||
//! forge API, persistence, scheduler or application orchestration.
|
||||
|
||||
mod backend;
|
||||
mod error;
|
||||
mod write;
|
||||
|
||||
/// Controlled Git backend that owns configured sources and local read materialization.
|
||||
pub use self::backend::GitBackend;
|
||||
@@ -28,3 +29,5 @@ pub use self::backend::GitSourceKind;
|
||||
pub use self::error::GitBackendError;
|
||||
/// Git operation category exposed by recoverable process errors without leaking command arguments.
|
||||
pub use self::error::GitOperation;
|
||||
|
||||
pub(crate) use self::backend::is_safe_revision;
|
||||
|
||||
322
crates/knowledge-git-lib/src/write.rs
Normal file
322
crates/knowledge-git-lib/src/write.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
// file: crates/knowledge-git-lib/src/write.rs
|
||||
// version: 1
|
||||
|
||||
//! Isolated candidate-commit creation for controlled Git sources.
|
||||
|
||||
impl crate::GitBackend {
|
||||
/// Create one detached candidate commit from an explicit resolved base revision.
|
||||
///
|
||||
/// This operation does not update any remote branch. The candidate is retained by an internal
|
||||
/// backend reference so a later guarded push can publish it without depending on worktree
|
||||
/// lifetime or reflog retention.
|
||||
pub fn create_candidate(
|
||||
&mut self,
|
||||
source_id: &knowledge_domain_lib::SourceId,
|
||||
request: &knowledge_git_api::GitWriteRequest,
|
||||
) -> Result<knowledge_git_api::GitRevision, crate::GitBackendError> {
|
||||
if !crate::is_safe_revision(request.base_revision().as_str()) {
|
||||
return Err(crate::GitBackendError::InvalidRevision);
|
||||
}
|
||||
let source_result = self.configured_source(source_id);
|
||||
let source = match source_result {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let preparation_result = self.prepare_state_directories();
|
||||
match preparation_result {
|
||||
Ok(()) => (),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
let mirror_result = self.ensure_mirror(source);
|
||||
let mirror = match mirror_result {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let source_key = crate::GitSourceKey::from_source_id(source_id);
|
||||
let source_write_root = self.config().write_worktrees_path().join(source_key.as_str());
|
||||
let worktree_path = source_write_root.join("candidate");
|
||||
if worktree_path.exists() {
|
||||
return Err(crate::GitBackendError::WriteWorktreeExists);
|
||||
}
|
||||
let create_parent_result = std::fs::create_dir_all(&source_write_root);
|
||||
if create_parent_result.is_err() {
|
||||
return Err(crate::GitBackendError::MutationFilesystemFailed);
|
||||
}
|
||||
let add_result = self.run_controlled_git(
|
||||
crate::GitOperation::CreateWriteWorktree,
|
||||
vec![
|
||||
std::ffi::OsString::from("--git-dir"),
|
||||
mirror.repository_path().as_os_str().to_os_string(),
|
||||
std::ffi::OsString::from("worktree"),
|
||||
std::ffi::OsString::from("add"),
|
||||
std::ffi::OsString::from("--detach"),
|
||||
std::ffi::OsString::from("--"),
|
||||
worktree_path.as_os_str().to_os_string(),
|
||||
std::ffi::OsString::from(request.base_revision().as_str()),
|
||||
],
|
||||
Vec::new(),
|
||||
);
|
||||
match add_result {
|
||||
Ok(_) => (),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
let candidate_result = self.populate_candidate_worktree(mirror.repository_path(), &worktree_path, request);
|
||||
let cleanup_result = self.remove_candidate_worktree(mirror.repository_path(), &worktree_path, candidate_result.is_err());
|
||||
match cleanup_result {
|
||||
Ok(()) => return candidate_result,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn populate_candidate_worktree(
|
||||
&self,
|
||||
repository_path: &std::path::Path,
|
||||
worktree_path: &std::path::Path,
|
||||
request: &knowledge_git_api::GitWriteRequest,
|
||||
) -> Result<knowledge_git_api::GitRevision, crate::GitBackendError> {
|
||||
for mutation in request.mutations() {
|
||||
let mutation_result = apply_mutation(worktree_path, mutation);
|
||||
match mutation_result {
|
||||
Ok(()) => (),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
let mut add_arguments = vec![
|
||||
std::ffi::OsString::from("-C"),
|
||||
worktree_path.as_os_str().to_os_string(),
|
||||
std::ffi::OsString::from("add"),
|
||||
std::ffi::OsString::from("--all"),
|
||||
std::ffi::OsString::from("--"),
|
||||
];
|
||||
for mutation in request.mutations() {
|
||||
add_arguments.push(std::ffi::OsString::from(mutation.path().as_str()));
|
||||
}
|
||||
let add_result = self.run_controlled_git(crate::GitOperation::StageCandidate, add_arguments, Vec::new());
|
||||
match add_result {
|
||||
Ok(_) => (),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
let staged_result = self.run_controlled_git(
|
||||
crate::GitOperation::InspectCandidate,
|
||||
vec![
|
||||
std::ffi::OsString::from("-C"),
|
||||
worktree_path.as_os_str().to_os_string(),
|
||||
std::ffi::OsString::from("diff"),
|
||||
std::ffi::OsString::from("--cached"),
|
||||
std::ffi::OsString::from("--name-only"),
|
||||
std::ffi::OsString::from("-z"),
|
||||
std::ffi::OsString::from("--"),
|
||||
],
|
||||
Vec::new(),
|
||||
);
|
||||
let staged = match staged_result {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if !staged_paths_match(&staged, request.mutations()) {
|
||||
return Err(crate::GitBackendError::CandidateMutationMismatch);
|
||||
}
|
||||
let identity = request.identity();
|
||||
let environment = vec![
|
||||
(std::ffi::OsString::from("GIT_AUTHOR_NAME"), std::ffi::OsString::from(identity.name())),
|
||||
(std::ffi::OsString::from("GIT_AUTHOR_EMAIL"), std::ffi::OsString::from(identity.email())),
|
||||
(std::ffi::OsString::from("GIT_COMMITTER_NAME"), std::ffi::OsString::from(identity.name())),
|
||||
(std::ffi::OsString::from("GIT_COMMITTER_EMAIL"), std::ffi::OsString::from(identity.email())),
|
||||
];
|
||||
let commit_result = self.run_controlled_git(
|
||||
crate::GitOperation::CommitCandidate,
|
||||
vec![
|
||||
std::ffi::OsString::from("-C"),
|
||||
worktree_path.as_os_str().to_os_string(),
|
||||
std::ffi::OsString::from("commit"),
|
||||
std::ffi::OsString::from("--no-verify"),
|
||||
std::ffi::OsString::from("--no-gpg-sign"),
|
||||
std::ffi::OsString::from("--cleanup=verbatim"),
|
||||
std::ffi::OsString::from("-m"),
|
||||
std::ffi::OsString::from(request.message()),
|
||||
],
|
||||
environment,
|
||||
);
|
||||
match commit_result {
|
||||
Ok(_) => (),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
let candidate_result = self.resolve_worktree_revision(worktree_path, "HEAD", crate::GitOperation::InspectCandidate);
|
||||
let candidate = match candidate_result {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let parent_result = self.resolve_worktree_revision(worktree_path, "HEAD^1", crate::GitOperation::InspectCandidate);
|
||||
let parent = match parent_result {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if &parent != request.base_revision() || candidate == parent {
|
||||
return Err(crate::GitBackendError::InvalidCandidate);
|
||||
}
|
||||
let candidate_reference = format!("refs/knowledge/candidates/{}", candidate.as_str());
|
||||
let preserve_result = self.run_controlled_git(
|
||||
crate::GitOperation::PreserveCandidate,
|
||||
vec![
|
||||
std::ffi::OsString::from("--git-dir"),
|
||||
repository_path.as_os_str().to_os_string(),
|
||||
std::ffi::OsString::from("update-ref"),
|
||||
std::ffi::OsString::from(candidate_reference),
|
||||
std::ffi::OsString::from(candidate.as_str()),
|
||||
],
|
||||
Vec::new(),
|
||||
);
|
||||
match preserve_result {
|
||||
Ok(_) => return Ok(candidate),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_worktree_revision(
|
||||
&self,
|
||||
worktree_path: &std::path::Path,
|
||||
expression: &str,
|
||||
operation: crate::GitOperation,
|
||||
) -> Result<knowledge_git_api::GitRevision, crate::GitBackendError> {
|
||||
let output_result = self.run_controlled_git(
|
||||
operation,
|
||||
vec![
|
||||
std::ffi::OsString::from("-C"),
|
||||
worktree_path.as_os_str().to_os_string(),
|
||||
std::ffi::OsString::from("rev-parse"),
|
||||
std::ffi::OsString::from("--verify"),
|
||||
std::ffi::OsString::from("--end-of-options"),
|
||||
std::ffi::OsString::from(expression),
|
||||
],
|
||||
Vec::new(),
|
||||
);
|
||||
let output = match output_result {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let text_result = std::str::from_utf8(&output);
|
||||
let text = match text_result {
|
||||
Ok(value) => value.trim(),
|
||||
Err(_) => return Err(crate::GitBackendError::UnexpectedGitOutput { operation }),
|
||||
};
|
||||
if !crate::is_safe_revision(text) {
|
||||
return Err(crate::GitBackendError::UnexpectedGitOutput { operation });
|
||||
}
|
||||
return Ok(knowledge_git_api::GitRevision::new(text));
|
||||
}
|
||||
|
||||
fn remove_candidate_worktree(&self, repository_path: &std::path::Path, worktree_path: &std::path::Path, force: bool) -> Result<(), crate::GitBackendError> {
|
||||
let mut arguments = vec![
|
||||
std::ffi::OsString::from("--git-dir"),
|
||||
repository_path.as_os_str().to_os_string(),
|
||||
std::ffi::OsString::from("worktree"),
|
||||
std::ffi::OsString::from("remove"),
|
||||
];
|
||||
if force {
|
||||
arguments.push(std::ffi::OsString::from("--force"));
|
||||
}
|
||||
arguments.push(std::ffi::OsString::from("--"));
|
||||
arguments.push(worktree_path.as_os_str().to_os_string());
|
||||
let remove_result = self.run_controlled_git(crate::GitOperation::RemoveWriteWorktree, arguments, Vec::new());
|
||||
match remove_result {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(_) => return Err(crate::GitBackendError::WriteCleanupFailed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_mutation(worktree_path: &std::path::Path, mutation: &knowledge_git_api::GitFileMutation) -> Result<(), crate::GitBackendError> {
|
||||
let target_result = checked_target_path(worktree_path, mutation.path());
|
||||
let target = match target_result {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
match mutation {
|
||||
knowledge_git_api::GitFileMutation::Write { content, .. } => {
|
||||
let parent = match target.parent() {
|
||||
Some(value) => value,
|
||||
None => return Err(crate::GitBackendError::MutationFilesystemFailed),
|
||||
};
|
||||
let create_result = std::fs::create_dir_all(parent);
|
||||
if create_result.is_err() {
|
||||
return Err(crate::GitBackendError::MutationFilesystemFailed);
|
||||
}
|
||||
let write_result = std::fs::write(target, content);
|
||||
if write_result.is_err() {
|
||||
return Err(crate::GitBackendError::MutationFilesystemFailed);
|
||||
}
|
||||
return Ok(());
|
||||
},
|
||||
knowledge_git_api::GitFileMutation::Delete { .. } => {
|
||||
if !target.exists() {
|
||||
return Err(crate::GitBackendError::MutationTargetMissing);
|
||||
}
|
||||
let remove_result = std::fs::remove_file(target);
|
||||
if remove_result.is_err() {
|
||||
return Err(crate::GitBackendError::MutationFilesystemFailed);
|
||||
}
|
||||
return Ok(());
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn checked_target_path(
|
||||
worktree_path: &std::path::Path,
|
||||
mutation_path: &knowledge_git_api::GitMutationPath,
|
||||
) -> Result<std::path::PathBuf, crate::GitBackendError> {
|
||||
let components: Vec<&str> = mutation_path.as_str().split('/').collect();
|
||||
let mut current = worktree_path.to_path_buf();
|
||||
for (index, component) in components.iter().enumerate() {
|
||||
current.push(*component);
|
||||
let metadata_result = std::fs::symlink_metadata(¤t);
|
||||
let metadata = match metadata_result {
|
||||
Ok(value) => Some(value),
|
||||
Err(error) => {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
None
|
||||
} else {
|
||||
return Err(crate::GitBackendError::MutationFilesystemFailed);
|
||||
}
|
||||
},
|
||||
};
|
||||
let metadata = match metadata {
|
||||
Some(value) => value,
|
||||
None => continue,
|
||||
};
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(crate::GitBackendError::UnsafeMutationTarget);
|
||||
}
|
||||
let is_final = index + 1 == components.len();
|
||||
if !is_final && !metadata.is_dir() {
|
||||
return Err(crate::GitBackendError::MutationFilesystemFailed);
|
||||
}
|
||||
if is_final && metadata.is_dir() {
|
||||
return Err(crate::GitBackendError::MutationFilesystemFailed);
|
||||
}
|
||||
}
|
||||
return Ok(current);
|
||||
}
|
||||
|
||||
fn staged_paths_match(staged: &[u8], mutations: &[knowledge_git_api::GitFileMutation]) -> bool {
|
||||
let mut observed = std::collections::HashSet::new();
|
||||
for bytes in staged.split(|byte| return *byte == 0) {
|
||||
if bytes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let path_result = std::str::from_utf8(bytes);
|
||||
let path = match path_result {
|
||||
Ok(value) => value,
|
||||
Err(_) => return false,
|
||||
};
|
||||
observed.insert(path);
|
||||
}
|
||||
if observed.len() != mutations.len() {
|
||||
return false;
|
||||
}
|
||||
for mutation in mutations {
|
||||
if !observed.contains(mutation.path().as_str()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
312
crates/knowledge-git-lib/tests/git_write_candidate.rs
Normal file
312
crates/knowledge-git-lib/tests/git_write_candidate.rs
Normal file
@@ -0,0 +1,312 @@
|
||||
// file: crates/knowledge-git-lib/tests/git_write_candidate.rs
|
||||
// version: 1
|
||||
|
||||
//! Integration tests for isolated real Git candidate creation using local temporary repositories.
|
||||
|
||||
fn test_root(label: &str) -> std::path::PathBuf {
|
||||
let nanos_result = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH);
|
||||
let nanos = match nanos_result {
|
||||
Ok(value) => value.as_nanos(),
|
||||
Err(_) => 0,
|
||||
};
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!("sasedev-knowledge-git-write-{label}-{}-{nanos}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
return path;
|
||||
}
|
||||
|
||||
fn run_git(repository: &std::path::Path, arguments: &[&str]) -> bool {
|
||||
let mut command = std::process::Command::new("git");
|
||||
command.current_dir(repository);
|
||||
command.args(arguments);
|
||||
command.env("GIT_TERMINAL_PROMPT", "0");
|
||||
let status_result = command.status();
|
||||
match status_result {
|
||||
Ok(status) => return status.success(),
|
||||
Err(_) => return false,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_git_output(arguments: Vec<std::ffi::OsString>) -> Option<Vec<u8>> {
|
||||
let mut command = std::process::Command::new("git");
|
||||
command.args(arguments);
|
||||
command.env("GIT_TERMINAL_PROMPT", "0");
|
||||
let output_result = command.output();
|
||||
let output = match output_result {
|
||||
Ok(value) => value,
|
||||
Err(_) => return None,
|
||||
};
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
return Some(output.stdout);
|
||||
}
|
||||
|
||||
fn prepare_source(root: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||
let source = root.join("source");
|
||||
if std::fs::create_dir_all(&source).is_err() {
|
||||
return None;
|
||||
}
|
||||
if !run_git(&source, &["init", "-b", "main"]) {
|
||||
return None;
|
||||
}
|
||||
if !run_git(&source, &["config", "user.name", "Ambient Identity"]) {
|
||||
return None;
|
||||
}
|
||||
if !run_git(&source, &["config", "user.email", "ambient@example.invalid"]) {
|
||||
return None;
|
||||
}
|
||||
if !run_git(&source, &["config", "commit.gpgsign", "false"]) {
|
||||
return None;
|
||||
}
|
||||
if std::fs::write(source.join("content.txt"), "first\n").is_err() {
|
||||
return None;
|
||||
}
|
||||
if std::fs::write(source.join("delete.txt"), "delete me\n").is_err() {
|
||||
return None;
|
||||
}
|
||||
if !run_git(&source, &["add", "content.txt", "delete.txt"]) {
|
||||
return None;
|
||||
}
|
||||
if !run_git(&source, &["commit", "-m", "first"]) {
|
||||
return None;
|
||||
}
|
||||
return Some(source);
|
||||
}
|
||||
|
||||
fn configured_backend(root: &std::path::Path, source: &std::path::Path) -> Option<(knowledge_git_lib::GitBackend, knowledge_domain_lib::SourceId)> {
|
||||
let source_id_result = knowledge_domain_lib::SourceId::new("integration-write-source");
|
||||
let source_id = match source_id_result {
|
||||
Ok(value) => value,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let config_result = knowledge_git_lib::GitBackendConfig::new(root.join("state"));
|
||||
let config = match config_result {
|
||||
Ok(value) => value,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let source_result = knowledge_git_lib::GitSourceConfig::local(source_id.clone(), source.to_path_buf());
|
||||
let source_config = match source_result {
|
||||
Ok(value) => value,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let mut backend = knowledge_git_lib::GitBackend::new(config);
|
||||
if backend.register_source(source_config).is_err() {
|
||||
return None;
|
||||
}
|
||||
return Some((backend, source_id));
|
||||
}
|
||||
|
||||
fn resolved_main(backend: &mut knowledge_git_lib::GitBackend, source_id: &knowledge_domain_lib::SourceId) -> Option<knowledge_git_api::GitRevision> {
|
||||
if knowledge_git_api::GitRepository::refresh(backend, source_id).is_err() {
|
||||
return None;
|
||||
}
|
||||
let reference = knowledge_git_api::GitReference::new("refs/heads/main");
|
||||
let revision_result = knowledge_git_api::GitRepository::resolve_revision(backend, source_id, &reference);
|
||||
match revision_result {
|
||||
Ok(value) => return Some(value),
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
|
||||
fn mutation_path(value: &str) -> Option<knowledge_git_api::GitMutationPath> {
|
||||
let result = knowledge_git_api::GitMutationPath::new(value);
|
||||
match result {
|
||||
Ok(value) => return Some(value),
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_identity() -> Option<knowledge_git_api::GitCommitIdentity> {
|
||||
let result = knowledge_git_api::GitCommitIdentity::new("Knowledge Writer", "writer@example.invalid");
|
||||
match result {
|
||||
Ok(value) => return Some(value),
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_commit_applies_exact_mutations_uses_explicit_identity_and_cleans_worktree() {
|
||||
let root = test_root("candidate");
|
||||
let source_result = prepare_source(&root);
|
||||
assert!(source_result.is_some());
|
||||
let source = match source_result {
|
||||
Some(value) => value,
|
||||
None => return,
|
||||
};
|
||||
let backend_result = configured_backend(&root, &source);
|
||||
assert!(backend_result.is_some());
|
||||
let (mut backend, source_id) = match backend_result {
|
||||
Some(value) => value,
|
||||
None => return,
|
||||
};
|
||||
let base_result = resolved_main(&mut backend, &source_id);
|
||||
assert!(base_result.is_some());
|
||||
let base = match base_result {
|
||||
Some(value) => value,
|
||||
None => return,
|
||||
};
|
||||
let snapshot_result = knowledge_git_api::GitRepository::prepare_snapshot(&backend, &source_id, &base);
|
||||
assert!(snapshot_result.is_ok());
|
||||
let snapshot = match snapshot_result {
|
||||
Ok(value) => value,
|
||||
Err(_) => return,
|
||||
};
|
||||
let content_path = match mutation_path("content.txt") {
|
||||
Some(value) => value,
|
||||
None => return,
|
||||
};
|
||||
let nested_path = match mutation_path("nested/new.txt") {
|
||||
Some(value) => value,
|
||||
None => return,
|
||||
};
|
||||
let delete_path = match mutation_path("delete.txt") {
|
||||
Some(value) => value,
|
||||
None => return,
|
||||
};
|
||||
let identity = match commit_identity() {
|
||||
Some(value) => value,
|
||||
None => return,
|
||||
};
|
||||
let request_result = knowledge_git_api::GitWriteRequest::new(
|
||||
base.clone(),
|
||||
vec![
|
||||
knowledge_git_api::GitFileMutation::Write { path: content_path, content: b"second\n".to_vec() },
|
||||
knowledge_git_api::GitFileMutation::Write { path: nested_path, content: b"nested\n".to_vec() },
|
||||
knowledge_git_api::GitFileMutation::Delete { path: delete_path },
|
||||
],
|
||||
identity,
|
||||
"candidate commit",
|
||||
);
|
||||
assert!(request_result.is_ok());
|
||||
let request = match request_result {
|
||||
Ok(value) => value,
|
||||
Err(_) => return,
|
||||
};
|
||||
let candidate_result = backend.create_candidate(&source_id, &request);
|
||||
assert!(candidate_result.is_ok());
|
||||
let candidate = match candidate_result {
|
||||
Ok(value) => value,
|
||||
Err(_) => return,
|
||||
};
|
||||
assert_ne!(candidate, base);
|
||||
let repository_path = backend.repository_path(&source_id);
|
||||
let parent_output = run_git_output(vec![
|
||||
std::ffi::OsString::from("--git-dir"),
|
||||
repository_path.as_os_str().to_os_string(),
|
||||
std::ffi::OsString::from("rev-parse"),
|
||||
std::ffi::OsString::from(format!("{}^1", candidate.as_str())),
|
||||
]);
|
||||
assert!(parent_output.is_some());
|
||||
let parent = match parent_output {
|
||||
Some(value) => String::from_utf8_lossy(&value).trim().to_owned(),
|
||||
None => return,
|
||||
};
|
||||
assert_eq!(parent, base.as_str());
|
||||
let content_output = run_git_output(vec![
|
||||
std::ffi::OsString::from("--git-dir"),
|
||||
repository_path.as_os_str().to_os_string(),
|
||||
std::ffi::OsString::from("show"),
|
||||
std::ffi::OsString::from(format!("{}:content.txt", candidate.as_str())),
|
||||
]);
|
||||
assert_eq!(content_output, Some(b"second\n".to_vec()));
|
||||
let nested_output = run_git_output(vec![
|
||||
std::ffi::OsString::from("--git-dir"),
|
||||
repository_path.as_os_str().to_os_string(),
|
||||
std::ffi::OsString::from("show"),
|
||||
std::ffi::OsString::from(format!("{}:nested/new.txt", candidate.as_str())),
|
||||
]);
|
||||
assert_eq!(nested_output, Some(b"nested\n".to_vec()));
|
||||
let deleted_output = run_git_output(vec![
|
||||
std::ffi::OsString::from("--git-dir"),
|
||||
repository_path.as_os_str().to_os_string(),
|
||||
std::ffi::OsString::from("show"),
|
||||
std::ffi::OsString::from(format!("{}:delete.txt", candidate.as_str())),
|
||||
]);
|
||||
assert!(deleted_output.is_none());
|
||||
let identity_output = run_git_output(vec![
|
||||
std::ffi::OsString::from("--git-dir"),
|
||||
repository_path.as_os_str().to_os_string(),
|
||||
std::ffi::OsString::from("show"),
|
||||
std::ffi::OsString::from("-s"),
|
||||
std::ffi::OsString::from("--format=%an%x00%ae%x00%cn%x00%ce"),
|
||||
std::ffi::OsString::from(candidate.as_str()),
|
||||
]);
|
||||
assert!(identity_output.is_some());
|
||||
let identity_text = match identity_output {
|
||||
Some(value) => String::from_utf8_lossy(&value).trim().to_owned(),
|
||||
None => return,
|
||||
};
|
||||
assert_eq!(identity_text, "Knowledge Writer\0writer@example.invalid\0Knowledge Writer\0writer@example.invalid");
|
||||
let candidate_ref = format!("refs/knowledge/candidates/{}", candidate.as_str());
|
||||
let retained = run_git_output(vec![
|
||||
std::ffi::OsString::from("--git-dir"),
|
||||
repository_path.as_os_str().to_os_string(),
|
||||
std::ffi::OsString::from("show-ref"),
|
||||
std::ffi::OsString::from("--verify"),
|
||||
std::ffi::OsString::from(candidate_ref),
|
||||
]);
|
||||
assert!(retained.is_some());
|
||||
let source_key = knowledge_git_lib::GitSourceKey::from_source_id(&source_id);
|
||||
assert!(!backend.config().write_worktrees_path().join(source_key.as_str()).join("candidate").exists());
|
||||
let snapshot_content = std::fs::read_to_string(snapshot.path().join("content.txt"));
|
||||
assert_eq!(snapshot_content, Ok(String::from("first\n")));
|
||||
assert!(snapshot.path().join("delete.txt").is_file());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn candidate_rejects_symlink_escape_and_removes_owned_failed_worktree() {
|
||||
let root = test_root("symlink");
|
||||
let source_result = prepare_source(&root);
|
||||
assert!(source_result.is_some());
|
||||
let source = match source_result {
|
||||
Some(value) => value,
|
||||
None => return,
|
||||
};
|
||||
let outside = root.join("outside");
|
||||
assert!(std::fs::create_dir_all(&outside).is_ok());
|
||||
assert!(std::os::unix::fs::symlink(&outside, source.join("escape")).is_ok());
|
||||
assert!(run_git(&source, &["add", "escape"]));
|
||||
assert!(run_git(&source, &["commit", "-m", "add escape symlink"]));
|
||||
let backend_result = configured_backend(&root, &source);
|
||||
assert!(backend_result.is_some());
|
||||
let (mut backend, source_id) = match backend_result {
|
||||
Some(value) => value,
|
||||
None => return,
|
||||
};
|
||||
let base_result = resolved_main(&mut backend, &source_id);
|
||||
assert!(base_result.is_some());
|
||||
let base = match base_result {
|
||||
Some(value) => value,
|
||||
None => return,
|
||||
};
|
||||
let path = match mutation_path("escape/pwned.txt") {
|
||||
Some(value) => value,
|
||||
None => return,
|
||||
};
|
||||
let identity = match commit_identity() {
|
||||
Some(value) => value,
|
||||
None => return,
|
||||
};
|
||||
let request_result = knowledge_git_api::GitWriteRequest::new(
|
||||
base,
|
||||
vec![knowledge_git_api::GitFileMutation::Write { path, content: b"forbidden\n".to_vec() }],
|
||||
identity,
|
||||
"must fail",
|
||||
);
|
||||
assert!(request_result.is_ok());
|
||||
let request = match request_result {
|
||||
Ok(value) => value,
|
||||
Err(_) => return,
|
||||
};
|
||||
let result = backend.create_candidate(&source_id, &request);
|
||||
assert_eq!(result, Err(knowledge_git_lib::GitBackendError::UnsafeMutationTarget));
|
||||
assert!(!outside.join("pwned.txt").exists());
|
||||
let source_key = knowledge_git_lib::GitSourceKey::from_source_id(&source_id);
|
||||
assert!(!backend.config().write_worktrees_path().join(source_key.as_str()).join("candidate").exists());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
return;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/knowledge-git-lib/unit_tests/backend.rs
|
||||
// version: 4
|
||||
// version: 5
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RecordingRunner {
|
||||
@@ -159,6 +159,9 @@ fn local_source_clone_uses_mirror_no_local_and_argument_separator() {
|
||||
assert!(backend.config().hooks_path().is_dir());
|
||||
assert!(backend.config().repositories_path().is_dir());
|
||||
assert!(backend.config().snapshots_path().is_dir());
|
||||
assert!(backend.config().write_worktrees_path().is_dir());
|
||||
assert!(backend.config().empty_global_config_path().is_file());
|
||||
assert_eq!(calls[0].global_config_path, backend.config().empty_global_config_path());
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user