1use std::path::Path;
11use std::process::Command;
12
13use tracing::{debug, trace};
14
15use crate::error::NapError;
16use crate::vcs::{CommitInfo, VcsBackend};
17
18#[derive(Debug, Default)]
20pub struct GitBackend;
21
22impl GitBackend {
23 pub fn new() -> Self {
24 Self
25 }
26
27 pub fn clone_repo(url: &str, dest: &Path) -> Result<(), NapError> {
33 debug!(url = %url, dest = %dest.display(), "cloning git repository");
34 let parent = dest.parent().unwrap_or(Path::new("."));
35 let dest_name = dest.file_name().and_then(|n| n.to_str()).unwrap_or(".");
36 Self::run_git(parent, &["clone", url, dest_name])?;
37 Ok(())
38 }
39
40 fn run_git(path: &Path, args: &[&str]) -> Result<String, NapError> {
42 let args_display = args.join(" ");
43 trace!(
44 cwd = %path.display(),
45 command = %format!("git {args_display}"),
46 "executing git command"
47 );
48
49 let output = Command::new("git")
50 .args(args)
51 .current_dir(path)
52 .output()
53 .map_err(|e| {
54 NapError::VcsError(format!("failed to execute git {args_display}: {e}"))
55 })?;
56
57 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
58 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
59
60 if !output.status.success() {
61 debug!(
62 command = %format!("git {args_display}"),
63 stderr = %stderr,
64 exit_code = ?output.status.code(),
65 "git command failed"
66 );
67 return Err(NapError::VcsError(format!(
68 "git {args_display} failed: {stderr}"
69 )));
70 }
71
72 trace!(
73 command = %format!("git {args_display}"),
74 stdout_len = stdout.len(),
75 "git command succeeded"
76 );
77 Ok(stdout.trim().to_string())
78 }
79}
80
81impl VcsBackend for GitBackend {
82 fn init(&self, path: &Path) -> Result<(), NapError> {
83 debug!(path = %path.display(), "initializing git repository");
84 std::fs::create_dir_all(path)?;
85 Self::run_git(path, &["init"])?;
86 Self::run_git(path, &["checkout", "-b", "main"]).ok();
88 Self::run_git(path, &["config", "user.email", "nap@cinematiccanvas.com"])?;
90 Self::run_git(path, &["config", "user.name", "NAP"])?;
91 debug!(path = %path.display(), "git repository initialized");
92 Ok(())
93 }
94
95 fn commit(&self, path: &Path, message: &str, author: &str) -> Result<String, NapError> {
96 debug!(
97 path = %path.display(),
98 message = %message,
99 author = %author,
100 "creating git commit"
101 );
102
103 Self::run_git(path, &["add", "-A"])?;
105
106 let status = Self::run_git(path, &["status", "--porcelain"])?;
108 if status.is_empty() {
109 return Err(NapError::VcsError("nothing to commit".to_string()));
110 }
111
112 let author_str = format!("{author} <{author}@nap>");
114 Self::run_git(path, &["commit", "-m", message, "--author", &author_str])?;
115
116 let hash = Self::run_git(path, &["rev-parse", "HEAD"])?;
118 debug!(commit_hash = %hash, "git commit created");
119 Ok(hash)
120 }
121
122 fn read_file_at_ref(
123 &self,
124 repo_path: &Path,
125 file_path: &str,
126 reference: Option<&str>,
127 ) -> Result<String, NapError> {
128 match reference {
129 Some(git_ref) => {
130 trace!(
131 repo = %repo_path.display(),
132 file = %file_path,
133 git_ref = %git_ref,
134 "reading file at ref"
135 );
136 let spec = format!("{git_ref}:{file_path}");
137 Self::run_git(repo_path, &["show", &spec])
138 }
139 None => {
140 trace!(
141 repo = %repo_path.display(),
142 file = %file_path,
143 "reading file from working tree"
144 );
145 let full_path = repo_path.join(file_path);
146 std::fs::read_to_string(&full_path).map_err(|e| {
147 NapError::ManifestNotFound(format!("{}: {e}", full_path.display()))
148 })
149 }
150 }
151 }
152
153 fn log(
154 &self,
155 path: &Path,
156 file: Option<&str>,
157 limit: usize,
158 ) -> Result<Vec<CommitInfo>, NapError> {
159 let limit_str = format!("-{limit}");
160 let format_flag = "--format=%H%n%P%n%an%n%s%n%aI%n---".to_string();
161
162 let mut args = vec!["log", &limit_str, &format_flag];
163 if let Some(file_path) = file {
164 args.push("--");
165 args.push(file_path);
166 }
167
168 let output = Self::run_git(path, &args)?;
169 if output.is_empty() {
170 return Ok(vec![]);
171 }
172
173 let mut commits = Vec::new();
174 for entry in output.split("---\n") {
175 let entry = entry.trim();
176 if entry.is_empty() {
177 continue;
178 }
179 let lines: Vec<&str> = entry.lines().collect();
180 if lines.len() >= 5 {
181 commits.push(CommitInfo {
182 id: lines[0].to_string(),
183 parent: if lines[1].is_empty() {
184 None
185 } else {
186 Some(lines[1].split_whitespace().next().unwrap_or("").to_string())
187 },
188 author: lines[2].to_string(),
189 message: lines[3].to_string(),
190 timestamp: lines[4].to_string(),
191 });
192 }
193 }
194
195 Ok(commits)
196 }
197
198 fn create_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
199 debug!(path = %path.display(), branch = %name, "creating git branch");
200 Self::run_git(path, &["branch", name])?;
201 Ok(())
202 }
203
204 fn switch_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
205 debug!(path = %path.display(), branch = %name, "switching git branch");
206 Self::run_git(path, &["checkout", name])?;
207 Ok(())
208 }
209
210 fn create_tag(&self, path: &Path, name: &str) -> Result<(), NapError> {
211 debug!(path = %path.display(), tag = %name, "creating git tag");
212 Self::run_git(path, &["tag", name])?;
213 Ok(())
214 }
215
216 fn current_branch(&self, path: &Path) -> Result<String, NapError> {
217 Self::run_git(path, &["rev-parse", "--abbrev-ref", "HEAD"])
218 }
219
220 fn head_hash(&self, path: &Path) -> Result<String, NapError> {
221 Self::run_git(path, &["rev-parse", "HEAD"])
222 }
223
224 fn resolve_branch_head(&self, path: &Path, branch: &str) -> Result<String, NapError> {
225 let output = Self::run_git(path, &["rev-parse", branch])?;
226 let hash = output.trim().to_string();
227 if hash.is_empty() {
228 return Err(NapError::VcsError(format!(
229 "branch '{branch}' has no commits"
230 )));
231 }
232 Ok(hash)
233 }
234
235 fn list_branches(&self, path: &Path) -> Result<Vec<String>, NapError> {
236 let output = Self::run_git(path, &["branch", "--format=%(refname:short)"])?;
237 Ok(output
238 .lines()
239 .map(|l| l.trim().to_string())
240 .filter(|l| !l.is_empty())
241 .collect())
242 }
243
244 fn list_tags(&self, path: &Path) -> Result<Vec<String>, NapError> {
245 let output = Self::run_git(path, &["tag", "--list"])?;
246 Ok(output
247 .lines()
248 .map(|l| l.trim().to_string())
249 .filter(|l| !l.is_empty())
250 .collect())
251 }
252
253 fn add_remote(&self, path: &Path, name: &str, url: &str) -> Result<(), NapError> {
256 debug!(
257 path = %path.display(),
258 remote = %name,
259 url = %url,
260 "adding git remote"
261 );
262 Self::run_git(path, &["remote", "add", name, url])?;
263 Ok(())
264 }
265
266 fn remove_remote(&self, path: &Path, name: &str) -> Result<(), NapError> {
267 debug!(
268 path = %path.display(),
269 remote = %name,
270 "removing git remote"
271 );
272 Self::run_git(path, &["remote", "remove", name])?;
273 Ok(())
274 }
275
276 fn list_remotes(&self, path: &Path) -> Result<Vec<(String, String)>, NapError> {
277 debug!(path = %path.display(), "listing git remotes");
278 let output = Self::run_git(path, &["remote", "-v"])?;
279 let mut remotes = Vec::new();
280 for line in output.lines() {
281 let line = line.trim();
282 if line.is_empty() {
283 continue;
284 }
285 if let Some(tab_pos) = line.find('\t') {
287 let name = line[..tab_pos].to_string();
288 let rest = &line[tab_pos + 1..];
289 if let Some(space_pos) = rest.rfind(" (") {
290 let url = rest[..space_pos].to_string();
291 if !remotes.iter().any(|(n, _): &(String, String)| n == &name) {
293 remotes.push((name, url));
294 }
295 }
296 }
297 }
298 Ok(remotes)
299 }
300
301 fn push(
302 &self,
303 path: &Path,
304 remote: Option<&str>,
305 branch: Option<&str>,
306 ) -> Result<(), NapError> {
307 let remote_display = remote.unwrap_or("origin");
308 let branch_display = branch.unwrap_or("current");
309 debug!(
310 path = %path.display(),
311 remote = %remote_display,
312 branch = %branch_display,
313 "pushing to git remote"
314 );
315
316 let mut args = vec!["push"];
317 if let Some(r) = remote {
318 args.push(r);
319 }
320 if let Some(b) = branch {
321 args.push(b);
322 }
323
324 Self::run_git(path, &args)?;
325 Ok(())
326 }
327
328 fn pull(
329 &self,
330 path: &Path,
331 remote: Option<&str>,
332 branch: Option<&str>,
333 ) -> Result<(), NapError> {
334 let remote_display = remote.unwrap_or("origin");
335 let branch_display = branch.unwrap_or("current");
336 debug!(
337 path = %path.display(),
338 remote = %remote_display,
339 branch = %branch_display,
340 "pulling from git remote"
341 );
342
343 let mut args = vec!["pull"];
344 if let Some(r) = remote {
345 args.push(r);
346 }
347 if let Some(b) = branch {
348 args.push(b);
349 }
350
351 Self::run_git(path, &args)?;
352 Ok(())
353 }
354
355 fn revert(&self, path: &Path, commit_hash: &str) -> Result<String, NapError> {
356 debug!(
357 path = %path.display(),
358 commit = %commit_hash,
359 "reverting git commit"
360 );
361
362 let status = Self::run_git(path, &["status", "--porcelain"])?;
367 let had_dirty = !status.is_empty();
368 if had_dirty {
369 Self::run_git(path, &["stash", "push", "-m", "nap-revert-stash"])?;
370 }
371
372 Self::run_git(path, &["revert", "--no-edit", commit_hash])?;
373
374 if had_dirty {
376 Self::run_git(path, &["stash", "drop"]).ok();
377 }
378
379 let hash = Self::run_git(path, &["rev-parse", "HEAD"])?;
380 debug!(revert_commit = %hash, "git revert created");
381 Ok(hash)
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use tempfile::TempDir;
389
390 #[test]
391 fn test_git_init_and_commit() {
392 let tmp = TempDir::new().unwrap();
393 let backend = GitBackend::new();
394
395 backend.init(tmp.path()).unwrap();
397
398 std::fs::write(tmp.path().join("test.txt"), "hello").unwrap();
400
401 let hash = backend
403 .commit(tmp.path(), "initial commit", "test-user")
404 .unwrap();
405 assert!(!hash.is_empty());
406
407 let head = backend.head_hash(tmp.path()).unwrap();
409 assert_eq!(hash, head);
410 }
411
412 #[test]
413 fn test_git_read_file_at_ref() {
414 let tmp = TempDir::new().unwrap();
415 let backend = GitBackend::new();
416 backend.init(tmp.path()).unwrap();
417
418 std::fs::write(tmp.path().join("data.txt"), "version 1").unwrap();
420 let hash_v1 = backend.commit(tmp.path(), "v1", "user").unwrap();
421
422 std::fs::write(tmp.path().join("data.txt"), "version 2").unwrap();
424 backend.commit(tmp.path(), "v2", "user").unwrap();
425
426 let current = backend
428 .read_file_at_ref(tmp.path(), "data.txt", None)
429 .unwrap();
430 assert_eq!(current, "version 2");
431
432 let at_v1 = backend
434 .read_file_at_ref(tmp.path(), "data.txt", Some(&hash_v1))
435 .unwrap();
436 assert_eq!(at_v1, "version 1");
437 }
438
439 #[test]
440 fn test_git_log() {
441 let tmp = TempDir::new().unwrap();
442 let backend = GitBackend::new();
443 backend.init(tmp.path()).unwrap();
444
445 std::fs::write(tmp.path().join("a.txt"), "a").unwrap();
446 backend.commit(tmp.path(), "first", "user").unwrap();
447
448 std::fs::write(tmp.path().join("b.txt"), "b").unwrap();
449 backend.commit(tmp.path(), "second", "user").unwrap();
450
451 let log = backend.log(tmp.path(), None, 10).unwrap();
452 assert_eq!(log.len(), 2);
453 assert_eq!(log[0].message, "second");
454 assert_eq!(log[1].message, "first");
455 }
456
457 #[test]
458 fn test_git_branches() {
459 let tmp = TempDir::new().unwrap();
460 let backend = GitBackend::new();
461 backend.init(tmp.path()).unwrap();
462
463 std::fs::write(tmp.path().join("init.txt"), "init").unwrap();
464 backend.commit(tmp.path(), "init", "user").unwrap();
465
466 backend.create_branch(tmp.path(), "canon").unwrap();
467 let branches = backend.list_branches(tmp.path()).unwrap();
468 assert!(branches.contains(&"main".to_string()));
469 assert!(branches.contains(&"canon".to_string()));
470 }
471
472 #[test]
473 fn test_git_tags() {
474 let tmp = TempDir::new().unwrap();
475 let backend = GitBackend::new();
476 backend.init(tmp.path()).unwrap();
477
478 std::fs::write(tmp.path().join("init.txt"), "init").unwrap();
479 backend.commit(tmp.path(), "init", "user").unwrap();
480
481 backend.create_tag(tmp.path(), "v1.0").unwrap();
482 let tags = backend.list_tags(tmp.path()).unwrap();
483 assert!(tags.contains(&"v1.0".to_string()));
484 }
485
486 #[test]
487 fn test_git_add_and_list_remotes() {
488 let tmp = TempDir::new().unwrap();
489 let backend = GitBackend::new();
490 backend.init(tmp.path()).unwrap();
491
492 backend
494 .add_remote(tmp.path(), "origin", "git@github.com:user/repo.git")
495 .unwrap();
496
497 let remotes = backend.list_remotes(tmp.path()).unwrap();
498 assert_eq!(remotes.len(), 1);
499 assert_eq!(remotes[0].0, "origin");
500 assert_eq!(remotes[0].1, "git@github.com:user/repo.git");
501 }
502
503 #[test]
504 fn test_git_remove_remote() {
505 let tmp = TempDir::new().unwrap();
506 let backend = GitBackend::new();
507 backend.init(tmp.path()).unwrap();
508
509 backend
510 .add_remote(tmp.path(), "origin", "git@github.com:user/repo.git")
511 .unwrap();
512 backend.remove_remote(tmp.path(), "origin").unwrap();
513
514 let remotes = backend.list_remotes(tmp.path()).unwrap();
515 assert!(remotes.is_empty());
516 }
517
518 #[test]
519 fn test_git_clone_repo() {
520 let tmp = TempDir::new().unwrap();
522 let backend = GitBackend::new();
523
524 let src = tmp.path().join("source");
526 backend.init(&src).unwrap();
527 std::fs::write(src.join("hello.txt"), "world").unwrap();
528 backend.commit(&src, "init", "user").unwrap();
529
530 let dest = tmp.path().join("clone");
532 GitBackend::clone_repo(src.to_str().unwrap(), &dest).unwrap();
533
534 assert!(dest.join("hello.txt").exists());
536 assert_eq!(
537 std::fs::read_to_string(dest.join("hello.txt")).unwrap(),
538 "world"
539 );
540 }
541}