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: Cargo.toml
# version: 32
# version: 33
[workspace]
resolver = "3"
@@ -16,7 +16,7 @@ members = [
]
[workspace.package]
version = "0.0.3-alpha.1.fix.2"
version = "0.0.3-alpha.2"
edition = "2024"
publish = false
repository = "https://git.sasedev.com/Sasedev/sasedev-knowledge"

View File

@@ -13,7 +13,7 @@ La version courante est définie par [`VERSION`](VERSION).
`0.0.1` est la première stable de fondation de SaseDev Knowledge. `0.0.2` est la stable du chemin Git générique de lecture, de l'autonomie normative locale et du cas d'usage partagé de synchronisation `Changed` / `Unchanged`.
`0.0.3-alpha.1` ouvre la release d'écriture Git contrôlée et de push gardé. Cette première tranche est exclusivement un cadrage : elle sélectionne un worktree d'écriture isolé, un contrat d'écriture distinct de la lecture et une mise à jour distante avec attente explicite sur l'état précédent. Aucun code produit n'est encore modifié. PostgreSQL, forge, workflow complet de proposition, rebase/merge automatique et auth/admin restent hors scope.
`0.0.3-alpha.2` matérialise le contrat d'écriture Git provider-neutral dans `knowledge-git-api` : chemins de mutation validés, create/update/delete, identité de commit explicite, branche `refs/heads/...` validée, état distant attendu, requêtes candidate/push et conflit explicite. Le contrat reste distinct de `GitRepository` afin de préserver les consommateurs lecture seule. Le worktree d'écriture et le push Git réels restent réservés aux tranches suivantes ; PostgreSQL, forge, workflow complet de proposition, rebase/merge automatique et auth/admin restent hors scope.
`knowledge-git-lib` implémente `knowledge-git-api::GitRepository` pour des sources physiques enregistrées : mirror contrôlé, refresh avec pruning, résolution d'une référence vers un commit et snapshot/worktree détaché adressé par révision. `alpha.4` ne change pas cette sémantique. Les règles nécessaires au développement courant sont désormais possédées localement sous `docs/rules/`, et les futurs prompts Knowledge ne chargent pas PGR sauf réévaluation explicitement engagée. `knowledge-app-lib` compose maintenant refresh, résolution, comparaison avec la révision précédente et préparation de snapshot en un résultat explicite `Changed` / `Unchanged`, sans persister la dernière révision ni matérialiser de scheduler.

View File

@@ -1 +1 @@
0.0.3-alpha.1.fix.2
0.0.3-alpha.2

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

232
deltas/0.0.3/alpha.2.md Normal file
View File

