1use std::path::Path;
4use std::process::Command;
5use ta_changeset::DraftPackage;
6use ta_goal::GoalRun;
7
8use crate::adapter::{
9 CommitResult, MergeResult, PushResult, Result, ReviewResult, ReviewStatus, SavedVcsState,
10 SourceAdapter, SubmitError, SyncResult,
11};
12use crate::config::SubmitConfig;
13use crate::config::SyncConfig;
14
15pub struct GitAdapter {
23 work_dir: std::path::PathBuf,
25 config: SubmitConfig,
27 sync_config: SyncConfig,
29 plan_file: String,
31}
32
33impl GitAdapter {
34 pub fn new(work_dir: impl Into<std::path::PathBuf>) -> Self {
36 Self {
37 work_dir: work_dir.into(),
38 config: SubmitConfig::default(),
39 sync_config: SyncConfig::default(),
40 plan_file: "PLAN.md".to_string(),
41 }
42 }
43
44 pub fn with_config(work_dir: impl Into<std::path::PathBuf>, config: SubmitConfig) -> Self {
46 Self {
47 work_dir: work_dir.into(),
48 config,
49 sync_config: SyncConfig::default(),
50 plan_file: "PLAN.md".to_string(),
51 }
52 }
53
54 pub fn with_full_config(
56 work_dir: impl Into<std::path::PathBuf>,
57 config: SubmitConfig,
58 sync_config: SyncConfig,
59 ) -> Self {
60 Self {
61 work_dir: work_dir.into(),
62 config,
63 sync_config,
64 plan_file: "PLAN.md".to_string(),
65 }
66 }
67
68 pub fn with_plan_file(mut self, plan_file: impl Into<String>) -> Self {
70 self.plan_file = plan_file.into();
71 self
72 }
73
74 fn git_cmd(&self, args: &[&str]) -> Result<String> {
76 let output = Command::new("git")
80 .args(args)
81 .current_dir(&self.work_dir)
82 .env_remove("GIT_DIR")
83 .env_remove("GIT_WORK_TREE")
84 .env_remove("GIT_CEILING_DIRECTORIES")
85 .output()?;
86
87 if !output.status.success() {
88 let stderr = String::from_utf8_lossy(&output.stderr);
89 return Err(SubmitError::VcsError(format!(
90 "git {} failed: {}",
91 args.join(" "),
92 stderr
93 )));
94 }
95
96 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
97 }
98
99 fn has_gh_cli(&self) -> bool {
101 Command::new("gh")
102 .arg("--version")
103 .output()
104 .map(|o| o.status.success())
105 .unwrap_or(false)
106 }
107
108 pub fn current_branch(&self) -> Result<String> {
110 self.git_cmd(&["rev-parse", "--abbrev-ref", "HEAD"])
111 }
112
113 fn branch_name(&self, goal: &GoalRun, config: &SubmitConfig) -> String {
124 let prefix = &config.git.branch_prefix;
125
126 let raw: String = goal
128 .title
129 .to_lowercase()
130 .chars()
131 .map(|c| {
132 if c.is_alphanumeric() || c == '-' {
133 c
134 } else {
135 '-'
136 }
137 })
138 .collect();
139
140 let mut collapsed = String::with_capacity(raw.len());
142 let mut prev_dash = false;
143 for c in raw.chars() {
144 if c == '-' {
145 if !prev_dash {
146 collapsed.push(c);
147 }
148 prev_dash = true;
149 } else {
150 collapsed.push(c);
151 prev_dash = false;
152 }
153 }
154
155 let trimmed = collapsed.trim_matches('-');
157
158 let slug = if trimmed.is_empty() { "goal" } else { trimmed };
160
161 let truncated = if slug.len() > 50 {
163 slug[..50].trim_end_matches('-')
164 } else {
165 slug
166 };
167
168 let shortref = goal.shortref();
171 format!("{}{}-{}", prefix, shortref, truncated)
172 }
173
174 pub fn detect(project_root: &Path) -> bool {
176 project_root.join(".git").exists()
177 }
178
179 pub const BUILTIN_LOCK_FILES: &'static [&'static str] = &[
185 "Cargo.lock",
186 "package-lock.json",
187 "go.sum",
188 "Pipfile.lock",
189 "poetry.lock",
190 "yarn.lock",
191 "bun.lockb",
192 "flake.lock",
193 ];
194
195 fn auto_stage_critical_files(&self, candidates: &[&str]) {
201 for path in candidates {
202 let full = self.work_dir.join(path);
203 if !full.exists() {
204 continue;
205 }
206 let dirty = Command::new("git")
208 .args(["status", "--porcelain", path])
209 .current_dir(&self.work_dir)
210 .env_remove("GIT_DIR")
211 .env_remove("GIT_WORK_TREE")
212 .env_remove("GIT_CEILING_DIRECTORIES")
213 .output()
214 .map(|o| !String::from_utf8_lossy(&o.stdout).trim().is_empty())
215 .unwrap_or(false);
216 if dirty {
217 if let Ok(()) = self.git_cmd(&["add", path]).map(|_| ()) {
218 println!(" ℹ️ auto-staged: {}", path);
219 tracing::info!(path = %path, "auto-staged critical file");
220 }
221 }
222 }
223 }
224
225 fn auto_stage_candidates(work_dir: &std::path::Path) -> Vec<String> {
227 let mut candidates: Vec<String> = Self::BUILTIN_LOCK_FILES
228 .iter()
229 .map(|s| s.to_string())
230 .collect();
231 candidates.push(".ta/plan_history.jsonl".to_string());
233 candidates.push(".ta/goal-audit.jsonl".to_string());
234 candidates.push(".ta/velocity-history.jsonl".to_string());
235 candidates.push(".ta/project-memory".to_string());
237 let workflow_path = work_dir.join(".ta/workflow.toml");
239 let workflow = crate::config::WorkflowConfig::load_or_default(&workflow_path);
240 for entry in workflow.commit.auto_stage {
241 if !candidates.contains(&entry) {
242 candidates.push(entry);
243 }
244 }
245 candidates
246 }
247
248 fn is_known_safe_ignored(path: &str) -> bool {
252 if path == ".mcp.json" || path == "daemon.toml" {
254 return true;
255 }
256 if path.ends_with(".local.toml") {
258 return true;
259 }
260 if let Some(rest) = path.strip_prefix(".ta/") {
262 if rest.ends_with(".pid") || rest.ends_with(".lock") || rest == "daemon.toml" {
263 return true;
264 }
265 }
266 false
267 }
268
269 fn filter_gitignored_artifacts(
275 &self,
276 paths: &[String],
277 ) -> (Vec<String>, Vec<ta_changeset::IgnoredArtifact>) {
278 if paths.is_empty() {
279 return (vec![], vec![]);
280 }
281
282 let input = paths.join("\n");
286 let output = Command::new("git")
287 .args(["check-ignore", "--stdin"])
288 .current_dir(&self.work_dir)
289 .env_remove("GIT_DIR")
290 .env_remove("GIT_WORK_TREE")
291 .env_remove("GIT_CEILING_DIRECTORIES")
292 .stdin(std::process::Stdio::piped())
293 .stdout(std::process::Stdio::piped())
294 .stderr(std::process::Stdio::null())
295 .spawn()
296 .and_then(|mut child| {
297 use std::io::Write;
298 if let Some(stdin) = child.stdin.take() {
299 let mut stdin = stdin;
300 let _ = stdin.write_all(input.as_bytes());
301 }
302 child.wait_with_output()
303 });
304
305 let ignored_set: std::collections::HashSet<String> = match output {
306 Ok(out) => std::str::from_utf8(&out.stdout)
307 .unwrap_or("")
308 .lines()
309 .map(|l| l.trim().to_string())
310 .filter(|l| !l.is_empty())
311 .collect(),
312 Err(_) => {
313 tracing::debug!("git check-ignore failed — assuming no artifacts are gitignored");
315 std::collections::HashSet::new()
316 }
317 };
318
319 let mut to_add = Vec::new();
320 let mut ignored = Vec::new();
321
322 for path in paths {
323 if ignored_set.contains(path.as_str()) {
324 let known_safe = Self::is_known_safe_ignored(path);
325 if known_safe {
326 tracing::debug!(path = %path, "dropping known-safe gitignored artifact from git add");
327 } else {
328 eprintln!(
329 "Warning: artifact '{}' is gitignored — dropping from git add. \
330 Was this intentional?",
331 path
332 );
333 }
334 ignored.push(ta_changeset::IgnoredArtifact {
335 path: path.clone(),
336 known_safe,
337 });
338 } else {
339 to_add.push(path.clone());
340 }
341 }
342
343 (to_add, ignored)
344 }
345}
346
347impl SourceAdapter for GitAdapter {
348 fn prepare(&self, goal: &GoalRun, config: &SubmitConfig) -> Result<()> {
349 let branch_name = self.branch_name(goal, config);
350
351 tracing::info!("GitAdapter: creating branch {}", branch_name);
352
353 let branches = self.git_cmd(&["branch", "--list", &branch_name])?;
355 if branches.is_empty() {
356 self.git_cmd(&["checkout", "-b", &branch_name])?;
358 } else {
359 self.git_cmd(&["checkout", &branch_name])?;
361 }
362
363 Ok(())
364 }
365
366 fn commit(&self, goal: &GoalRun, pr: &DraftPackage, message: &str) -> Result<CommitResult> {
367 tracing::info!("GitAdapter: committing changes");
368
369 let mut seen = std::collections::HashSet::new();
376 let artifact_paths: Vec<String> = pr
377 .changes
378 .artifacts
379 .iter()
380 .filter_map(|a| {
381 a.resource_uri
382 .strip_prefix("fs://workspace/")
383 .map(|p| p.to_string())
384 })
385 .filter(|p| seen.insert(p.clone()))
386 .collect();
387
388 let ignored_artifacts = if artifact_paths.is_empty() {
392 vec![]
393 } else {
394 let (to_add, ignored) = self.filter_gitignored_artifacts(&artifact_paths);
395 if to_add.is_empty() {
396 if !ignored.is_empty() {
398 let unknown_count = ignored.iter().filter(|a| !a.known_safe).count();
399 if unknown_count > 0 {
400 eprintln!(
401 "Warning: all {} artifact(s) were gitignored — nothing was committed.",
402 ignored.len()
403 );
404 }
405 }
406 if self.work_dir.join(&self.plan_file).exists() {
409 let _ = self.git_cmd(&["add", &self.plan_file]);
410 }
411 let candidates = Self::auto_stage_candidates(&self.work_dir);
412 let candidate_refs: Vec<&str> = candidates.iter().map(|s| s.as_str()).collect();
413 self.auto_stage_critical_files(&candidate_refs);
414 return Ok(CommitResult {
415 commit_id: String::new(),
416 message: "All artifacts were gitignored — nothing was committed.".to_string(),
417 metadata: std::collections::HashMap::new(),
418 ignored_artifacts: ignored,
419 });
420 } else {
421 let (existing, deleted): (Vec<_>, Vec<_>) = to_add
427 .iter()
428 .partition(|p| self.work_dir.join(p.as_str()).exists());
429
430 if !existing.is_empty() {
431 let mut add_args = vec!["add"];
432 for p in &existing {
433 add_args.push(p.as_str());
434 }
435 self.git_cmd(&add_args)?;
436 }
437
438 if !deleted.is_empty() {
439 let mut rm_args = vec!["rm", "--cached", "--ignore-unmatch"];
442 for p in &deleted {
443 rm_args.push(p.as_str());
444 }
445 tracing::info!(
446 count = deleted.len(),
447 paths = ?deleted,
448 "git rm --cached for deleted artifacts"
449 );
450 self.git_cmd(&rm_args)?;
451 }
452
453 if self.work_dir.join(&self.plan_file).exists() {
457 let _ = self.git_cmd(&["add", &self.plan_file]);
458 }
459 let candidates = Self::auto_stage_candidates(&self.work_dir);
460 let candidate_refs: Vec<&str> = candidates.iter().map(|s| s.as_str()).collect();
461 self.auto_stage_critical_files(&candidate_refs);
462 }
463 ignored
464 };
465
466 if artifact_paths.is_empty() {
467 self.git_cmd(&["add", "."])?;
470 }
471
472 let status = self.git_cmd(&["status", "--porcelain"])?;
474 if status.trim().is_empty() {
475 return Err(SubmitError::InvalidState(
476 "No changes to commit".to_string(),
477 ));
478 }
479
480 let phase_line = goal
482 .plan_phase
483 .as_ref()
484 .map(|p| format!("\nPhase: {}", p))
485 .unwrap_or_default();
486 let co_author_line = if self.config.co_author.is_empty() {
487 String::new()
488 } else {
489 format!("\n\nCo-Authored-By: {}", self.config.co_author)
490 };
491 let commit_msg = format!(
492 "{}\n\nGoal-ID: {}\nPR-ID: {}{}{}",
493 message, goal.goal_run_id, pr.package_id, phase_line, co_author_line
494 );
495
496 self.git_cmd(&["commit", "-m", &commit_msg])?;
498
499 let commit_id = self.git_cmd(&["rev-parse", "HEAD"])?;
501
502 Ok(CommitResult {
503 commit_id: commit_id.clone(),
504 message: format!("Committed as {}", &commit_id[..8]),
505 metadata: [("full_hash".to_string(), commit_id)].into_iter().collect(),
506 ignored_artifacts,
507 })
508 }
509
510 fn push(&self, goal: &GoalRun) -> Result<PushResult> {
511 let branch_name = self.branch_name(goal, &self.config);
512 let remote = &self.config.git.remote;
513
514 tracing::info!("GitAdapter: pushing branch {} to {}", branch_name, remote);
515
516 self.git_cmd(&["push", "-u", remote, &branch_name])?;
518
519 Ok(PushResult {
520 remote_ref: format!("{}/{}", remote, branch_name),
521 message: format!("Pushed to {}/{}", remote, branch_name),
522 metadata: [
523 ("branch".to_string(), branch_name),
524 ("remote".to_string(), remote.clone()),
525 ]
526 .into_iter()
527 .collect(),
528 })
529 }
530
531 fn open_review(&self, goal: &GoalRun, pr: &DraftPackage) -> Result<ReviewResult> {
532 if !self.has_gh_cli() {
533 return Err(SubmitError::ReviewError(
534 "gh CLI not found - install GitHub CLI to create PRs".to_string(),
535 ));
536 }
537
538 let target_branch = &self.config.git.target_branch;
541 let head_branch = self.branch_name(goal, &self.config);
542
543 let body = self.build_pr_body(goal, pr, &self.config)?;
545
546 tracing::info!(
547 "GitAdapter: creating PR {} → {}",
548 head_branch,
549 target_branch
550 );
551
552 let existing = Command::new("gh")
556 .args([
557 "pr",
558 "list",
559 "--head",
560 &head_branch,
561 "--state",
562 "open",
563 "--json",
564 "url,number",
565 "--limit",
566 "1",
567 ])
568 .current_dir(&self.work_dir)
569 .output();
570 if let Ok(out) = existing {
571 if out.status.success() {
572 let json = String::from_utf8_lossy(&out.stdout);
573 if let Ok(prs) = serde_json::from_str::<Vec<serde_json::Value>>(json.trim()) {
574 if let Some(existing_pr) = prs.into_iter().next() {
575 let url = existing_pr
576 .get("url")
577 .and_then(|v| v.as_str())
578 .unwrap_or("")
579 .to_string();
580 let number = existing_pr
581 .get("number")
582 .and_then(|v| v.as_u64())
583 .map(|n| n.to_string())
584 .unwrap_or_else(|| {
585 url.split('/').next_back().unwrap_or("unknown").to_string()
586 });
587 if !url.is_empty() {
588 tracing::info!(
589 "GitAdapter: PR already exists for branch {}: {}",
590 head_branch,
591 url
592 );
593 if self.config.git.auto_merge {
595 let merge_strategy = &self.config.git.merge_strategy;
596 let merge_flag = match merge_strategy.as_str() {
597 "rebase" => "--rebase",
598 "merge" => "--merge",
599 _ => "--squash",
600 };
601 let _ = Command::new("gh")
602 .args(["pr", "merge", "--auto", merge_flag, &number])
603 .current_dir(&self.work_dir)
604 .output();
605 }
606 return Ok(ReviewResult {
607 review_url: url.clone(),
608 review_id: number,
609 message: format!("PR already open (reused): {}", url),
610 metadata: [("pr_url".to_string(), url)].into_iter().collect(),
611 });
612 }
613 }
614 }
615 }
616 }
617
618 let pr_title = format!("[{}] {}", goal.shortref(), goal.title);
623 let output = Command::new("gh")
624 .args([
625 "pr",
626 "create",
627 "--head",
628 &head_branch,
629 "--base",
630 target_branch,
631 "--title",
632 &pr_title,
633 "--body",
634 &body,
635 ])
636 .current_dir(&self.work_dir)
637 .output()?;
638
639 if !output.status.success() {
640 let stderr = String::from_utf8_lossy(&output.stderr);
641 return Err(SubmitError::ReviewError(format!(
642 "gh pr create failed: {}",
643 stderr
644 )));
645 }
646
647 let pr_url = String::from_utf8_lossy(&output.stdout).trim().to_string();
648
649 let pr_number = pr_url
651 .split('/')
652 .next_back()
653 .unwrap_or("unknown")
654 .to_string();
655
656 let auto_merge_active;
658 if self.config.git.auto_merge && self.has_gh_cli() {
659 let merge_strategy = &self.config.git.merge_strategy;
660 let merge_flag = match merge_strategy.as_str() {
661 "rebase" => "--rebase",
662 "merge" => "--merge",
663 _ => "--squash",
664 };
665 eprintln!(
670 "\n[!] AUTO-MERGE ENABLED (workflow.toml: auto_merge = true)\n\
671 [!] PR #{pr_number} will be merged to '{target}' automatically when CI passes.\n\
672 [!] There is NO human review gate. Disable with: auto_merge = false in .ta/workflow.toml\n",
673 pr_number = pr_number,
674 target = target_branch,
675 );
676 let auto_merge_output = Command::new("gh")
677 .args(["pr", "merge", "--auto", merge_flag, &pr_number])
678 .current_dir(&self.work_dir)
679 .output();
680 match auto_merge_output {
681 Ok(o) if o.status.success() => {
682 eprintln!(
683 "[!] Auto-merge queued for PR #{} ({} into {}).",
684 pr_number, merge_flag, target_branch
685 );
686 tracing::info!("GitAdapter: auto-merge enabled for PR #{}", pr_number);
687 auto_merge_active = true;
688 }
689 Ok(o) => {
690 let stderr = String::from_utf8_lossy(&o.stderr);
691 eprintln!(
692 "[warn] Auto-merge request failed for PR #{}: {}",
693 pr_number, stderr
694 );
695 tracing::warn!(
696 "GitAdapter: auto-merge failed for PR #{}: {}",
697 pr_number,
698 stderr
699 );
700 auto_merge_active = false;
701 }
702 Err(e) => {
703 eprintln!(
704 "[warn] Could not enable auto-merge for PR #{}: {}",
705 pr_number, e
706 );
707 tracing::warn!(
708 "GitAdapter: could not enable auto-merge for PR #{}: {}",
709 pr_number,
710 e
711 );
712 auto_merge_active = false;
713 }
714 }
715 } else {
716 auto_merge_active = false;
717 }
718
719 let message = if auto_merge_active {
720 format!(
721 "Created PR: {} [AUTO-MERGE ENABLED — will merge when CI passes]",
722 pr_url
723 )
724 } else {
725 format!("Created PR: {}", pr_url)
726 };
727
728 let mut meta: std::collections::HashMap<String, String> =
729 [("pr_url".to_string(), pr_url.clone())]
730 .into_iter()
731 .collect();
732 if auto_merge_active {
733 meta.insert("auto_merge".to_string(), "true".to_string());
734 }
735
736 Ok(ReviewResult {
737 review_url: pr_url,
738 review_id: pr_number,
739 message,
740 metadata: meta,
741 })
742 }
743
744 fn name(&self) -> &str {
745 "git"
746 }
747
748 fn exclude_patterns(&self) -> Vec<String> {
749 vec![".git/".to_string()]
750 }
751
752 fn sync_upstream(&self) -> Result<SyncResult> {
753 let remote = &self.sync_config.remote;
754 let branch = &self.sync_config.branch;
755 let strategy = &self.sync_config.strategy;
756
757 tracing::info!(
758 remote = %remote,
759 branch = %branch,
760 strategy = %strategy,
761 "GitAdapter: syncing upstream"
762 );
763
764 self.git_cmd(&["fetch", remote])?;
766
767 let remote_ref = format!("{}/{}", remote, branch);
769 let count_output = self
770 .git_cmd(&["rev-list", "--count", &format!("HEAD..{}", remote_ref)])
771 .unwrap_or_else(|_| "0".to_string());
772 let new_commits: u32 = count_output.trim().parse().unwrap_or(0);
773
774 if new_commits == 0 {
775 return Ok(SyncResult {
776 updated: false,
777 conflicts: vec![],
778 new_commits: 0,
779 message: format!("Already up to date with {}/{}.", remote, branch),
780 metadata: [
781 ("remote".to_string(), remote.clone()),
782 ("branch".to_string(), branch.clone()),
783 ]
784 .into_iter()
785 .collect(),
786 });
787 }
788
789 let merge_result = match strategy.as_str() {
791 "rebase" => self.git_cmd(&["rebase", &remote_ref]),
792 "ff-only" => self.git_cmd(&["merge", "--ff-only", &remote_ref]),
793 _ => self.git_cmd(&["merge", &remote_ref]),
794 };
795
796 match merge_result {
797 Ok(output) => Ok(SyncResult {
798 updated: true,
799 conflicts: vec![],
800 new_commits,
801 message: format!(
802 "Synced {} new commit(s) from {}/{} (strategy: {}). {}",
803 new_commits, remote, branch, strategy, output
804 ),
805 metadata: [
806 ("remote".to_string(), remote.clone()),
807 ("branch".to_string(), branch.clone()),
808 ("strategy".to_string(), strategy.clone()),
809 ]
810 .into_iter()
811 .collect(),
812 }),
813 Err(e) => {
814 let conflict_output = self
816 .git_cmd(&["diff", "--name-only", "--diff-filter=U"])
817 .unwrap_or_default();
818 let conflicts: Vec<String> = conflict_output
819 .lines()
820 .filter(|l| !l.is_empty())
821 .map(|l| l.to_string())
822 .collect();
823
824 if conflicts.is_empty() {
825 Err(SubmitError::SyncError(format!(
827 "Failed to sync {}/{} using strategy '{}': {}",
828 remote, branch, strategy, e
829 )))
830 } else {
831 Ok(SyncResult {
834 updated: true,
835 conflicts: conflicts.clone(),
836 new_commits,
837 message: format!(
838 "Synced {} new commit(s) from {}/{} but {} file(s) have conflicts. \
839 Resolve conflicts manually, then `git add` and `git commit`.",
840 new_commits,
841 remote,
842 branch,
843 conflicts.len()
844 ),
845 metadata: [
846 ("remote".to_string(), remote.clone()),
847 ("branch".to_string(), branch.clone()),
848 ("strategy".to_string(), strategy.clone()),
849 ]
850 .into_iter()
851 .collect(),
852 })
853 }
854 }
855 }
856 }
857
858 fn save_state(&self) -> Result<Option<SavedVcsState>> {
859 let branch = self.current_branch()?;
860 tracing::debug!(branch = %branch, "GitAdapter: saved branch state");
861 Ok(Some(SavedVcsState {
862 adapter: "git".to_string(),
863 data: Box::new(branch),
864 }))
865 }
866
867 fn restore_state(&self, state: Option<SavedVcsState>) -> Result<()> {
868 let state = match state {
869 Some(s) => s,
870 None => return Ok(()),
871 };
872
873 if state.adapter != "git" {
874 return Err(SubmitError::InvalidState(format!(
875 "Cannot restore state from adapter '{}' in GitAdapter",
876 state.adapter
877 )));
878 }
879
880 let original_branch = state
881 .data
882 .downcast::<String>()
883 .map_err(|_| SubmitError::InvalidState("Invalid saved state type".to_string()))?;
884
885 let current = self.current_branch()?;
886 if current != *original_branch {
887 match self.git_cmd(&["checkout", &original_branch]) {
888 Ok(_) => {
889 tracing::info!(
890 branch = %original_branch,
891 "GitAdapter: restored to original branch"
892 );
893 }
894 Err(e) => {
895 tracing::warn!(
896 branch = %original_branch,
897 current = %current,
898 error = %e,
899 "GitAdapter: could not restore branch. Run: git checkout {}",
900 original_branch
901 );
902 }
903 }
904 }
905 Ok(())
906 }
907
908 fn current_branch(&self) -> Result<String> {
909 self.git_cmd(&["rev-parse", "--abbrev-ref", "HEAD"])
910 }
911
912 fn revision_id(&self) -> Result<String> {
913 let hash = self.git_cmd(&["rev-parse", "--short", "HEAD"])?;
914
915 let status = self.git_cmd(&["status", "--porcelain"])?;
917 if status.is_empty() {
918 Ok(hash)
919 } else {
920 Ok(format!("{}-dirty", hash))
921 }
922 }
923
924 fn protected_submit_targets(&self) -> Vec<String> {
925 let custom = &self.config.git.protected_branches;
927 if !custom.is_empty() {
928 return custom.clone();
929 }
930 vec![
931 "main".to_string(),
932 "master".to_string(),
933 "trunk".to_string(),
934 "dev".to_string(),
935 ]
936 }
937
938 fn verify_not_on_protected_target(&self) -> Result<()> {
939 let current = self.current_branch()?;
940 let protected = self.protected_submit_targets();
941 if protected.iter().any(|b| b == ¤t) {
942 return Err(SubmitError::InvalidState(format!(
943 "Refusing to commit: still on protected branch '{}' after prepare(). \
944 This would bypass the feature branch + PR workflow. \
945 Check that the VCS adapter created a feature branch, then \
946 re-run `ta draft apply --submit`.",
947 current
948 )));
949 }
950 Ok(())
951 }
952
953 fn stage_env(
954 &self,
955 staging_dir: &Path,
956 config: &crate::config::VcsAgentConfig,
957 ) -> Result<std::collections::HashMap<String, String>> {
958 let mut env = std::collections::HashMap::new();
959
960 env.insert("GIT_AUTHOR_NAME".to_string(), "TA Agent".to_string());
962 env.insert("GIT_COMMITTER_NAME".to_string(), "TA Agent".to_string());
963 env.insert("GIT_AUTHOR_EMAIL".to_string(), "ta-agent@local".to_string());
964 env.insert(
965 "GIT_COMMITTER_EMAIL".to_string(),
966 "ta-agent@local".to_string(),
967 );
968
969 match config.git_mode.as_str() {
970 "none" => {
971 env.insert("GIT_DIR".to_string(), "/dev/null".to_string());
973 }
974 "inherit-read" => {
975 if config.ceiling_always {
977 if let Some(parent) = staging_dir.parent() {
978 env.insert(
979 "GIT_CEILING_DIRECTORIES".to_string(),
980 parent.to_string_lossy().to_string(),
981 );
982 }
983 }
984 }
985 _ => {
986 let git_dir = staging_dir.join(".git");
990 if !git_dir.exists() {
991 let init_output = std::process::Command::new("git")
994 .args(["init", "-b", "main"])
995 .current_dir(staging_dir)
996 .env_remove("GIT_DIR")
997 .env_remove("GIT_WORK_TREE")
998 .env_remove("GIT_CEILING_DIRECTORIES")
999 .output()
1000 .map_err(|e| SubmitError::VcsError(format!("git init failed: {}", e)))?;
1001 if !init_output.status.success() {
1002 let init2 = std::process::Command::new("git")
1003 .args(["init"])
1004 .current_dir(staging_dir)
1005 .env_remove("GIT_DIR")
1006 .env_remove("GIT_WORK_TREE")
1007 .env_remove("GIT_CEILING_DIRECTORIES")
1008 .output()
1009 .map_err(|e| {
1010 SubmitError::VcsError(format!("git init failed: {}", e))
1011 })?;
1012 if !init2.status.success() {
1013 let stderr = String::from_utf8_lossy(&init2.stderr);
1014 return Err(SubmitError::VcsError(format!(
1015 "git init in staging dir failed: {}",
1016 stderr
1017 )));
1018 }
1019 }
1020 let _ = std::process::Command::new("git")
1022 .args(["config", "user.name", "TA Agent"])
1023 .current_dir(staging_dir)
1024 .env_remove("GIT_DIR")
1025 .env_remove("GIT_WORK_TREE")
1026 .env_remove("GIT_CEILING_DIRECTORIES")
1027 .output();
1028 let _ = std::process::Command::new("git")
1029 .args(["config", "user.email", "ta-agent@local"])
1030 .current_dir(staging_dir)
1031 .env_remove("GIT_DIR")
1032 .env_remove("GIT_WORK_TREE")
1033 .env_remove("GIT_CEILING_DIRECTORIES")
1034 .output();
1035
1036 if config.init_baseline_commit {
1037 let _ = std::process::Command::new("git")
1040 .args(["add", "-A"])
1041 .current_dir(staging_dir)
1042 .env_remove("GIT_DIR")
1043 .env_remove("GIT_WORK_TREE")
1044 .env_remove("GIT_CEILING_DIRECTORIES")
1045 .output();
1046 let _ = std::process::Command::new("git")
1047 .args(["commit", "--allow-empty", "-m", "pre-agent baseline"])
1048 .current_dir(staging_dir)
1049 .env_remove("GIT_DIR")
1050 .env_remove("GIT_WORK_TREE")
1051 .env_remove("GIT_CEILING_DIRECTORIES")
1052 .env("GIT_AUTHOR_NAME", "TA Agent")
1053 .env("GIT_AUTHOR_EMAIL", "ta-agent@local")
1054 .env("GIT_COMMITTER_NAME", "TA Agent")
1055 .env("GIT_COMMITTER_EMAIL", "ta-agent@local")
1056 .output();
1057 }
1058 }
1059
1060 env.insert("GIT_DIR".to_string(), git_dir.to_string_lossy().to_string());
1062 env.insert(
1063 "GIT_WORK_TREE".to_string(),
1064 staging_dir.to_string_lossy().to_string(),
1065 );
1066 if config.ceiling_always {
1068 if let Some(parent) = staging_dir.parent() {
1069 env.insert(
1070 "GIT_CEILING_DIRECTORIES".to_string(),
1071 parent.to_string_lossy().to_string(),
1072 );
1073 }
1074 }
1075 }
1076 }
1077
1078 Ok(env)
1079 }
1080
1081 fn check_review(&self, review_id: &str) -> Result<Option<ReviewStatus>> {
1082 if !self.has_gh_cli() {
1083 return Ok(None);
1084 }
1085
1086 let output = Command::new("gh")
1087 .args(["pr", "view", review_id, "--json", "state,statusCheckRollup"])
1088 .current_dir(&self.work_dir)
1089 .output();
1090
1091 match output {
1092 Ok(o) if o.status.success() => {
1093 let stdout = String::from_utf8_lossy(&o.stdout);
1094 let json: serde_json::Value = serde_json::from_str(&stdout).map_err(|e| {
1095 SubmitError::VcsError(format!("Failed to parse gh pr view output: {}", e))
1096 })?;
1097
1098 let state = json
1099 .get("state")
1100 .and_then(|v| v.as_str())
1101 .unwrap_or("unknown")
1102 .to_lowercase();
1103
1104 let checks_passing = json.get("statusCheckRollup").and_then(|v| {
1105 v.as_array().map(|checks| {
1106 checks.iter().all(|c| {
1107 c.get("conclusion").and_then(|v| v.as_str()) == Some("SUCCESS")
1108 })
1109 })
1110 });
1111
1112 Ok(Some(ReviewStatus {
1113 state,
1114 checks_passing,
1115 }))
1116 }
1117 _ => Ok(None),
1118 }
1119 }
1120
1121 fn merge_review(&self, review_id: &str) -> Result<MergeResult> {
1122 if !self.has_gh_cli() {
1123 return Err(SubmitError::ReviewError(
1124 "gh CLI not found — install GitHub CLI to merge PRs automatically. \
1125 Merge manually at the PR URL, then run `ta sync`."
1126 .to_string(),
1127 ));
1128 }
1129
1130 let merge_strategy = &self.config.git.merge_strategy;
1131 let merge_flag = match merge_strategy.as_str() {
1132 "rebase" => "--rebase",
1133 "merge" => "--merge",
1134 _ => "--squash",
1135 };
1136
1137 tracing::info!(
1138 review_id = %review_id,
1139 strategy = %merge_strategy,
1140 "GitAdapter: merging PR"
1141 );
1142
1143 let output = Command::new("gh")
1144 .args(["pr", "merge", review_id, "--auto", merge_flag])
1145 .current_dir(&self.work_dir)
1146 .output()?;
1147
1148 if output.status.success() {
1149 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
1150 let merged =
1152 !stdout.contains("auto-merge") && !stdout.is_empty() || stdout.contains("Merged");
1153
1154 Ok(MergeResult {
1155 merged,
1156 merge_commit: None,
1157 message: if merged {
1158 format!("PR #{} merged ({}).", review_id, merge_strategy)
1159 } else {
1160 format!(
1161 "Auto-merge enabled for PR #{} — will merge when CI passes.",
1162 review_id
1163 )
1164 },
1165 metadata: [
1166 ("review_id".to_string(), review_id.to_string()),
1167 ("strategy".to_string(), merge_strategy.clone()),
1168 ]
1169 .into_iter()
1170 .collect(),
1171 })
1172 } else {
1173 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1174 if stderr.contains("not mergeable") || stderr.contains("auto-merge") {
1176 Ok(MergeResult {
1177 merged: false,
1178 merge_commit: None,
1179 message: format!(
1180 "PR #{} is not yet mergeable (CI may be pending). \
1181 Auto-merge is set — it will merge when checks pass. \
1182 Run `ta draft watch <id>` to monitor.",
1183 review_id
1184 ),
1185 metadata: [("review_id".to_string(), review_id.to_string())]
1186 .into_iter()
1187 .collect(),
1188 })
1189 } else {
1190 Err(SubmitError::ReviewError(format!(
1191 "gh pr merge failed for PR #{}: {}",
1192 review_id, stderr
1193 )))
1194 }
1195 }
1196 }
1197}
1198
1199impl GitAdapter {
1200 fn build_pr_body(
1207 &self,
1208 goal: &GoalRun,
1209 pr: &DraftPackage,
1210 config: &SubmitConfig,
1211 ) -> Result<String> {
1212 if let Some(template_path) = &config.git.pr_template {
1214 if template_path.exists() {
1215 let template = std::fs::read_to_string(template_path)?;
1216 return Ok(self.substitute_template(&template, goal, pr));
1217 }
1218 }
1219
1220 let convention_path = self.work_dir.join(".ta/pr-template.md");
1222 if convention_path.exists() {
1223 if let Ok(template) = std::fs::read_to_string(&convention_path) {
1224 return Ok(self.substitute_template(&template, goal, pr));
1225 }
1226 }
1227
1228 let artifact_detail = Self::format_artifacts_detail(pr);
1230 Ok(format!(
1231 "## Summary\n\n\
1232 {}\n\n\
1233 **Why**: {}\n\n\
1234 **Impact**: {}\n\n\
1235 ## Changes ({} artifacts)\n\n\
1236 {}\n\n\
1237 ## Goal Context\n\n\
1238 - **Goal ID**: `{}`\n\
1239 - **PR ID**: `{}`\n\
1240 {}\n\n\
1241 ---\n\n\
1242 Generated by [Trusted Autonomy](https://github.com/trustedautonomy/ta)",
1243 pr.summary.what_changed,
1244 pr.summary.why,
1245 pr.summary.impact,
1246 pr.changes.artifacts.len(),
1247 artifact_detail,
1248 goal.goal_run_id,
1249 pr.package_id,
1250 goal.plan_phase
1251 .as_ref()
1252 .map(|p| format!("- **Plan Phase**: `{}`", p))
1253 .unwrap_or_default()
1254 ))
1255 }
1256
1257 fn format_artifacts_detail(pr: &DraftPackage) -> String {
1259 pr.changes
1260 .artifacts
1261 .iter()
1262 .map(|a| {
1263 let change_icon = match a.change_type {
1264 ta_changeset::draft_package::ChangeType::Add => "+",
1265 ta_changeset::draft_package::ChangeType::Modify => "~",
1266 ta_changeset::draft_package::ChangeType::Delete => "-",
1267 ta_changeset::draft_package::ChangeType::Rename => ">",
1268 };
1269 let summary = a
1270 .explanation_tiers
1271 .as_ref()
1272 .map(|t| t.summary.as_str())
1273 .or(a.rationale.as_deref())
1274 .unwrap_or("");
1275
1276 let mut line = if summary.is_empty() {
1277 format!("- `{change_icon}` `{}`", a.resource_uri)
1278 } else {
1279 format!("- `{change_icon}` `{}` — {}", a.resource_uri, summary)
1280 };
1281
1282 if let Some(tiers) = &a.explanation_tiers {
1284 if !tiers.explanation.is_empty() && tiers.explanation != tiers.summary {
1285 line.push_str(&format!("\n - {}", tiers.explanation));
1286 }
1287 }
1288
1289 line
1290 })
1291 .collect::<Vec<_>>()
1292 .join("\n")
1293 }
1294
1295 fn substitute_template(&self, template: &str, goal: &GoalRun, pr: &DraftPackage) -> String {
1309 let artifact_lines = Self::format_artifacts_detail(pr);
1310
1311 template
1312 .replace("{summary}", &pr.summary.what_changed)
1313 .replace("{why}", &pr.summary.why)
1314 .replace("{impact}", &pr.summary.impact)
1315 .replace("{goal_id}", &goal.goal_run_id.to_string())
1316 .replace("{pr_id}", &pr.package_id.to_string())
1317 .replace("{title}", &goal.title)
1318 .replace("{objective}", &goal.objective)
1319 .replace("{plan_phase}", goal.plan_phase.as_deref().unwrap_or("N/A"))
1320 .replace("{artifact_count}", &pr.changes.artifacts.len().to_string())
1321 .replace("{artifacts}", &artifact_lines)
1322 }
1323}
1324
1325#[cfg(test)]
1326mod tests {
1327 use super::*;
1328 use tempfile::tempdir;
1329
1330 fn init_git_repo(dir: &Path) -> Result<()> {
1331 let clear_git_env = |cmd: &mut Command| {
1334 cmd.env_remove("GIT_DIR")
1335 .env_remove("GIT_WORK_TREE")
1336 .env_remove("GIT_CEILING_DIRECTORIES");
1337 };
1338
1339 let mut cmd = Command::new("git");
1340 cmd.args(["init"]).current_dir(dir);
1341 clear_git_env(&mut cmd);
1342 cmd.output()?;
1343
1344 let mut cmd = Command::new("git");
1345 cmd.args(["config", "user.name", "Test User"])
1346 .current_dir(dir);
1347 clear_git_env(&mut cmd);
1348 cmd.output()?;
1349
1350 let mut cmd = Command::new("git");
1351 cmd.args(["config", "user.email", "test@example.com"])
1352 .current_dir(dir);
1353 clear_git_env(&mut cmd);
1354 cmd.output()?;
1355
1356 std::fs::write(dir.join("README.md"), "# Test\n")?;
1358
1359 let mut cmd = Command::new("git");
1360 cmd.args(["add", "."]).current_dir(dir);
1361 clear_git_env(&mut cmd);
1362 cmd.output()?;
1363
1364 let mut cmd = Command::new("git");
1365 cmd.args(["commit", "-m", "Initial commit"])
1366 .current_dir(dir);
1367 clear_git_env(&mut cmd);
1368 cmd.output()?;
1369
1370 Ok(())
1371 }
1372
1373 #[test]
1374 fn test_git_adapter_protected_targets_default() {
1375 let dir = tempdir().unwrap();
1376 let adapter = GitAdapter::new(dir.path());
1377 let targets = adapter.protected_submit_targets();
1378 assert!(targets.contains(&"main".to_string()));
1379 assert!(targets.contains(&"master".to_string()));
1380 assert!(targets.contains(&"trunk".to_string()));
1381 assert!(targets.contains(&"dev".to_string()));
1382 }
1383
1384 #[test]
1385 fn test_git_adapter_protected_targets_custom() {
1386 let dir = tempdir().unwrap();
1387 let config = SubmitConfig {
1388 git: crate::config::GitConfig {
1389 protected_branches: vec!["release".to_string(), "staging".to_string()],
1390 ..Default::default()
1391 },
1392 ..Default::default()
1393 };
1394 let adapter = GitAdapter::with_config(dir.path(), config);
1395 let targets = adapter.protected_submit_targets();
1396 assert_eq!(targets, vec!["release", "staging"]);
1397 }
1398
1399 #[test]
1400 fn test_verify_not_on_protected_target_feature_branch() {
1401 let dir = tempdir().unwrap();
1402 init_git_repo(dir.path()).unwrap();
1403
1404 let adapter = GitAdapter::new(dir.path());
1405 let goal = GoalRun::new(
1406 "Test Goal",
1407 "Test",
1408 "test-agent",
1409 dir.path().to_path_buf(),
1410 dir.path().join("store"),
1411 );
1412
1413 let config = SubmitConfig::default();
1415 adapter.prepare(&goal, &config).unwrap();
1416
1417 assert!(adapter.verify_not_on_protected_target().is_ok());
1419 }
1420
1421 #[test]
1422 fn test_verify_not_on_protected_target_on_main() {
1423 let dir = tempdir().unwrap();
1424 init_git_repo(dir.path()).unwrap();
1425
1426 let adapter = GitAdapter::new(dir.path());
1427
1428 let current = adapter.current_branch().unwrap();
1430 if ["main", "master", "trunk", "dev"].contains(¤t.as_str()) {
1432 assert!(adapter.verify_not_on_protected_target().is_err());
1433 }
1434 }
1435
1436 #[test]
1437 fn test_git_adapter_branch_name() {
1438 let dir = tempdir().unwrap();
1439 init_git_repo(dir.path()).unwrap();
1440
1441 let adapter = GitAdapter::new(dir.path());
1442 let goal = GoalRun::new(
1443 "Add New Feature",
1444 "Test",
1445 "test-agent",
1446 dir.path().to_path_buf(),
1447 dir.path().join("store"),
1448 );
1449
1450 let config = SubmitConfig::default();
1451 let branch = adapter.branch_name(&goal, &config);
1452
1453 assert!(branch.starts_with("ta/"));
1454 assert!(branch.contains("add-new-feature"));
1455 }
1456
1457 #[test]
1458 fn test_branch_name_backtick_title() {
1459 let dir = tempdir().unwrap();
1460 init_git_repo(dir.path()).unwrap();
1461 let adapter = GitAdapter::new(dir.path());
1462 let config = SubmitConfig::default();
1463
1464 let goal = GoalRun::new(
1466 "`ta sync`",
1467 "Test",
1468 "test-agent",
1469 dir.path().to_path_buf(),
1470 dir.path().join("store"),
1471 );
1472 let branch = adapter.branch_name(&goal, &config);
1473 assert!(
1474 !branch.contains("--"),
1475 "consecutive dashes should be collapsed: {}",
1476 branch
1477 );
1478 assert!(
1479 !branch.ends_with('-'),
1480 "branch should not end with dash: {}",
1481 branch
1482 );
1483 let slug = branch.strip_prefix("ta/").unwrap_or(&branch);
1484 assert!(
1485 !slug.starts_with('-'),
1486 "slug should not start with dash: {}",
1487 branch
1488 );
1489 }
1490
1491 #[test]
1492 fn test_branch_name_all_special_chars() {
1493 let dir = tempdir().unwrap();
1494 init_git_repo(dir.path()).unwrap();
1495 let adapter = GitAdapter::new(dir.path());
1496 let config = SubmitConfig::default();
1497
1498 let goal = GoalRun::new(
1500 "!!! ???",
1501 "Test",
1502 "test-agent",
1503 dir.path().to_path_buf(),
1504 dir.path().join("store"),
1505 );
1506 let branch = adapter.branch_name(&goal, &config);
1507 assert!(
1508 branch.ends_with("goal"),
1509 "fallback should be 'goal': {}",
1510 branch
1511 );
1512 }
1513
1514 #[test]
1515 fn test_branch_name_single_quotes_and_spaces() {
1516 let dir = tempdir().unwrap();
1517 init_git_repo(dir.path()).unwrap();
1518 let adapter = GitAdapter::new(dir.path());
1519 let config = SubmitConfig::default();
1520
1521 let goal = GoalRun::new(
1523 "Fix 'ta run' timeout",
1524 "Test",
1525 "test-agent",
1526 dir.path().to_path_buf(),
1527 dir.path().join("store"),
1528 );
1529 let branch = adapter.branch_name(&goal, &config);
1530 assert!(!branch.contains("--"), "no consecutive dashes: {}", branch);
1531 assert!(branch.contains("fix"), "should contain 'fix': {}", branch);
1532 }
1533
1534 #[test]
1535 fn test_git_adapter_prepare() {
1536 let dir = tempdir().unwrap();
1537 init_git_repo(dir.path()).unwrap();
1538
1539 let adapter = GitAdapter::new(dir.path());
1540 let goal = GoalRun::new(
1541 "Test Goal",
1542 "Test",
1543 "test-agent",
1544 dir.path().to_path_buf(),
1545 dir.path().join("store"),
1546 );
1547
1548 let config = SubmitConfig::default();
1549 assert!(adapter.prepare(&goal, &config).is_ok());
1550
1551 let current = adapter.current_branch().unwrap();
1553 assert!(current.starts_with("ta/"));
1554 }
1555
1556 #[test]
1557 fn test_git_adapter_exclude_patterns() {
1558 let dir = tempdir().unwrap();
1559 let adapter = GitAdapter::new(dir.path());
1560 let patterns = adapter.exclude_patterns();
1561 assert_eq!(patterns, vec![".git/"]);
1562 }
1563
1564 #[test]
1565 fn test_git_adapter_detect() {
1566 let dir = tempdir().unwrap();
1567
1568 assert!(!GitAdapter::detect(dir.path()));
1570
1571 init_git_repo(dir.path()).unwrap();
1573 assert!(GitAdapter::detect(dir.path()));
1574 }
1575
1576 #[test]
1577 fn test_git_adapter_save_restore_state() {
1578 let dir = tempdir().unwrap();
1579 init_git_repo(dir.path()).unwrap();
1580
1581 let adapter = GitAdapter::new(dir.path());
1582
1583 let original_branch = adapter.current_branch().unwrap();
1585 let state = adapter.save_state().unwrap();
1586 assert!(state.is_some());
1587
1588 let goal = GoalRun::new(
1590 "Test Goal",
1591 "Test",
1592 "test-agent",
1593 dir.path().to_path_buf(),
1594 dir.path().join("store"),
1595 );
1596 let config = SubmitConfig::default();
1597 adapter.prepare(&goal, &config).unwrap();
1598
1599 let current = adapter.current_branch().unwrap();
1601 assert_ne!(current, original_branch);
1602
1603 adapter.restore_state(state).unwrap();
1605 let restored = adapter.current_branch().unwrap();
1606 assert_eq!(restored, original_branch);
1607 }
1608
1609 #[test]
1610 fn test_git_adapter_sync_upstream_already_up_to_date() {
1611 let dir = tempdir().unwrap();
1612 init_git_repo(dir.path()).unwrap();
1613
1614 let adapter = GitAdapter::new(dir.path());
1615 let result = adapter.sync_upstream();
1618 assert!(result.is_err());
1620 }
1621
1622 #[test]
1623 fn test_git_adapter_sync_upstream_with_local_remote() {
1624 let remote_dir = tempdir().unwrap();
1626 init_git_repo(remote_dir.path()).unwrap();
1627
1628 let local_dir = tempdir().unwrap();
1630 Command::new("git")
1631 .args(["clone", &remote_dir.path().to_string_lossy(), "."])
1632 .current_dir(local_dir.path())
1633 .env_remove("GIT_DIR")
1634 .env_remove("GIT_WORK_TREE")
1635 .env_remove("GIT_CEILING_DIRECTORIES")
1636 .output()
1637 .unwrap();
1638
1639 let branch_output = Command::new("git")
1641 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1642 .current_dir(local_dir.path())
1643 .env_remove("GIT_DIR")
1644 .env_remove("GIT_WORK_TREE")
1645 .env_remove("GIT_CEILING_DIRECTORIES")
1646 .output()
1647 .unwrap();
1648 let branch_name = String::from_utf8_lossy(&branch_output.stdout)
1649 .trim()
1650 .to_string();
1651
1652 let sync_config = crate::config::SyncConfig {
1654 branch: branch_name,
1655 ..Default::default()
1656 };
1657 let adapter =
1658 GitAdapter::with_full_config(local_dir.path(), SubmitConfig::default(), sync_config);
1659
1660 let result = adapter.sync_upstream().unwrap();
1662 assert!(!result.updated);
1663 assert_eq!(result.new_commits, 0);
1664 assert!(result.is_clean());
1665
1666 std::fs::write(remote_dir.path().join("new_file.txt"), "hello\n").unwrap();
1668 Command::new("git")
1669 .args(["add", "."])
1670 .current_dir(remote_dir.path())
1671 .env_remove("GIT_DIR")
1672 .env_remove("GIT_WORK_TREE")
1673 .env_remove("GIT_CEILING_DIRECTORIES")
1674 .output()
1675 .unwrap();
1676 Command::new("git")
1677 .args(["commit", "-m", "Remote commit"])
1678 .current_dir(remote_dir.path())
1679 .env_remove("GIT_DIR")
1680 .env_remove("GIT_WORK_TREE")
1681 .env_remove("GIT_CEILING_DIRECTORIES")
1682 .output()
1683 .unwrap();
1684
1685 let result = adapter.sync_upstream().unwrap();
1687 assert!(result.updated);
1688 assert_eq!(result.new_commits, 1);
1689 assert!(result.is_clean());
1690
1691 assert!(local_dir.path().join("new_file.txt").exists());
1693 }
1694
1695 #[test]
1696 fn test_git_adapter_revision_id() {
1697 let dir = tempdir().unwrap();
1698 init_git_repo(dir.path()).unwrap();
1699
1700 let adapter = GitAdapter::new(dir.path());
1701 let rev = adapter.revision_id().unwrap();
1702
1703 assert!(!rev.is_empty());
1705 assert_ne!(rev, "unknown");
1706 }
1707
1708 #[test]
1711 fn test_git_none_mode_sets_dev_null() {
1712 let dir = tempdir().unwrap();
1713 let adapter = GitAdapter::new(dir.path());
1714 let config = crate::config::VcsAgentConfig {
1715 git_mode: "none".to_string(),
1716 ..Default::default()
1717 };
1718 let env = adapter.stage_env(dir.path(), &config).unwrap();
1719 assert_eq!(env.get("GIT_DIR").map(|s| s.as_str()), Some("/dev/null"));
1720 assert!(!env.contains_key("GIT_WORK_TREE"));
1721 }
1722
1723 #[test]
1724 fn test_git_inherit_read_sets_ceiling() {
1725 let dir = tempdir().unwrap();
1726 let adapter = GitAdapter::new(dir.path());
1727 let config = crate::config::VcsAgentConfig {
1728 git_mode: "inherit-read".to_string(),
1729 ceiling_always: true,
1730 ..Default::default()
1731 };
1732 let env = adapter.stage_env(dir.path(), &config).unwrap();
1733 assert!(env.contains_key("GIT_CEILING_DIRECTORIES"));
1734 let ceiling = env.get("GIT_CEILING_DIRECTORIES").unwrap();
1735 assert_eq!(ceiling, dir.path().parent().unwrap().to_str().unwrap());
1736 }
1737
1738 #[test]
1739 fn test_git_isolated_inits_repo() {
1740 let dir = tempdir().unwrap();
1741 let adapter = GitAdapter::new(dir.path());
1742 let config = crate::config::VcsAgentConfig {
1743 git_mode: "isolated".to_string(),
1744 init_baseline_commit: false, ..Default::default()
1746 };
1747 let env = adapter.stage_env(dir.path(), &config).unwrap();
1748 assert!(
1750 dir.path().join(".git").exists(),
1751 ".git should be created by isolated mode"
1752 );
1753 let git_dir = env.get("GIT_DIR").unwrap();
1755 assert!(
1756 git_dir.contains(".git"),
1757 "GIT_DIR should point to staging .git"
1758 );
1759 let work_tree = env.get("GIT_WORK_TREE").unwrap();
1761 assert_eq!(work_tree, dir.path().to_str().unwrap());
1762 }
1763
1764 #[test]
1765 fn test_git_isolated_sets_ceiling() {
1766 let dir = tempdir().unwrap();
1767 let adapter = GitAdapter::new(dir.path());
1768 let config = crate::config::VcsAgentConfig {
1769 git_mode: "isolated".to_string(),
1770 ceiling_always: true,
1771 init_baseline_commit: false,
1772 ..Default::default()
1773 };
1774 let env = adapter.stage_env(dir.path(), &config).unwrap();
1775 assert!(
1776 env.contains_key("GIT_CEILING_DIRECTORIES"),
1777 "GIT_CEILING_DIRECTORIES should be set in isolated mode"
1778 );
1779 }
1780
1781 #[test]
1782 fn test_git_ceiling_prevents_upward_traversal() {
1783 let dir = tempdir().unwrap();
1784 let adapter = GitAdapter::new(dir.path());
1785 let config = crate::config::VcsAgentConfig {
1786 git_mode: "isolated".to_string(),
1787 ceiling_always: true,
1788 init_baseline_commit: false,
1789 ..Default::default()
1790 };
1791 let env = adapter.stage_env(dir.path(), &config).unwrap();
1792 let ceiling = env.get("GIT_CEILING_DIRECTORIES").unwrap();
1793 let staging_path = dir.path().to_str().unwrap();
1796 assert_ne!(
1797 ceiling.as_str(),
1798 staging_path,
1799 "GIT_CEILING_DIRECTORIES should be parent of staging dir, not staging dir itself"
1800 );
1801 }
1802
1803 #[test]
1804 fn test_artifact_path_extraction_from_uris() {
1805 let uris = [
1808 "fs://workspace/src/main.rs",
1809 "fs://workspace/Cargo.toml",
1810 "mailto://nowhere", "fs://workspace/README.md", ];
1813 let fs_paths: Vec<String> = uris
1814 .iter()
1815 .filter_map(|uri| uri.strip_prefix("fs://workspace/").map(|p| p.to_string()))
1816 .collect();
1817 assert_eq!(fs_paths.len(), 3);
1818 assert!(fs_paths.contains(&"src/main.rs".to_string()));
1819 assert!(fs_paths.contains(&"Cargo.toml".to_string()));
1820 assert!(fs_paths.contains(&"README.md".to_string()));
1821 assert!(!fs_paths.iter().any(|p| p.contains("mailto")));
1823 }
1824
1825 #[test]
1831 fn test_known_safe_classification() {
1832 assert!(GitAdapter::is_known_safe_ignored(".mcp.json"));
1833 assert!(GitAdapter::is_known_safe_ignored("settings.local.toml"));
1834 assert!(GitAdapter::is_known_safe_ignored("project.local.toml"));
1835 assert!(GitAdapter::is_known_safe_ignored(".ta/daemon.toml"));
1836 assert!(GitAdapter::is_known_safe_ignored(".ta/agent.pid"));
1837 assert!(GitAdapter::is_known_safe_ignored(".ta/staging.lock"));
1838 assert!(!GitAdapter::is_known_safe_ignored("src/main.rs"));
1840 assert!(!GitAdapter::is_known_safe_ignored("Cargo.toml"));
1841 assert!(!GitAdapter::is_known_safe_ignored("secret.txt"));
1842 }
1843
1844 #[test]
1846 fn test_known_safe_dropped_silently() {
1847 let dir = tempdir().unwrap();
1848 init_git_repo(dir.path()).unwrap();
1849
1850 std::fs::write(dir.path().join(".gitignore"), ".mcp.json\n").unwrap();
1852
1853 let adapter = GitAdapter::new(dir.path());
1854 let paths = vec![".mcp.json".to_string(), "README.md".to_string()];
1855 let (to_add, ignored) = adapter.filter_gitignored_artifacts(&paths);
1856
1857 assert_eq!(to_add, vec!["README.md".to_string()]);
1858 assert_eq!(ignored.len(), 1);
1859 assert_eq!(ignored[0].path, ".mcp.json");
1860 assert!(
1861 ignored[0].known_safe,
1862 ".mcp.json must be classified as known_safe"
1863 );
1864 }
1865
1866 #[test]
1869 fn test_unexpected_ignored() {
1870 let dir = tempdir().unwrap();
1871 init_git_repo(dir.path()).unwrap();
1872
1873 std::fs::write(dir.path().join(".gitignore"), "src/secret.rs\n").unwrap();
1875
1876 let adapter = GitAdapter::new(dir.path());
1877 let paths = vec!["src/secret.rs".to_string(), "README.md".to_string()];
1878 let (to_add, ignored) = adapter.filter_gitignored_artifacts(&paths);
1879
1880 assert_eq!(to_add, vec!["README.md".to_string()]);
1881 assert_eq!(ignored.len(), 1);
1882 assert_eq!(ignored[0].path, "src/secret.rs");
1883 assert!(
1884 !ignored[0].known_safe,
1885 "src/secret.rs must be unexpected-ignored"
1886 );
1887 }
1888
1889 #[test]
1893 fn test_all_ignored_returns_empty_to_add() {
1894 let dir = tempdir().unwrap();
1895 init_git_repo(dir.path()).unwrap();
1896
1897 std::fs::write(
1898 dir.path().join(".gitignore"),
1899 ".mcp.json\nsettings.local.toml\n",
1900 )
1901 .unwrap();
1902
1903 let adapter = GitAdapter::new(dir.path());
1904 let paths = vec![".mcp.json".to_string(), "settings.local.toml".to_string()];
1905 let (to_add, ignored) = adapter.filter_gitignored_artifacts(&paths);
1906
1907 assert!(to_add.is_empty(), "all paths should be filtered out");
1908 assert_eq!(ignored.len(), 2);
1909 assert!(ignored.iter().all(|a| a.known_safe), "both are known-safe");
1910 }
1911
1912 #[test]
1915 fn builtin_lock_files_contains_expected_entries() {
1916 let list = GitAdapter::BUILTIN_LOCK_FILES;
1917 assert!(list.contains(&"Cargo.lock"));
1918 assert!(list.contains(&"package-lock.json"));
1919 assert!(list.contains(&"go.sum"));
1920 assert!(list.contains(&"poetry.lock"));
1921 assert!(list.contains(&"yarn.lock"));
1922 assert!(list.contains(&"bun.lockb"));
1923 assert!(list.contains(&"flake.lock"));
1924 assert!(list.contains(&"Pipfile.lock"));
1925 }
1926
1927 #[test]
1928 fn auto_stage_candidates_includes_builtin_and_plan_history() {
1929 let dir = tempdir().unwrap();
1930 let candidates = GitAdapter::auto_stage_candidates(dir.path());
1931 assert!(candidates.iter().any(|c| c == "Cargo.lock"));
1933 assert!(candidates.iter().any(|c| c == "go.sum"));
1934 assert!(candidates.iter().any(|c| c == ".ta/plan_history.jsonl"));
1936 assert!(candidates.iter().any(|c| c == ".ta/velocity-history.jsonl"));
1937 }
1938
1939 #[test]
1940 fn auto_stage_candidates_merges_user_config() {
1941 let dir = tempdir().unwrap();
1942 std::fs::create_dir_all(dir.path().join(".ta")).unwrap();
1944 std::fs::write(
1945 dir.path().join(".ta/workflow.toml"),
1946 "[commit]\nauto_stage = [\"docs/generated/api.md\"]\n",
1947 )
1948 .unwrap();
1949 let candidates = GitAdapter::auto_stage_candidates(dir.path());
1950 assert!(
1951 candidates.iter().any(|c| c == "docs/generated/api.md"),
1952 "user-configured entry should be present"
1953 );
1954 assert!(candidates.iter().any(|c| c == "Cargo.lock"));
1956 }
1957
1958 #[test]
1959 fn auto_stage_candidates_no_duplicates_with_user_config() {
1960 let dir = tempdir().unwrap();
1961 std::fs::create_dir_all(dir.path().join(".ta")).unwrap();
1962 std::fs::write(
1964 dir.path().join(".ta/workflow.toml"),
1965 "[commit]\nauto_stage = [\"Cargo.lock\"]\n",
1966 )
1967 .unwrap();
1968 let candidates = GitAdapter::auto_stage_candidates(dir.path());
1969 let cargo_lock_count = candidates
1970 .iter()
1971 .filter(|c| c.as_str() == "Cargo.lock")
1972 .count();
1973 assert_eq!(cargo_lock_count, 1, "Cargo.lock should appear exactly once");
1974 }
1975
1976 fn git_in(dir: &std::path::Path, args: &[&str]) -> std::process::Output {
1978 let mut cmd = Command::new("git");
1979 cmd.args(args).current_dir(dir);
1980 cmd.env_remove("GIT_DIR")
1981 .env_remove("GIT_WORK_TREE")
1982 .env_remove("GIT_CEILING_DIRECTORIES");
1983 cmd.output().unwrap()
1984 }
1985
1986 #[test]
1987 fn auto_stage_critical_files_stages_modified_file() {
1988 let dir = tempdir().unwrap();
1989 init_git_repo(dir.path()).unwrap();
1990
1991 std::fs::write(dir.path().join("Cargo.lock"), "version = 3\n").unwrap();
1993 git_in(dir.path(), &["add", "Cargo.lock"]);
1994 git_in(dir.path(), &["commit", "-m", "add lock"]);
1995
1996 std::fs::write(dir.path().join("Cargo.lock"), "version = 3\n# updated\n").unwrap();
1998
1999 let adapter = GitAdapter::new(dir.path());
2000 adapter.auto_stage_critical_files(&["Cargo.lock"]);
2001
2002 let output = git_in(dir.path(), &["diff", "--cached", "--name-only"]);
2004 let staged = String::from_utf8_lossy(&output.stdout);
2005 assert!(
2006 staged.contains("Cargo.lock"),
2007 "Cargo.lock should be staged after auto_stage_critical_files"
2008 );
2009 }
2010
2011 #[test]
2012 fn auto_stage_critical_files_skips_unmodified_file() {
2013 let dir = tempdir().unwrap();
2014 init_git_repo(dir.path()).unwrap();
2015
2016 std::fs::write(dir.path().join("Cargo.lock"), "version = 3\n").unwrap();
2018 git_in(dir.path(), &["add", "Cargo.lock"]);
2019 git_in(dir.path(), &["commit", "-m", "add lock"]);
2020
2021 let adapter = GitAdapter::new(dir.path());
2023 adapter.auto_stage_critical_files(&["Cargo.lock"]);
2024
2025 let output = git_in(dir.path(), &["diff", "--cached", "--name-only"]);
2026 let staged = String::from_utf8_lossy(&output.stdout);
2027 assert!(
2028 !staged.contains("Cargo.lock"),
2029 "Cargo.lock should not be staged when unmodified"
2030 );
2031 }
2032
2033 #[test]
2034 fn auto_stage_critical_files_skips_nonexistent_file() {
2035 let dir = tempdir().unwrap();
2036 init_git_repo(dir.path()).unwrap();
2037
2038 let adapter = GitAdapter::new(dir.path());
2040 adapter.auto_stage_critical_files(&["Cargo.lock"]); let output = git_in(dir.path(), &["diff", "--cached", "--name-only"]);
2043 let staged = String::from_utf8_lossy(&output.stdout);
2044 assert!(!staged.contains("Cargo.lock"));
2045 }
2046}