mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
refactor: split Nip34WritePolicy into focused sub-policies
Split the ~900 line Nip34WritePolicy into focused sub-policies for improved testability and maintainability: - AnnouncementPolicy - Repository announcement validation - StatePolicy - State event validation + ref alignment - PrEventPolicy - PR/PR Update validation - RelatedEventPolicy - Forward/backward reference checking The main Nip34WritePolicy now delegates to these sub-policies via a shared PolicyContext that provides domain, database, and git_data_path. Also updates: - README.md: Accurate project structure reflecting actual implementation - docs/learnings: Marks this technical debt item as complete
This commit is contained in:
@@ -326,23 +326,35 @@ nix develop -c cargo tarpaulin --out Html
|
||||
ngit-grasp/
|
||||
├── src/
|
||||
│ ├── main.rs # Entry point, server setup
|
||||
│ ├── lib.rs # Library exports
|
||||
│ ├── config.rs # Configuration
|
||||
│ ├── git/
|
||||
│ │ ├── mod.rs # Git module
|
||||
│ │ ├── handler.rs # Git HTTP handlers
|
||||
│ │ └── authorization.rs # Push validation logic
|
||||
│ │ ├── mod.rs # Git module + repository operations
|
||||
│ │ ├── handlers.rs # Git HTTP handlers
|
||||
│ │ ├── authorization.rs # Push validation logic
|
||||
│ │ ├── protocol.rs # Git protocol encoding
|
||||
│ │ └── subprocess.rs # Git subprocess management
|
||||
│ ├── nostr/
|
||||
│ │ ├── mod.rs # Nostr module
|
||||
│ │ ├── relay.rs # Relay setup and policies
|
||||
│ │ └── events.rs # Event handlers
|
||||
│ ├── storage/
|
||||
│ │ ├── mod.rs # Storage abstraction
|
||||
│ │ └── repository.rs # Repository management
|
||||
│ └── config.rs # Configuration
|
||||
├── docs/
|
||||
│ └── ARCHITECTURE.md # Detailed architecture
|
||||
├── tests/
|
||||
│ ├── integration/ # Integration tests
|
||||
│ └── fixtures/ # Test data
|
||||
│ │ ├── builder.rs # Relay builder + Nip34WritePolicy
|
||||
│ │ ├── events.rs # Event parsing and validation
|
||||
│ │ └── policy/ # Sub-policies (split for maintainability)
|
||||
│ │ ├── mod.rs # Policy module exports
|
||||
│ │ ├── announcement.rs # Repository announcement validation
|
||||
│ │ ├── state.rs # State event validation + ref alignment
|
||||
│ │ ├── pr_event.rs # PR/PR Update validation
|
||||
│ │ └── related.rs # Forward/backward reference checking
|
||||
│ ├── http/
|
||||
│ │ ├── mod.rs # HTTP module
|
||||
│ │ ├── landing.rs # Landing page handler
|
||||
│ │ └── nip11.rs # NIP-11 relay info document
|
||||
│ └── metrics/
|
||||
│ ├── mod.rs # Prometheus metrics
|
||||
│ ├── bandwidth.rs # Bandwidth tracking
|
||||
│ └── connection.rs # Connection tracking
|
||||
├── docs/ # Documentation (Diátaxis framework)
|
||||
├── tests/ # Integration tests
|
||||
├── grasp-audit/ # Compliance audit subproject
|
||||
└── README.md
|
||||
```
|
||||
|
||||
|
||||
@@ -156,17 +156,17 @@ This enables parallel CI runs without interference.
|
||||
|
||||
**Better approach:** Treat architecture docs as living documents. When implementation diverges from the plan, update the doc immediately. The initial design document was valuable and should remain, but it should reflect what was built.
|
||||
|
||||
### 2. Smaller Nip34WritePolicy
|
||||
### 2. ~~Smaller Nip34WritePolicy~~ ✅ DONE
|
||||
|
||||
**What happened:** The [`Nip34WritePolicy`](src/nostr/builder.rs:51) grew to ~900 lines handling all event types.
|
||||
**What happened:** The `Nip34WritePolicy` grew to ~900 lines handling all event types.
|
||||
|
||||
**Better approach:** Split into:
|
||||
- `AnnouncementPolicy` - Repository announcement validation
|
||||
- `StatePolicy` - State event validation + ref alignment
|
||||
- `RelatedEventPolicy` - Forward/backward reference checking
|
||||
- `PrEventPolicy` - PR/PR Update validation
|
||||
**Resolution:** Split into focused sub-policies in [`src/nostr/policy/`](src/nostr/policy/mod.rs:1):
|
||||
- [`AnnouncementPolicy`](src/nostr/policy/announcement.rs:1) - Repository announcement validation
|
||||
- [`StatePolicy`](src/nostr/policy/state.rs:1) - State event validation + ref alignment
|
||||
- [`RelatedEventPolicy`](src/nostr/policy/related.rs:1) - Forward/backward reference checking
|
||||
- [`PrEventPolicy`](src/nostr/policy/pr_event.rs:1) - PR/PR Update validation
|
||||
|
||||
This would improve testability and readability.
|
||||
The main [`Nip34WritePolicy`](src/nostr/builder.rs:51) now delegates to these sub-policies, improving testability and readability.
|
||||
|
||||
### 3. Git Operations Module Organization
|
||||
|
||||
@@ -190,7 +190,7 @@ This would improve testability and readability.
|
||||
|
||||
### High Priority
|
||||
|
||||
1. **Split `Nip34WritePolicy`** - Too large, hard to test/maintain
|
||||
1. ~~**Split `Nip34WritePolicy`**~~ ✅ DONE - Split into sub-policies in [`src/nostr/policy/`](src/nostr/policy/mod.rs:1)
|
||||
2. **Add unit tests for policy logic** - Currently relies on integration tests
|
||||
3. **Document actual architecture** - Docs describe plans, not implementation
|
||||
|
||||
|
||||
+179
-1183
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,6 @@
|
||||
pub mod builder;
|
||||
pub mod events;
|
||||
pub mod policy;
|
||||
|
||||
/// Re-export SharedDatabase for use by policy modules
|
||||
pub use builder::SharedDatabase;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/// Announcement Policy - Repository announcement validation
|
||||
///
|
||||
/// Handles validation of NIP-34 repository announcements (kind 30617)
|
||||
/// according to GRASP-01 specification.
|
||||
use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag};
|
||||
|
||||
use super::PolicyContext;
|
||||
use crate::nostr::events::{
|
||||
validate_announcement, RepositoryAnnouncement, KIND_REPOSITORY_ANNOUNCEMENT,
|
||||
};
|
||||
|
||||
/// Result of announcement policy evaluation
|
||||
#[derive(Debug)]
|
||||
pub enum AnnouncementResult {
|
||||
/// Accept: Event passes validation
|
||||
Accept,
|
||||
/// Accept as maintainer: Event accepted via maintainer exception
|
||||
AcceptMaintainer,
|
||||
/// Reject: Event fails validation with reason
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
/// Policy for validating repository announcements
|
||||
#[derive(Clone)]
|
||||
pub struct AnnouncementPolicy {
|
||||
ctx: PolicyContext,
|
||||
}
|
||||
|
||||
impl AnnouncementPolicy {
|
||||
pub fn new(ctx: PolicyContext) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
|
||||
/// Validate a repository announcement event
|
||||
///
|
||||
/// Returns `Accept` if the announcement lists the service properly,
|
||||
/// `AcceptMaintainer` if accepted via maintainer exception,
|
||||
/// or `Reject` with reason.
|
||||
pub async fn validate(&self, event: &Event) -> AnnouncementResult {
|
||||
// First, try normal validation (announcement lists service)
|
||||
match validate_announcement(event, &self.ctx.domain) {
|
||||
Ok(_) => AnnouncementResult::Accept,
|
||||
Err(validation_err) => {
|
||||
// Validation failed - check if this is a recursive maintainer announcement
|
||||
// GRASP-01 Exception: Accept announcements from recursive maintainers
|
||||
// even without listing the service, for chain discovery and GRASP-02 sync
|
||||
|
||||
// Try to parse the announcement to get identifier
|
||||
match RepositoryAnnouncement::from_event(event.clone()) {
|
||||
Ok(announcement) => {
|
||||
// Check if author is listed as maintainer in any existing announcement
|
||||
match self
|
||||
.is_maintainer_in_any_announcement(
|
||||
&announcement.identifier,
|
||||
&event.pubkey,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(true) => AnnouncementResult::AcceptMaintainer,
|
||||
Ok(false) => AnnouncementResult::Reject(validation_err.to_string()),
|
||||
Err(_) => {
|
||||
// Fail-secure: reject on database errors
|
||||
AnnouncementResult::Reject(validation_err.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => AnnouncementResult::Reject(validation_err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a bare git repository if it doesn't exist
|
||||
/// Path format: <git_data_path>/<npub>/<identifier>.git
|
||||
pub fn ensure_bare_repository(&self, announcement: &RepositoryAnnouncement) -> Result<(), String> {
|
||||
let repo_path = self.ctx.git_data_path.join(announcement.repo_path());
|
||||
|
||||
// Check if repository already exists
|
||||
if repo_path.exists() {
|
||||
tracing::debug!("Repository already exists at {}", repo_path.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Create parent directory (npub directory)
|
||||
let parent = repo_path
|
||||
.parent()
|
||||
.ok_or_else(|| format!("Invalid repository path: {}", repo_path.display()))?;
|
||||
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
|
||||
|
||||
// Initialize bare repository using git command
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["init", "--bare", repo_path.to_str().unwrap()])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute git init: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("git init failed: {}", stderr));
|
||||
}
|
||||
|
||||
tracing::info!("Created bare repository at {}", repo_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a pubkey is listed as a maintainer in any announcement for this identifier
|
||||
///
|
||||
/// A pubkey is considered a maintainer if:
|
||||
/// 1. They are the owner (pubkey) of an accepted announcement with this identifier, OR
|
||||
/// 2. They are listed in the maintainers tag of ANY announcement with this identifier
|
||||
///
|
||||
/// This enables accepting announcements from maintainers even when they don't list
|
||||
/// this GRASP server, for maintainer chain discovery and GRASP-02 sync.
|
||||
async fn is_maintainer_in_any_announcement(
|
||||
&self,
|
||||
identifier: &str,
|
||||
author: &PublicKey,
|
||||
) -> Result<bool, String> {
|
||||
// Query all announcements with this identifier that are already in the database
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT))
|
||||
.custom_tag(
|
||||
SingleLetterTag::lowercase(Alphabet::D),
|
||||
identifier.to_string(),
|
||||
);
|
||||
|
||||
let announcements: Vec<Event> = match self.ctx.database.query(filter).await {
|
||||
Ok(events) => events.into_iter().collect(),
|
||||
Err(e) => return Err(format!("Database query failed: {}", e)),
|
||||
};
|
||||
|
||||
if announcements.is_empty() {
|
||||
// No existing announcements for this identifier - author cannot be a maintainer
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let author_hex = author.to_hex();
|
||||
|
||||
// Check each announcement to see if author is listed as a maintainer
|
||||
for event in &announcements {
|
||||
// Check if author is the owner of this announcement
|
||||
if event.pubkey == *author {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// Check if author is listed in the maintainers tag
|
||||
if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) {
|
||||
if announcement.maintainers.contains(&author_hex) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/// Policy module for NIP-34 write policies
|
||||
///
|
||||
/// This module splits the large Nip34WritePolicy into focused sub-policies:
|
||||
/// - `AnnouncementPolicy` - Repository announcement validation
|
||||
/// - `StatePolicy` - State event validation + ref alignment
|
||||
/// - `PrEventPolicy` - PR/PR Update validation
|
||||
/// - `RelatedEventPolicy` - Forward/backward reference checking
|
||||
|
||||
mod announcement;
|
||||
mod pr_event;
|
||||
mod related;
|
||||
mod state;
|
||||
|
||||
pub use announcement::{AnnouncementPolicy, AnnouncementResult};
|
||||
pub use pr_event::PrEventPolicy;
|
||||
pub use related::{ReferenceResult, RelatedEventPolicy};
|
||||
pub use state::{AlignmentResult, StatePolicy, StateResult};
|
||||
|
||||
use super::SharedDatabase;
|
||||
|
||||
/// Shared context for all sub-policies
|
||||
#[derive(Clone)]
|
||||
pub struct PolicyContext {
|
||||
pub domain: String,
|
||||
pub database: SharedDatabase,
|
||||
pub git_data_path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl PolicyContext {
|
||||
pub fn new(
|
||||
domain: impl Into<String>,
|
||||
database: SharedDatabase,
|
||||
git_data_path: impl Into<std::path::PathBuf>,
|
||||
) -> Self {
|
||||
Self {
|
||||
domain: domain.into(),
|
||||
database,
|
||||
git_data_path: git_data_path.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/// PR Event Policy - PR/PR Update validation
|
||||
///
|
||||
/// Handles validation of NIP-34 PR events (kind 1618) and PR Update events (kind 1619)
|
||||
/// according to GRASP-01 specification.
|
||||
use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag};
|
||||
|
||||
use super::PolicyContext;
|
||||
use crate::git;
|
||||
use crate::nostr::events::{RepositoryAnnouncement, KIND_REPOSITORY_ANNOUNCEMENT};
|
||||
|
||||
/// Policy for validating PR and PR Update events
|
||||
#[derive(Clone)]
|
||||
pub struct PrEventPolicy {
|
||||
ctx: PolicyContext,
|
||||
}
|
||||
|
||||
impl PrEventPolicy {
|
||||
pub fn new(ctx: PolicyContext) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
|
||||
/// Validate refs/nostr/<event-id> ref against a PR or PR Update event's `c` tag
|
||||
///
|
||||
/// When a PR event (kind 1618) or PR Update event (kind 1619) is received,
|
||||
/// this checks if a corresponding refs/nostr/<event-id> ref exists in the
|
||||
/// repository and validates that it points to the correct commit (from the
|
||||
/// `c` tag). If the ref exists but points to a different commit, the ref is
|
||||
/// deleted.
|
||||
///
|
||||
/// PR and PR Update events can have multiple `a` tags to update multiple
|
||||
/// repositories simultaneously.
|
||||
///
|
||||
/// This is part of GRASP-01 compliance: ensuring refs/nostr refs are consistent
|
||||
/// with their corresponding events.
|
||||
///
|
||||
/// # Returns
|
||||
/// Ok(Some(n)) if n refs were deleted, Ok(None) if no action taken, Err on failure
|
||||
pub async fn validate_nostr_ref(&self, event: &Event) -> Result<Option<usize>, String> {
|
||||
let event_id = event.id.to_hex();
|
||||
|
||||
// Extract the `c` tag (commit hash) from the PR event
|
||||
let expected_commit = event.tags.iter().find_map(|tag| {
|
||||
let tag_vec = tag.clone().to_vec();
|
||||
if tag_vec.len() >= 2 && tag_vec[0] == "c" {
|
||||
Some(tag_vec[1].clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let expected_commit = match expected_commit {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
tracing::debug!(
|
||||
"PR event {} has no 'c' tag, skipping ref validation",
|
||||
event_id
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
// Extract ALL `a` tags (repository references) from the PR event
|
||||
// PR events can reference multiple repositories
|
||||
// Format: 30617:<pubkey>:<identifier>
|
||||
let repo_refs: Vec<String> = event
|
||||
.tags
|
||||
.iter()
|
||||
.filter_map(|tag| {
|
||||
let tag_vec = tag.clone().to_vec();
|
||||
if tag_vec.len() >= 2 && tag_vec[0] == "a" && tag_vec[1].starts_with("30617:") {
|
||||
Some(tag_vec[1].clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if repo_refs.is_empty() {
|
||||
tracing::debug!(
|
||||
"PR event {} has no repo 'a' tags, skipping ref validation",
|
||||
event_id
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut deleted_count = 0;
|
||||
|
||||
// Process each repository reference
|
||||
for repo_ref in repo_refs {
|
||||
// Parse the repo reference: 30617:<pubkey>:<identifier>
|
||||
let parts: Vec<&str> = repo_ref.split(':').collect();
|
||||
if parts.len() < 3 {
|
||||
tracing::debug!(
|
||||
"PR event {} has invalid 'a' tag format: {}",
|
||||
event_id,
|
||||
repo_ref
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let repo_pubkey = match PublicKey::from_hex(parts[1]) {
|
||||
Ok(pk) => pk,
|
||||
Err(_) => {
|
||||
tracing::debug!(
|
||||
"PR event {} has invalid pubkey in 'a' tag: {}",
|
||||
event_id,
|
||||
parts[1]
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let identifier = parts[2];
|
||||
|
||||
// Look up repository announcement to get the npub for path
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT))
|
||||
.author(repo_pubkey)
|
||||
.custom_tag(
|
||||
SingleLetterTag::lowercase(Alphabet::D),
|
||||
identifier.to_string(),
|
||||
);
|
||||
|
||||
let announcements: Vec<Event> = match self.ctx.database.query(filter).await {
|
||||
Ok(events) => events.into_iter().collect(),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to query for repository announcement for PR {}: {}",
|
||||
event_id,
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if announcements.is_empty() {
|
||||
tracing::debug!(
|
||||
"No repository announcement found for PR event {} (repo {}:{})",
|
||||
event_id,
|
||||
repo_pubkey.to_hex(),
|
||||
identifier
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process each matching announcement (there could be multiple)
|
||||
for announcement_event in announcements {
|
||||
let announcement = match RepositoryAnnouncement::from_event(announcement_event) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to parse announcement for PR {} validation: {}",
|
||||
event_id,
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Build repository path
|
||||
let repo_path = self.ctx.git_data_path.join(announcement.repo_path());
|
||||
|
||||
// Validate the ref
|
||||
match git::validate_nostr_ref(&repo_path, &event_id, &expected_commit) {
|
||||
Ok(true) => {
|
||||
tracing::info!(
|
||||
"Deleted mismatched refs/nostr/{} in {} (expected commit {})",
|
||||
event_id,
|
||||
repo_path.display(),
|
||||
expected_commit
|
||||
);
|
||||
deleted_count += 1;
|
||||
}
|
||||
Ok(false) => {
|
||||
tracing::debug!(
|
||||
"refs/nostr/{} in {} is valid or doesn't exist",
|
||||
event_id,
|
||||
repo_path.display()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to validate refs/nostr/{} in {}: {}",
|
||||
event_id,
|
||||
repo_path.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if deleted_count > 0 {
|
||||
Ok(Some(deleted_count))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
/// Related Event Policy - Forward/backward reference checking
|
||||
///
|
||||
/// Handles validation of events that reference accepted repositories or events
|
||||
/// (backward references) and events that are referenced by accepted events
|
||||
/// (forward references).
|
||||
use nostr_relay_builder::prelude::{
|
||||
Alphabet, Event, EventId, Filter, Kind, PublicKey, SingleLetterTag,
|
||||
};
|
||||
|
||||
use super::PolicyContext;
|
||||
|
||||
/// Result of reference checking
|
||||
#[derive(Debug)]
|
||||
pub enum ReferenceResult {
|
||||
/// Event references an accepted repository (addressable ref found)
|
||||
ReferencesRepository(String),
|
||||
/// Event references an accepted event (event ID found)
|
||||
ReferencesEvent(EventId),
|
||||
/// Event is referenced by an accepted event (forward reference)
|
||||
ReferencedByAccepted,
|
||||
/// No valid references found - event is an orphan
|
||||
Orphan,
|
||||
}
|
||||
|
||||
/// Policy for checking event references (backward and forward)
|
||||
#[derive(Clone)]
|
||||
pub struct RelatedEventPolicy {
|
||||
ctx: PolicyContext,
|
||||
}
|
||||
|
||||
impl RelatedEventPolicy {
|
||||
pub fn new(ctx: PolicyContext) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
|
||||
/// Check all reference types for an event
|
||||
///
|
||||
/// Returns the first valid reference found, or `Orphan` if none found.
|
||||
pub async fn check_references(&self, event: &Event) -> Result<ReferenceResult, String> {
|
||||
// Extract all reference tags from event
|
||||
let (addressable_refs, event_refs) = Self::extract_reference_tags(event);
|
||||
|
||||
// Check 1: Does this event reference an accepted repository?
|
||||
if let Some(addr_ref) = self.find_accepted_repository(&addressable_refs).await? {
|
||||
return Ok(ReferenceResult::ReferencesRepository(addr_ref));
|
||||
}
|
||||
|
||||
// Check 2: Does this event reference an accepted event?
|
||||
if let Some(event_ref) = self.find_accepted_event(&event_refs).await? {
|
||||
return Ok(ReferenceResult::ReferencesEvent(event_ref));
|
||||
}
|
||||
|
||||
// Check 3: Is this event referenced by an accepted event?
|
||||
if self.is_referenced_by_accepted(event).await? {
|
||||
return Ok(ReferenceResult::ReferencedByAccepted);
|
||||
}
|
||||
|
||||
// No valid references found
|
||||
Ok(ReferenceResult::Orphan)
|
||||
}
|
||||
|
||||
/// Extract all reference tags from an event (a, A, q, e, E)
|
||||
/// Returns (addressable_refs, event_refs)
|
||||
pub fn extract_reference_tags(event: &Event) -> (Vec<String>, Vec<EventId>) {
|
||||
let mut addressable_refs = Vec::new();
|
||||
let mut event_refs = Vec::new();
|
||||
|
||||
for tag in event.tags.iter() {
|
||||
let tag_vec = tag.clone().to_vec();
|
||||
if tag_vec.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match tag_vec[0].as_str() {
|
||||
// Addressable event references (a, A, q with kind:pubkey:identifier format)
|
||||
"a" | "A" | "q" if tag_vec.len() > 1 && tag_vec[1].contains(':') => {
|
||||
addressable_refs.push(tag_vec[1].clone());
|
||||
}
|
||||
// Event ID references (e, E, q with event ID format)
|
||||
"e" | "E" if tag_vec.len() > 1 => {
|
||||
if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) {
|
||||
event_refs.push(event_id);
|
||||
}
|
||||
}
|
||||
"q" if tag_vec.len() > 1 && !tag_vec[1].contains(':') => {
|
||||
if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) {
|
||||
event_refs.push(event_id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
(addressable_refs, event_refs)
|
||||
}
|
||||
|
||||
/// Check if any addressable events (repositories) exist in database
|
||||
/// Returns the first matching addressable reference found, or None if none match
|
||||
async fn find_accepted_repository(
|
||||
&self,
|
||||
addressables: &[String],
|
||||
) -> Result<Option<String>, String> {
|
||||
if addressables.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Parse all addressable references
|
||||
let mut parsed_refs = Vec::new();
|
||||
for addr in addressables {
|
||||
let parts: Vec<&str> = addr.split(':').collect();
|
||||
if parts.len() < 3 {
|
||||
continue; // Skip invalid format
|
||||
}
|
||||
|
||||
let kind = match parts[0].parse::<u16>() {
|
||||
Ok(k) => k,
|
||||
Err(_) => continue, // Skip invalid kind
|
||||
};
|
||||
let pubkey = match PublicKey::from_hex(parts[1]) {
|
||||
Ok(pk) => pk,
|
||||
Err(_) => continue, // Skip invalid pubkey
|
||||
};
|
||||
let identifier = parts[2].to_string();
|
||||
|
||||
parsed_refs.push((addr.clone(), kind, pubkey, identifier));
|
||||
}
|
||||
|
||||
if parsed_refs.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Group by kind to reduce queries
|
||||
use std::collections::HashMap;
|
||||
let mut by_kind: HashMap<u16, Vec<_>> = HashMap::new();
|
||||
for (addr, kind, pubkey, identifier) in parsed_refs {
|
||||
by_kind
|
||||
.entry(kind)
|
||||
.or_default()
|
||||
.push((addr, pubkey, identifier));
|
||||
}
|
||||
|
||||
// Query each kind group
|
||||
for (kind, refs) in by_kind {
|
||||
let authors: Vec<PublicKey> = refs.iter().map(|(_, pk, _)| *pk).collect();
|
||||
|
||||
let filter = Filter::new().kind(Kind::from(kind)).authors(authors);
|
||||
|
||||
match self.ctx.database.query(filter).await {
|
||||
Ok(events) => {
|
||||
// Check if any event matches our identifier requirements
|
||||
for event in events {
|
||||
for (addr, _pubkey, identifier) in &refs {
|
||||
// Match identifier tag
|
||||
if event.tags.iter().any(|tag| {
|
||||
let tag_vec = tag.clone().to_vec();
|
||||
tag_vec.len() >= 2 && tag_vec[0] == "d" && tag_vec[1] == *identifier
|
||||
}) {
|
||||
return Ok(Some(addr.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("Database query failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Check if any events exist in database
|
||||
/// Returns the first matching event ID found, or None if none match
|
||||
async fn find_accepted_event(
|
||||
&self,
|
||||
event_ids: &[EventId],
|
||||
) -> Result<Option<EventId>, String> {
|
||||
if event_ids.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Single query for all event IDs
|
||||
let filter = Filter::new().ids(event_ids.iter().copied());
|
||||
|
||||
match self.ctx.database.query(filter).await {
|
||||
Ok(events) => {
|
||||
// Get first event from the iterator
|
||||
Ok(events.into_iter().next().map(|e| e.id))
|
||||
}
|
||||
Err(e) => Err(format!("Database query failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if any accepted event references this event (forward reference)
|
||||
///
|
||||
/// For regular replaceable events (10000-19999): Checks addressable tags with kind:pubkey format
|
||||
/// For parameterized replaceable (30000-39999): Checks addressable tags with kind:pubkey:d-identifier format
|
||||
/// For regular events: Only checks event ID reference tags (e, E, q)
|
||||
async fn is_referenced_by_accepted(&self, event: &Event) -> Result<bool, String> {
|
||||
let kind_u16 = event.kind.as_u16();
|
||||
|
||||
// Check if this is any kind of replaceable event
|
||||
let is_regular_replaceable = (10000..20000).contains(&kind_u16);
|
||||
let is_parameterized_replaceable = (30000..40000).contains(&kind_u16);
|
||||
|
||||
if is_regular_replaceable || is_parameterized_replaceable {
|
||||
// Build the appropriate address format based on event type
|
||||
let address = if is_parameterized_replaceable {
|
||||
// For parameterized replaceable: kind:pubkey:d-identifier format (2 colons)
|
||||
let identifier = event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| {
|
||||
let tag_vec = tag.clone().to_vec();
|
||||
if tag_vec.len() >= 2 && tag_vec[0] == "d" {
|
||||
Some(tag_vec[1].clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_default(); // Empty string if no 'd' tag
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
event.kind.as_u16(),
|
||||
event.pubkey.to_hex(),
|
||||
identifier
|
||||
)
|
||||
} else {
|
||||
// For regular replaceable: kind:pubkey format (1 colon)
|
||||
format!("{}:{}", event.kind.as_u16(), event.pubkey.to_hex())
|
||||
};
|
||||
|
||||
// Check addressable reference tags: a, A, q (with address format)
|
||||
let addressable_tags = [
|
||||
SingleLetterTag::lowercase(Alphabet::A), // 'a' - addressable event reference
|
||||
SingleLetterTag::uppercase(Alphabet::A), // 'A' - uppercase addressable reference
|
||||
SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote (can be address or ID)
|
||||
];
|
||||
|
||||
for tag_type in &addressable_tags {
|
||||
let filter = Filter::new().custom_tag(*tag_type, address.clone());
|
||||
|
||||
match self.ctx.database.query(filter).await {
|
||||
Ok(events) => {
|
||||
if !events.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("Database query failed: {}", e)),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For regular events, check event ID reference tags: e, E, q (with hex ID)
|
||||
let event_id_hex = event.id.to_hex();
|
||||
|
||||
let event_id_tags = [
|
||||
SingleLetterTag::lowercase(Alphabet::E), // 'e' - standard event reference
|
||||
SingleLetterTag::uppercase(Alphabet::E), // 'E' - NIP-22 root event reference
|
||||
SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote reference
|
||||
];
|
||||
|
||||
for tag_type in &event_id_tags {
|
||||
let filter = Filter::new().custom_tag(*tag_type, event_id_hex.clone());
|
||||
|
||||
match self.ctx.database.query(filter).await {
|
||||
Ok(events) => {
|
||||
if !events.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("Database query failed: {}", e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
/// State Policy - State event validation + ref alignment
|
||||
///
|
||||
/// Handles validation of NIP-34 repository state events (kind 30618)
|
||||
/// and aligns git refs with authorized state according to GRASP-01.
|
||||
use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag};
|
||||
|
||||
use super::PolicyContext;
|
||||
use crate::git;
|
||||
use crate::nostr::events::{
|
||||
validate_state, RepositoryAnnouncement, RepositoryState, KIND_REPOSITORY_ANNOUNCEMENT,
|
||||
KIND_REPOSITORY_STATE,
|
||||
};
|
||||
|
||||
/// Result of aligning a repository with authorized state
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AlignmentResult {
|
||||
/// Number of refs created
|
||||
pub refs_created: usize,
|
||||
/// Number of refs updated
|
||||
pub refs_updated: usize,
|
||||
/// Number of refs deleted
|
||||
pub refs_deleted: usize,
|
||||
/// Whether HEAD was set
|
||||
pub head_set: bool,
|
||||
}
|
||||
|
||||
impl AlignmentResult {
|
||||
pub fn has_changes(&self) -> bool {
|
||||
self.refs_created > 0 || self.refs_updated > 0 || self.refs_deleted > 0 || self.head_set
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of state policy evaluation
|
||||
#[derive(Debug)]
|
||||
pub enum StateResult {
|
||||
/// Accept: Event passes validation
|
||||
Accept,
|
||||
/// Reject: Event fails validation with reason
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
/// Policy for validating repository state events and aligning refs
|
||||
#[derive(Clone)]
|
||||
pub struct StatePolicy {
|
||||
ctx: PolicyContext,
|
||||
}
|
||||
|
||||
impl StatePolicy {
|
||||
pub fn new(ctx: PolicyContext) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
|
||||
/// Validate a repository state event
|
||||
pub fn validate(&self, event: &Event) -> StateResult {
|
||||
match validate_state(event) {
|
||||
Ok(_) => StateResult::Accept,
|
||||
Err(e) => StateResult::Reject(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a state event: validate and align owner repositories
|
||||
///
|
||||
/// Returns the number of repositories aligned if successful.
|
||||
pub async fn process_state_event(&self, event: &Event) -> Result<usize, String> {
|
||||
// Parse state to get HEAD and branch info
|
||||
let state = RepositoryState::from_event(event.clone())
|
||||
.map_err(|e| format!("Failed to parse state: {}", e))?;
|
||||
|
||||
// Identify owner repositories for which this is the latest authorized state
|
||||
let owner_repos = self.identify_owner_repositories(&state).await?;
|
||||
let repo_count = owner_repos.len();
|
||||
let mut total_aligned = 0;
|
||||
|
||||
// Align each owner repository with the authorized state
|
||||
for (_announcement, repo_path) in owner_repos {
|
||||
let result = self.align_repository_with_state(&repo_path, &state);
|
||||
|
||||
if result.has_changes() {
|
||||
tracing::info!(
|
||||
"Aligned {} with state: created={}, updated={}, deleted={}, head_set={}",
|
||||
repo_path.display(),
|
||||
result.refs_created,
|
||||
result.refs_updated,
|
||||
result.refs_deleted,
|
||||
result.head_set
|
||||
);
|
||||
total_aligned += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if repo_count > 0 {
|
||||
tracing::info!(
|
||||
"Processed state event for {} repo(s) ({} aligned) with identifier {}",
|
||||
repo_count,
|
||||
total_aligned,
|
||||
state.identifier
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"No owner repos to align for state - git data not available yet or not latest"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(total_aligned)
|
||||
}
|
||||
|
||||
/// Check if this state event is the latest for its identifier among authorized authors
|
||||
///
|
||||
/// A state is considered "latest" if no other state event in the database
|
||||
/// from an authorized author has a newer timestamp.
|
||||
async fn is_latest_state_for_identifier(
|
||||
&self,
|
||||
state: &RepositoryState,
|
||||
authorized_pubkeys: &[PublicKey],
|
||||
) -> Result<bool, String> {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::from(KIND_REPOSITORY_STATE))
|
||||
.custom_tag(
|
||||
SingleLetterTag::lowercase(Alphabet::D),
|
||||
state.identifier.clone(),
|
||||
);
|
||||
|
||||
match self.ctx.database.query(filter).await {
|
||||
Ok(events) => {
|
||||
for event in events {
|
||||
// Skip comparing to self (same event ID)
|
||||
if event.id == state.event.id {
|
||||
continue;
|
||||
}
|
||||
// Only consider events from authorized authors for this announcement
|
||||
if !authorized_pubkeys.contains(&event.pubkey) {
|
||||
continue;
|
||||
}
|
||||
// If any existing event from an authorized author is newer, this is not the latest
|
||||
if event.created_at > state.event.created_at {
|
||||
tracing::debug!(
|
||||
"State {} is not latest: found newer state {} from {} (ts {} > {})",
|
||||
state.event.id.to_hex(),
|
||||
event.id.to_hex(),
|
||||
event.pubkey.to_hex(),
|
||||
event.created_at.as_secs(),
|
||||
state.event.created_at.as_secs()
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
Err(e) => Err(format!("Database query failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find all repository announcements where the given pubkey is authorized
|
||||
async fn find_authorized_announcements(
|
||||
&self,
|
||||
identifier: &str,
|
||||
state_author: &PublicKey,
|
||||
) -> Result<Vec<RepositoryAnnouncement>, String> {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT))
|
||||
.custom_tag(
|
||||
SingleLetterTag::lowercase(Alphabet::D),
|
||||
identifier.to_string(),
|
||||
);
|
||||
|
||||
match self.ctx.database.query(filter).await {
|
||||
Ok(events) => {
|
||||
let mut authorized = Vec::new();
|
||||
let state_author_hex = state_author.to_hex();
|
||||
|
||||
for event in events {
|
||||
if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) {
|
||||
// Check if state author is authorized for this announcement
|
||||
let is_owner = event.pubkey == *state_author;
|
||||
let is_maintainer = announcement.maintainers.contains(&state_author_hex);
|
||||
|
||||
if is_owner || is_maintainer {
|
||||
tracing::debug!(
|
||||
"Found authorized announcement for {}: owner={}, maintainer={}",
|
||||
identifier,
|
||||
if is_owner {
|
||||
event.pubkey.to_hex()
|
||||
} else {
|
||||
"n/a".to_string()
|
||||
},
|
||||
is_maintainer
|
||||
);
|
||||
authorized.push(announcement);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(authorized)
|
||||
}
|
||||
Err(e) => Err(format!("Database query failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Identify all owner repositories for which this state event is the latest authorized state
|
||||
async fn identify_owner_repositories(
|
||||
&self,
|
||||
state: &RepositoryState,
|
||||
) -> Result<Vec<(RepositoryAnnouncement, std::path::PathBuf)>, String> {
|
||||
// Find all announcements where state author is authorized
|
||||
let announcements = self
|
||||
.find_authorized_announcements(&state.identifier, &state.event.pubkey)
|
||||
.await?;
|
||||
|
||||
if announcements.is_empty() {
|
||||
tracing::debug!(
|
||||
"No authorized announcements found for state {} by {}",
|
||||
state.identifier,
|
||||
state.event.pubkey.to_hex()
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut owner_repos = Vec::new();
|
||||
|
||||
for announcement in announcements {
|
||||
// Build the list of authorized pubkeys for this specific announcement
|
||||
let mut authorized_pubkeys = vec![announcement.event.pubkey];
|
||||
for maintainer_hex in &announcement.maintainers {
|
||||
if let Ok(pk) = PublicKey::from_hex(maintainer_hex) {
|
||||
authorized_pubkeys.push(pk);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is the latest state event for THIS announcement's context
|
||||
if !self
|
||||
.is_latest_state_for_identifier(state, &authorized_pubkeys)
|
||||
.await?
|
||||
{
|
||||
tracing::debug!(
|
||||
"Skipping {} in {}'s repo - not the latest state event for this context",
|
||||
state.identifier,
|
||||
announcement.event.pubkey.to_hex()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build repository path: <git_data_path>/<owner_npub>/<identifier>.git
|
||||
let repo_path = self.ctx.git_data_path.join(announcement.repo_path().clone());
|
||||
owner_repos.push((announcement, repo_path));
|
||||
}
|
||||
|
||||
Ok(owner_repos)
|
||||
}
|
||||
|
||||
/// Align a repository's refs with the authorized state
|
||||
///
|
||||
/// This function:
|
||||
/// 1. Deletes refs that are in the repo but not in the state (for refs/heads/ and refs/tags/)
|
||||
/// 2. Updates refs that exist in state if we have the commit
|
||||
/// 3. Sets HEAD if the HEAD branch's commit is available
|
||||
pub fn align_repository_with_state(
|
||||
&self,
|
||||
repo_path: &std::path::Path,
|
||||
state: &RepositoryState,
|
||||
) -> AlignmentResult {
|
||||
let mut result = AlignmentResult::default();
|
||||
|
||||
// Check if repository exists
|
||||
if !repo_path.exists() {
|
||||
tracing::debug!(
|
||||
"Repository not found at {}, cannot align with state",
|
||||
repo_path.display()
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get current refs from the repository
|
||||
let current_refs = match git::list_refs(repo_path) {
|
||||
Ok(refs) => refs,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to list refs in {}: {}", repo_path.display(), e);
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
// Build expected refs from state
|
||||
let mut expected_refs: std::collections::HashMap<String, String> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for branch in &state.branches {
|
||||
let ref_name = format!("refs/heads/{}", branch.name);
|
||||
expected_refs.insert(ref_name, branch.commit.clone());
|
||||
}
|
||||
|
||||
for tag in &state.tags {
|
||||
let ref_name = format!("refs/tags/{}", tag.name);
|
||||
expected_refs.insert(ref_name, tag.commit.clone());
|
||||
}
|
||||
|
||||
// Process current refs: update or delete as needed
|
||||
for (ref_name, current_commit) in ¤t_refs {
|
||||
// Only process refs/heads/ and refs/tags/
|
||||
if !ref_name.starts_with("refs/heads/") && !ref_name.starts_with("refs/tags/") {
|
||||
continue;
|
||||
}
|
||||
|
||||
match expected_refs.get(ref_name) {
|
||||
Some(expected_commit) => {
|
||||
// Ref should exist - check if commit matches
|
||||
if current_commit != expected_commit {
|
||||
// Check if we have the expected commit
|
||||
if git::commit_exists(repo_path, expected_commit) {
|
||||
// Update the ref
|
||||
match git::update_ref(repo_path, ref_name, expected_commit) {
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
"Updated {} from {} to {} in {}",
|
||||
ref_name,
|
||||
current_commit,
|
||||
expected_commit,
|
||||
repo_path.display()
|
||||
);
|
||||
result.refs_updated += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to update {} in {}: {}",
|
||||
ref_name,
|
||||
repo_path.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"Commit {} not available for {} in {}",
|
||||
expected_commit,
|
||||
ref_name,
|
||||
repo_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Ref should not exist - delete it
|
||||
match git::delete_ref(repo_path, ref_name) {
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
"Deleted {} (not in state) from {}",
|
||||
ref_name,
|
||||
repo_path.display()
|
||||
);
|
||||
result.refs_deleted += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to delete {} from {}: {}",
|
||||
ref_name,
|
||||
repo_path.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add refs that exist in state but not in repo (if we have the commit)
|
||||
for (ref_name, expected_commit) in &expected_refs {
|
||||
let exists = current_refs.iter().any(|(r, _)| r == ref_name);
|
||||
if !exists && git::commit_exists(repo_path, expected_commit) {
|
||||
match git::update_ref(repo_path, ref_name, expected_commit) {
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
"Created {} at {} in {}",
|
||||
ref_name,
|
||||
expected_commit,
|
||||
repo_path.display()
|
||||
);
|
||||
result.refs_created += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to create {} in {}: {}",
|
||||
ref_name,
|
||||
repo_path.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set HEAD if specified in state
|
||||
if let Some(head_ref) = &state.head {
|
||||
if let Some(branch_name) = state.get_head_branch() {
|
||||
if let Some(head_commit) = state.get_branch_commit(branch_name) {
|
||||
match git::try_set_head_if_available(repo_path, head_ref, head_commit) {
|
||||
Ok(true) => {
|
||||
tracing::info!(
|
||||
"Set HEAD to {} in {} (from state by {})",
|
||||
head_ref,
|
||||
repo_path.display(),
|
||||
state.event.pubkey.to_hex()
|
||||
);
|
||||
result.head_set = true;
|
||||
}
|
||||
Ok(false) => {
|
||||
tracing::debug!(
|
||||
"HEAD commit {} not available yet in {}",
|
||||
head_commit,
|
||||
repo_path.display()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to set HEAD in {}: {}", repo_path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user