@@ -0,0 +1,232 @@
<!-- file: deltas/0.0.3/alpha.2.md -->
<!-- version: 1 -->
# Delta 0.0.3-alpha.2 — contrat d'écriture Git provider-neutral
## Baseline requise
Partir de `0.0.3-alpha.1.fix.2` après application, commit et push de son history `PASS`.
La version devient :
```text
0.0.3-alpha.2
```
## Objet
Matérialiser uniquement la frontière publique provider-neutral nécessaire aux futures opérations d'écriture Git, sans implémenter encore le worktree d'écriture ou le push distant réel.
La tranche :
- conserve `GitRepository` comme contrat de lecture inchangé ;
- ajoute `GitRepositoryWriter` comme capacité distincte afin de ne pas imposer l'écriture aux consommateurs/fakes lecture seule ;
- introduit `GitMutationPath` pour des chemins relatifs normalisés, sans `.` / `..`, séparateur plateforme, chemin absolu, drive Windows ou composant `.git` ;
- introduit `GitFileMutation` pour écrire/créer/remplacer ou supprimer un fichier ;
- introduit `GitCommitIdentity` et exige une identité explicite ;
- introduit `GitBranch` et borne la publication à une référence complète valide sous `refs/heads/...` ;
- introduit `GitRemoteState` pour représenter explicitement branche absente ou révision attendue ;
- introduit `GitWriteRequest`, `GitPushRequest` et `GitPushOutcome` pour séparer création de candidate, mise à jour distante gardée et conflit ;
- rejette une requête sans mutation, un message vide/NUL et plusieurs mutations visant le même chemin ;
- documente la matérialisation de la frontière sans déplacer les responsabilités backend prévues pour `alpha.3` / `alpha.4`.
Aucun processus Git d'écriture n'est lancé par cette tranche.
## Scope des fichiers
REPLACE :
```text
VERSION
Cargo.toml
README.md
crates/knowledge-git-api/src/lib.rs
crates/knowledge-git-api/unit_tests/lib.rs
docs/architecture/002-KNOWLEDGE_ARCHITECTURE.md
docs/plans/003-V0_0_3_GIT_WRITE_PLAN.md
docs/validation/001-VALIDATION_MATRIX.md
```
ADD :
```text
crates/knowledge-git-api/src/write.rs
deltas/0.0.3/alpha.2.md
```
DELETE : aucun.
Aucun fichier sous `knowledge-git-lib`, `knowledge-app-lib` ou `Web/` n'est modifié.
Aucun history `alpha.2` n'est inclus dans ce delta.
## Contrat public matérialisé
Le contrat expose les valeurs suivantes depuis la façade `knowledge_git_api` :
```text
GitWriteInputError
GitMutationPath
GitFileMutation
GitCommitIdentity
GitBranch
GitRemoteState
GitWriteRequest
GitPushRequest
GitPushOutcome
GitRepositoryWriter
```
Le chemin conceptuel reste :
```text
GitRevision de base
+ mutations confinées
+ identité/message explicites
-> create_candidate
-> GitRevision candidate
GitBranch
+ GitRemoteState attendu
+ GitRevision candidate
-> push_candidate
-> Updated ou Conflict
```
`alpha.2` définit ce contrat mais ne prétend pas encore prouver le confinement filesystem face aux symlinks, la neutralisation des hooks, la création effective d'un commit ou le compare-and-exchange distant. Ces preuves appartiennent au backend `knowledge-git-lib` des tranches suivantes.
## Gate opérateur
Exécuter depuis la racine du dépôt après application du delta :
```bash
test "$(cat VERSION)" = '0.0.3-alpha.2'
grep -F 'version = "0.0.3-alpha.2"' Cargo.toml
git diff --check
python3 -B scripts/audit_markdown_headers.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts deltas history
python3 -B scripts/audit_markdown_links.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts deltas history
python3 -B scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts deltas history
cargo fmt --all
cargo fmt --all -- --check
python3 -B scripts/audit_rust_test_layout.py crates
python3 -B scripts/audit_rust_blank_lines.py crates
python3 -B scripts/audit_rust_item_spacing.py crates
python3 -B scripts/audit_rust_module_order.py crates
python3 -B scripts/audit_rust_trait_import_blocks.py crates
python3 -B scripts/audit_rust_reexport_blocks.py crates
python3 -B scripts/audit_rust_crate_root_facades.py crates
python3 -B scripts/audit_rust_export_completeness.py --root . --summary-only
cargo metadata --format-version 1 --no-deps > /tmp/sasedev-knowledge-0.0.3-alpha.2-metadata.json
cargo check --workspace
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test -p knowledge-git-api --all-targets --all-features
cargo test --workspace --all-targets --all-features
python3 - <<'PYGATE'
from pathlib import Path
api = Path('crates/knowledge-git-api/src/lib.rs').read_text(encoding='utf-8')
write = Path('crates/knowledge-git-api/src/write.rs').read_text(encoding='utf-8')
read_contract = Path('crates/knowledge-git-api/src/repository.rs').read_text(encoding='utf-8')
manifest = Path('crates/knowledge-git-api/Cargo.toml').read_text(encoding='utf-8')
plan = Path('docs/plans/003-V0_0_3_GIT_WRITE_PLAN.md').read_text(encoding='utf-8')
changelog = Path('CHANGELOG.md').read_text(encoding='utf-8')
for item in (
'GitWriteInputError',
'GitMutationPath',
'GitFileMutation',
'GitCommitIdentity',
'GitBranch',
'GitRemoteState',
'GitWriteRequest',
'GitPushRequest',
'GitPushOutcome',
'GitRepositoryWriter',
):
assert item in api, item
assert item in write, item
assert 'fn refresh(' in read_contract
assert 'fn resolve_revision(' in read_contract
assert 'fn prepare_snapshot(' in read_contract
assert 'create_candidate' not in read_contract
assert 'push_candidate' not in read_contract
assert 'refs/heads/' in write
assert 'ProtectedGitPath' in write
assert 'DuplicateMutationPath' in write
assert 'GitRemoteState::Absent' not in write or 'GitRemoteState' in write
assert 'rebase' not in write.lower()
assert 'merge' not in write.lower()
for forbidden in ('actix', 'tokio', 'gitea', 'gitlab', 'github', 'postgres', 'sqlx', 'diesel'):
assert forbidden not in manifest.lower(), forbidden
assert '`alpha.1.fix.2` a complété la gate documentaire et technique en `PASS`' in plan
assert '### alpha.2 — contrat d\'écriture provider-neutral' in plan
assert '## 0.0.3' not in changelog
assert Path('history/0.0.3/alpha.1.fix.2.md').is_file()
assert not Path('history/0.0.3/alpha.2.md').exists()
assert not Path('crates/knowledge-forge-gitea-lib').exists()
assert not Path('crates/knowledge-store-postgres-lib').exists()
assert not Path('migrations').exists()
print('Knowledge 0.0.3-alpha.2 write-contract audit: clean')
PYGATE
python3 - <<'PYTEXT'
from pathlib import Path
roots = [Path('README.md'), Path('RULES.md'), Path('ROADMAP.md'), Path('CHANGELOG.md'), Path('docs'), Path('prompts'), Path('deltas'), Path('history')]
paths = []
for root in roots:
if root.is_file():
paths.append(root)
elif root.is_dir():
paths.extend(path for path in root.rglob('*') if path.is_file())
for path in paths:
data = path.read_bytes()
assert b'\r' not in data, path
assert data.endswith(b'\n'), path
assert not data.endswith(b'\n\n'), path
text = data.decode('utf-8')
for number, line in enumerate(text.splitlines(), start=1):
assert line == line.rstrip(' \t'), (path, number)
print(f'Knowledge 0.0.3-alpha.2 text hygiene: clean ({len(paths)} file(s))')
PYTEXT
test -z "$(git diff --name-only -- crates/knowledge-git-lib crates/knowledge-app-lib Web)"
test ! -e history/0.0.3/alpha.2.md
test -f Cargo.lock
git check-ignore -q Cargo.lock
if git ls-files --error-unmatch Cargo.lock >/dev/null 2>&1; then
echo 'Cargo.lock must remain untracked for Knowledge' >&2
exit 1
fi
test -f Web/knowledge-template-lab/package-lock.json
git add -A
git diff --cached --check
git status --short
```
La gate ne rejoue pas npm/Vite/HTTP : `Web/`, les launchers et leur contrat runtime ne sont pas modifiés.
## Transition
### PASS
Produire `history/0.0.3/alpha.2.md`, l'appliquer, commit/push `0.0.3-alpha.2`, puis préparer `0.0.3-alpha.3` pour le worktree d'écriture isolé et le commit candidat réel dans `knowledge-git-lib`.
### FAIL / PARTIAL / BLOCKED
Conserver le résultat réel dans son history avant toute correction. Une correction bornée utilise `alpha.2.fix.N` ; une modification de la mission ou de la frontière d'ownership exige un `REPLAN` explicite plutôt qu'un fix absorbant une nouvelle fonctionnalité.

