Skip to main content

git_valet/
valet.rs

1//! Core valet logic: init, sync, push, pull, add, deinit.
2
3use 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
14// ── Path validation & normalization ─────────────────────────────────────────
15
16/// Normalizes path separators to forward slashes (git convention on all platforms).
17fn normalize_path(entry: &str) -> String {
18    entry.replace('\\', "/")
19}
20
21/// Validates that a tracked file path does not escape the work tree.
22fn validate_path(entry: &str) -> Result<()> {
23    let normalized = normalize_path(entry);
24    let path = Path::new(&normalized);
25
26    // Reject absolute paths (including Unix-style on Windows)
27    if path.is_absolute() || normalized.starts_with('/') {
28        bail!("Tracked path must be relative: {entry}");
29    }
30
31    // Reject paths that escape the work tree via ..
32    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
41// ── .gitvalet file ──────────────────────────────────────────────────────────
42
43/// Reads the .gitvalet file and returns the list of tracked entries.
44/// Normalizes path separators to `/` on all platforms.
45/// Returns an empty Vec if the file does not exist.
46fn 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
62/// Reads and validates all entries from .gitvalet.
63fn 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
71/// Returns tracked files from .gitvalet + .gitvalet itself (always implicitly tracked).
72/// Validates all paths before returning.
73fn 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
81/// Writes the .gitvalet file with the given entries (normalized to `/`).
82fn 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
90// ── Gitignore ────────────────────────────────────────────────────────────────
91
92/// Replaces the git-valet section in .git/info/exclude with the given files.
93/// This ensures the exclude list always matches the current .gitvalet content.
94fn 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    // Remove any existing git-valet section
103    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    // Remove trailing empty lines from base
122    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
146/// Removes git-valet entries from .git/info/exclude
147fn 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
167// ── Public commands ──────────────────────────────────────────────────────────
168
169/// First-time init: files provided, create .gitvalet and push
170fn 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
217/// Fresh clone init: no files provided, fetch from remote
218fn 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
247/// `git valet init <remote> [files...]`
248pub 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    // 1. Init bare repo
264    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    // 2. Temporary config (tracked list will be finalized below)
271    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    // 3. Hide untracked files from sgit status
281    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    // 4. Remote
289    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    // 5. Hooks
302    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
326/// `git valet status`
327pub fn status() -> Result<()> {
328    let cfg = load_config()?;
329    let work_tree = PathBuf::from(&cfg.work_tree);
330
331    // Show tracked files from .gitvalet (source of truth)
332    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
365/// `git valet sync` — re-read .gitvalet, update excludes/config, add + commit + push
366pub 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    // Re-read .gitvalet to pick up any changes
372    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
414/// `git valet push`
415pub 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
434/// `git valet pull`
435pub 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            // Re-read .gitvalet in case it was updated by the pull
449            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
468/// `git valet add <files>` — adds entries to .gitvalet and stages them
469pub 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    // Validate new paths before adding
476    for f in files {
477        validate_path(f)?;
478    }
479
480    // Read current .gitvalet and merge new entries (normalized)
481    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    // Update config + excludes
491    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    // Stage the new files + .gitvalet itself
498    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
508/// `git valet deinit`
509pub 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    // Clean up all tracked files including .gitvalet itself
523    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// ── Tests ────────────────────────────────────────────────────────────────────
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547    use tempfile::TempDir;
548
549    // ── path normalization ──────────────────────────────────────────────
550
551    #[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    // ── .gitvalet read/write ─────────────────────────────────────────────
563
564    #[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    // ── tracked_with_gitvalet ────────────────────────────────────────────
645
646    #[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    // ── update_exclude ───────────────────────────────────────────────────
673
674    #[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    // ── remove_from_exclude ──────────────────────────────────────────────
743
744    #[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    // ── path validation ────────────────────────────────────────────────
774
775    #[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}