Skip to main content

atman_runtime/
git.rs

1use std::path::{Path, PathBuf};
2use std::process::Output;
3
4pub type Result<T> = std::result::Result<T, GitError>;
5
6#[derive(Debug, thiserror::Error)]
7pub enum GitError {
8    #[error("git binary not available: {0}")]
9    NotAvailable(String),
10    #[error("git spawn failed: {0}")]
11    Spawn(#[from] std::io::Error),
12    #[error("git {args} exit {code}: {stderr}")]
13    ExitNonZero {
14        args: String,
15        code: i32,
16        stderr: String,
17    },
18    #[error("libgit2: {0}")]
19    Libgit2(#[from] git2::Error),
20    #[error("not a git repository at {0}")]
21    NotARepo(PathBuf),
22    #[error("invalid worktree operation: {0}")]
23    InvalidWorktree(String),
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct WorktreeInfo {
28    pub path: PathBuf,
29    pub head: Option<String>,
30    pub branch: Option<String>,
31    pub detached: bool,
32    pub bare: bool,
33    pub locked: Option<String>,
34    pub prunable: Option<String>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct RepositoryInfo {
39    pub path: PathBuf,
40    pub workdir: Option<PathBuf>,
41    pub git_dir: PathBuf,
42    pub bare: bool,
43}
44
45impl RepositoryInfo {
46    fn from_repository(repo: &git2::Repository, requested: &Path) -> Self {
47        Self {
48            path: canonical_path(requested),
49            workdir: repo.workdir().map(canonical_path),
50            git_dir: canonical_path(repo.path()),
51            bare: repo.is_bare(),
52        }
53    }
54}
55
56fn canonical_path(path: &Path) -> PathBuf {
57    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
58}
59
60pub fn init_repository(
61    path: &Path,
62    bare: bool,
63    initial_branch: Option<&str>,
64) -> Result<RepositoryInfo> {
65    let mut options = git2::RepositoryInitOptions::new();
66    options.bare(bare);
67    if let Some(branch) = initial_branch {
68        options.initial_head(branch);
69    }
70    let repo = git2::Repository::init_opts(path, &options)?;
71    Ok(RepositoryInfo::from_repository(&repo, path))
72}
73
74pub fn discover_repository(start: &Path) -> Result<RepositoryInfo> {
75    let repo =
76        git2::Repository::discover(start).map_err(|_| GitError::NotARepo(start.to_path_buf()))?;
77    Ok(RepositoryInfo::from_repository(&repo, start))
78}
79
80pub fn discover_toplevel(start: &Path) -> Result<PathBuf> {
81    discover_repository(start)?
82        .workdir
83        .ok_or_else(|| GitError::NotARepo(start.to_path_buf()))
84}
85
86pub fn diff_range(cwd: &Path, range: &str, paths: &[String]) -> Result<DiffResult> {
87    let repo = git2::Repository::open(cwd).map_err(|_| GitError::NotARepo(cwd.to_path_buf()))?;
88    let revspec = repo.revparse(range)?;
89    let from = revspec
90        .from()
91        .ok_or_else(|| GitError::Libgit2(git2::Error::from_str("revspec missing 'from'")))?
92        .peel_to_commit()?
93        .tree()?;
94    let to = revspec
95        .to()
96        .map(|t| t.peel_to_commit().and_then(|c| c.tree()))
97        .transpose()?;
98
99    let mut opts = git2::DiffOptions::new();
100    for p in paths {
101        opts.pathspec(p);
102    }
103    let diff = match to {
104        Some(to_tree) => repo.diff_tree_to_tree(Some(&from), Some(&to_tree), Some(&mut opts))?,
105        None => repo.diff_tree_to_workdir_with_index(Some(&from), Some(&mut opts))?,
106    };
107
108    let mut files = Vec::new();
109    diff.foreach(
110        &mut |delta, _| {
111            let path = delta
112                .new_file()
113                .path()
114                .or_else(|| delta.old_file().path())
115                .map(|p| p.to_string_lossy().into_owned());
116            if let Some(p) = path {
117                if !files.contains(&p) {
118                    files.push(p);
119                }
120            }
121            true
122        },
123        None,
124        None,
125        None,
126    )?;
127
128    let mut body = String::new();
129    diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
130        match line.origin() {
131            'F' | 'H' => body.push_str(&String::from_utf8_lossy(line.content())),
132            '+' | '-' | ' ' => {
133                body.push(line.origin());
134                body.push_str(&String::from_utf8_lossy(line.content()));
135            }
136            _ => body.push_str(&String::from_utf8_lossy(line.content())),
137        }
138        true
139    })?;
140
141    Ok(DiffResult { body, files })
142}
143
144pub struct DiffResult {
145    pub body: String,
146    pub files: Vec<String>,
147}
148
149pub fn status_porcelain(cwd: &Path) -> Result<String> {
150    let repo = git2::Repository::open(cwd).map_err(|_| GitError::NotARepo(cwd.to_path_buf()))?;
151    let mut opts = git2::StatusOptions::new();
152    opts.include_untracked(true).include_ignored(false);
153    let statuses = repo.statuses(Some(&mut opts))?;
154    let mut out = String::new();
155    for s in statuses.iter() {
156        let bits = s.status();
157        let (index_c, wt_c) = if bits.contains(git2::Status::WT_NEW)
158            && !bits.intersects(
159                git2::Status::INDEX_NEW
160                    | git2::Status::INDEX_MODIFIED
161                    | git2::Status::INDEX_DELETED
162                    | git2::Status::INDEX_RENAMED
163                    | git2::Status::INDEX_TYPECHANGE,
164            ) {
165            ('?', '?')
166        } else {
167            (index_flag(bits), worktree_flag(bits))
168        };
169        let path = s.path().unwrap_or("").to_string();
170        out.push(index_c);
171        out.push(wt_c);
172        out.push(' ');
173        out.push_str(&path);
174        out.push('\n');
175    }
176    Ok(out)
177}
178
179pub fn has_changes(cwd: &Path) -> Result<bool> {
180    Ok(!status_porcelain(cwd)?.trim().is_empty())
181}
182
183pub fn current_branch(cwd: &Path) -> Result<String> {
184    let repo = git2::Repository::open(cwd).map_err(|_| GitError::NotARepo(cwd.to_path_buf()))?;
185    let head_ref = repo.find_reference("HEAD")?;
186    let sym = head_ref
187        .symbolic_target()
188        .ok_or_else(|| GitError::Libgit2(git2::Error::from_str("HEAD is not symbolic")))?;
189    Ok(sym.strip_prefix("refs/heads/").unwrap_or(sym).to_string())
190}
191
192fn index_flag(s: git2::Status) -> char {
193    if s.contains(git2::Status::INDEX_NEW) {
194        'A'
195    } else if s.contains(git2::Status::INDEX_MODIFIED) {
196        'M'
197    } else if s.contains(git2::Status::INDEX_DELETED) {
198        'D'
199    } else if s.contains(git2::Status::INDEX_RENAMED) {
200        'R'
201    } else if s.contains(git2::Status::INDEX_TYPECHANGE) {
202        'T'
203    } else {
204        ' '
205    }
206}
207
208fn worktree_flag(s: git2::Status) -> char {
209    if s.contains(git2::Status::WT_NEW) {
210        '?'
211    } else if s.contains(git2::Status::WT_MODIFIED) {
212        'M'
213    } else if s.contains(git2::Status::WT_DELETED) {
214        'D'
215    } else if s.contains(git2::Status::WT_RENAMED) {
216        'R'
217    } else if s.contains(git2::Status::WT_TYPECHANGE) {
218        'T'
219    } else {
220        ' '
221    }
222}
223
224pub struct GitCli {
225    cwd: PathBuf,
226}
227
228impl GitCli {
229    pub fn at(cwd: impl Into<PathBuf>) -> Self {
230        Self { cwd: cwd.into() }
231    }
232
233    pub fn cwd(&self) -> &Path {
234        &self.cwd
235    }
236
237    pub fn ensure_available() -> Result<()> {
238        let out = std::process::Command::new("git").arg("--version").output();
239        match out {
240            Ok(o) if o.status.success() => Ok(()),
241            Ok(o) => Err(GitError::NotAvailable(format!(
242                "git --version exit {}",
243                o.status
244            ))),
245            Err(e) => Err(GitError::NotAvailable(format!("spawn: {e}"))),
246        }
247    }
248
249    pub fn worktree_list(&self) -> Result<Vec<WorktreeInfo>> {
250        parse_worktree_porcelain(&self.run(&["worktree", "list", "--porcelain"])?)
251    }
252
253    pub fn worktree_add(
254        &self,
255        path: &Path,
256        branch: Option<&str>,
257        base: Option<&str>,
258        create_branch: bool,
259        detach: bool,
260    ) -> Result<WorktreeInfo> {
261        if create_branch && branch.is_none() {
262            return Err(GitError::InvalidWorktree(
263                "create_branch requires branch".into(),
264            ));
265        }
266        if detach && (create_branch || branch.is_some()) {
267            return Err(GitError::InvalidWorktree(
268                "detach cannot be combined with branch or create_branch".into(),
269            ));
270        }
271        let target = canonicalize_new_path(path)?;
272        if target.exists() {
273            return Err(GitError::InvalidWorktree(format!(
274                "target path already exists: {}",
275                target.display()
276            )));
277        }
278        let worktrees = self.worktree_list()?;
279        if worktrees.iter().any(|entry| entry.path == target) {
280            return Err(GitError::InvalidWorktree(format!(
281                "worktree path already registered: {}",
282                target.display()
283            )));
284        }
285        let branch = branch.map(|branch| branch.strip_prefix("refs/heads/").unwrap_or(branch));
286        if let Some(branch) = branch {
287            let full_branch = format!("refs/heads/{branch}");
288            if worktrees
289                .iter()
290                .any(|entry| entry.branch.as_deref() == Some(full_branch.as_str()))
291            {
292                return Err(GitError::InvalidWorktree(format!(
293                    "branch is already checked out: {branch}"
294                )));
295            }
296            let repo = git2::Repository::discover(&self.cwd)
297                .map_err(|_| GitError::NotARepo(self.cwd.clone()))?;
298            let exists = repo.find_branch(branch, git2::BranchType::Local).is_ok();
299            if create_branch == exists {
300                let state = if exists {
301                    "already exists"
302                } else {
303                    "does not exist"
304                };
305                return Err(GitError::InvalidWorktree(format!(
306                    "branch {branch} {state}"
307                )));
308            }
309        }
310
311        let path_arg = target.to_string_lossy().into_owned();
312        let mut owned = vec!["worktree".to_string(), "add".to_string()];
313        if detach {
314            owned.push("--detach".into());
315        } else if create_branch {
316            owned.push("-b".into());
317            owned.push(branch.expect("validated branch").into());
318        }
319        owned.push("--".into());
320        owned.push(path_arg);
321        if !create_branch {
322            if let Some(branch) = branch {
323                owned.push(branch.into());
324            } else if let Some(base) = base {
325                owned.push(base.into());
326            }
327        } else if let Some(base) = base {
328            owned.push(base.into());
329        }
330        let args: Vec<&str> = owned.iter().map(String::as_str).collect();
331        self.run(&args)?;
332        self.worktree_list()?
333            .into_iter()
334            .find(|entry| entry.path == target)
335            .ok_or_else(|| GitError::InvalidWorktree("created worktree was not listed".into()))
336    }
337
338    pub fn worktree_remove(&self, path: &Path, force: bool) -> Result<()> {
339        let target = self.known_worktree(path, true)?;
340        let repo = discover_repository(&self.cwd)?;
341        if repo.workdir.as_deref() == Some(target.path.as_path()) || target.bare {
342            return Err(GitError::InvalidWorktree(
343                "refusing to remove the main worktree or repository root".into(),
344            ));
345        }
346        if !force
347            && !GitCli::at(&target.path)
348                .run(&["status", "--porcelain"])?
349                .is_empty()
350        {
351            return Err(GitError::InvalidWorktree(
352                "worktree has uncommitted changes; set force=true to remove it".into(),
353            ));
354        }
355        let target_arg = target.path.to_string_lossy().into_owned();
356        let mut args = vec!["worktree", "remove"];
357        if force {
358            args.push("--force");
359        }
360        args.extend(["--", target_arg.as_str()]);
361        self.run(&args).map(|_| ())
362    }
363
364    pub fn worktree_prune(&self, dry_run: bool) -> Result<String> {
365        let mut args = vec!["worktree", "prune", "--verbose"];
366        if dry_run {
367            args.push("--dry-run");
368        }
369        self.run(&args)
370    }
371
372    pub fn worktree_lock(&self, path: &Path, reason: Option<&str>) -> Result<()> {
373        let target = self.known_worktree(path, false)?;
374        let target_arg = target.path.to_string_lossy().into_owned();
375        let mut args = vec!["worktree", "lock"];
376        if let Some(reason) = reason {
377            args.extend(["--reason", reason]);
378        }
379        args.extend(["--", target_arg.as_str()]);
380        self.run(&args).map(|_| ())
381    }
382
383    pub fn worktree_unlock(&self, path: &Path) -> Result<()> {
384        let target = self.known_worktree(path, false)?;
385        let target_arg = target.path.to_string_lossy().into_owned();
386        self.run(&["worktree", "unlock", "--", &target_arg])
387            .map(|_| ())
388    }
389
390    fn known_worktree(&self, path: &Path, allow_missing: bool) -> Result<WorktreeInfo> {
391        let target = if allow_missing && !path.exists() {
392            canonicalize_new_path(path)?
393        } else {
394            path.canonicalize().map_err(|error| {
395                GitError::InvalidWorktree(format!(
396                    "cannot resolve worktree path {}: {error}",
397                    path.display()
398                ))
399            })?
400        };
401        self.worktree_list()?
402            .into_iter()
403            .find(|entry| entry.path == target)
404            .ok_or_else(|| {
405                GitError::InvalidWorktree(format!(
406                    "path is not a registered worktree: {}",
407                    target.display()
408                ))
409            })
410    }
411
412    pub fn run(&self, args: &[&str]) -> Result<String> {
413        let out = self.spawn(args)?;
414        if !out.status.success() {
415            return Err(GitError::ExitNonZero {
416                args: args.join(" "),
417                code: out.status.code().unwrap_or(-1),
418                stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(),
419            });
420        }
421        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
422    }
423
424    fn spawn(&self, args: &[&str]) -> Result<Output> {
425        let out = std::process::Command::new("git")
426            .args(args)
427            .current_dir(&self.cwd)
428            .output()?;
429        Ok(out)
430    }
431
432    pub fn init(&self, branch: &str) -> Result<()> {
433        std::fs::create_dir_all(&self.cwd)?;
434        self.run(&["init"])?;
435        self.run(&["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")])?;
436        Ok(())
437    }
438
439    pub fn add_all(&self) -> Result<()> {
440        self.run(&["add", "."]).map(|_| ())
441    }
442
443    pub fn switch_branch(&self, name: &str, create: bool, start: Option<&str>) -> Result<()> {
444        let mut args = vec!["switch"];
445        if create {
446            args.push("-c");
447        }
448        args.push(name);
449        if let Some(start) = start {
450            args.push(start);
451        }
452        self.run(&args).map(|_| ())
453    }
454
455    pub fn commit(&self, message: &str) -> Result<()> {
456        self.commit_with_options(message, false).map(|_| ())
457    }
458
459    pub fn commit_with_options(&self, message: &str, amend: bool) -> Result<String> {
460        let mut args = vec!["commit", "-m", message];
461        if amend {
462            args.insert(1, "--amend");
463        }
464        self.run(&args)
465    }
466
467    pub fn head_oid(&self) -> Result<String> {
468        self.run(&["rev-parse", "HEAD"])
469            .map(|output| output.trim().to_string())
470    }
471
472    pub fn push(&self, remote: &str, branch: &str) -> Result<String> {
473        self.run(&["push", "-u", remote, branch])
474    }
475
476    pub fn push_with_lease(
477        &self,
478        remote: &str,
479        branch: &str,
480        force_with_lease: bool,
481    ) -> Result<String> {
482        let mut args = vec!["push", "-u"];
483        if force_with_lease {
484            args.push("--force-with-lease");
485        }
486        args.extend([remote, branch]);
487        self.run(&args)
488    }
489
490    pub fn branch_list(&self) -> Result<Vec<String>> {
491        Ok(self
492            .run(&["for-each-ref", "--format=%(refname:short)", "refs/heads"])?
493            .lines()
494            .map(str::trim)
495            .filter(|name| !name.is_empty())
496            .map(str::to_owned)
497            .collect())
498    }
499
500    pub fn branch_create(&self, name: &str, start: Option<&str>) -> Result<()> {
501        let mut args = vec!["branch", name];
502        if let Some(start) = start {
503            args.push(start);
504        }
505        self.run(&args).map(|_| ())
506    }
507
508    pub fn branch_switch(&self, name: &str) -> Result<()> {
509        self.run(&["switch", name]).map(|_| ())
510    }
511
512    pub fn branch_delete(&self, name: &str, force: bool) -> Result<()> {
513        let active = self
514            .worktree_list()?
515            .into_iter()
516            .any(|entry| entry.branch.as_deref() == Some(&format!("refs/heads/{name}")));
517        if active {
518            return Err(GitError::InvalidWorktree(format!(
519                "branch {name} is checked out in a worktree"
520            )));
521        }
522        let flag = if force { "-D" } else { "-d" };
523        self.run(&["branch", flag, name]).map(|_| ())
524    }
525
526    pub fn branch_rename(&self, old: &str, new: &str) -> Result<()> {
527        let active = self
528            .worktree_list()?
529            .into_iter()
530            .any(|entry| entry.branch.as_deref() == Some(&format!("refs/heads/{old}")));
531        if active {
532            return Err(GitError::InvalidWorktree(format!(
533                "branch {old} is checked out in a worktree"
534            )));
535        }
536        self.run(&["branch", "-m", old, new]).map(|_| ())
537    }
538
539    pub fn remote_list(&self) -> Result<Vec<(String, String)>> {
540        Ok(self
541            .run(&["remote", "-v"])?
542            .lines()
543            .filter_map(|line| {
544                let mut parts = line.split_whitespace();
545                let name = parts.next()?;
546                let url = parts.next()?;
547                let kind = parts.next()?;
548                (kind == "(fetch)").then(|| (name.to_owned(), url.to_owned()))
549            })
550            .collect())
551    }
552
553    pub fn pull_rebase(&self, remote: &str, branch: &str) -> Result<String> {
554        self.run(&["pull", "--rebase", remote, branch])
555    }
556
557    pub fn fetch(&self, remote: &str, branch: &str) -> Result<()> {
558        self.run(&["fetch", remote, branch]).map(|_| ())
559    }
560
561    pub fn fetch_remote(&self, remote: &str, refspec: Option<&str>) -> Result<String> {
562        match refspec {
563            Some(refspec) => self.run(&["fetch", remote, refspec]),
564            None => self.run(&["fetch", remote]),
565        }
566    }
567
568    pub fn reset_hard(&self, target: &str) -> Result<()> {
569        self.run(&["reset", "--hard", target]).map(|_| ())
570    }
571
572    pub fn ref_exists(&self, refname: &str) -> Result<bool> {
573        match self.spawn(&["show-ref", "--verify", refname])? {
574            o if o.status.success() => Ok(true),
575            _ => Ok(false),
576        }
577    }
578
579    pub fn remote_exists(&self, name: &str) -> Result<bool> {
580        let text = self.run(&["remote"])?;
581        Ok(text.lines().any(|l| l.trim() == name))
582    }
583
584    pub fn remote_add(&self, name: &str, url: &str) -> Result<()> {
585        self.run(&["remote", "add", name, url]).map(|_| ())
586    }
587
588    pub fn remote_set_url(&self, name: &str, url: &str) -> Result<()> {
589        self.run(&["remote", "set-url", name, url]).map(|_| ())
590    }
591}
592
593pub fn parse_worktree_porcelain(input: &str) -> Result<Vec<WorktreeInfo>> {
594    let mut entries = Vec::new();
595    let mut current: Option<WorktreeInfo> = None;
596    for line in input.lines().chain(std::iter::once("")) {
597        if line.is_empty() {
598            if let Some(entry) = current.take() {
599                entries.push(entry);
600            }
601            continue;
602        }
603        let (key, value) = line.split_once(' ').unwrap_or((line, ""));
604        if key == "worktree" {
605            if current.is_some() {
606                return Err(GitError::InvalidWorktree(
607                    "malformed porcelain: missing record separator".into(),
608                ));
609            }
610            current = Some(WorktreeInfo {
611                path: PathBuf::from(value),
612                head: None,
613                branch: None,
614                detached: false,
615                bare: false,
616                locked: None,
617                prunable: None,
618            });
619            continue;
620        }
621        let entry = current.as_mut().ok_or_else(|| {
622            GitError::InvalidWorktree(format!("malformed porcelain: {key} before worktree"))
623        })?;
624        match key {
625            "HEAD" => entry.head = Some(value.into()),
626            "branch" => entry.branch = Some(value.into()),
627            "detached" => entry.detached = true,
628            "bare" => entry.bare = true,
629            "locked" => entry.locked = Some(value.into()),
630            "prunable" => entry.prunable = Some(value.into()),
631            _ => {}
632        }
633    }
634    Ok(entries)
635}
636
637fn canonicalize_new_path(path: &Path) -> Result<PathBuf> {
638    if path.as_os_str().is_empty() {
639        return Err(GitError::InvalidWorktree("worktree path is empty".into()));
640    }
641    let absolute = if path.is_absolute() {
642        path.to_path_buf()
643    } else {
644        std::env::current_dir()?.join(path)
645    };
646    let name = absolute.file_name().ok_or_else(|| {
647        GitError::InvalidWorktree(format!("invalid worktree path: {}", path.display()))
648    })?;
649    let parent = absolute.parent().ok_or_else(|| {
650        GitError::InvalidWorktree(format!("invalid worktree path: {}", path.display()))
651    })?;
652    let parent = parent.canonicalize().map_err(|error| {
653        GitError::InvalidWorktree(format!(
654            "cannot resolve worktree parent {}: {error}",
655            parent.display()
656        ))
657    })?;
658    Ok(parent.join(name))
659}
660
661#[cfg(test)]
662mod tests {
663    use super::*;
664
665    fn have_git() -> bool {
666        GitCli::ensure_available().is_ok()
667    }
668
669    #[test]
670    fn parses_worktree_porcelain_states() {
671        let parsed = parse_worktree_porcelain(
672            "worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /tmp/detached\nHEAD def456\ndetached\nlocked maintenance window\nprunable gitdir file points to non-existent location\n\n",
673        )
674        .unwrap();
675
676        assert_eq!(parsed.len(), 2);
677        assert_eq!(parsed[0].branch.as_deref(), Some("refs/heads/main"));
678        assert!(!parsed[0].detached);
679        assert!(parsed[1].detached);
680        assert_eq!(parsed[1].locked.as_deref(), Some("maintenance window"));
681        assert_eq!(
682            parsed[1].prunable.as_deref(),
683            Some("gitdir file points to non-existent location")
684        );
685    }
686
687    #[test]
688    fn rejects_malformed_worktree_porcelain() {
689        let error = parse_worktree_porcelain("HEAD abc123\n").unwrap_err();
690        assert!(error.to_string().contains("before worktree"));
691    }
692
693    fn seed_two_commits(dir: &Path) {
694        let cli = GitCli::at(dir);
695        cli.init("main").unwrap();
696        for (k, v) in [
697            ("user.email", "t@atman.local"),
698            ("user.name", "atman test"),
699            ("commit.gpgsign", "false"),
700        ] {
701            cli.run(&["config", k, v]).unwrap();
702        }
703        std::fs::write(dir.join("a.txt"), "line one\n").unwrap();
704        std::fs::write(dir.join("b.txt"), "b\n").unwrap();
705        cli.add_all().unwrap();
706        cli.commit("initial").unwrap();
707        std::fs::write(dir.join("a.txt"), "line one\nline two\n").unwrap();
708        std::fs::write(dir.join("c.txt"), "new file\n").unwrap();
709        cli.add_all().unwrap();
710        cli.commit("second").unwrap();
711    }
712
713    #[test]
714    fn init_repository_supports_empty_and_non_empty_directories() {
715        let empty = tempfile::tempdir().unwrap();
716        let info = init_repository(empty.path(), false, None).unwrap();
717        assert!(!info.bare);
718        assert_eq!(info.workdir, Some(empty.path().canonicalize().unwrap()));
719        assert!(info.git_dir.is_dir());
720
721        let non_empty = tempfile::tempdir().unwrap();
722        std::fs::write(non_empty.path().join("README.md"), "content\n").unwrap();
723        let info = init_repository(non_empty.path(), false, None).unwrap();
724        assert!(!info.bare);
725        assert_eq!(info.workdir, Some(non_empty.path().canonicalize().unwrap()));
726    }
727
728    #[test]
729    fn init_repository_sets_initial_branch() {
730        let tmp = tempfile::tempdir().unwrap();
731        let info = init_repository(tmp.path(), false, Some("trunk")).unwrap();
732        let repo = git2::Repository::open(info.path).unwrap();
733        assert_eq!(
734            repo.find_reference("HEAD").unwrap().symbolic_target(),
735            Some("refs/heads/trunk")
736        );
737    }
738
739    #[test]
740    fn init_repository_supports_bare_directories() {
741        let tmp = tempfile::tempdir().unwrap();
742        let info = init_repository(tmp.path(), true, None).unwrap();
743        assert!(info.bare);
744        assert_eq!(info.workdir, None);
745        assert_eq!(info.git_dir, tmp.path().canonicalize().unwrap());
746        assert!(tmp.path().join("HEAD").is_file());
747    }
748
749    #[test]
750    fn git_init_discover_reports_worktree_and_bare_repositories() {
751        let worktree = tempfile::tempdir().unwrap();
752        init_repository(worktree.path(), false, None).unwrap();
753        let nested = worktree.path().join("nested");
754        std::fs::create_dir(&nested).unwrap();
755        let info = discover_repository(&nested).unwrap();
756        assert!(!info.bare);
757        assert_eq!(
758            std::fs::canonicalize(info.workdir.as_ref().unwrap()).unwrap(),
759            std::fs::canonicalize(worktree.path()).unwrap()
760        );
761
762        let bare = tempfile::tempdir().unwrap();
763        init_repository(bare.path(), true, None).unwrap();
764        let info = discover_repository(bare.path()).unwrap();
765        assert!(info.bare);
766        assert_eq!(info.workdir, None);
767    }
768
769    #[test]
770    fn discover_toplevel_finds_repo_root_from_subdir() {
771        if !have_git() {
772            eprintln!("skip: git not on PATH");
773            return;
774        }
775        let tmp = tempfile::tempdir().unwrap();
776        seed_two_commits(tmp.path());
777        let sub = tmp.path().join("nested/deep");
778        std::fs::create_dir_all(&sub).unwrap();
779        let root = discover_toplevel(&sub).unwrap();
780        assert_eq!(
781            std::fs::canonicalize(&root).unwrap(),
782            std::fs::canonicalize(tmp.path()).unwrap()
783        );
784    }
785
786    #[test]
787    fn discover_toplevel_outside_repo_errors() {
788        let tmp = tempfile::tempdir().unwrap();
789        let err = discover_toplevel(tmp.path()).unwrap_err();
790        assert!(matches!(err, GitError::NotARepo(_)), "got {err:?}");
791    }
792
793    #[test]
794    fn diff_range_reports_files_and_body() {
795        if !have_git() {
796            eprintln!("skip: git not on PATH");
797            return;
798        }
799        let tmp = tempfile::tempdir().unwrap();
800        seed_two_commits(tmp.path());
801        let out = diff_range(tmp.path(), "HEAD~1..HEAD", &[]).unwrap();
802        assert!(
803            out.body.contains("+line two"),
804            "want addition, got:\n{}",
805            out.body
806        );
807        assert!(
808            out.body.contains("+new file"),
809            "want new file body:\n{}",
810            out.body
811        );
812        assert!(
813            out.files.contains(&"a.txt".to_string()),
814            "files={:?}",
815            out.files
816        );
817        assert!(
818            out.files.contains(&"c.txt".to_string()),
819            "files={:?}",
820            out.files
821        );
822    }
823
824    #[test]
825    fn diff_range_paths_filter_narrows() {
826        if !have_git() {
827            eprintln!("skip");
828            return;
829        }
830        let tmp = tempfile::tempdir().unwrap();
831        seed_two_commits(tmp.path());
832        let out = diff_range(tmp.path(), "HEAD~1..HEAD", &["a.txt".to_string()]).unwrap();
833        assert_eq!(
834            out.files,
835            vec!["a.txt".to_string()],
836            "files={:?}",
837            out.files
838        );
839    }
840
841    #[test]
842    fn status_porcelain_reflects_worktree_changes() {
843        if !have_git() {
844            eprintln!("skip");
845            return;
846        }
847        let tmp = tempfile::tempdir().unwrap();
848        seed_two_commits(tmp.path());
849        assert!(!has_changes(tmp.path()).unwrap(), "clean tree");
850        std::fs::write(tmp.path().join("a.txt"), "changed\n").unwrap();
851        std::fs::write(tmp.path().join("d.txt"), "new\n").unwrap();
852        let text = status_porcelain(tmp.path()).unwrap();
853        assert!(text.contains(" M a.txt"), "want dirty a.txt: {text}");
854        assert!(text.contains("?? d.txt"), "want untracked d.txt: {text}");
855        assert!(has_changes(tmp.path()).unwrap());
856    }
857
858    #[test]
859    fn worktree_lifecycle_enforces_safety_checks() {
860        if !have_git() {
861            eprintln!("skip");
862            return;
863        }
864        let root = tempfile::tempdir().unwrap();
865        let repo_path = root.path().join("repo");
866        std::fs::create_dir(&repo_path).unwrap();
867        seed_two_commits(&repo_path);
868        let worktree_path = root.path().join("feature-worktree");
869        let cli = GitCli::at(&repo_path);
870
871        let added = cli
872            .worktree_add(&worktree_path, Some("feature"), Some("HEAD"), true, false)
873            .unwrap();
874        assert_eq!(added.path, worktree_path.canonicalize().unwrap());
875        assert_eq!(added.branch.as_deref(), Some("refs/heads/feature"));
876        assert_eq!(cli.worktree_list().unwrap().len(), 2);
877
878        cli.worktree_lock(&worktree_path, Some("test lock"))
879            .unwrap();
880        assert_eq!(
881            cli.worktree_list().unwrap()[1].locked.as_deref(),
882            Some("test lock")
883        );
884        cli.worktree_unlock(&worktree_path).unwrap();
885
886        std::fs::write(worktree_path.join("dirty.txt"), "dirty\n").unwrap();
887        let error = cli.worktree_remove(&worktree_path, false).unwrap_err();
888        assert!(error.to_string().contains("uncommitted changes"));
889        assert!(worktree_path.exists());
890
891        cli.worktree_remove(&worktree_path, true).unwrap();
892        assert!(!worktree_path.exists());
893        assert_eq!(cli.worktree_list().unwrap().len(), 1);
894        cli.worktree_prune(true).unwrap();
895    }
896
897    #[test]
898    fn current_branch_after_first_commit_is_main() {
899        if !have_git() {
900            eprintln!("skip");
901            return;
902        }
903        let tmp = tempfile::tempdir().unwrap();
904        seed_two_commits(tmp.path());
905        assert_eq!(current_branch(tmp.path()).unwrap(), "main");
906    }
907
908    #[test]
909    fn git_cli_remote_add_and_lookup() {
910        if !have_git() {
911            eprintln!("skip");
912            return;
913        }
914        let tmp = tempfile::tempdir().unwrap();
915        let cli = GitCli::at(tmp.path());
916        cli.init("main").unwrap();
917        assert!(!cli.remote_exists("origin").unwrap());
918        cli.remote_add("origin", "https://example.invalid/repo.git")
919            .unwrap();
920        assert!(cli.remote_exists("origin").unwrap());
921        cli.remote_set_url("origin", "https://example.invalid/other.git")
922            .unwrap();
923        let list = cli.run(&["remote", "get-url", "origin"]).unwrap();
924        assert!(list.contains("other.git"), "want reset url: {list}");
925    }
926
927    #[test]
928    fn git_cli_fetches_from_local_bare_remote() {
929        if !have_git() {
930            eprintln!("skip");
931            return;
932        }
933        let source = tempfile::tempdir().unwrap();
934        let remote = tempfile::tempdir().unwrap();
935        let cli = GitCli::at(source.path());
936        cli.init("main").unwrap();
937        for (key, value) in [
938            ("user.email", "t@atman.local"),
939            ("user.name", "atman test"),
940            ("commit.gpgsign", "false"),
941        ] {
942            cli.run(&["config", key, value]).unwrap();
943        }
944        std::fs::write(source.path().join("a.txt"), "one\n").unwrap();
945        cli.add_all().unwrap();
946        cli.commit("initial").unwrap();
947        GitCli::at(remote.path())
948            .run(&["init", "--bare", "-q"])
949            .unwrap();
950        cli.remote_add("origin", remote.path().to_str().unwrap())
951            .unwrap();
952        cli.run(&["push", "-q", "origin", "main"]).unwrap();
953        let clone = tempfile::tempdir().unwrap();
954        GitCli::at(clone.path())
955            .run(&["clone", "-q", remote.path().to_str().unwrap(), "."])
956            .unwrap();
957        std::fs::write(clone.path().join("b.txt"), "two\n").unwrap();
958        let clone_cli = GitCli::at(clone.path());
959        clone_cli
960            .run(&["config", "user.email", "t@atman.local"])
961            .unwrap();
962        clone_cli
963            .run(&["config", "user.name", "atman test"])
964            .unwrap();
965        clone_cli.add_all().unwrap();
966        clone_cli.commit("second").unwrap();
967        clone_cli.run(&["push", "-q", "origin", "main"]).unwrap();
968        cli.run(&["fetch", "origin"]).unwrap();
969        assert!(
970            !cli.run(&["rev-parse", "origin/main"])
971                .unwrap()
972                .trim()
973                .is_empty()
974        );
975    }
976
977    #[test]
978    fn git_cli_commit_honors_amend_and_hooks() {
979        if !have_git() {
980            eprintln!("skip");
981            return;
982        }
983        let tmp = tempfile::tempdir().unwrap();
984        let cli = GitCli::at(tmp.path());
985        cli.init("main").unwrap();
986        cli.run(&["config", "user.name", "atman test"]).unwrap();
987        cli.run(&["config", "user.email", "test@atman.local"])
988            .unwrap();
989        std::fs::write(tmp.path().join("file.txt"), "one\n").unwrap();
990        cli.add_all().unwrap();
991        cli.commit_with_options("first\n\nbody", false).unwrap();
992        let first = cli.head_oid().unwrap();
993        std::fs::write(tmp.path().join("file.txt"), "two\n").unwrap();
994        cli.add_all().unwrap();
995        cli.commit_with_options("amended", true).unwrap();
996        assert_ne!(first, cli.head_oid().unwrap());
997        assert_eq!(
998            cli.run(&["log", "-1", "--format=%B"]).unwrap().trim(),
999            "amended"
1000        );
1001
1002        let hook = tmp.path().join(".git/hooks/pre-commit");
1003        std::fs::write(&hook, "#!/bin/sh\nexit 17\n").unwrap();
1004        #[cfg(unix)]
1005        {
1006            use std::os::unix::fs::PermissionsExt;
1007            std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
1008        }
1009        std::fs::write(tmp.path().join("file.txt"), "three\n").unwrap();
1010        cli.add_all().unwrap();
1011        let err = cli.commit_with_options("blocked", false).unwrap_err();
1012        assert!(matches!(err, GitError::ExitNonZero { .. }));
1013    }
1014
1015    #[test]
1016    fn git_cli_run_maps_exit_code_to_error() {
1017        if !have_git() {
1018            eprintln!("skip");
1019            return;
1020        }
1021        let tmp = tempfile::tempdir().unwrap();
1022        let cli = GitCli::at(tmp.path());
1023        let err = cli.run(&["diff", "HEAD"]).unwrap_err();
1024        match err {
1025            GitError::ExitNonZero { code, .. } => assert_ne!(code, 0),
1026            other => panic!("want ExitNonZero, got {other:?}"),
1027        }
1028    }
1029}