View File

@@ -385,6 +385,10 @@ Après publication vérifiée de `v0.0.2`, la prochaine mission explicitement en
`0.0.3` borne la mise à jour distante à des branches `refs/heads/...` et exige un état distant attendu. Un push n'est accepté que lorsque cette attente reste vraie ; sinon le résultat est un conflit récupérable. Le backend ne résout pas ce conflit par rebase/merge automatique.
`alpha.2` matérialise cette frontière dans `knowledge-git-api` sans implémentation de backend : `GitMutationPath` confine lexicalement les mutations à des chemins relatifs normalisés hors `.git`, `GitFileMutation` représente écriture ou suppression, `GitCommitIdentity` impose une identité explicite, `GitBranch` borne la cible à une référence de branche complète valide, et `GitRemoteState` représente soit l'absence attendue soit une révision distante attendue. `GitWriteRequest`, `GitPushRequest` et `GitPushOutcome` portent respectivement la création de candidate, la comparaison-et-échange distante et son résultat. Le trait `GitRepositoryWriter` reste séparé de `GitRepository` afin qu'un consommateur ou fake lecture seule n'acquière aucune obligation d'écriture.
Le confinement filesystem contre les symlinks, la neutralisation des hooks, la création du commit et l'exécution du push gardé restent des responsabilités de `knowledge-git-lib` à partir de `alpha.3`; l'API passive ne prétend pas les prouver seule.
PostgreSQL, workflow complet de proposition, auth/admin, parsing final et adapter Gitea restent des missions distinctes.
## 10. Administration

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/003-V0_0_3_GIT_WRITE_PLAN.md -->
<!-- version: 3 -->
<!-- version: 4 -->
# Plan 0.0.3 — écriture Git contrôlée et push gardé
@@ -19,7 +19,7 @@ Knowledge start prompt prompts/003-V0_0_3_START_PROMPT.md
PGR `v0.1.3` reste une provenance historique de l'adoption fermée pendant `0.0.2`. Aucune version PGR plus récente n'est une source de cette release et aucune réévaluation PGR n'est engagée.
`0.0.3-alpha.1` a produit une gate technique propre jusqu'aux tests workspace mais reste `PARTIAL` : la fin de gate n'a pas été fournie et la revue opérateur a détecté un mauvais cycle de rédaction du changelog, du prompt suivant et de la roadmap. `0.0.3-alpha.1.fix.1` corrige uniquement ce workflow documentaire avant l'implémentation fonctionnelle.
`0.0.3-alpha.1` et `alpha.1.fix.1` sont conservées `PARTIAL` avec leurs preuves réelles. `alpha.1.fix.2` a complété la gate documentaire et technique en `PASS`, ce qui ferme le cadrage et autorise `alpha.2`. `alpha.2` matérialise maintenant uniquement le contrat d'écriture provider-neutral dans `knowledge-git-api`; le backend de worktree reste réservé à `alpha.3`.
## Mission

