Skip to main content

aida_core/
git_ops.rs

1// trace:ARCH-distributed-git-ops | ai:claude
2//! Git operations for distributed AIDA.
3//!
4//! Provides wrappers around git commands for:
5//! - Node registration CAS (compare-and-swap) loop
6//! - Committing object changes
7//! - Push/pull synchronization
8//!
9//! These operations shell out to the `git` CLI rather than using libgit2,
10//! keeping the dependency light and behavior identical to what users see
11//! when they run git manually.
12
13use anyhow::{Context, Result};
14use std::path::Path;
15use std::process::Command;
16
17/// Result of a git command execution.
18#[derive(Debug)]
19pub struct GitResult {
20    pub success: bool,
21    pub stdout: String,
22    pub stderr: String,
23}
24
25/// Run a git command in the given working directory.
26fn git(cwd: &Path, args: &[&str]) -> Result<GitResult> {
27    let output = Command::new("git")
28        .current_dir(cwd)
29        .args(args)
30        .output()
31        .with_context(|| format!("Failed to run: git {}", args.join(" ")))?;
32
33    Ok(GitResult {
34        success: output.status.success(),
35        stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(),
36        stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
37    })
38}
39
40/// Check if a directory is a git repository.
41pub fn is_git_repo(path: &Path) -> bool {
42    git(path, &["rev-parse", "--git-dir"])
43        .map(|r| r.success)
44        .unwrap_or(false)
45}
46
47/// Initialize a new git repository.
48pub fn init(path: &Path) -> Result<()> {
49    std::fs::create_dir_all(path)?;
50    let result = git(path, &["init"])?;
51    if !result.success {
52        anyhow::bail!("git init failed: {}", result.stderr);
53    }
54    Ok(())
55}
56
57/// Stage specific files.
58pub fn add(repo: &Path, paths: &[&str]) -> Result<()> {
59    let mut args = vec!["add"];
60    args.extend(paths);
61    let result = git(repo, &args)?;
62    if !result.success {
63        anyhow::bail!("git add failed: {}", result.stderr);
64    }
65    Ok(())
66}
67
68/// Stage all changes (tracked and untracked) in a subdirectory.
69pub fn add_all(repo: &Path, subdir: &str) -> Result<()> {
70    let result = git(repo, &["add", "-A", subdir])?;
71    if !result.success {
72        anyhow::bail!("git add -A {} failed: {}", subdir, result.stderr);
73    }
74    Ok(())
75}
76
77/// Commit staged changes.
78pub fn commit(repo: &Path, message: &str) -> Result<bool> {
79    let result = git(repo, &["commit", "-m", message])?;
80    if result.success {
81        Ok(true)
82    } else if result.stdout.contains("nothing to commit") || result.stderr.contains("nothing to commit") {
83        Ok(false) // nothing to commit — not an error
84    } else {
85        anyhow::bail!("git commit failed: {}", result.stderr);
86    }
87}
88
89/// Push to the remote. Returns true on success, false if rejected (non-fast-forward).
90pub fn push(repo: &Path, remote: &str, branch: &str) -> Result<bool> {
91    let result = git(repo, &["push", remote, branch])?;
92    if result.success {
93        Ok(true)
94    } else if result.stderr.contains("non-fast-forward")
95        || result.stderr.contains("rejected")
96        || result.stderr.contains("fetch first")
97    {
98        Ok(false) // push rejected — caller should pull and retry
99    } else {
100        anyhow::bail!("git push failed: {}", result.stderr);
101    }
102}
103
104/// Pull with rebase from remote.
105pub fn pull_rebase(repo: &Path, remote: &str, branch: &str) -> Result<()> {
106    let result = git(repo, &["pull", "--rebase", remote, branch])?;
107    if !result.success {
108        anyhow::bail!("git pull --rebase failed: {}", result.stderr);
109    }
110    Ok(())
111}
112
113/// Pull (merge) from remote.
114pub fn pull(repo: &Path, remote: &str, branch: &str) -> Result<()> {
115    let result = git(repo, &["pull", remote, branch])?;
116    if !result.success {
117        anyhow::bail!("git pull failed: {}", result.stderr);
118    }
119    Ok(())
120}
121
122/// Get the current HEAD commit SHA (short form).
123pub fn head_sha(repo: &Path) -> Result<String> {
124    let result = git(repo, &["rev-parse", "--short", "HEAD"])?;
125    if !result.success {
126        anyhow::bail!("git rev-parse HEAD failed: {}", result.stderr);
127    }
128    Ok(result.stdout)
129}
130
131/// Get the current branch name.
132pub fn current_branch(repo: &Path) -> Result<String> {
133    let result = git(repo, &["rev-parse", "--abbrev-ref", "HEAD"])?;
134    if !result.success {
135        anyhow::bail!("git branch detection failed: {}", result.stderr);
136    }
137    Ok(result.stdout)
138}
139
140/// Check if the working tree has uncommitted changes.
141pub fn has_changes(repo: &Path) -> Result<bool> {
142    let result = git(repo, &["status", "--porcelain"])?;
143    Ok(!result.stdout.is_empty())
144}
145
146// ---------------------------------------------------------------------------
147// Orphan Branch + Worktree
148// ---------------------------------------------------------------------------
149
150/// Create an orphan branch and worktree for the AIDA store.
151///
152/// This is the recommended approach for single-repo projects:
153/// - Creates an orphan branch (no shared history with main)
154/// - Checks it out as a worktree at the given path
155/// - The worktree directory should be added to .gitignore
156///
157/// Returns the worktree path on success.
158pub fn create_store_worktree(
159    repo_root: &Path,
160    worktree_dir: &str,
161    branch_name: &str,
162) -> Result<std::path::PathBuf> {
163    let worktree_path = repo_root.join(worktree_dir);
164
165    // Check if worktree already exists
166    if worktree_path.exists() {
167        // Verify it's actually a worktree
168        let result = git(repo_root, &["worktree", "list"])?;
169        if result.stdout.contains(worktree_dir) {
170            return Ok(worktree_path); // already set up
171        }
172        anyhow::bail!(
173            "Directory {} already exists but is not a git worktree",
174            worktree_path.display()
175        );
176    }
177
178    // Check if the orphan branch already exists (e.g., after clone)
179    let branch_exists = git(repo_root, &["rev-parse", "--verify", branch_name])
180        .map(|r| r.success)
181        .unwrap_or(false);
182
183    if branch_exists {
184        // Branch exists (e.g., from a clone) — just add the worktree
185        let result = git(
186            repo_root,
187            &["worktree", "add", worktree_dir, branch_name],
188        )?;
189        if !result.success {
190            anyhow::bail!(
191                "Failed to add worktree: {}",
192                result.stderr
193            );
194        }
195    } else {
196        // Create orphan branch via worktree
197        // git worktree add --orphan was added in git 2.42+
198        // For compatibility, create it manually
199        let result = git(
200            repo_root,
201            &["worktree", "add", "--detach", worktree_dir],
202        )?;
203        if !result.success {
204            anyhow::bail!(
205                "Failed to create worktree: {}",
206                result.stderr
207            );
208        }
209
210        // Create the orphan branch in the worktree
211        let result = git(&worktree_path, &["checkout", "--orphan", branch_name])?;
212        if !result.success {
213            anyhow::bail!(
214                "Failed to create orphan branch: {}",
215                result.stderr
216            );
217        }
218
219        // Clear the index (orphan branch starts with main's files staged)
220        git(&worktree_path, &["rm", "-rf", "--cached", "."])?;
221        // Clean working tree
222        let _ = git(&worktree_path, &["clean", "-fd"]);
223    }
224
225    Ok(worktree_path)
226}
227
228/// Remove a store worktree and optionally delete the branch.
229pub fn remove_store_worktree(
230    repo_root: &Path,
231    worktree_dir: &str,
232) -> Result<()> {
233    let worktree_path = repo_root.join(worktree_dir);
234    if worktree_path.exists() {
235        git(repo_root, &["worktree", "remove", "--force", worktree_dir])?;
236    }
237    Ok(())
238}
239
240/// Check if a worktree exists for the given directory.
241pub fn has_worktree(repo_root: &Path, worktree_dir: &str) -> bool {
242    let result = git(repo_root, &["worktree", "list"]).ok();
243    result
244        .map(|r| r.stdout.contains(worktree_dir))
245        .unwrap_or(false)
246}
247
248/// Check if the remote is reachable (can we push/pull?).
249pub fn is_remote_reachable(repo: &Path, remote: &str) -> bool {
250    git(repo, &["ls-remote", "--exit-code", remote])
251        .map(|r| r.success)
252        .unwrap_or(false)
253}
254
255/// Get a git config value (checks local, then global).
256pub fn git_config_get(key: &str) -> Result<String> {
257    let output = Command::new("git")
258        .args(["config", key])
259        .output()?;
260    if output.status.success() {
261        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
262    } else {
263        anyhow::bail!("git config {} not set", key)
264    }
265}
266
267/// Configure user name and email for commits (repo-local, not global).
268pub fn configure_user(repo: &Path, name: &str, email: &str) -> Result<()> {
269    git(repo, &["config", "user.name", name])?;
270    git(repo, &["config", "user.email", email])?;
271    Ok(())
272}
273
274// ---------------------------------------------------------------------------
275// Node Registration CAS Loop
276// ---------------------------------------------------------------------------
277
278/// Maximum retries for the CAS push loop.
279const MAX_CAS_RETRIES: u32 = 10;
280
281/// Register a new node in the aida registry via git CAS push loop.
282///
283/// This is the core distributed identity mechanism:
284/// 1. Pull latest from remote
285/// 2. Read node_counter, claim the next ID
286/// 3. Write updated counter + registry entry
287/// 4. Commit and push
288/// 5. If push rejected (someone else registered first), pull and retry
289///
290/// Returns the assigned node_id on success.
291pub fn register_node(
292    aida_repo: &Path,
293    user_id: u32,
294    hostname: &str,
295) -> Result<u32> {
296    use crate::node::{NodeConfig, NodeRegistry};
297
298    let registry_dir = aida_repo.join("registry");
299    std::fs::create_dir_all(&registry_dir)?;
300
301    let registry_path = registry_dir.join("nodes.toml");
302    let branch = current_branch(aida_repo)
303        .unwrap_or_else(|_| "main".to_string());
304
305    for attempt in 0..MAX_CAS_RETRIES {
306        // Step 1: Pull latest (skip on first attempt if no remote)
307        if attempt > 0 {
308            if let Err(e) = pull_rebase(aida_repo, "origin", &branch) {
309                eprintln!("Warning: pull failed (attempt {}): {}", attempt, e);
310                anyhow::bail!(
311                    "Cannot complete node registration: remote unreachable after {} attempts. Error: {}",
312                    attempt, e
313                );
314            }
315        }
316
317        // Step 2: Load registry and claim next ID
318        let mut registry = NodeRegistry::load(&registry_path)
319            .unwrap_or_default();
320        let node_id = registry.next_node_id();
321
322        // Step 3: Register the node
323        registry.register(user_id, hostname.to_string());
324        registry.save(&registry_path)?;
325
326        // Step 4: Stage, commit, push
327        add(aida_repo, &["registry/nodes.toml"])?;
328
329        let msg = format!(
330            "chore(registry): register node {} for user {} ({})",
331            node_id, user_id, hostname
332        );
333        commit(aida_repo, &msg)?;
334
335        // Step 5: Push — if rejected, pull and retry
336        match push(aida_repo, "origin", &branch) {
337            Ok(true) => {
338                // Success! Save local node config
339                let config = NodeConfig {
340                    node_id,
341                    user_id,
342                    hostname: hostname.to_string(),
343                    registered_at: chrono::Utc::now(),
344                };
345                let node_config_path = aida_repo.join(".aida").join("node.toml");
346                config.save(&node_config_path)?;
347
348                return Ok(node_id);
349            }
350            Ok(false) => {
351                // Push rejected — another node registered first. Retry.
352                eprintln!(
353                    "Node registration: push rejected (attempt {}), retrying...",
354                    attempt + 1
355                );
356                continue;
357            }
358            Err(e) => {
359                anyhow::bail!("Node registration failed: {}", e);
360            }
361        }
362    }
363
364    anyhow::bail!(
365        "Node registration failed after {} attempts — too much contention on the registry",
366        MAX_CAS_RETRIES
367    );
368}
369
370/// Commit and push all pending object changes in the aida repo.
371/// This is the "sync" operation — called when the user wants to share changes.
372/// Run the merge gate: assign agreed IDs to all objects that don't have one.
373///
374/// This is the two-tier ID mechanism: node-namespaced IDs (FR-7-048) get
375/// short agreed IDs (FR-423) assigned at merge time via CAS counter.
376///
377/// Returns the number of agreed IDs assigned.
378pub fn merge_gate(store_path: &Path) -> Result<Vec<(String, String)>> {
379    use crate::node::AgreedCounters;
380    use crate::object_store;
381
382    let objects_root = store_path.join("objects");
383    let registry_dir = store_path.join("registry");
384    std::fs::create_dir_all(&registry_dir)?;
385
386    let counters_path = registry_dir.join("agreed_counters.toml");
387
388    // Load counters
389    let mut counters = if counters_path.exists() {
390        let content = std::fs::read_to_string(&counters_path)?;
391        toml::from_str::<AgreedCounters>(&content).unwrap_or_default()
392    } else {
393        AgreedCounters::default()
394    };
395
396    // Find all objects without an agreed_id
397    let files = object_store::list_objects(&objects_root)?;
398    let mut assignments = Vec::new();
399
400    for (_spec_id, path) in &files {
401        let mut req = match object_store::read_object_from_path(path) {
402            Ok(r) => r,
403            Err(_) => continue,
404        };
405
406        if req.agreed_id.is_some() {
407            continue; // already has an agreed ID
408        }
409
410        let spec_id = match &req.spec_id {
411            Some(s) => s.clone(),
412            None => continue,
413        };
414
415        // Use the requirement's type for the agreed ID prefix (FR, BUG, TASK, etc.)
416        // This gives short, standard prefixes regardless of the original feature-based prefix
417        let type_prefix = match req.req_type {
418            crate::models::RequirementType::Functional => "FR",
419            crate::models::RequirementType::NonFunctional => "NFR",
420            crate::models::RequirementType::System => "SR",
421            crate::models::RequirementType::User => "UR",
422            crate::models::RequirementType::ChangeRequest => "CR",
423            crate::models::RequirementType::Bug => "BUG",
424            crate::models::RequirementType::Epic => "EPIC",
425            crate::models::RequirementType::Story => "STORY",
426            crate::models::RequirementType::Task => "TASK",
427            crate::models::RequirementType::Spike => "SPIKE",
428            crate::models::RequirementType::Sprint => "SPRINT",
429            crate::models::RequirementType::Folder => "FOLDER",
430            crate::models::RequirementType::Meta => "META",
431        };
432        let seq = counters.next(&type_prefix);
433        let agreed = AgreedCounters::format_agreed_id(&type_prefix, seq);
434
435        req.agreed_id = Some(agreed.clone());
436        object_store::write_object(&objects_root, &req)?;
437        assignments.push((spec_id, agreed));
438    }
439
440    // Save updated counters
441    let content = toml::to_string_pretty(&counters)?;
442    std::fs::write(&counters_path, content)?;
443
444    // Stage and commit
445    if !assignments.is_empty() {
446        add_all(store_path, "objects")?;
447        add(store_path, &["registry/agreed_counters.toml"])?;
448        let msg = format!(
449            "chore(merge-gate): assign {} agreed ID(s)",
450            assignments.len()
451        );
452        commit(store_path, &msg)?;
453    }
454
455    Ok(assignments)
456}
457
458pub fn sync_objects(aida_repo: &Path, message: &str) -> Result<bool> {
459    let branch = current_branch(aida_repo)
460        .unwrap_or_else(|_| "main".to_string());
461
462    // Stage all changes in objects/ and metadata.yaml
463    add_all(aida_repo, "objects")?;
464    if aida_repo.join("metadata.yaml").exists() {
465        add(aida_repo, &["metadata.yaml"])?;
466    }
467
468    // Commit
469    let committed = commit(aida_repo, message)?;
470    if !committed {
471        return Ok(false); // nothing to sync
472    }
473
474    // Push — retry with pull on rejection
475    for _attempt in 0..MAX_CAS_RETRIES {
476        match push(aida_repo, "origin", &branch)? {
477            true => return Ok(true),
478            false => {
479                pull_rebase(aida_repo, "origin", &branch)?;
480            }
481        }
482    }
483
484    anyhow::bail!("Sync failed after {} push attempts", MAX_CAS_RETRIES);
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use std::path::PathBuf;
491
492    #[test]
493    fn test_init_and_is_git_repo() {
494        let dir = tempfile::tempdir().unwrap();
495        let repo = dir.path().join("test-repo");
496
497        assert!(!is_git_repo(&repo));
498        init(&repo).unwrap();
499        assert!(is_git_repo(&repo));
500    }
501
502    #[test]
503    fn test_add_commit() {
504        let dir = tempfile::tempdir().unwrap();
505        let repo = dir.path().join("test-repo");
506        init(&repo).unwrap();
507        configure_user(&repo, "Test User", "test@example.com").unwrap();
508
509        // Create a file and commit
510        std::fs::write(repo.join("test.txt"), "hello").unwrap();
511        add(&repo, &["test.txt"]).unwrap();
512        let committed = commit(&repo, "initial commit").unwrap();
513        assert!(committed);
514
515        // Nothing to commit now
516        let committed2 = commit(&repo, "empty").unwrap();
517        assert!(!committed2);
518    }
519
520    #[test]
521    fn test_has_changes() {
522        let dir = tempfile::tempdir().unwrap();
523        let repo = dir.path().join("test-repo");
524        init(&repo).unwrap();
525        configure_user(&repo, "Test User", "test@example.com").unwrap();
526
527        // Initially no changes (empty repo)
528        // Create and commit a file first
529        std::fs::write(repo.join("test.txt"), "hello").unwrap();
530        add(&repo, &["test.txt"]).unwrap();
531        commit(&repo, "initial").unwrap();
532
533        assert!(!has_changes(&repo).unwrap());
534
535        // Modify the file
536        std::fs::write(repo.join("test.txt"), "modified").unwrap();
537        assert!(has_changes(&repo).unwrap());
538    }
539
540    #[test]
541    fn test_head_sha_and_branch() {
542        let dir = tempfile::tempdir().unwrap();
543        let repo = dir.path().join("test-repo");
544        init(&repo).unwrap();
545        configure_user(&repo, "Test User", "test@example.com").unwrap();
546
547        std::fs::write(repo.join("test.txt"), "hello").unwrap();
548        add(&repo, &["test.txt"]).unwrap();
549        commit(&repo, "initial").unwrap();
550
551        let sha = head_sha(&repo).unwrap();
552        assert!(!sha.is_empty());
553        assert!(sha.len() >= 7);
554
555        let branch = current_branch(&repo).unwrap();
556        // Could be "main" or "master" depending on git config
557        assert!(!branch.is_empty());
558    }
559
560    /// Helper: create a bare remote and a working clone with an initial commit pushed.
561    /// Returns (bare_path, work_path, branch_name).
562    fn setup_remote_and_clone(dir: &Path, name: &str) -> (PathBuf, PathBuf, String) {
563        let bare = dir.join(format!("{}.git", name));
564        std::fs::create_dir_all(&bare).unwrap();
565        git(&bare, &["init", "--bare"]).unwrap();
566
567        let work = dir.join(name);
568        init(&work).unwrap();
569        configure_user(&work, "Test User", "test@example.com").unwrap();
570        git(&work, &["remote", "add", "origin", bare.to_str().unwrap()]).unwrap();
571
572        // Initial commit so the branch exists
573        std::fs::write(work.join("README.md"), "# Test").unwrap();
574        add(&work, &["README.md"]).unwrap();
575        commit(&work, "initial").unwrap();
576        let branch = current_branch(&work).unwrap();
577        git(&work, &["push", "-u", "origin", &branch]).unwrap();
578
579        (bare, work, branch)
580    }
581
582    #[test]
583    fn test_push_to_local_bare_repo() {
584        let dir = tempfile::tempdir().unwrap();
585        let (_bare, work, branch) = setup_remote_and_clone(dir.path(), "push-test");
586
587        // Add another file and push
588        std::fs::write(work.join("second.txt"), "hello").unwrap();
589        add(&work, &["second.txt"]).unwrap();
590        commit(&work, "second commit").unwrap();
591        let pushed = push(&work, "origin", &branch).unwrap();
592        assert!(pushed);
593    }
594
595    #[test]
596    fn test_register_node_local() {
597        let dir = tempfile::tempdir().unwrap();
598        let (bare, aida, _branch) = setup_remote_and_clone(dir.path(), "aida");
599
600        // Register first node
601        let node_id = register_node(&aida, 1, "test-laptop").unwrap();
602        assert_eq!(node_id, 1);
603
604        // Verify registry file exists
605        assert!(aida.join("registry/nodes.toml").exists());
606
607        // Verify local node config was saved
608        assert!(aida.join(".aida/node.toml").exists());
609
610        // Register another node (simulating a second clone)
611        let aida2 = dir.path().join("aida2");
612        git(dir.path(), &["clone", bare.to_str().unwrap(), "aida2"]).unwrap();
613        configure_user(&aida2, "Alice", "alice@example.com").unwrap();
614
615        let node_id2 = register_node(&aida2, 2, "alice-dev").unwrap();
616        assert_eq!(node_id2, 2);
617    }
618}