Skip to main content

ta_submit/
git.rs

1//! Git adapter for branch-based workflows with GitHub/GitLab PR creation
2
3use 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
15/// Git adapter implementing branch-based workflow
16///
17/// Features:
18/// - Automatic feature branch creation
19/// - Git commit with PR metadata
20/// - Push to remote
21/// - GitHub/GitLab PR creation via gh/glab CLI
22pub struct GitAdapter {
23    /// Working directory for git operations
24    work_dir: std::path::PathBuf,
25    /// Submit configuration (co-author, branch prefix, etc.)
26    config: SubmitConfig,
27    /// Sync configuration (strategy, remote, branch)
28    sync_config: SyncConfig,
29    /// Plan file name relative to workspace root (default: "PLAN.md").
30    plan_file: String,
31}
32
33impl GitAdapter {
34    /// Create a new GitAdapter for the given working directory
35    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    /// Create a new GitAdapter with explicit configuration
45    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    /// Create a new GitAdapter with submit and sync configuration
55    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    /// Set the plan file name (relative to work_dir). Default: "PLAN.md".
69    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    /// Run a git command in the working directory
75    fn git_cmd(&self, args: &[&str]) -> Result<String> {
76        // Clear TA agent VCS isolation env vars so git operates on the
77        // work_dir's own repo, not the staging directory's repo (v0.13.17.3
78        // sets GIT_DIR/GIT_WORK_TREE/GIT_CEILING_DIRECTORIES for agents).
79        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    /// Check if gh CLI is available
100    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    /// Get current branch name
109    pub fn current_branch(&self) -> Result<String> {
110        self.git_cmd(&["rev-parse", "--abbrev-ref", "HEAD"])
111    }
112
113    /// Generate a safe git branch name from the goal title.
114    ///
115    /// Sanitization steps (item 28):
116    /// 1. Lowercase and map all non-alphanumeric chars to `-`
117    /// 2. Collapse consecutive dashes into a single `-`
118    /// 3. Trim leading and trailing dashes (fixes titles like `` `ta sync` ``)
119    /// 4. Truncate to 50 chars and trim any trailing dashes from truncation
120    ///
121    /// All characters are passed directly to git as command arguments, not
122    /// through shell interpolation, so no shell-escaping is needed.
123    fn branch_name(&self, goal: &GoalRun, config: &SubmitConfig) -> String {
124        let prefix = &config.git.branch_prefix;
125
126        // Step 1: lowercase + replace non-alphanumeric/dash with dash.
127        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        // Step 2: collapse consecutive dashes.
141        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        // Step 3: trim leading/trailing dashes.
156        let trimmed = collapsed.trim_matches('-');
157
158        // Fallback if trimming produced an empty string (e.g. title was "!!!").
159        let slug = if trimmed.is_empty() { "goal" } else { trimmed };
160
161        // Step 4: truncate to 50 chars, then trim any trailing dash from truncation.
162        let truncated = if slug.len() > 50 {
163            slug[..50].trim_end_matches('-')
164        } else {
165            slug
166        };
167
168        // v0.14.7.3: Prefix branch with goal shortref for traceability.
169        // e.g. ta/2159d87e-v0-14-7-1-shell-ux-fixes
170        let shortref = goal.shortref();
171        format!("{}{}-{}", prefix, shortref, truncated)
172    }
173
174    /// Auto-detect whether this is a git repository.
175    pub fn detect(project_root: &Path) -> bool {
176        project_root.join(".git").exists()
177    }
178
179    /// Built-in lock files that are always auto-staged when modified (v0.14.3.7).
180    ///
181    /// These are deterministic outputs of the build process — when a version
182    /// bump or dependency change occurs, the lock file regenerates and must be
183    /// committed together with the source change to keep the tree self-consistent.
184    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    /// Auto-stage critical files that should always accompany a draft apply commit.
196    ///
197    /// Stages each file in `candidates` that (a) exists in the working tree and
198    /// (b) is dirty according to `git status --porcelain`. Logs each auto-staged
199    /// path to stdout with the `ℹ️  auto-staged` prefix.
200    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            // Check if modified (both unstaged and staged changes).
207            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    /// Build the full list of auto-stage candidates from built-in + user config.
226    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        // Always include TA state files.
232        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        // v0.15.13.3: Include project-memory directory (VCS-committed shared knowledge).
236        candidates.push(".ta/project-memory".to_string());
237        // Merge user-configured entries from [commit] auto_stage.
238        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    /// Known-safe artifact patterns that are silently dropped from `git add`
249    /// when gitignored (v0.13.17.5). These are TA infrastructure files that
250    /// should never reach a commit.
251    fn is_known_safe_ignored(path: &str) -> bool {
252        // Exact filename matches
253        if path == ".mcp.json" || path == "daemon.toml" {
254            return true;
255        }
256        // *.local.toml files anywhere
257        if path.ends_with(".local.toml") {
258            return true;
259        }
260        // .ta/ runtime state files
261        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    /// Filter artifact paths using `git check-ignore --stdin` (v0.13.17.5).
270    ///
271    /// Returns `(to_add, ignored)` where:
272    /// - `to_add`: paths not gitignored — pass these to `git add`
273    /// - `ignored`: paths that are gitignored, with `known_safe` classified
274    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        // Run `git check-ignore --stdin` — prints only the ignored paths.
283        // Clear TA agent VCS isolation env vars so the check uses the work_dir
284        // repo, not the staging workspace repo (v0.13.17.3).
285        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                // If git check-ignore fails (e.g., not a git repo), assume nothing is ignored.
314                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        // Check if branch already exists
354        let branches = self.git_cmd(&["branch", "--list", &branch_name])?;
355        if branches.is_empty() {
356            // Create new branch
357            self.git_cmd(&["checkout", "-b", &branch_name])?;
358        } else {
359            // Switch to existing branch
360            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        // Build list of explicit artifact paths from draft package.
370        // Using explicit paths avoids accidentally staging unrelated files.
371        // Non-fs URIs (mailto://, drive://, etc.) are excluded — only real
372        // filesystem paths are staged.
373        // Deduplicate: a follow-up draft or combined parent+child diff can
374        // produce the same path more than once in the artifact list.
375        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        // Filter out gitignored paths before calling git add (v0.13.17.5).
389        // Known-safe paths (.mcp.json, *.local.toml, .ta/ runtime files) are
390        // silently dropped. Unexpected-ignored paths emit a warning.
391        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                // All artifacts were gitignored — complete with warning, not an error.
397                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                // Still attempt to stage the plan file and critical files even when all
407                // draft artifacts were gitignored, then check if there's anything to commit.
408                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                // Split paths into those that exist on disk (git add) and those
422                // that don't (deleted by the agent — git rm --cached).
423                // This handles the case where an agent renames or deletes a file:
424                // the artifact is still in the draft package but is absent from
425                // the working tree after apply copies files from staging.
426                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                    // --cached: remove from index only (file is already gone from disk).
440                    // --ignore-unmatch: don't error if the path was never tracked.
441                    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                // Auto-stage lock files, .ta/plan_history.jsonl, and user-configured
454                // files that are modified but were not in the draft artifact list.
455                // The plan file is always staged if it exists (may have been updated by apply).
456                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            // Fall back to `git add .` when there are no fs:// artifacts
468            // (e.g. all artifacts are external URIs like mailto://).
469            self.git_cmd(&["add", "."])?;
470        }
471
472        // Check if there are changes to commit
473        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        // Append metadata trailers to the caller-provided message.
481        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        // Commit
497        self.git_cmd(&["commit", "-m", &commit_msg])?;
498
499        // Get commit hash
500        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        // Push with --set-upstream
517        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        // Use self.config (not SubmitConfig::default()) so target_branch and
539        // other git settings from workflow.toml are respected.
540        let target_branch = &self.config.git.target_branch;
541        let head_branch = self.branch_name(goal, &self.config);
542
543        // Build PR body
544        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        // Idempotency check: if a PR already exists for this branch (e.g., from
553        // a prior apply attempt that failed after push), return the existing URL
554        // rather than failing with "already exists".
555        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                            // Still attempt auto-merge in case it wasn't enabled before.
594                            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        // Create PR using gh CLI. Pass --head explicitly so the correct branch
619        // is targeted even if the working tree HEAD has drifted (e.g. daemon
620        // restart between push and PR creation).
621        // v0.14.7.3: Prefix PR title with [<shortref>] for goal traceability.
622        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        // Extract PR number from URL (e.g., https://github.com/owner/repo/pull/123)
650        let pr_number = pr_url
651            .split('/')
652            .next_back()
653            .unwrap_or("unknown")
654            .to_string();
655
656        // Enable auto-merge if configured (v0.11.2.3).
657        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            // Warn loudly before enabling auto-merge. This bypasses the normal human
666            // review gate and merges to main as soon as CI passes (or immediately if
667            // there are no required checks). The user must see this — silent auto-merge
668            // was the root cause of the v0.14.10 direct-to-main incident.
669            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        // Fetch from remote.
765        self.git_cmd(&["fetch", remote])?;
766
767        // Count new commits on remote that we don't have locally.
768        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        // Apply the upstream changes using the configured strategy.
790        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                // Check for merge conflicts.
815                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                    // Not a conflict — infrastructure failure.
826                    Err(SubmitError::SyncError(format!(
827                        "Failed to sync {}/{} using strategy '{}': {}",
828                        remote, branch, strategy, e
829                    )))
830                } else {
831                    // Conflicts detected — return Ok with conflict info.
832                    // The caller decides whether to abort the merge.
833                    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        // Check for uncommitted changes
916        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        // Configured protected branches (from submit config), or the well-known defaults.
926        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 == &current) {
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        // Always set author identity so the agent's git commits are clearly labeled.
961        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                // Block all git operations.
972                env.insert("GIT_DIR".to_string(), "/dev/null".to_string());
973            }
974            "inherit-read" => {
975                // Allow reading from the parent repo but block writes via ceiling.
976                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                // "isolated" (default): init a fresh git repo in the staging dir.
987                // Clear TA agent VCS env vars so git init creates .git in staging_dir,
988                // not the workspace repo (GIT_DIR may be set by the outer agent env).
989                let git_dir = staging_dir.join(".git");
990                if !git_dir.exists() {
991                    // Init the repo — try with -b main first, fall back without it
992                    // for older git versions.
993                    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                    // Configure local identity so commits work without global config.
1021                    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                        // Create a baseline commit so `git diff` has something to compare
1038                        // against. Use -A to add all files (staging .taignore excludes .ta/).
1039                        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                // Pin the agent to the staging repo.
1061                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                // Ceiling prevents git from looking outside staging_dir.
1067                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            // Check if merged immediately or queued for auto-merge.
1151            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            // "Pull request #N is not mergeable" — auto-merge may still be set.
1175            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    /// Build PR body from template or default format.
1201    ///
1202    /// Template resolution order:
1203    /// 1. Explicit `config.git.pr_template` path
1204    /// 2. `.ta/pr-template.md` in the working directory
1205    /// 3. Built-in default format with per-artifact detail
1206    fn build_pr_body(
1207        &self,
1208        goal: &GoalRun,
1209        pr: &DraftPackage,
1210        config: &SubmitConfig,
1211    ) -> Result<String> {
1212        // Try explicit config path first.
1213        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        // Try .ta/pr-template.md in the working directory.
1221        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        // Default PR body with per-artifact detail (matches ta draft view medium).
1229        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    /// Format artifacts with summaries and explanations for PR body (markdown).
1258    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                // Add explanation as sub-bullet if present and different from summary.
1283                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    /// Substitute template variables.
1296    ///
1297    /// Available variables:
1298    ///   {title}          -- goal title
1299    ///   {summary}        -- what changed (from change_summary.json)
1300    ///   {why}            -- why it changed
1301    ///   {impact}         -- impact assessment
1302    ///   {objective}      -- full goal objective text
1303    ///   {artifact_count} -- number of files changed
1304    ///   {artifacts}      -- per-artifact detail with summaries and explanations
1305    ///   {goal_id}        -- goal UUID
1306    ///   {pr_id}          -- PR package UUID
1307    ///   {plan_phase}     -- plan phase (or "N/A")
1308    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        // Clear TA agent VCS isolation env vars so test git operations target
1332        // the temp dir, not the staging directory's repo.
1333        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        // Create initial commit
1357        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        // Create a feature branch
1414        let config = SubmitConfig::default();
1415        adapter.prepare(&goal, &config).unwrap();
1416
1417        // On a feature branch: verify should pass
1418        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        // On main/master (initial branch after init): verify should fail
1429        let current = adapter.current_branch().unwrap();
1430        // Only test if we're on a protected branch
1431        if ["main", "master", "trunk", "dev"].contains(&current.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        // "`ta sync`" → should become "ta/ta-sync" (no leading/trailing dashes)
1465        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        // All special chars → should fall back to "goal"
1499        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        // "Fix 'ta run' timeout" → "ta/fix-ta-run-timeout"
1522        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        // Verify we're on the new branch
1552        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        // No .git directory — should not detect
1569        assert!(!GitAdapter::detect(dir.path()));
1570
1571        // Create .git directory — should detect
1572        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        // Save state on main/master
1584        let original_branch = adapter.current_branch().unwrap();
1585        let state = adapter.save_state().unwrap();
1586        assert!(state.is_some());
1587
1588        // Create and switch to a new branch
1589        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        // Verify we're on a different branch
1600        let current = adapter.current_branch().unwrap();
1601        assert_ne!(current, original_branch);
1602
1603        // Restore state
1604        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        // No remote configured, so sync should fail gracefully or show up-to-date.
1616        // Since there's no remote "origin", fetch will fail.
1617        let result = adapter.sync_upstream();
1618        // Without a remote, this will return an error (VCS operation failed).
1619        assert!(result.is_err());
1620    }
1621
1622    #[test]
1623    fn test_git_adapter_sync_upstream_with_local_remote() {
1624        // Create a "remote" repo and a "local" clone to test sync.
1625        let remote_dir = tempdir().unwrap();
1626        init_git_repo(remote_dir.path()).unwrap();
1627
1628        // Clone it to create a local repo with origin pointing to remote.
1629        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        // Detect the actual default branch name (may be "main" or "master").
1640        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        // Configure the sync adapter with the correct branch.
1653        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        // At this point local is up to date with remote.
1661        let result = adapter.sync_upstream().unwrap();
1662        assert!(!result.updated);
1663        assert_eq!(result.new_commits, 0);
1664        assert!(result.is_clean());
1665
1666        // Now add a commit to the remote.
1667        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        // Sync should pick up the new commit.
1686        let result = adapter.sync_upstream().unwrap();
1687        assert!(result.updated);
1688        assert_eq!(result.new_commits, 1);
1689        assert!(result.is_clean());
1690
1691        // Verify the file is now present locally.
1692        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        // Should be a short hash (7+ chars)
1704        assert!(!rev.is_empty());
1705        assert_ne!(rev, "unknown");
1706    }
1707
1708    // ── VCS isolation tests (v0.13.17.3) ─────────────────────────────────────
1709
1710    #[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, // skip commit for speed
1745            ..Default::default()
1746        };
1747        let env = adapter.stage_env(dir.path(), &config).unwrap();
1748        // A .git directory should now exist in the staging dir.
1749        assert!(
1750            dir.path().join(".git").exists(),
1751            ".git should be created by isolated mode"
1752        );
1753        // GIT_DIR should point to the staging .git.
1754        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        // GIT_WORK_TREE should be the staging dir.
1760        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        // The ceiling must be above the staging dir (its parent), not the staging
1794        // dir itself — otherwise git could still discover the developer's .git above.
1795        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        // Verify the logic for extracting fs:// artifact paths used in commit().
1806        // Non-fs URIs should be excluded so we only add real filesystem paths.
1807        let uris = [
1808            "fs://workspace/src/main.rs",
1809            "fs://workspace/Cargo.toml",
1810            "mailto://nowhere",         // non-fs, should be excluded
1811            "fs://workspace/README.md", // fs, should be included
1812        ];
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        // non-fs URI is filtered out
1822        assert!(!fs_paths.iter().any(|p| p.contains("mailto")));
1823    }
1824
1825    // ── v0.13.17.5: gitignore filtering tests ─────────────────────
1826
1827    /// test_known_safe_dropped_silently (plan item 9.3):
1828    /// Known-safe paths (.mcp.json, *.local.toml, .ta/ runtime files) are
1829    /// classified as known_safe=true by is_known_safe_ignored().
1830    #[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        // Non-known-safe paths.
1839        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_filter_gitignored_artifacts — .mcp.json gitignored → known_safe=true (plan item 9.3).
1845    #[test]
1846    fn test_known_safe_dropped_silently() {
1847        let dir = tempdir().unwrap();
1848        init_git_repo(dir.path()).unwrap();
1849
1850        // Add .mcp.json to .gitignore.
1851        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_unexpected_ignored_warns (plan item 9.4):
1867    /// A source file that happens to be gitignored is classified as known_safe=false.
1868    #[test]
1869    fn test_unexpected_ignored() {
1870        let dir = tempdir().unwrap();
1871        init_git_repo(dir.path()).unwrap();
1872
1873        // Add a source file to .gitignore (unusual but possible).
1874        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_all_ignored_completes_with_warning (plan item 9.5):
1890    /// When all artifacts are gitignored, filter returns empty to_add list.
1891    /// The commit() caller handles this gracefully (no panic, no error).
1892    #[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    // ── v0.14.3.7: lock file auto-staging ────────────────────────
1913
1914    #[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        // Built-in lock files must be present.
1932        assert!(candidates.iter().any(|c| c == "Cargo.lock"));
1933        assert!(candidates.iter().any(|c| c == "go.sum"));
1934        // TA state files must be present.
1935        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        // Create workflow.toml with a custom auto_stage entry.
1943        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        // Built-in entries must still be present.
1955        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        // User lists Cargo.lock, which is already in the built-in list.
1963        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    /// Run a git command in `dir` without TA env var interference.
1977    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        // Create and commit Cargo.lock initially.
1992        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        // Modify Cargo.lock (simulating a version bump regenerating it).
1997        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        // Cargo.lock should now be in the index.
2003        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        // Create and commit Cargo.lock.
2017        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        // Do NOT modify Cargo.lock — it should not be staged.
2022        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        // Cargo.lock does not exist — auto_stage_critical_files should not error.
2039        let adapter = GitAdapter::new(dir.path());
2040        adapter.auto_stage_critical_files(&["Cargo.lock"]); // must not panic
2041
2042        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}