View File

@@ -1,5 +1,5 @@
<!-- file: docs/validation/001-VALIDATION_MATRIX.md -->
<!-- version: 33 -->
<!-- version: 34 -->
# Matrice de validation Knowledge
@@ -117,6 +117,24 @@ Les tests doivent permettre plusieurs `source_id` et ne pas confondre famille de
Un test live de remote ou de forge doit être explicitement classé comme externe, séparé des tests unitaires et ne doit pas devenir une condition cachée pour les opérations locales.
## Gate de `0.0.3-alpha.2`
La tranche de contrat d'écriture Git doit prouver :
- `VERSION = 0.0.3-alpha.2` et version workspace identique ;
- `knowledge-git-api` reste une crate passive sans dépendance de forge, runtime, filesystem backend ou PostgreSQL ;
- `GitRepository` de lecture reste inchangé et implémentable sans capacité d'écriture ;
- `GitRepositoryWriter` est une capacité distincte ;
- les chemins de mutation refusent valeur vide, chemin absolu/séparateur plateforme, `.` / `..`, composant vide et `.git` ;
- les mutations représentent écriture create/update et suppression, avec rejet des chemins dupliqués dans une même requête ;
- l'identité de commit et le message sont explicites et validés ;
- la branche cible est une référence complète valide sous `refs/heads/...` ;
- l'état distant attendu représente explicitement branche absente ou révision connue ;
- le résultat de push peut représenter succès ou conflit sans rebase/merge automatique ;
- `cargo fmt --all`, check, Clippy strict et tests ciblés `knowledge-git-api` passent ;
- les tests workspace passent car la façade publique de la crate change ;
- `knowledge-git-lib`, `knowledge-app-lib`, `Web/`, PostgreSQL et adapters forge ne sont pas modifiés par cette tranche.
## PostgreSQL
Aucune gate PostgreSQL n'est créée avant matérialisation d'une dépendance ou d'un schéma réel.

142
history/0.0.3/alpha.2.md Normal file
View File

