1use anyhow::{Context, Result};
14use std::path::Path;
15use std::process::Command;
16
17#[derive(Debug)]
19pub struct GitResult {
20 pub success: bool,
21 pub stdout: String,
22 pub stderr: String,
23}
24
25fn 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
40pub 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
47pub 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
57pub 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
68pub 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
77pub 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) } else {
85 anyhow::bail!("git commit failed: {}", result.stderr);
86 }
87}
88
89pub 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) } else {
100 anyhow::bail!("git push failed: {}", result.stderr);
101 }
102}
103
104pub 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
113pub 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
122pub 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
131pub 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
140pub fn has_changes(repo: &Path) -> Result<bool> {
142 let result = git(repo, &["status", "--porcelain"])?;
143 Ok(!result.stdout.is_empty())
144}
145
146pub 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 if worktree_path.exists() {
167 let result = git(repo_root, &["worktree", "list"])?;
169 if result.stdout.contains(worktree_dir) {
170 return Ok(worktree_path); }
172 anyhow::bail!(
173 "Directory {} already exists but is not a git worktree",
174 worktree_path.display()
175 );
176 }
177
178 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 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 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 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 git(&worktree_path, &["rm", "-rf", "--cached", "."])?;
221 let _ = git(&worktree_path, &["clean", "-fd"]);
223 }
224
225 Ok(worktree_path)
226}
227
228pub 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
240pub 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
248pub 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
255pub 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
267pub 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
274const MAX_CAS_RETRIES: u32 = 10;
280
281pub 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(®istry_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 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 let mut registry = NodeRegistry::load(®istry_path)
319 .unwrap_or_default();
320 let node_id = registry.next_node_id();
321
322 registry.register(user_id, hostname.to_string());
324 registry.save(®istry_path)?;
325
326 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 match push(aida_repo, "origin", &branch) {
337 Ok(true) => {
338 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 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
370pub 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(®istry_dir)?;
385
386 let counters_path = registry_dir.join("agreed_counters.toml");
387
388 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 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; }
409
410 let spec_id = match &req.spec_id {
411 Some(s) => s.clone(),
412 None => continue,
413 };
414
415 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 let content = toml::to_string_pretty(&counters)?;
442 std::fs::write(&counters_path, content)?;
443
444 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 add_all(aida_repo, "objects")?;
464 if aida_repo.join("metadata.yaml").exists() {
465 add(aida_repo, &["metadata.yaml"])?;
466 }
467
468 let committed = commit(aida_repo, message)?;
470 if !committed {
471 return Ok(false); }
473
474 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 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 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 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 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 assert!(!branch.is_empty());
558 }
559
560 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 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 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 let node_id = register_node(&aida, 1, "test-laptop").unwrap();
602 assert_eq!(node_id, 1);
603
604 assert!(aida.join("registry/nodes.toml").exists());
606
607 assert!(aida.join(".aida/node.toml").exists());
609
610 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}