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}