@@ -0,0 +1,142 @@
<!-- file: history/0.0.3/alpha.2.md -->
<!-- version: 1 -->
# History 0.0.3-alpha.2 — contrat d'écriture Git provider-neutral
## Statut
PASS
La gate opérateur de `0.0.3-alpha.2` exécutée le `2026-09-29` est complète et propre. Elle valide la frontière publique d'écriture Git provider-neutral et autorise `0.0.3-alpha.3` pour le worktree d'écriture isolé et la création réelle du commit candidat.
## Baseline opérateur
`alpha.1.fix.2` `PASS` a été historisé, committé et poussé avant application de `alpha.2` :
```text
history/0.0.3/alpha.1.fix.2.md PASS
commit 3004e4b
message 0.0.3-alpha.1.fix.2
branch master
push origin/master PASS
workspace après push propre
```
Le push observé a avancé `origin/master` de `dd63e85` à `3004e4b`.
## Version et documentation
Les contrôles de version et Markdown passent :
```text
VERSION 0.0.3-alpha.2
Cargo workspace version 0.0.3-alpha.2
git diff --check PASS
Markdown header audit clean (106 file(s))
Markdown link audit clean (78 local link(s), 106 file(s))
Markdown table audit clean (4 table(s), 106 file(s))
```
## Formatage et audits Rust
La séquence locale imposée a été respectée :
```text
cargo fmt --all PASS
cargo fmt --all -- --check PASS
Rust test-layout audit clean (9 crate(s))
Rust blank-line audit clean (33 file(s))
Rust item-spacing audit clean (33 file(s))
Rust module-order audit clean (33 file(s))
Rust trait-import-block audit clean (33 file(s))
Rust re-export-block audit clean (33 file(s))
Rust crate-root facade audit clean (6 crate root(s))
Rust export completeness audit 0 candidate(s) across 9 package(s)
```
## Cargo, Clippy et tests
Les neuf packages compilent en `0.0.3-alpha.2` et Clippy ne produit aucun warning avec `-D warnings`.
```text
cargo metadata --format-version 1 --no-deps PASS
cargo check --workspace PASS
cargo clippy --workspace --all-targets --all-features
-- -D warnings PASS
cargo test -p knowledge-git-api --all-targets --all-features PASS (7)
cargo test --workspace --all-targets --all-features PASS
```
Tests workspace observés :
```text
knowledge-app-lib 3 PASS
knowledge-domain-lib 3 PASS
knowledge-git-api 7 PASS
knowledge-git-lib 8 unit PASS + 2 integration PASS
knowledge-sync-api 1 PASS
knowledge-web-lib 4 PASS
launchers 0 test chacun
```
Les sept tests `knowledge-git-api` valident notamment :
- la séparation du contrat d'écriture du contrat de lecture ;
- l'absence de dépendance à une forge ;
- la validation des chemins de mutation et la protection de `.git` ;
- l'identité de commit explicite ;
- les branches bornées à `refs/heads/...` ;
- les états distants absent/révision ;
- le rejet des requêtes sans mutation, messages invalides et chemins dupliqués.
## Audit spécifique d'écriture
L'audit de tranche passe :
```text
Knowledge 0.0.3-alpha.2 write-contract audit: clean
```
Il confirme notamment :
- la présence et le réexport des dix types/traits d'écriture attendus ;
- `GitRepository` de lecture inchangé et sans méthodes `create_candidate` / `push_candidate` ;
- la validation `refs/heads/...`, `ProtectedGitPath` et `DuplicateMutationPath` ;
- l'absence de dépendances Actix, Tokio, forge, PostgreSQL ou ORM dans `knowledge-git-api` ;
- `CHANGELOG.md` toujours sans section prématurée `0.0.3` ;
- absence de history `alpha.2` avant staging ;
- absence de crates Gitea/PostgreSQL et de migrations.
## Hygiène et scope
```text
Knowledge 0.0.3-alpha.2 text hygiene: clean (106 file(s))
```
La gate confirme également :
- aucun diff sous `crates/knowledge-git-lib`, `crates/knowledge-app-lib` ou `Web/` ;
- `Cargo.lock` local présent, ignoré et non suivi ;
- `Web/knowledge-template-lab/package-lock.json` présent ;
- `git diff --cached --check` propre.
Le staging final contient exactement les changements attendus de la tranche :
```text
M Cargo.toml
M README.md
M VERSION
M crates/knowledge-git-api/src/lib.rs
A crates/knowledge-git-api/src/write.rs
M crates/knowledge-git-api/unit_tests/lib.rs
A deltas/0.0.3/alpha.2.md
M docs/architecture/002-KNOWLEDGE_ARCHITECTURE.md
M docs/plans/003-V0_0_3_GIT_WRITE_PLAN.md
M docs/validation/001-VALIDATION_MATRIX.md
```
## Décision
`0.0.3-alpha.2` est `PASS`.
La frontière provider-neutral est acquise. La tranche suivante peut implémenter dans `knowledge-git-lib` le worktree d'écriture isolé, les mutations confinées, l'identité explicite, la neutralisation des hooks/configurations pertinentes et la création d'une révision candidate vérifiée. Le push distant gardé reste réservé à `alpha.4`.