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 list_branches(&self, path: &Path) -> Result<Vec<String>, NapError> {
225 let output = Self::run_git(path, &["branch", "--format=%(refname:short)"])?;
226 Ok(output
227 .lines()
228 .map(|l| l.trim().to_string())
229 .filter(|l| !l.is_empty())
230 .collect())
231 }
232
233 fn list_tags(&self, path: &Path) -> Result<Vec<String>, NapError> {
234 let output = Self::run_git(path, &["tag", "--list"])?;
235 Ok(output
236 .lines()
237 .map(|l| l.trim().to_string())
238 .filter(|l| !l.is_empty())
239 .collect())
240 }
241
242 fn add_remote(&self, path: &Path, name: &str, url: &str) -> Result<(), NapError> {
245 debug!(
246 path = %path.display(),
247 remote = %name,
248 url = %url,
249 "adding git remote"
250 );
251 Self::run_git(path, &["remote", "add", name, url])?;
252 Ok(())
253 }
254
255 fn remove_remote(&self, path: &Path, name: &str) -> Result<(), NapError> {
256 debug!(
257 path = %path.display(),
258 remote = %name,
259 "removing git remote"
260 );
261 Self::run_git(path, &["remote", "remove", name])?;
262 Ok(())
263 }
264
265 fn list_remotes(&self, path: &Path) -> Result<Vec<(String, String)>, NapError> {
266 debug!(path = %path.display(), "listing git remotes");
267 let output = Self::run_git(path, &["remote", "-v"])?;
268 let mut remotes = Vec::new();
269 for line in output.lines() {
270 let line = line.trim();
271 if line.is_empty() {
272 continue;
273 }
274 if let Some(tab_pos) = line.find('\t') {
276 let name = line[..tab_pos].to_string();
277 let rest = &line[tab_pos + 1..];
278 if let Some(space_pos) = rest.rfind(" (") {
279 let url = rest[..space_pos].to_string();
280 if !remotes.iter().any(|(n, _): &(String, String)| n == &name) {
282 remotes.push((name, url));
283 }
284 }
285 }
286 }
287 Ok(remotes)
288 }
289
290 fn push(
291 &self,
292 path: &Path,
293 remote: Option<&str>,
294 branch: Option<&str>,
295 ) -> Result<(), NapError> {
296 let remote_display = remote.unwrap_or("origin");
297 let branch_display = branch.unwrap_or("current");
298 debug!(
299 path = %path.display(),
300 remote = %remote_display,
301 branch = %branch_display,
302 "pushing to git remote"
303 );
304
305 let mut args = vec!["push"];
306 if let Some(r) = remote {
307 args.push(r);
308 }
309 if let Some(b) = branch {
310 args.push(b);
311 }
312
313 Self::run_git(path, &args)?;
314 Ok(())
315 }
316
317 fn pull(
318 &self,
319 path: &Path,
320 remote: Option<&str>,
321 branch: Option<&str>,
322 ) -> Result<(), NapError> {
323 let remote_display = remote.unwrap_or("origin");
324 let branch_display = branch.unwrap_or("current");
325 debug!(
326 path = %path.display(),
327 remote = %remote_display,
328 branch = %branch_display,
329 "pulling from git remote"
330 );
331
332 let mut args = vec!["pull"];
333 if let Some(r) = remote {
334 args.push(r);
335 }
336 if let Some(b) = branch {
337 args.push(b);
338 }
339
340 Self::run_git(path, &args)?;
341 Ok(())
342 }
343
344 fn revert(&self, path: &Path, commit_hash: &str) -> Result<String, NapError> {
345 debug!(
346 path = %path.display(),
347 commit = %commit_hash,
348 "reverting git commit"
349 );
350
351 let status = Self::run_git(path, &["status", "--porcelain"])?;
356 let had_dirty = !status.is_empty();
357 if had_dirty {
358 Self::run_git(path, &["stash", "push", "-m", "nap-revert-stash"])?;
359 }
360
361 Self::run_git(path, &["revert", "--no-edit", commit_hash])?;
362
363 if had_dirty {
365 Self::run_git(path, &["stash", "drop"]).ok();
366 }
367
368 let hash = Self::run_git(path, &["rev-parse", "HEAD"])?;
369 debug!(revert_commit = %hash, "git revert created");
370 Ok(hash)
371 }
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377 use tempfile::TempDir;
378
379 #[test]
380 fn test_git_init_and_commit() {
381 let tmp = TempDir::new().unwrap();
382 let backend = GitBackend::new();
383
384 backend.init(tmp.path()).unwrap();
386
387 std::fs::write(tmp.path().join("test.txt"), "hello").unwrap();
389
390 let hash = backend
392 .commit(tmp.path(), "initial commit", "test-user")
393 .unwrap();
394 assert!(!hash.is_empty());
395
396 let head = backend.head_hash(tmp.path()).unwrap();
398 assert_eq!(hash, head);
399 }
400
401 #[test]
402 fn test_git_read_file_at_ref() {
403 let tmp = TempDir::new().unwrap();
404 let backend = GitBackend::new();
405 backend.init(tmp.path()).unwrap();
406
407 std::fs::write(tmp.path().join("data.txt"), "version 1").unwrap();
409 let hash_v1 = backend.commit(tmp.path(), "v1", "user").unwrap();
410
411 std::fs::write(tmp.path().join("data.txt"), "version 2").unwrap();
413 backend.commit(tmp.path(), "v2", "user").unwrap();
414
415 let current = backend
417 .read_file_at_ref(tmp.path(), "data.txt", None)
418 .unwrap();
419 assert_eq!(current, "version 2");
420
421 let at_v1 = backend
423 .read_file_at_ref(tmp.path(), "data.txt", Some(&hash_v1))
424 .unwrap();
425 assert_eq!(at_v1, "version 1");
426 }
427
428 #[test]
429 fn test_git_log() {
430 let tmp = TempDir::new().unwrap();
431 let backend = GitBackend::new();
432 backend.init(tmp.path()).unwrap();
433
434 std::fs::write(tmp.path().join("a.txt"), "a").unwrap();
435 backend.commit(tmp.path(), "first", "user").unwrap();
436
437 std::fs::write(tmp.path().join("b.txt"), "b").unwrap();
438 backend.commit(tmp.path(), "second", "user").unwrap();
439
440 let log = backend.log(tmp.path(), None, 10).unwrap();
441 assert_eq!(log.len(), 2);
442 assert_eq!(log[0].message, "second");
443 assert_eq!(log[1].message, "first");
444 }
445
446 #[test]
447 fn test_git_branches() {
448 let tmp = TempDir::new().unwrap();
449 let backend = GitBackend::new();
450 backend.init(tmp.path()).unwrap();
451
452 std::fs::write(tmp.path().join("init.txt"), "init").unwrap();
453 backend.commit(tmp.path(), "init", "user").unwrap();
454
455 backend.create_branch(tmp.path(), "canon").unwrap();
456 let branches = backend.list_branches(tmp.path()).unwrap();
457 assert!(branches.contains(&"main".to_string()));
458 assert!(branches.contains(&"canon".to_string()));
459 }
460
461 #[test]
462 fn test_git_tags() {
463 let tmp = TempDir::new().unwrap();
464 let backend = GitBackend::new();
465 backend.init(tmp.path()).unwrap();
466
467 std::fs::write(tmp.path().join("init.txt"), "init").unwrap();
468 backend.commit(tmp.path(), "init", "user").unwrap();
469
470 backend.create_tag(tmp.path(), "v1.0").unwrap();
471 let tags = backend.list_tags(tmp.path()).unwrap();
472 assert!(tags.contains(&"v1.0".to_string()));
473 }
474
475 #[test]
476 fn test_git_add_and_list_remotes() {
477 let tmp = TempDir::new().unwrap();
478 let backend = GitBackend::new();
479 backend.init(tmp.path()).unwrap();
480
481 backend
483 .add_remote(tmp.path(), "origin", "git@github.com:user/repo.git")
484 .unwrap();
485
486 let remotes = backend.list_remotes(tmp.path()).unwrap();
487 assert_eq!(remotes.len(), 1);
488 assert_eq!(remotes[0].0, "origin");
489 assert_eq!(remotes[0].1, "git@github.com:user/repo.git");
490 }
491
492 #[test]
493 fn test_git_remove_remote() {
494 let tmp = TempDir::new().unwrap();
495 let backend = GitBackend::new();
496 backend.init(tmp.path()).unwrap();
497
498 backend
499 .add_remote(tmp.path(), "origin", "git@github.com:user/repo.git")
500 .unwrap();
501 backend.remove_remote(tmp.path(), "origin").unwrap();
502
503 let remotes = backend.list_remotes(tmp.path()).unwrap();
504 assert!(remotes.is_empty());
505 }
506
507 #[test]
508 fn test_git_clone_repo() {
509 let tmp = TempDir::new().unwrap();
511 let backend = GitBackend::new();
512
513 let src = tmp.path().join("source");
515 backend.init(&src).unwrap();
516 std::fs::write(src.join("hello.txt"), "world").unwrap();
517 backend.commit(&src, "init", "user").unwrap();
518
519 let dest = tmp.path().join("clone");
521 GitBackend::clone_repo(src.to_str().unwrap(), &dest).unwrap();
522
523 assert!(dest.join("hello.txt").exists());
525 assert_eq!(
526 std::fs::read_to_string(dest.join("hello.txt")).unwrap(),
527 "world"
528 );
529 }
530}