1use anyhow::{Result, bail};
4use colored::Colorize;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8use crate::config::{self, ValetConfig};
9use crate::git_helpers::{get_git_dir, get_origin, get_work_tree, load_config, path_str, sgit};
10use crate::hooks;
11
12const VALET_FILE: &str = ".gitvalet";
13
14fn normalize_path(entry: &str) -> String {
18 entry.replace('\\', "/")
19}
20
21fn validate_path(entry: &str) -> Result<()> {
23 let normalized = normalize_path(entry);
24 let path = Path::new(&normalized);
25
26 if path.is_absolute() || normalized.starts_with('/') {
28 bail!("Tracked path must be relative: {entry}");
29 }
30
31 for component in path.components() {
33 if matches!(component, std::path::Component::ParentDir) {
34 bail!("Tracked path must not contain '..': {entry}");
35 }
36 }
37
38 Ok(())
39}
40
41fn read_gitvalet(work_tree: &Path) -> Vec<String> {
47 let path = work_tree.join(VALET_FILE);
48 if !path.exists() {
49 return Vec::new();
50 }
51 let Ok(content) = std::fs::read_to_string(&path) else {
52 return Vec::new();
53 };
54 content
55 .lines()
56 .map(str::trim)
57 .filter(|l| !l.is_empty() && !l.starts_with('#'))
58 .map(normalize_path)
59 .collect()
60}
61
62fn read_gitvalet_validated(work_tree: &Path) -> Result<Vec<String>> {
64 let entries = read_gitvalet(work_tree);
65 for entry in &entries {
66 validate_path(entry)?;
67 }
68 Ok(entries)
69}
70
71fn tracked_with_gitvalet_validated(work_tree: &Path) -> Result<Vec<String>> {
74 let mut tracked = read_gitvalet_validated(work_tree)?;
75 if !tracked.iter().any(|f| f == VALET_FILE) {
76 tracked.push(VALET_FILE.to_string());
77 }
78 Ok(tracked)
79}
80
81fn write_gitvalet(work_tree: &Path, files: &[String]) -> Result<()> {
83 let path = work_tree.join(VALET_FILE);
84 let normalized: Vec<String> = files.iter().map(|f| normalize_path(f)).collect();
85 let content = normalized.join("\n") + "\n";
86 std::fs::write(&path, content)?;
87 Ok(())
88}
89
90fn update_exclude(git_dir: &Path, files: &[String]) -> Result<()> {
95 let info_dir = git_dir.join("info");
96 std::fs::create_dir_all(&info_dir)?;
97 let exclude_path = info_dir.join("exclude");
98
99 let existing =
100 if exclude_path.exists() { std::fs::read_to_string(&exclude_path)? } else { String::new() };
101
102 let marker = "# git-valet: files versioned in the valet repo";
104 let mut base_lines: Vec<&str> = Vec::new();
105 let mut in_valet_section = false;
106 for line in existing.lines() {
107 if line.trim() == marker {
108 in_valet_section = true;
109 continue;
110 }
111 if in_valet_section {
112 if line.trim().is_empty() || (line.starts_with('#') && line.trim() != marker) {
113 in_valet_section = false;
114 base_lines.push(line);
115 }
116 continue;
117 }
118 base_lines.push(line);
119 }
120
121 while base_lines.last().is_some_and(|l| l.trim().is_empty()) {
123 base_lines.pop();
124 }
125
126 let mut content = base_lines.join("\n");
127 if !content.is_empty() && !content.ends_with('\n') {
128 content.push('\n');
129 }
130
131 if !files.is_empty() {
132 content.push('\n');
133 content.push_str(marker);
134 content.push('\n');
135 for f in files {
136 content.push_str(f);
137 content.push('\n');
138 }
139 }
140
141 std::fs::write(&exclude_path, content)?;
142 println!("{} .git/info/exclude updated ({} entries)", "->".cyan(), files.len());
143 Ok(())
144}
145
146fn remove_from_exclude(git_dir: &Path, files: &[String]) -> Result<()> {
148 let exclude_path = git_dir.join("info").join("exclude");
149 if !exclude_path.exists() {
150 return Ok(());
151 }
152
153 let content = std::fs::read_to_string(&exclude_path)?;
154 let filtered: Vec<&str> = content
155 .lines()
156 .filter(|line| {
157 let trimmed = line.trim();
158 !files.iter().any(|f| f == trimmed)
159 && trimmed != "# git-valet: files versioned in the valet repo"
160 })
161 .collect();
162
163 std::fs::write(&exclude_path, filtered.join("\n") + "\n")?;
164 Ok(())
165}
166
167fn init_with_files(
171 work_tree: &Path,
172 git_dir: &Path,
173 files: &[String],
174 cfg: &mut ValetConfig,
175 project_id: &str,
176) -> Result<()> {
177 for f in files {
178 validate_path(f)?;
179 }
180 write_gitvalet(work_tree, files)?;
181 println!("{} .gitvalet created with {} entries", "->".cyan(), files.len());
182
183 let tracked = tracked_with_gitvalet_validated(work_tree)?;
184 cfg.tracked.clone_from(&tracked);
185 config::save(cfg, project_id)?;
186 update_exclude(git_dir, &tracked)?;
187
188 let existing: Vec<&str> =
189 tracked.iter().filter(|f| work_tree.join(f).exists()).map(String::as_str).collect();
190
191 if !existing.is_empty() {
192 let mut add_args = vec!["add", "-f"];
193 add_args.extend(existing.iter());
194 sgit(&add_args, cfg)?;
195
196 let commit_out = sgit(&["commit", "-m", "feat: init valet repo"], cfg)?;
197 if commit_out.status.success() {
198 println!("{} Initial commit done", "->".cyan());
199
200 let push_out = sgit(&["push", "-u", "origin", &format!("HEAD:{}", cfg.branch)], cfg)?;
201 if push_out.status.success() {
202 println!("{} Initial push done", "->".cyan());
203 } else {
204 let err = String::from_utf8_lossy(&push_out.stderr);
205 println!(
206 "{} Initial push failed (remote unreachable?): {}",
207 "!".yellow(),
208 err.trim()
209 );
210 println!(" You can push manually with: {}", "git valet push".cyan());
211 }
212 }
213 }
214 Ok(())
215}
216
217fn init_fresh_clone(
219 work_tree: &Path,
220 git_dir: &Path,
221 cfg: &mut ValetConfig,
222 project_id: &str,
223) -> Result<()> {
224 config::save(cfg, project_id)?;
225
226 let fetch_out = sgit(&["fetch", "origin", &cfg.branch], cfg)?;
227 if fetch_out.status.success() {
228 let checkout_out = sgit(&["checkout", &format!("origin/{}", cfg.branch), "--", "."], cfg)?;
229 if checkout_out.status.success() {
230 sgit(&["branch", &cfg.branch, &format!("origin/{}", cfg.branch)], cfg)?;
231 sgit(&["symbolic-ref", "HEAD", &format!("refs/heads/{}", cfg.branch)], cfg)?;
232 println!("{} Pulled existing files from remote", "->".cyan());
233
234 let tracked = tracked_with_gitvalet_validated(work_tree)?;
235 cfg.tracked.clone_from(&tracked);
236 config::save(cfg, project_id)?;
237 update_exclude(git_dir, &tracked)?;
238 } else {
239 println!("{} Remote exists but checkout failed", "!".yellow());
240 }
241 } else {
242 println!("{} Remote is empty — create a .gitvalet file and run git valet sync", "i".blue());
243 }
244 Ok(())
245}
246
247pub fn init(remote: &str, files: &[String]) -> Result<()> {
249 let work_tree = get_work_tree()?;
250 let origin = get_origin(&work_tree)?;
251 let git_dir = get_git_dir(&work_tree)?;
252
253 let project_id = config::project_id(&origin);
254 let bare_path = config::valets_dir()?.join(&project_id).join("repo.git");
255 let bare_path = dunce::simplified(&bare_path).to_path_buf();
256 let bare_str = path_str(&bare_path)?;
257
258 println!("{}", "Initializing valet repo...".bold());
259 println!(" Project : {}", origin.dimmed());
260 println!(" Valet : {}", remote.cyan());
261 println!(" Bare repo : {}", bare_str.dimmed());
262
263 std::fs::create_dir_all(&bare_path)?;
265 let init_out = Command::new("git").args(["init", "--bare", bare_str]).output()?;
266 if !init_out.status.success() {
267 bail!("Failed to initialize bare repo");
268 }
269
270 let work_str = path_str(&work_tree)?;
272 let mut cfg = ValetConfig {
273 work_tree: work_str.to_string(),
274 remote: remote.to_string(),
275 bare_path: bare_str.to_string(),
276 tracked: vec![VALET_FILE.to_string()],
277 branch: "main".to_string(),
278 };
279
280 let config_out = Command::new("git")
282 .args(["--git-dir", bare_str, "config", "status.showUntrackedFiles", "no"])
283 .output()?;
284 if !config_out.status.success() {
285 bail!("Failed to configure valet repo");
286 }
287
288 let remote_out = Command::new("git")
290 .args(["--git-dir", bare_str, "remote", "add", "origin", remote])
291 .output()?;
292 if !remote_out.status.success() {
293 let set_url_out = Command::new("git")
294 .args(["--git-dir", bare_str, "remote", "set-url", "origin", remote])
295 .output()?;
296 if !set_url_out.status.success() {
297 bail!("Failed to set valet remote URL");
298 }
299 }
300
301 hooks::install(&git_dir)?;
303 println!(
304 "{} Git hooks installed (pre-commit, pre-push, post-merge, post-checkout)",
305 "->".cyan()
306 );
307
308 if files.is_empty() {
309 init_fresh_clone(&work_tree, &git_dir, &mut cfg, &project_id)?;
310 } else {
311 init_with_files(&work_tree, &git_dir, files, &mut cfg, &project_id)?;
312 }
313
314 let tracked = &cfg.tracked;
315 println!("\n{}", "Done! Valet repo initialized.".green().bold());
316 println!("The following files are managed by git-valet:");
317 for f in tracked {
318 println!(" {} {}", "-".dimmed(), f.cyan());
319 }
320 println!("\nEdit {} to add/remove tracked files.", VALET_FILE.cyan());
321 println!("Your usual git commands work as before.");
322
323 Ok(())
324}
325
326pub fn status() -> Result<()> {
328 let cfg = load_config()?;
329 let work_tree = PathBuf::from(&cfg.work_tree);
330
331 let tracked = tracked_with_gitvalet_validated(&work_tree)?;
333
334 println!("{}", "Valet repo status".bold());
335 println!(" Remote : {}", cfg.remote.cyan());
336 println!(" Tracked ({}):", VALET_FILE.cyan());
337 for f in &tracked {
338 let exists = work_tree.join(f).exists();
339 let marker = if exists { "+".green() } else { "x".red() };
340 println!(" {marker} {f}");
341 }
342 println!();
343
344 let head_check = sgit(&["rev-parse", "HEAD"], &cfg)?;
345 if !head_check.status.success() {
346 println!(
347 "{}",
348 "Valet repo has no commits yet — run `git valet sync` to create the initial commit."
349 .yellow()
350 );
351 return Ok(());
352 }
353
354 let out = sgit(&["status", "--short"], &cfg)?;
355 let stdout = String::from_utf8_lossy(&out.stdout);
356 if stdout.trim().is_empty() {
357 println!("{}", "Nothing to commit — valet repo is clean.".green());
358 } else {
359 println!("{stdout}");
360 }
361
362 Ok(())
363}
364
365pub fn sync(message: &str) -> Result<()> {
367 let mut cfg = load_config()?;
368 let work_tree = PathBuf::from(&cfg.work_tree);
369 let git_dir = get_git_dir(&work_tree)?;
370
371 let tracked = tracked_with_gitvalet_validated(&work_tree)?;
373 if tracked != cfg.tracked {
374 let origin = get_origin(&work_tree)?;
375 let project_id = config::project_id(&origin);
376 cfg.tracked.clone_from(&tracked);
377 config::save(&cfg, &project_id)?;
378 update_exclude(&git_dir, &tracked)?;
379 }
380
381 let existing: Vec<&str> =
382 cfg.tracked.iter().filter(|f| work_tree.join(f).exists()).map(String::as_str).collect();
383
384 if existing.is_empty() {
385 println!("{}", "No tracked files found.".yellow());
386 return Ok(());
387 }
388
389 let mut add_args = vec!["add", "-f"];
390 add_args.extend(existing.iter());
391 sgit(&add_args, &cfg)?;
392
393 let head_check = sgit(&["rev-parse", "HEAD"], &cfg)?;
394 let is_empty_repo = !head_check.status.success();
395
396 let status_out = sgit(&["status", "--porcelain"], &cfg)?;
397 let has_changes =
398 is_empty_repo || !String::from_utf8_lossy(&status_out.stdout).trim().is_empty();
399
400 if has_changes {
401 let commit_out = sgit(&["commit", "-m", message], &cfg)?;
402 if commit_out.status.success() {
403 println!("{} Valet committed", "->".cyan());
404 } else {
405 let err = String::from_utf8_lossy(&commit_out.stderr);
406 println!("{} Valet commit: {}", "!".yellow(), err.trim());
407 }
408 }
409
410 push()?;
411 Ok(())
412}
413
414pub fn push() -> Result<()> {
416 let cfg = load_config()?;
417
418 let out = sgit(&["push", "origin", &format!("HEAD:{}", cfg.branch)], &cfg)?;
419
420 if out.status.success() {
421 println!("{} Valet pushed to {}", "+".green(), cfg.remote.cyan());
422 } else {
423 let err = String::from_utf8_lossy(&out.stderr);
424 if err.contains("Everything up-to-date") || err.contains("up to date") {
425 println!("{} Valet already up to date", "+".green());
426 } else {
427 println!("{} Valet push failed: {}", "!".yellow(), err.trim());
428 }
429 }
430
431 Ok(())
432}
433
434pub fn pull() -> Result<()> {
436 let mut cfg = load_config()?;
437
438 let out = sgit(&["pull", "origin", &cfg.branch], &cfg)?;
439
440 if out.status.success() {
441 let stdout = String::from_utf8_lossy(&out.stdout);
442 if stdout.contains("Already up to date") || stdout.contains("up to date") {
443 println!("{} Valet already up to date", "+".green());
444 } else {
445 println!("{} Valet updated", "+".green());
446 println!("{}", stdout.trim().dimmed());
447
448 let work_tree = PathBuf::from(&cfg.work_tree);
450 let tracked = tracked_with_gitvalet_validated(&work_tree)?;
451 if tracked != cfg.tracked {
452 let origin = get_origin(&work_tree)?;
453 let project_id = config::project_id(&origin);
454 let git_dir = get_git_dir(&work_tree)?;
455 cfg.tracked.clone_from(&tracked);
456 config::save(&cfg, &project_id)?;
457 update_exclude(&git_dir, &tracked)?;
458 }
459 }
460 } else {
461 let err = String::from_utf8_lossy(&out.stderr);
462 println!("{} Valet pull failed: {}", "!".yellow(), err.trim());
463 }
464
465 Ok(())
466}
467
468pub fn add_files(files: &[String]) -> Result<()> {
470 let work_tree = get_work_tree()?;
471 let origin = get_origin(&work_tree)?;
472 let project_id = config::project_id(&origin);
473 let git_dir = get_git_dir(&work_tree)?;
474
475 for f in files {
477 validate_path(f)?;
478 }
479
480 let mut entries = read_gitvalet(&work_tree);
482 for f in files {
483 let normalized = normalize_path(f);
484 if !entries.contains(&normalized) {
485 entries.push(normalized);
486 }
487 }
488 write_gitvalet(&work_tree, &entries)?;
489
490 let tracked = tracked_with_gitvalet_validated(&work_tree)?;
492 let mut cfg = load_config()?;
493 cfg.tracked.clone_from(&tracked);
494 config::save(&cfg, &project_id)?;
495 update_exclude(&git_dir, &tracked)?;
496
497 let existing: Vec<&str> =
499 tracked.iter().filter(|f| work_tree.join(f).exists()).map(String::as_str).collect();
500 let mut add_args = vec!["add", "-f"];
501 add_args.extend(existing.iter());
502 sgit(&add_args, &cfg)?;
503
504 println!("{} {} file(s) added to valet", "+".green(), files.len());
505 Ok(())
506}
507
508pub fn deinit() -> Result<()> {
510 let work_tree = get_work_tree()?;
511 let origin = get_origin(&work_tree)?;
512 let git_dir = get_git_dir(&work_tree)?;
513 let project_id = config::project_id(&origin);
514
515 let cfg = load_config()?;
516
517 println!("{}", "Removing valet repo...".yellow().bold());
518
519 hooks::uninstall(&git_dir)?;
520 println!("{} Hooks removed", "->".cyan());
521
522 let mut all_tracked = cfg.tracked.clone();
524 if !all_tracked.contains(&VALET_FILE.to_string()) {
525 all_tracked.push(VALET_FILE.to_string());
526 }
527 remove_from_exclude(&git_dir, &all_tracked)?;
528 println!("{} .git/info/exclude cleaned up", "->".cyan());
529
530 config::remove(&project_id)?;
531 println!("{} Local config removed", "->".cyan());
532
533 println!("\n{}", "Done! Valet repo removed.".green());
534 println!(
535 "{}",
536 "Note: the remote repo and local files (.gitvalet, etc.) are unchanged.".dimmed()
537 );
538
539 Ok(())
540}
541
542#[cfg(test)]
545mod tests {
546 use super::*;
547 use tempfile::TempDir;
548
549 #[test]
552 fn normalize_path_converts_backslashes() {
553 assert_eq!(normalize_path(r"secrets\key.pem"), "secrets/key.pem");
554 assert_eq!(normalize_path(r"a\b\c"), "a/b/c");
555 }
556
557 #[test]
558 fn normalize_path_keeps_forward_slashes() {
559 assert_eq!(normalize_path("secrets/key.pem"), "secrets/key.pem");
560 }
561
562 #[test]
565 fn read_gitvalet_returns_empty_when_no_file() {
566 let tmp = TempDir::new().unwrap();
567 let result = read_gitvalet(tmp.path());
568 assert!(result.is_empty());
569 }
570
571 #[test]
572 fn read_gitvalet_parses_entries() {
573 let tmp = TempDir::new().unwrap();
574 std::fs::write(tmp.path().join(".gitvalet"), ".env\nsecrets/\nnotes/ai.md\n").unwrap();
575
576 let result = read_gitvalet(tmp.path());
577 assert_eq!(result, vec![".env", "secrets/", "notes/ai.md"]);
578 }
579
580 #[test]
581 fn read_gitvalet_ignores_comments_and_blank_lines() {
582 let tmp = TempDir::new().unwrap();
583 std::fs::write(
584 tmp.path().join(".gitvalet"),
585 "# This is a comment\n\n.env\n \n# Another comment\nsecrets/\n",
586 )
587 .unwrap();
588
589 let result = read_gitvalet(tmp.path());
590 assert_eq!(result, vec![".env", "secrets/"]);
591 }
592
593 #[test]
594 fn read_gitvalet_trims_whitespace() {
595 let tmp = TempDir::new().unwrap();
596 std::fs::write(tmp.path().join(".gitvalet"), " .env \n secrets/ \n").unwrap();
597
598 let result = read_gitvalet(tmp.path());
599 assert_eq!(result, vec![".env", "secrets/"]);
600 }
601
602 #[test]
603 fn read_gitvalet_normalizes_backslashes() {
604 let tmp = TempDir::new().unwrap();
605 std::fs::write(tmp.path().join(".gitvalet"), "secrets\\key.pem\n").unwrap();
606
607 let result = read_gitvalet(tmp.path());
608 assert_eq!(result, vec!["secrets/key.pem"]);
609 }
610
611 #[test]
612 fn write_gitvalet_creates_file() {
613 let tmp = TempDir::new().unwrap();
614 let files = vec![".env".to_string(), "secrets/".to_string()];
615
616 write_gitvalet(tmp.path(), &files).unwrap();
617
618 let content = std::fs::read_to_string(tmp.path().join(".gitvalet")).unwrap();
619 assert_eq!(content, ".env\nsecrets/\n");
620 }
621
622 #[test]
623 fn write_gitvalet_normalizes_backslashes() {
624 let tmp = TempDir::new().unwrap();
625 let files = vec!["secrets\\key.pem".to_string()];
626
627 write_gitvalet(tmp.path(), &files).unwrap();
628
629 let content = std::fs::read_to_string(tmp.path().join(".gitvalet")).unwrap();
630 assert_eq!(content, "secrets/key.pem\n");
631 }
632
633 #[test]
634 fn write_then_read_roundtrip() {
635 let tmp = TempDir::new().unwrap();
636 let files = vec![".env".to_string(), "config/local.toml".to_string(), "notes/".to_string()];
637
638 write_gitvalet(tmp.path(), &files).unwrap();
639 let result = read_gitvalet(tmp.path());
640
641 assert_eq!(result, files);
642 }
643
644 #[test]
647 fn tracked_with_gitvalet_adds_gitvalet_implicitly() {
648 let tmp = TempDir::new().unwrap();
649 std::fs::write(tmp.path().join(".gitvalet"), ".env\n").unwrap();
650
651 let result = tracked_with_gitvalet_validated(tmp.path()).unwrap();
652 assert_eq!(result, vec![".env", ".gitvalet"]);
653 }
654
655 #[test]
656 fn tracked_with_gitvalet_no_duplicate_if_explicit() {
657 let tmp = TempDir::new().unwrap();
658 std::fs::write(tmp.path().join(".gitvalet"), ".env\n.gitvalet\n").unwrap();
659
660 let result = tracked_with_gitvalet_validated(tmp.path()).unwrap();
661 assert_eq!(result, vec![".env", ".gitvalet"]);
662 }
663
664 #[test]
665 fn tracked_with_gitvalet_returns_just_itself_when_no_file() {
666 let tmp = TempDir::new().unwrap();
667
668 let result = tracked_with_gitvalet_validated(tmp.path()).unwrap();
669 assert_eq!(result, vec![".gitvalet"]);
670 }
671
672 #[test]
675 fn update_exclude_creates_section() {
676 let tmp = TempDir::new().unwrap();
677 let git_dir = tmp.path();
678 std::fs::create_dir_all(git_dir.join("info")).unwrap();
679
680 let files = vec![".env".to_string(), ".gitvalet".to_string()];
681 update_exclude(git_dir, &files).unwrap();
682
683 let content = std::fs::read_to_string(git_dir.join("info/exclude")).unwrap();
684 assert!(content.contains("# git-valet: files versioned in the valet repo"));
685 assert!(content.contains(".env"));
686 assert!(content.contains(".gitvalet"));
687 }
688
689 #[test]
690 fn update_exclude_preserves_existing_content() {
691 let tmp = TempDir::new().unwrap();
692 let git_dir = tmp.path();
693 let info_dir = git_dir.join("info");
694 std::fs::create_dir_all(&info_dir).unwrap();
695 std::fs::write(info_dir.join("exclude"), "# my custom excludes\n*.log\n").unwrap();
696
697 let files = vec![".env".to_string()];
698 update_exclude(git_dir, &files).unwrap();
699
700 let content = std::fs::read_to_string(info_dir.join("exclude")).unwrap();
701 assert!(content.contains("*.log"));
702 assert!(content.contains(".env"));
703 }
704
705 #[test]
706 fn update_exclude_replaces_valet_section_on_update() {
707 let tmp = TempDir::new().unwrap();
708 let git_dir = tmp.path();
709 std::fs::create_dir_all(git_dir.join("info")).unwrap();
710
711 let files1 = vec![".env".to_string(), ".gitvalet".to_string()];
712 update_exclude(git_dir, &files1).unwrap();
713
714 let files2 = vec![".env".to_string(), "secrets/".to_string(), ".gitvalet".to_string()];
715 update_exclude(git_dir, &files2).unwrap();
716
717 let content = std::fs::read_to_string(git_dir.join("info/exclude")).unwrap();
718 assert_eq!(content.matches("# git-valet: files versioned in the valet repo").count(), 1);
719 assert!(content.contains("secrets/"));
720 assert!(content.contains(".env"));
721 }
722
723 #[test]
724 fn update_exclude_removes_section_when_empty() {
725 let tmp = TempDir::new().unwrap();
726 let git_dir = tmp.path();
727 let info_dir = git_dir.join("info");
728 std::fs::create_dir_all(&info_dir).unwrap();
729 std::fs::write(
730 info_dir.join("exclude"),
731 "*.log\n\n# git-valet: files versioned in the valet repo\n.env\n",
732 )
733 .unwrap();
734
735 update_exclude(git_dir, &[]).unwrap();
736
737 let content = std::fs::read_to_string(info_dir.join("exclude")).unwrap();
738 assert!(!content.contains("git-valet"));
739 assert!(content.contains("*.log"));
740 }
741
742 #[test]
745 fn remove_from_exclude_cleans_entries_and_marker() {
746 let tmp = TempDir::new().unwrap();
747 let git_dir = tmp.path();
748 let info_dir = git_dir.join("info");
749 std::fs::create_dir_all(&info_dir).unwrap();
750 std::fs::write(
751 info_dir.join("exclude"),
752 "*.log\n# git-valet: files versioned in the valet repo\n.env\n.gitvalet\n",
753 )
754 .unwrap();
755
756 let files = vec![".env".to_string(), ".gitvalet".to_string()];
757 remove_from_exclude(git_dir, &files).unwrap();
758
759 let content = std::fs::read_to_string(info_dir.join("exclude")).unwrap();
760 assert!(!content.contains(".env"));
761 assert!(!content.contains(".gitvalet"));
762 assert!(!content.contains("git-valet"));
763 assert!(content.contains("*.log"));
764 }
765
766 #[test]
767 fn remove_from_exclude_noop_when_no_file() {
768 let tmp = TempDir::new().unwrap();
769 let files = vec![".env".to_string()];
770 remove_from_exclude(tmp.path(), &files).unwrap();
771 }
772
773 #[test]
776 fn validate_path_accepts_relative_paths() {
777 assert!(validate_path(".env").is_ok());
778 assert!(validate_path("secrets/key.pem").is_ok());
779 assert!(validate_path("a/b/c").is_ok());
780 }
781
782 #[test]
783 fn validate_path_rejects_parent_traversal() {
784 assert!(validate_path("../outside").is_err());
785 assert!(validate_path("a/../../etc/passwd").is_err());
786 assert!(validate_path("..").is_err());
787 }
788
789 #[test]
790 fn validate_path_rejects_absolute_paths() {
791 assert!(validate_path("/etc/passwd").is_err());
792 }
793
794 #[cfg(windows)]
795 #[test]
796 fn validate_path_rejects_windows_absolute() {
797 assert!(validate_path("C:\\Windows\\System32").is_err());
798 }
799
800 #[test]
801 fn validate_path_handles_backslash_traversal() {
802 assert!(validate_path(r"..\outside").is_err());
803 assert!(validate_path(r"a\..\..\etc").is_err());
804 }
805
806 #[test]
807 fn tracked_with_gitvalet_validated_rejects_bad_paths() {
808 let tmp = TempDir::new().unwrap();
809 std::fs::write(tmp.path().join(".gitvalet"), "../escape\n").unwrap();
810
811 let result = tracked_with_gitvalet_validated(tmp.path());
812 assert!(result.is_err());
813 }
814
815 #[test]
816 fn tracked_with_gitvalet_validated_accepts_good_paths() {
817 let tmp = TempDir::new().unwrap();
818 std::fs::write(tmp.path().join(".gitvalet"), ".env\nsecrets/key.pem\n").unwrap();
819
820 let result = tracked_with_gitvalet_validated(tmp.path()).unwrap();
821 assert_eq!(result, vec![".env", "secrets/key.pem", ".gitvalet"]);
822 }
823}