1use std::env;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5#[derive(Debug)]
6pub struct SyncState {
7 pub shadow_path: PathBuf,
9 pub branch: String,
11 pub online: bool,
13 pub last_error: Option<String>,
15}
16
17#[derive(Debug, PartialEq, Eq)]
18pub enum SyncStatus {
19 Updated,
20 AlreadyUpToDate,
21 Offline,
22}
23
24#[derive(Debug, thiserror::Error)]
25pub enum SyncError {
26 #[error("io error: {0}")]
27 Io(#[from] std::io::Error),
28 #[error("not a git repository")]
29 NotGitRepo,
30 #[error("no remote configured")]
31 NoRemote,
32 #[error("git command failed: {0}")]
33 GitFailed(String),
34}
35
36pub fn find_git_root(start: &Path) -> Option<PathBuf> {
38 let mut dir = start.to_path_buf();
39 loop {
40 if dir.join(".git").exists() {
41 return Some(dir);
42 }
43 if !dir.pop() {
44 return None;
45 }
46 }
47}
48
49pub fn check_ssh_agent(remote_url: &str) -> bool {
52 let is_ssh = remote_url.starts_with("git@")
53 || remote_url.starts_with("ssh://")
54 || remote_url.contains("@") && !remote_url.starts_with("http");
55
56 if !is_ssh {
57 return false;
58 }
59
60 match env::var("SSH_AUTH_SOCK") {
62 Ok(sock) if !sock.is_empty() => {
63 let output = Command::new("ssh-add").arg("-l").output();
65 match output {
66 Ok(out) if out.status.success() => false, _ => true, }
69 }
70 _ => true, }
72}
73
74fn git_cmd(shadow_path: &Path) -> Command {
78 let mut cmd = Command::new("git");
79 cmd.current_dir(shadow_path);
80
81 let hash = djb2(&shadow_path.to_string_lossy());
84 let ssh_cmd = format!(
85 "ssh -o ControlMaster=auto -o ControlPath=/tmp/kando-ssh-{hash:016x} -o ControlPersist=600",
86 );
87 cmd.env("GIT_SSH_COMMAND", ssh_cmd);
88
89 cmd
90}
91
92pub fn get_remote_url(repo_root: &Path) -> Result<String, SyncError> {
94 let output = Command::new("git")
95 .args(["remote", "get-url", "origin"])
96 .current_dir(repo_root)
97 .output()?;
98
99 if !output.status.success() {
100 return Err(SyncError::NoRemote);
101 }
102
103 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
104}
105
106fn djb2(s: &str) -> u64 {
108 let mut hash: u64 = 5381;
109 for b in s.bytes() {
110 hash = hash.wrapping_mul(33).wrapping_add(u64::from(b));
111 }
112 hash
113}
114
115pub fn shadow_dir_for(remote_url: &str) -> PathBuf {
117 let cache_dir = dirs::cache_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
118 let hash = djb2(remote_url);
119 cache_dir.join(".kando").join(format!("{hash:016x}"))
120}
121
122pub fn init_shadow(kando_dir: &Path, branch: &str) -> Result<SyncState, SyncError> {
124 let repo_root = kando_dir
126 .parent()
127 .ok_or_else(|| SyncError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "no parent")))?;
128
129 let git_root = find_git_root(repo_root).ok_or(SyncError::NotGitRepo)?;
130 let remote_url = get_remote_url(&git_root)?;
131 let shadow_path = shadow_dir_for(&remote_url);
132
133 let ssh_warning = if check_ssh_agent(&remote_url) {
134 Some("No ssh-agent keys found. Run `ssh-add` to avoid passphrase prompts.".to_string())
135 } else {
136 None
137 };
138
139 if shadow_path.join(".git").exists() {
140 let shadow_remote = get_remote_url(&shadow_path).unwrap_or_default();
142 if shadow_remote != remote_url {
143 std::fs::remove_dir_all(&shadow_path)?;
145 clone_shadow(&remote_url, &shadow_path, branch)?;
146 } else {
147 let _ = git_cmd(&shadow_path)
149 .args(["checkout", branch])
150 .output();
151 let _ = git_cmd(&shadow_path)
152 .args(["branch", "--set-upstream-to", &format!("origin/{branch}"), branch])
153 .output();
154 }
155 } else {
156 clone_shadow(&remote_url, &shadow_path, branch)?;
157 }
158
159 Ok(SyncState {
160 shadow_path,
161 branch: branch.to_string(),
162 online: true,
163 last_error: ssh_warning,
164 })
165}
166
167fn clone_shadow(remote_url: &str, shadow_path: &Path, branch: &str) -> Result<(), SyncError> {
168 std::fs::create_dir_all(shadow_path)?;
169
170 let output = Command::new("git")
172 .args([
173 "clone",
174 "--branch",
175 branch,
176 "--single-branch",
177 remote_url,
178 &shadow_path.to_string_lossy(),
179 ])
180 .output()?;
181
182 if !output.status.success() {
183 let _ = std::fs::remove_dir_all(shadow_path);
185 std::fs::create_dir_all(shadow_path)?;
186
187 let output = Command::new("git")
188 .args(["clone", remote_url, &shadow_path.to_string_lossy()])
189 .output()?;
190
191 if !output.status.success() {
192 return Err(SyncError::GitFailed(
193 String::from_utf8_lossy(&output.stderr).to_string(),
194 ));
195 }
196
197 let _ = Command::new("git")
199 .args(["checkout", "-b", branch])
200 .current_dir(shadow_path)
201 .output();
202
203 let _ = Command::new("git")
205 .args(["config", "push.default", "current"])
206 .current_dir(shadow_path)
207 .output();
208 }
209
210 Ok(())
211}
212
213pub fn pull(state: &mut SyncState, kando_dir: &Path) -> SyncStatus {
215 let output = git_cmd(&state.shadow_path)
217 .args(["pull", "--rebase", "--autostash"])
218 .output();
219
220 match output {
221 Ok(out) if out.status.success() => {
222 state.online = true;
223 state.last_error = None;
224
225 let stderr = String::from_utf8_lossy(&out.stderr);
226 let stdout = String::from_utf8_lossy(&out.stdout);
227
228 let shadow_kando = state.shadow_path.join(".kando");
230 if shadow_kando.exists() {
231 if let Err(e) = copy_dir_contents(&shadow_kando, kando_dir) {
232 state.last_error = Some(format!("sync copy failed: {e}"));
233 }
234 }
235
236 if stdout.contains("Already up to date") || stderr.contains("Already up to date") {
237 SyncStatus::AlreadyUpToDate
238 } else {
239 SyncStatus::Updated
240 }
241 }
242 Ok(out) => {
243 let stderr = String::from_utf8_lossy(&out.stderr);
244 if stderr.contains("no tracking information") {
246 state.online = true;
247 state.last_error = None;
248 SyncStatus::AlreadyUpToDate
249 } else {
250 state.online = false;
251 state.last_error = Some(stderr.trim().to_string());
252 SyncStatus::Offline
253 }
254 }
255 Err(e) => {
256 state.online = false;
257 state.last_error = Some(format!("git pull failed: {e}"));
258 SyncStatus::Offline
259 }
260 }
261}
262
263pub fn commit_and_push(state: &mut SyncState, kando_dir: &Path, message: &str) {
265 let shadow_kando = state.shadow_path.join(".kando");
267 let _ = std::fs::remove_dir_all(&shadow_kando);
268 if let Err(e) = copy_dir_contents(kando_dir, &shadow_kando) {
269 state.last_error = Some(format!("sync copy failed: {e}"));
270 return;
271 }
272
273 let _ = git_cmd(&state.shadow_path)
275 .args(["add", "-A", ".kando/"])
276 .output();
277
278 let diff_output = git_cmd(&state.shadow_path)
280 .args(["diff", "--cached", "--quiet"])
281 .output();
282
283 match diff_output {
284 Ok(out) if out.status.success() => {
285 return;
287 }
288 _ => {}
289 }
290
291 let commit_result = git_cmd(&state.shadow_path)
293 .args(["commit", "-m", message])
294 .output();
295
296 match commit_result {
297 Ok(out) if !out.status.success() => {
298 state.last_error = Some(
299 String::from_utf8_lossy(&out.stderr).trim().to_string()
300 );
301 return;
302 }
303 Err(e) => {
304 state.last_error = Some(format!("git commit failed: {e}"));
305 return;
306 }
307 _ => {}
308 }
309
310 let push_result = git_cmd(&state.shadow_path)
312 .args(["push", "origin", &state.branch])
313 .output();
314
315 match push_result {
316 Ok(out) if out.status.success() => {
317 state.online = true;
318 state.last_error = None;
319 }
320 Ok(out) => {
321 state.online = false;
322 state.last_error = Some(
323 String::from_utf8_lossy(&out.stderr).trim().to_string()
324 );
325 }
326 Err(e) => {
327 state.online = false;
328 state.last_error = Some(format!("git push failed: {e}"));
329 }
330 }
331}
332
333pub fn init_shadow_for_gitsync(project_root: &Path, branch: &str) -> Result<SyncState, SyncError> {
338 let git_root = find_git_root(project_root).ok_or(SyncError::NotGitRepo)?;
339 let remote_url = get_remote_url(&git_root)?;
340 let shadow_path = shadow_dir_for(&remote_url);
341
342 let ssh_warning = if check_ssh_agent(&remote_url) {
343 Some("No ssh-agent keys found. Run `ssh-add` to avoid passphrase prompts.".to_string())
344 } else {
345 None
346 };
347
348 if shadow_path.join(".git").exists() {
349 let shadow_remote = get_remote_url(&shadow_path).unwrap_or_default();
350 if shadow_remote != remote_url {
351 std::fs::remove_dir_all(&shadow_path)?;
352 clone_shadow(&remote_url, &shadow_path, branch)?;
353 } else {
354 let _ = git_cmd(&shadow_path)
355 .args(["checkout", branch])
356 .output();
357 let _ = git_cmd(&shadow_path)
358 .args(["branch", "--set-upstream-to", &format!("origin/{branch}"), branch])
359 .output();
360 }
361 } else {
362 clone_shadow(&remote_url, &shadow_path, branch)?;
363 }
364
365 Ok(SyncState {
366 shadow_path,
367 branch: branch.to_string(),
368 online: true,
369 last_error: ssh_warning,
370 })
371}
372
373pub fn pull_shadow(state: &mut SyncState) -> SyncStatus {
375 let output = git_cmd(&state.shadow_path)
376 .args(["pull", "--rebase", "--autostash"])
377 .output();
378
379 match output {
380 Ok(out) if out.status.success() => {
381 state.online = true;
382 state.last_error = None;
383
384 let stderr = String::from_utf8_lossy(&out.stderr);
385 let stdout = String::from_utf8_lossy(&out.stdout);
386
387 if stdout.contains("Already up to date") || stderr.contains("Already up to date") {
388 SyncStatus::AlreadyUpToDate
389 } else {
390 SyncStatus::Updated
391 }
392 }
393 Ok(out) => {
394 let stderr = String::from_utf8_lossy(&out.stderr);
395 if stderr.contains("no tracking information") {
396 state.online = true;
397 state.last_error = None;
398 SyncStatus::AlreadyUpToDate
399 } else {
400 state.online = false;
401 state.last_error = Some(stderr.trim().to_string());
402 SyncStatus::Offline
403 }
404 }
405 Err(e) => {
406 state.online = false;
407 state.last_error = Some(format!("git pull failed: {e}"));
408 SyncStatus::Offline
409 }
410 }
411}
412
413pub fn commit_and_push_shadow(state: &mut SyncState, message: &str) {
415 let _ = git_cmd(&state.shadow_path)
417 .args(["add", "-A", ".kando/"])
418 .output();
419
420 let diff_output = git_cmd(&state.shadow_path)
422 .args(["diff", "--cached", "--quiet"])
423 .output();
424
425 match diff_output {
426 Ok(out) if out.status.success() => {
427 return;
429 }
430 _ => {}
431 }
432
433 let commit_result = git_cmd(&state.shadow_path)
435 .args(["commit", "-m", message])
436 .output();
437
438 match commit_result {
439 Ok(out) if !out.status.success() => {
440 state.last_error = Some(
441 String::from_utf8_lossy(&out.stderr).trim().to_string()
442 );
443 return;
444 }
445 Err(e) => {
446 state.last_error = Some(format!("git commit failed: {e}"));
447 return;
448 }
449 _ => {}
450 }
451
452 let push_result = git_cmd(&state.shadow_path)
454 .args(["push", "origin", &state.branch])
455 .output();
456
457 match push_result {
458 Ok(out) if out.status.success() => {
459 state.online = true;
460 state.last_error = None;
461 }
462 Ok(out) => {
463 state.online = false;
464 state.last_error = Some(
465 String::from_utf8_lossy(&out.stderr).trim().to_string()
466 );
467 }
468 Err(e) => {
469 state.online = false;
470 state.last_error = Some(format!("git push failed: {e}"));
471 }
472 }
473}
474
475pub fn copy_dir_contents_pub(src: &Path, dst: &Path) -> std::io::Result<()> {
477 copy_dir_contents(src, dst)
478}
479
480fn copy_dir_contents(src: &Path, dst: &Path) -> std::io::Result<()> {
485 if !dst.exists() {
486 std::fs::create_dir_all(dst)?;
487 }
488
489 let mut src_names = std::collections::HashSet::new();
491
492 for entry in std::fs::read_dir(src)? {
493 let entry = entry?;
494 let file_type = entry.file_type()?;
495
496 if file_type.is_symlink() {
498 continue;
499 }
500
501 let name = entry.file_name();
502 src_names.insert(name.clone());
503 let src_path = entry.path();
504 let dst_path = dst.join(&name);
505
506 if file_type.is_dir() {
507 copy_dir_contents(&src_path, &dst_path)?;
508 } else {
509 std::fs::copy(&src_path, &dst_path)?;
510 }
511 }
512
513 for entry in std::fs::read_dir(dst)? {
515 let entry = entry?;
516 if !src_names.contains(&entry.file_name()) {
517 let path = entry.path();
518 if path.is_dir() {
519 std::fs::remove_dir_all(&path)?;
520 } else {
521 std::fs::remove_file(&path)?;
522 }
523 }
524 }
525
526 Ok(())
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532 use std::fs;
533
534 #[test]
539 fn test_find_git_root_at_root() {
540 let dir = tempfile::tempdir().unwrap();
541 fs::create_dir(dir.path().join(".git")).unwrap();
542 let result = find_git_root(dir.path());
543 assert_eq!(result, Some(dir.path().to_path_buf()));
544 }
545
546 #[test]
547 fn test_find_git_root_nested() {
548 let dir = tempfile::tempdir().unwrap();
549 fs::create_dir(dir.path().join(".git")).unwrap();
550 let nested = dir.path().join("src/deep/nested");
551 fs::create_dir_all(&nested).unwrap();
552 let result = find_git_root(&nested);
553 assert_eq!(result, Some(dir.path().to_path_buf()));
554 }
555
556 #[test]
557 fn test_find_git_root_not_found() {
558 let dir = tempfile::tempdir().unwrap();
559 let result = find_git_root(dir.path());
561 assert!(result.is_none());
562 }
563
564 #[test]
569 fn test_djb2_deterministic() {
570 let url = "git@github.com:user/repo.git";
571 assert_eq!(djb2(url), djb2(url));
572 }
573
574 #[test]
575 fn test_djb2_different_inputs() {
576 assert_ne!(djb2("repo-a"), djb2("repo-b"));
577 }
578
579 #[test]
580 fn test_djb2_empty_string() {
581 assert_eq!(djb2(""), 5381);
583 }
584
585 #[test]
586 fn test_djb2_known_value() {
587 assert_eq!(djb2("git@github.com:user/repo.git"), 5107748758901446025);
589 }
590
591 #[test]
596 fn test_shadow_dir_deterministic() {
597 let url = "git@github.com:user/repo.git";
598 assert_eq!(shadow_dir_for(url), shadow_dir_for(url));
599 }
600
601 #[test]
602 fn test_shadow_dir_different_urls() {
603 let a = shadow_dir_for("git@github.com:user/repo-a.git");
604 let b = shadow_dir_for("git@github.com:user/repo-b.git");
605 assert_ne!(a, b);
606 }
607
608 #[test]
609 fn test_shadow_dir_uses_kando_subdir() {
610 let path = shadow_dir_for("git@github.com:user/repo.git");
611 let parent = path.parent().unwrap();
613 assert_eq!(parent.file_name().unwrap(), ".kando");
614 }
615
616 #[test]
621 fn test_check_ssh_agent_https_url() {
622 assert!(!check_ssh_agent("https://github.com/user/repo.git"));
624 }
625
626 #[test]
627 fn test_check_ssh_agent_http_url() {
628 assert!(!check_ssh_agent("http://github.com/user/repo.git"));
629 }
630
631 #[test]
639 fn test_copy_basic_files() {
640 let dir = tempfile::tempdir().unwrap();
641 let src = dir.path().join("src");
642 let dst = dir.path().join("dst");
643 fs::create_dir(&src).unwrap();
644
645 fs::write(src.join("a.txt"), "hello").unwrap();
646 fs::write(src.join("b.txt"), "world").unwrap();
647
648 copy_dir_contents(&src, &dst).unwrap();
649
650 assert_eq!(fs::read_to_string(dst.join("a.txt")).unwrap(), "hello");
651 assert_eq!(fs::read_to_string(dst.join("b.txt")).unwrap(), "world");
652 }
653
654 #[test]
655 fn test_copy_nested_dirs() {
656 let dir = tempfile::tempdir().unwrap();
657 let src = dir.path().join("src");
658 let dst = dir.path().join("dst");
659 let sub = src.join("sub");
660 fs::create_dir_all(&sub).unwrap();
661
662 fs::write(src.join("top.txt"), "top").unwrap();
663 fs::write(sub.join("deep.txt"), "deep").unwrap();
664
665 copy_dir_contents(&src, &dst).unwrap();
666
667 assert_eq!(fs::read_to_string(dst.join("top.txt")).unwrap(), "top");
668 assert_eq!(fs::read_to_string(dst.join("sub/deep.txt")).unwrap(), "deep");
669 }
670
671 #[test]
672 fn test_copy_creates_dst_if_missing() {
673 let dir = tempfile::tempdir().unwrap();
674 let src = dir.path().join("src");
675 let dst = dir.path().join("nonexistent/deep/dst");
676 fs::create_dir(&src).unwrap();
677 fs::write(src.join("f.txt"), "data").unwrap();
678
679 copy_dir_contents(&src, &dst).unwrap();
680
681 assert!(dst.exists());
682 assert_eq!(fs::read_to_string(dst.join("f.txt")).unwrap(), "data");
683 }
684
685 #[test]
686 fn test_copy_removes_stale_files() {
687 let dir = tempfile::tempdir().unwrap();
688 let src = dir.path().join("src");
689 let dst = dir.path().join("dst");
690 fs::create_dir_all(&src).unwrap();
691 fs::create_dir_all(&dst).unwrap();
692
693 fs::write(src.join("keep.txt"), "keep").unwrap();
695 fs::write(dst.join("stale.txt"), "old").unwrap();
697
698 copy_dir_contents(&src, &dst).unwrap();
699
700 assert!(dst.join("keep.txt").exists());
701 assert!(!dst.join("stale.txt").exists(), "stale file should be removed");
702 }
703
704 #[test]
705 fn test_copy_removes_stale_dirs() {
706 let dir = tempfile::tempdir().unwrap();
707 let src = dir.path().join("src");
708 let dst = dir.path().join("dst");
709 fs::create_dir_all(&src).unwrap();
710 fs::create_dir_all(dst.join("stale_dir")).unwrap();
711 fs::write(dst.join("stale_dir/f.txt"), "old").unwrap();
712
713 fs::write(src.join("keep.txt"), "keep").unwrap();
714
715 copy_dir_contents(&src, &dst).unwrap();
716
717 assert!(dst.join("keep.txt").exists());
718 assert!(!dst.join("stale_dir").exists(), "stale dir should be removed");
719 }
720
721 #[test]
722 fn test_copy_overwrites_existing_files() {
723 let dir = tempfile::tempdir().unwrap();
724 let src = dir.path().join("src");
725 let dst = dir.path().join("dst");
726 fs::create_dir_all(&src).unwrap();
727 fs::create_dir_all(&dst).unwrap();
728
729 fs::write(src.join("f.txt"), "new content").unwrap();
730 fs::write(dst.join("f.txt"), "old content").unwrap();
731
732 copy_dir_contents(&src, &dst).unwrap();
733
734 assert_eq!(fs::read_to_string(dst.join("f.txt")).unwrap(), "new content");
735 }
736
737 #[cfg(unix)]
738 #[test]
739 fn test_copy_skips_symlinks() {
740 use std::os::unix::fs as unix_fs;
741
742 let dir = tempfile::tempdir().unwrap();
743 let src = dir.path().join("src");
744 let dst = dir.path().join("dst");
745 fs::create_dir(&src).unwrap();
746
747 fs::write(src.join("real.txt"), "real").unwrap();
748 unix_fs::symlink(src.join("real.txt"), src.join("link.txt")).unwrap();
749
750 copy_dir_contents(&src, &dst).unwrap();
751
752 assert!(dst.join("real.txt").exists());
753 assert!(!dst.join("link.txt").exists(), "symlink should be skipped");
754 }
755
756 #[test]
757 fn test_copy_empty_src() {
758 let dir = tempfile::tempdir().unwrap();
759 let src = dir.path().join("src");
760 let dst = dir.path().join("dst");
761 fs::create_dir(&src).unwrap();
762
763 copy_dir_contents(&src, &dst).unwrap();
764
765 assert!(dst.exists());
766 let entries: Vec<_> = fs::read_dir(&dst).unwrap().collect();
768 assert!(entries.is_empty());
769 }
770
771 #[test]
772 fn test_copy_cleans_dst_fully_when_src_empty() {
773 let dir = tempfile::tempdir().unwrap();
774 let src = dir.path().join("src");
775 let dst = dir.path().join("dst");
776 fs::create_dir(&src).unwrap();
777 fs::create_dir_all(&dst).unwrap();
778 fs::write(dst.join("leftover.txt"), "old").unwrap();
779
780 copy_dir_contents(&src, &dst).unwrap();
781
782 assert!(!dst.join("leftover.txt").exists(), "leftover should be cleaned");
783 }
784}