Skip to main content

brainwires_agent/
git_coordination.rs

1//! Git operation coordination for multi-agent systems
2//!
3//! Maps git tool operations to their required resource locks and
4//! coordinates with the resource lock manager to ensure safe concurrent access.
5//!
6//! ## Lock Requirements by Git Operation
7//!
8//! | Git Tool | Required Locks | Notes |
9//! |----------|---------------|-------|
10//! | git_status, git_diff, git_log, git_search, git_fetch | None | Read-only |
11//! | git_stage, git_unstage | GitIndex | Modifies staging area |
12//! | git_commit | GitIndex, GitCommit | Creates commit |
13//! | git_push | GitRemoteWrite | Writes to remote |
14//! | git_pull | GitRemoteMerge, GitIndex | Reads remote, modifies working tree |
15//! | git_branch | GitBranch | Branch operations |
16//! | git_discard | GitDestructive, GitIndex | Dangerous: loses changes |
17
18use std::path::PathBuf;
19use std::sync::Arc;
20
21use anyhow::Result;
22
23use crate::communication::{AgentMessage, CommunicationHub, GitOperationType};
24use crate::resource_checker::ResourceChecker;
25use crate::resource_locks::{ResourceLockGuard, ResourceLockManager, ResourceScope, ResourceType};
26
27/// Git tool name constants
28pub mod git_tools {
29    /// Git status tool name.
30    pub const STATUS: &str = "git_status";
31    /// Git diff tool name.
32    pub const DIFF: &str = "git_diff";
33    /// Git log tool name.
34    pub const LOG: &str = "git_log";
35    /// Git search tool name.
36    pub const SEARCH: &str = "git_search";
37    /// Git fetch tool name.
38    pub const FETCH: &str = "git_fetch";
39    /// Git stage tool name.
40    pub const STAGE: &str = "git_stage";
41    /// Git unstage tool name.
42    pub const UNSTAGE: &str = "git_unstage";
43    /// Git commit tool name.
44    pub const COMMIT: &str = "git_commit";
45    /// Git push tool name.
46    pub const PUSH: &str = "git_push";
47    /// Git pull tool name.
48    pub const PULL: &str = "git_pull";
49    /// Git branch tool name.
50    pub const BRANCH: &str = "git_branch";
51    /// Git discard tool name.
52    pub const DISCARD: &str = "git_discard";
53}
54
55/// Lock requirements for a git operation
56#[derive(Debug, Clone)]
57pub struct GitLockRequirements {
58    /// Primary resource types needed
59    pub resource_types: Vec<ResourceType>,
60    /// Whether to check for file write conflicts
61    pub check_file_conflicts: bool,
62    /// Whether to check for build conflicts
63    pub check_build_conflicts: bool,
64    /// Git operation type for messaging
65    pub operation_type: GitOperationType,
66    /// Human-readable description
67    pub description: &'static str,
68}
69
70impl GitLockRequirements {
71    /// Returns true if no locks are needed (read-only operation)
72    pub fn is_read_only(&self) -> bool {
73        self.resource_types.is_empty()
74    }
75}
76
77/// Get the lock requirements for a git tool
78pub fn get_lock_requirements(tool_name: &str) -> GitLockRequirements {
79    match tool_name {
80        // Read-only operations - no locks needed
81        git_tools::STATUS | git_tools::DIFF | git_tools::LOG | git_tools::SEARCH => {
82            GitLockRequirements {
83                resource_types: vec![],
84                check_file_conflicts: false,
85                check_build_conflicts: false,
86                operation_type: GitOperationType::ReadOnly,
87                description: "Read-only git operation",
88            }
89        }
90        git_tools::FETCH => GitLockRequirements {
91            resource_types: vec![],
92            check_file_conflicts: false,
93            check_build_conflicts: false,
94            operation_type: GitOperationType::ReadOnly,
95            description: "Fetch from remote",
96        },
97
98        // Staging operations
99        git_tools::STAGE | git_tools::UNSTAGE => GitLockRequirements {
100            resource_types: vec![ResourceType::GitIndex],
101            check_file_conflicts: true, // Wait for files being edited
102            check_build_conflicts: false,
103            operation_type: GitOperationType::Staging,
104            description: "Staging area modification",
105        },
106
107        // Commit operation
108        git_tools::COMMIT => GitLockRequirements {
109            resource_types: vec![ResourceType::GitIndex, ResourceType::GitCommit],
110            check_file_conflicts: true,  // Wait for files being edited
111            check_build_conflicts: true, // Wait for builds to complete
112            operation_type: GitOperationType::Commit,
113            description: "Create commit",
114        },
115
116        // Remote write operation
117        git_tools::PUSH => GitLockRequirements {
118            resource_types: vec![ResourceType::GitRemoteWrite],
119            check_file_conflicts: false,
120            check_build_conflicts: true, // Don't push during active build
121            operation_type: GitOperationType::RemoteWrite,
122            description: "Push to remote",
123        },
124
125        // Remote merge operation
126        git_tools::PULL => GitLockRequirements {
127            resource_types: vec![ResourceType::GitRemoteMerge, ResourceType::GitIndex],
128            check_file_conflicts: true, // Wait for files being edited (pull modifies working tree)
129            check_build_conflicts: true, // Don't pull during active build
130            operation_type: GitOperationType::RemoteMerge,
131            description: "Pull from remote",
132        },
133
134        // Branch operation
135        git_tools::BRANCH => GitLockRequirements {
136            resource_types: vec![ResourceType::GitBranch],
137            check_file_conflicts: false,
138            check_build_conflicts: false,
139            operation_type: GitOperationType::Branch,
140            description: "Branch operation",
141        },
142
143        // Destructive operation
144        git_tools::DISCARD => GitLockRequirements {
145            resource_types: vec![ResourceType::GitDestructive, ResourceType::GitIndex],
146            check_file_conflicts: true,  // Wait for files being edited
147            check_build_conflicts: true, // Wait for builds
148            operation_type: GitOperationType::Destructive,
149            description: "Discard changes (destructive)",
150        },
151
152        // Unknown operation - treat as read-only for safety
153        _ => GitLockRequirements {
154            resource_types: vec![],
155            check_file_conflicts: false,
156            check_build_conflicts: false,
157            operation_type: GitOperationType::ReadOnly,
158            description: "Unknown git operation",
159        },
160    }
161}
162
163/// Coordinator for git operations across agents
164pub struct GitCoordinator {
165    resource_locks: Arc<ResourceLockManager>,
166    resource_checker: Option<Arc<ResourceChecker>>,
167    communication_hub: Option<Arc<CommunicationHub>>,
168    project_root: PathBuf,
169}
170
171impl GitCoordinator {
172    /// Create a new git coordinator
173    pub fn new(resource_locks: Arc<ResourceLockManager>, project_root: PathBuf) -> Self {
174        Self {
175            resource_locks,
176            resource_checker: None,
177            communication_hub: None,
178            project_root,
179        }
180    }
181
182    /// Create a git coordinator with full integration
183    pub fn with_full_integration(
184        resource_locks: Arc<ResourceLockManager>,
185        resource_checker: Arc<ResourceChecker>,
186        communication_hub: Arc<CommunicationHub>,
187        project_root: PathBuf,
188    ) -> Self {
189        Self {
190            resource_locks,
191            resource_checker: Some(resource_checker),
192            communication_hub: Some(communication_hub),
193            project_root,
194        }
195    }
196
197    /// Get the project scope for this coordinator
198    pub fn project_scope(&self) -> ResourceScope {
199        ResourceScope::Project(self.project_root.clone())
200    }
201
202    /// Acquire all locks needed for a git operation
203    ///
204    /// Returns a vector of lock guards that must be held during the operation.
205    /// The guards are released when dropped.
206    #[tracing::instrument(name = "agent.git.acquire", skip(self))]
207    pub async fn acquire_for_git_op(
208        &self,
209        agent_id: &str,
210        tool_name: &str,
211    ) -> Result<GitOperationLocks> {
212        let requirements = get_lock_requirements(tool_name);
213
214        // If read-only, no locks needed
215        if requirements.is_read_only() {
216            return Ok(GitOperationLocks {
217                guards: vec![],
218                operation_type: requirements.operation_type,
219                description: requirements.description.to_string(),
220            });
221        }
222
223        let scope = self.project_scope();
224
225        // Check for cross-resource conflicts if resource checker is available
226        if let Some(checker) = &self.resource_checker {
227            // Check file conflicts
228            if requirements.check_file_conflicts {
229                let git_op_type = match requirements.operation_type {
230                    GitOperationType::Staging => ResourceType::GitIndex,
231                    GitOperationType::Commit => ResourceType::GitCommit,
232                    GitOperationType::RemoteWrite => ResourceType::GitRemoteWrite,
233                    GitOperationType::RemoteMerge => ResourceType::GitRemoteMerge,
234                    GitOperationType::Branch => ResourceType::GitBranch,
235                    GitOperationType::Destructive => ResourceType::GitDestructive,
236                    GitOperationType::ReadOnly => ResourceType::GitIndex, // fallback
237                };
238
239                let conflict_check = checker
240                    .can_start_git_operation(git_op_type, &scope, agent_id)
241                    .await;
242
243                if conflict_check.is_blocked() {
244                    let conflicts: Vec<String> = conflict_check
245                        .conflicts()
246                        .iter()
247                        .map(|c| format!("{}: {} by {}", c.resource, c.status, c.holder_agent))
248                        .collect();
249
250                    return Err(anyhow::anyhow!(
251                        "Git operation blocked by conflicts: {}",
252                        conflicts.join(", ")
253                    ));
254                }
255            }
256        }
257
258        // Broadcast operation start if communication hub is available
259        if let Some(hub) = &self.communication_hub {
260            let _ = hub
261                .broadcast(
262                    agent_id.to_string(),
263                    AgentMessage::GitOperationStarted {
264                        agent_id: agent_id.to_string(),
265                        git_op: requirements.operation_type,
266                        branch: None, // Could be enhanced to include branch info
267                        description: requirements.description.to_string(),
268                    },
269                )
270                .await;
271        }
272
273        // Acquire all required locks
274        let mut guards = Vec::new();
275        for resource_type in &requirements.resource_types {
276            let guard = self
277                .resource_locks
278                .acquire_resource(
279                    agent_id,
280                    *resource_type,
281                    scope.clone(),
282                    requirements.description,
283                )
284                .await?;
285            guards.push(guard);
286        }
287
288        Ok(GitOperationLocks {
289            guards,
290            operation_type: requirements.operation_type,
291            description: requirements.description.to_string(),
292        })
293    }
294
295    /// Check if a git operation can be performed without blocking
296    pub async fn can_perform_git_op(&self, agent_id: &str, tool_name: &str) -> bool {
297        let requirements = get_lock_requirements(tool_name);
298
299        // Read-only operations can always proceed
300        if requirements.is_read_only() {
301            return true;
302        }
303
304        let scope = self.project_scope();
305
306        // Check resource locks
307        for resource_type in &requirements.resource_types {
308            if !self
309                .resource_locks
310                .can_acquire(agent_id, *resource_type, &scope)
311                .await
312            {
313                return false;
314            }
315        }
316
317        // Check cross-resource conflicts
318        if let Some(checker) = &self.resource_checker
319            && requirements.check_file_conflicts
320        {
321            let git_op_type = match requirements.operation_type {
322                GitOperationType::Staging => ResourceType::GitIndex,
323                GitOperationType::Commit => ResourceType::GitCommit,
324                GitOperationType::RemoteWrite => ResourceType::GitRemoteWrite,
325                GitOperationType::RemoteMerge => ResourceType::GitRemoteMerge,
326                GitOperationType::Branch => ResourceType::GitBranch,
327                GitOperationType::Destructive => ResourceType::GitDestructive,
328                GitOperationType::ReadOnly => return true,
329            };
330
331            let check = checker
332                .can_start_git_operation(git_op_type, &scope, agent_id)
333                .await;
334
335            if check.is_blocked() {
336                return false;
337            }
338        }
339
340        true
341    }
342
343    /// Broadcast that a git operation has completed
344    pub async fn broadcast_completion(
345        &self,
346        agent_id: &str,
347        operation_type: GitOperationType,
348        success: bool,
349        summary: &str,
350    ) {
351        if let Some(hub) = &self.communication_hub {
352            let _ = hub
353                .broadcast(
354                    agent_id.to_string(),
355                    AgentMessage::GitOperationCompleted {
356                        agent_id: agent_id.to_string(),
357                        git_op: operation_type,
358                        success,
359                        summary: summary.to_string(),
360                    },
361                )
362                .await;
363        }
364    }
365}
366
367/// Holds locks for a git operation
368///
369/// The locks are released when this struct is dropped.
370pub struct GitOperationLocks {
371    guards: Vec<ResourceLockGuard>,
372    /// Type of git operation.
373    pub operation_type: GitOperationType,
374    /// Human-readable description.
375    pub description: String,
376}
377
378impl GitOperationLocks {
379    /// Returns true if this represents a read-only operation (no locks held)
380    pub fn is_read_only(&self) -> bool {
381        self.guards.is_empty()
382    }
383
384    /// Get the number of locks held
385    pub fn lock_count(&self) -> usize {
386        self.guards.len()
387    }
388}
389
390/// Helper trait for git operations with automatic lock management
391#[async_trait::async_trait]
392pub trait GitOperationRunner {
393    /// Run a git operation with automatic lock acquisition and release
394    async fn run_with_locks<F, T>(
395        &self,
396        agent_id: &str,
397        tool_name: &str,
398        operation: F,
399    ) -> Result<T>
400    where
401        F: FnOnce() -> Result<T> + Send,
402        T: Send;
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn test_read_only_operations() {
411        assert!(get_lock_requirements(git_tools::STATUS).is_read_only());
412        assert!(get_lock_requirements(git_tools::DIFF).is_read_only());
413        assert!(get_lock_requirements(git_tools::LOG).is_read_only());
414        assert!(get_lock_requirements(git_tools::SEARCH).is_read_only());
415        assert!(get_lock_requirements(git_tools::FETCH).is_read_only());
416    }
417
418    #[test]
419    fn test_staging_operations() {
420        let stage_req = get_lock_requirements(git_tools::STAGE);
421        assert!(!stage_req.is_read_only());
422        assert!(stage_req.resource_types.contains(&ResourceType::GitIndex));
423        assert!(matches!(
424            stage_req.operation_type,
425            GitOperationType::Staging
426        ));
427
428        let unstage_req = get_lock_requirements(git_tools::UNSTAGE);
429        assert!(!unstage_req.is_read_only());
430        assert!(unstage_req.resource_types.contains(&ResourceType::GitIndex));
431    }
432
433    #[test]
434    fn test_commit_operation() {
435        let req = get_lock_requirements(git_tools::COMMIT);
436        assert!(!req.is_read_only());
437        assert!(req.resource_types.contains(&ResourceType::GitIndex));
438        assert!(req.resource_types.contains(&ResourceType::GitCommit));
439        assert!(req.check_file_conflicts);
440        assert!(req.check_build_conflicts);
441        assert!(matches!(req.operation_type, GitOperationType::Commit));
442    }
443
444    #[test]
445    fn test_push_operation() {
446        let req = get_lock_requirements(git_tools::PUSH);
447        assert!(!req.is_read_only());
448        assert!(req.resource_types.contains(&ResourceType::GitRemoteWrite));
449        assert!(!req.check_file_conflicts);
450        assert!(req.check_build_conflicts);
451        assert!(matches!(req.operation_type, GitOperationType::RemoteWrite));
452    }
453
454    #[test]
455    fn test_pull_operation() {
456        let req = get_lock_requirements(git_tools::PULL);
457        assert!(!req.is_read_only());
458        assert!(req.resource_types.contains(&ResourceType::GitRemoteMerge));
459        assert!(req.resource_types.contains(&ResourceType::GitIndex));
460        assert!(req.check_file_conflicts);
461        assert!(req.check_build_conflicts);
462        assert!(matches!(req.operation_type, GitOperationType::RemoteMerge));
463    }
464
465    #[test]
466    fn test_destructive_operation() {
467        let req = get_lock_requirements(git_tools::DISCARD);
468        assert!(!req.is_read_only());
469        assert!(req.resource_types.contains(&ResourceType::GitDestructive));
470        assert!(req.resource_types.contains(&ResourceType::GitIndex));
471        assert!(req.check_file_conflicts);
472        assert!(req.check_build_conflicts);
473        assert!(matches!(req.operation_type, GitOperationType::Destructive));
474    }
475
476    #[test]
477    fn test_unknown_operation() {
478        let req = get_lock_requirements("unknown_git_tool");
479        assert!(req.is_read_only());
480    }
481
482    #[tokio::test]
483    async fn test_coordinator_read_only_no_locks() {
484        let resource_locks = Arc::new(ResourceLockManager::new());
485        let coordinator = GitCoordinator::new(resource_locks, PathBuf::from("/test/project"));
486
487        let locks = coordinator
488            .acquire_for_git_op("agent-1", git_tools::STATUS)
489            .await
490            .unwrap();
491
492        assert!(locks.is_read_only());
493        assert_eq!(locks.lock_count(), 0);
494    }
495
496    #[tokio::test]
497    async fn test_coordinator_staging_acquires_index_lock() {
498        let resource_locks = Arc::new(ResourceLockManager::new());
499        let coordinator =
500            GitCoordinator::new(resource_locks.clone(), PathBuf::from("/test/project"));
501
502        let locks = coordinator
503            .acquire_for_git_op("agent-1", git_tools::STAGE)
504            .await
505            .unwrap();
506
507        assert!(!locks.is_read_only());
508        assert_eq!(locks.lock_count(), 1);
509        assert!(matches!(locks.operation_type, GitOperationType::Staging));
510
511        // Verify the lock is held
512        let scope = ResourceScope::Project(PathBuf::from("/test/project"));
513        assert!(
514            !resource_locks
515                .can_acquire("agent-2", ResourceType::GitIndex, &scope)
516                .await
517        );
518    }
519
520    #[tokio::test]
521    async fn test_coordinator_commit_acquires_multiple_locks() {
522        let resource_locks = Arc::new(ResourceLockManager::new());
523        let coordinator =
524            GitCoordinator::new(resource_locks.clone(), PathBuf::from("/test/project"));
525
526        let locks = coordinator
527            .acquire_for_git_op("agent-1", git_tools::COMMIT)
528            .await
529            .unwrap();
530
531        assert!(!locks.is_read_only());
532        assert_eq!(locks.lock_count(), 2); // GitIndex + GitCommit
533        assert!(matches!(locks.operation_type, GitOperationType::Commit));
534    }
535
536    #[tokio::test]
537    async fn test_can_perform_git_op() {
538        let resource_locks = Arc::new(ResourceLockManager::new());
539        let coordinator =
540            GitCoordinator::new(resource_locks.clone(), PathBuf::from("/test/project"));
541
542        // Read-only should always be allowed
543        assert!(
544            coordinator
545                .can_perform_git_op("agent-1", git_tools::STATUS)
546                .await
547        );
548
549        // Staging should be allowed when no conflicts
550        assert!(
551            coordinator
552                .can_perform_git_op("agent-1", git_tools::STAGE)
553                .await
554        );
555
556        // Agent 1 acquires the index lock
557        let _locks = coordinator
558            .acquire_for_git_op("agent-1", git_tools::STAGE)
559            .await
560            .unwrap();
561
562        // Agent 2 should not be able to stage
563        assert!(
564            !coordinator
565                .can_perform_git_op("agent-2", git_tools::STAGE)
566                .await
567        );
568
569        // But agent 1 can (idempotent)
570        assert!(
571            coordinator
572                .can_perform_git_op("agent-1", git_tools::STAGE)
573                .await
574        );
575    }
576}