use anyhow::{Context, Result}; use chrono::Utc; use git2::Repository; use serde::{Deserialize, Serialize}; use std::fs; use std::path::{Path, PathBuf}; use crate::config::{expand_path, SyncConfig}; use crate::delegates::sync_dir; use crate::machine::{hostname, MachineProfile}; use crate::packages; /// Maps a staged path back to the original absolute path on the source machine. /// Drives the import — no hardcoded paths needed. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PathRecord { /// Relative path within the export (e.g. "configs/hypr"). pub staging: String, /// Original path with `~` (e.g. "~/.config/hypr"). pub original: String, /// Whether this is a single file (false = directory). #[serde(default)] pub is_file: bool, } /// A git repository found on the machine, keyed by its remote URL. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GitRepoRecord { /// Path relative to $HOME (e.g. "Projects/bread"). pub path: String, /// Remote URL (e.g. "https://github.com/Breadway/bread.git"). pub remote: String, /// Branch that was checked out at export time. pub branch: String, } /// Manifest stored in the export root as `manifest.toml`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExportManifest { pub version: u32, pub machine: String, pub hostname: String, pub exported_at: String, /// Explicit staging→original path map for all captured items. #[serde(default)] pub path_map: Vec, /// High-level list of config dir names (for display). pub configs: Vec, /// Git repos found on the source machine. #[serde(default)] pub repos: Vec, pub system: bool, pub packages: Vec, // Legacy fields kept for forward compat (ignored on import) #[serde(default)] pub bread: bool, #[serde(default)] pub dotfiles: Vec, #[serde(default)] pub local_bin: Vec, #[serde(default)] pub systemd_units: Vec, } /// Config directories always included in the export (if they exist on disk). static BUILTIN_CONFIGS: &[(&str, &str)] = &[ ("hypr", "~/.config/hypr"), ("fish", "~/.config/fish"), ("kitty", "~/.config/kitty"), ("nvim", "~/.config/nvim"), ("ags", "~/.config/ags"), ("wofi", "~/.config/wofi"), ("waybar", "~/.config/waybar"), ("dunst", "~/.config/dunst"), ("mako", "~/.config/mako"), ("hyprlock", "~/.config/hyprlock"), ("hyprpaper", "~/.config/hyprpaper"), ("swaylock", "~/.config/swaylock"), ("wlogout", "~/.config/wlogout"), ("swappy", "~/.config/swappy"), ("btop", "~/.config/btop"), ("waypaper", "~/.config/waypaper"), ("wal", "~/.config/wal"), ("gtk-3.0", "~/.config/gtk-3.0"), ("gtk-4.0", "~/.config/gtk-4.0"), ("keyd", "~/.config/keyd"), ("autostart", "~/.config/autostart"), ]; /// Standalone dotfiles captured as individual files: (staging-name, source-path). static BUILTIN_DOTFILES: &[(&str, &str)] = &[ (".gitconfig", "~/.gitconfig"), ("user-dirs.dirs", "~/.config/user-dirs.dirs"), ("mimeapps.list", "~/.config/mimeapps.list"), ("ssh_config", "~/.ssh/config"), (".zshrc", "~/.zshrc"), (".zprofile", "~/.zprofile"), (".zshenv", "~/.zshenv"), ]; /// System-level directories. World-readable ones are copied directly; /// root-only ones (networkmanager, bluetooth) require running with sudo. static SYSTEM_PATHS: &[(&str, &str)] = &[ ("udev", "/etc/udev/rules.d"), ("modprobe", "/etc/modprobe.d"), ("sysctl", "/etc/sysctl.d"), ("networkmanager", "/etc/NetworkManager/system-connections"), ("bluetooth", "/var/lib/bluetooth"), ]; /// Directories excluded from every recursive copy. static DEFAULT_EXCLUDES: &[&str] = &[ "**/.git", "**/*.cache", "**/node_modules", "**/@girs", "**/__pycache__", "fish_variables?*", ]; /// Directories skipped when searching for git repos. static GIT_SKIP_DIRS: &[&str] = &[ ".local", "Nextcloud", "target", "node_modules", "__pycache__", ".cache", "snap", "flatpak", "@girs", "Steam", ]; // ── stage_export ──────────────────────────────────────────────────────────── /// Build a self-contained snapshot directory at `staging`. pub fn stage_export(cfg_dir: &Path, config: &SyncConfig, staging: &Path) -> Result { fs::create_dir_all(staging)?; let excludes: Vec = DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect(); let mut path_map: Vec = Vec::new(); let mut included_configs: Vec = Vec::new(); // Helper: tilde-ify an absolute path for storage in the manifest. let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root")); let tilde = |p: &Path| -> String { p.strip_prefix(&home) .map(|rel| format!("~/{}", rel.display())) .unwrap_or_else(|_| p.display().to_string()) }; // 1. Bread config → bread/ let bread_dest = staging.join("bread"); sync_dir(cfg_dir, &bread_dest, &excludes).context("failed to snapshot bread config")?; path_map.push(PathRecord { staging: "bread".to_string(), original: tilde(cfg_dir), is_file: false, }); // 2. Built-in + delegate configs → configs// let configs_dir = staging.join("configs"); for (name, raw_path) in BUILTIN_CONFIGS { let src = expand_path(raw_path); if src.exists() { let dst = configs_dir.join(name); sync_dir(&src, &dst, &excludes) .with_context(|| format!("failed to snapshot {raw_path}"))?; path_map.push(PathRecord { staging: format!("configs/{name}"), original: raw_path.to_string(), is_file: false, }); included_configs.push(name.to_string()); } } let delegate_paths = crate::delegates::resolve_include_paths(&config.delegates.include); for (basename, src_path) in &delegate_paths { if src_path.exists() && !included_configs.contains(basename) { let dst = configs_dir.join(basename); sync_dir(src_path, &dst, &config.delegates.exclude) .with_context(|| format!("failed to snapshot delegate {}", src_path.display()))?; path_map.push(PathRecord { staging: format!("configs/{basename}"), original: tilde(src_path), is_file: false, }); included_configs.push(basename.clone()); } } // 3. Dotfiles → dotfiles/ let dotfiles_dir = staging.join("dotfiles"); fs::create_dir_all(&dotfiles_dir)?; for (dest_name, raw_path) in BUILTIN_DOTFILES { let src = expand_path(raw_path); if src.exists() { fs::copy(&src, dotfiles_dir.join(dest_name)) .with_context(|| format!("failed to copy {raw_path}"))?; path_map.push(PathRecord { staging: format!("dotfiles/{dest_name}"), original: raw_path.to_string(), is_file: true, }); } } // 4. ~/.local/bin custom scripts → local-bin/ // Skip symlinks (point to installed binaries) and files >512 KB (compiled artifacts). let local_bin_src = expand_path("~/.local/bin"); let local_bin_dst = staging.join("local-bin"); if local_bin_src.exists() { fs::create_dir_all(&local_bin_dst)?; let mut any = false; for entry in fs::read_dir(&local_bin_src).context("failed to read ~/.local/bin")? { let entry = entry?; let meta = entry.metadata()?; if meta.file_type().is_symlink() || meta.len() > 512 * 1024 { continue; } let path = entry.path(); if path.is_file() { let name = path.file_name().unwrap().to_string_lossy().to_string(); fs::copy(&path, local_bin_dst.join(&name))?; any = true; } } if any { path_map.push(PathRecord { staging: "local-bin".to_string(), original: "~/.local/bin".to_string(), is_file: false, }); } } // 5. ~/.local/share/fonts → local-fonts/ let fonts_src = expand_path("~/.local/share/fonts"); let fonts_dst = staging.join("local-fonts"); if fonts_src.exists() { sync_dir(&fonts_src, &fonts_dst, &excludes).context("failed to snapshot fonts")?; path_map.push(PathRecord { staging: "local-fonts".to_string(), original: "~/.local/share/fonts".to_string(), is_file: false, }); } // 7. ~/.config/systemd/user → systemd/ let systemd_src = expand_path("~/.config/systemd/user"); let systemd_dst = staging.join("systemd"); if systemd_src.exists() { sync_dir(&systemd_src, &systemd_dst, &excludes) .context("failed to snapshot systemd user units")?; path_map.push(PathRecord { staging: "systemd".to_string(), original: "~/.config/systemd/user".to_string(), is_file: false, }); } // 8. System configs → system/ (read-only; restore needs sudo) let system_dst = staging.join("system"); let mut has_system = false; for (name, raw_path) in SYSTEM_PATHS { let src = PathBuf::from(raw_path); if !src.exists() { continue; } match sync_dir(&src, &system_dst.join(name), &excludes) { Ok(_) => has_system = true, Err(e) => { let msg = e.to_string(); if msg.contains("Permission denied") || msg.contains("permission denied") { eprintln!( "bread: warning: {raw_path} requires sudo to export (skipping — re-run with sudo to include)" ); } else { eprintln!("bread: warning: failed to snapshot {raw_path}: {e}"); } } } } // 9. Package snapshots → packages/ let packages_dir = staging.join("packages"); let mut included_managers: Vec = Vec::new(); if config.packages.enabled { for manager in &config.packages.managers { let dest_file = packages_dir.join(format!("{manager}.txt")); match packages::snapshot(manager, &dest_file) { Ok(true) => included_managers.push(manager.clone()), Ok(false) => {} Err(e) => eprintln!("bread: warning: package snapshot for {manager} failed: {e}"), } } } // 10. Machine profile → machines/ let machines_dir = staging.join("machines"); MachineProfile::new(config.machine.name.clone(), config.machine.tags.clone()) .write(&machines_dir)?; // 11. Git repositories — find all repos with a remote, commit+push each let nc_dirs = nextcloud_sync_dirs(&home); if !nc_dirs.is_empty() { let labels: Vec<_> = nc_dirs .iter() .map(|p| { p.strip_prefix(&home) .map(|r| format!("~/{}", r.display())) .unwrap_or_else(|_| p.display().to_string()) }) .collect(); eprintln!( "bread: skipping Nextcloud-tracked folders: {}", labels.join(", ") ); } let repos = find_git_repos(&home); commit_and_push_repos(&repos, &home); // 12. Manifest let manifest = ExportManifest { version: 2, machine: config.machine.name.clone(), hostname: hostname(), exported_at: Utc::now().to_rfc3339(), path_map, configs: included_configs, repos, system: has_system, packages: included_managers, bread: true, dotfiles: vec![], local_bin: vec![], systemd_units: vec![], }; fs::write( staging.join("manifest.toml"), toml::to_string_pretty(&manifest).context("failed to serialize manifest")?, )?; // 11. restore.sh let restore_path = staging.join("restore.sh"); fs::write(&restore_path, generate_restore_sh(&manifest))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = fs::metadata(&restore_path)?.permissions(); perms.set_mode(0o755); fs::set_permissions(&restore_path, perms)?; } Ok(manifest) } // ── apply_import ──────────────────────────────────────────────────────────── /// Apply a staged snapshot directory to this machine. /// Returns a list of human-readable descriptions of what was applied. pub fn apply_import( staging: &Path, cfg_dir: &Path, install_packages: bool, clone_repos: bool, ) -> Result> { let mut applied: Vec = Vec::new(); // Read manifest to get the path map let manifest_path = staging.join("manifest.toml"); let path_map: Vec = if manifest_path.exists() { let raw = fs::read_to_string(&manifest_path)?; toml::from_str::(&raw) .map(|m| m.path_map) .unwrap_or_default() } else { vec![] }; if !path_map.is_empty() { // Manifest-driven restore: use path_map for exact original locations for record in &path_map { let src = staging.join(&record.staging); if !src.exists() { continue; } let dst = expand_path(&record.original); if record.is_file { if let Some(parent) = dst.parent() { fs::create_dir_all(parent)?; } // Secure directory permissions for SSH if record.staging.contains("ssh_config") { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; if let Some(p) = dst.parent() { if let Ok(m) = fs::metadata(p) { let mut perms = m.permissions(); perms.set_mode(0o700); let _ = fs::set_permissions(p, perms); } } } } fs::copy(&src, &dst) .with_context(|| format!("failed to restore {}", record.original))?; applied.push(record.original.clone()); } else { sync_dir(&src, &dst, &[]) .with_context(|| format!("failed to restore {}", record.original))?; applied.push(record.original.clone()); // Reload systemd if this was the systemd dir if record.staging == "systemd" { let _ = std::process::Command::new("systemctl") .args(["--user", "daemon-reload"]) .status(); } // Rebuild font cache after restoring fonts if record.staging == "local-fonts" { let _ = std::process::Command::new("fc-cache").arg("-f").status(); } // Make local-bin scripts executable if record.staging == "local-bin" { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; if let Ok(entries) = fs::read_dir(&dst) { for entry in entries.filter_map(|e| e.ok()) { if entry.path().is_file() { if let Ok(m) = fs::metadata(entry.path()) { let mut perms = m.permissions(); perms.set_mode(perms.mode() | 0o111); let _ = fs::set_permissions(entry.path(), perms); } } } } } } } } } else { // Legacy fallback for v1 exports without path_map let bread_src = staging.join("bread"); if bread_src.exists() { sync_dir(&bread_src, cfg_dir, &[])?; applied.push("~/.config/bread".to_string()); } let configs_dir = staging.join("configs"); if configs_dir.exists() { let config_home = expand_path("~/.config"); for entry in fs::read_dir(&configs_dir)?.filter_map(|e| e.ok()) { let src = entry.path(); if src.is_dir() { let name = src.file_name().unwrap().to_string_lossy().to_string(); sync_dir(&src, &config_home.join(&name), &[])?; applied.push(format!("~/.config/{name}")); } } } } // Package installs if install_packages { let packages_dir = staging.join("packages"); if packages_dir.exists() { install_packages_from(&packages_dir)?; applied.push("packages installed".to_string()); } } // Clone git repos if clone_repos { let manifest_path = staging.join("manifest.toml"); if manifest_path.exists() { let raw = fs::read_to_string(&manifest_path)?; if let Ok(manifest) = toml::from_str::(&raw) { let home = dirs::home_dir() .unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default())); for repo in &manifest.repos { let dest = home.join(&repo.path); if dest.exists() { applied.push(format!("skip (exists): ~/{}", repo.path)); continue; } if let Some(parent) = dest.parent() { fs::create_dir_all(parent)?; } eprint!(" cloning ~/{} ... ", repo.path); let status = std::process::Command::new("git") .args(["clone", "--branch", &repo.branch, &repo.remote]) .arg(&dest) .status(); match status { Ok(s) if s.success() => { eprintln!("done"); applied.push(format!("cloned ~/{}", repo.path)); } _ => { eprintln!("failed"); applied.push(format!("clone failed: ~/{}", repo.path)); } } } } } } Ok(applied) } // ── commit_and_push_repos ─────────────────────────────────────────────────── fn commit_and_push_repos(repos: &[GitRepoRecord], home: &Path) { if repos.is_empty() { return; } eprintln!("bread: committing and pushing {} repo(s)...", repos.len()); for repo in repos { let dir = home.join(&repo.path); let dir_str = dir.to_string_lossy(); // Stage all changes let add = std::process::Command::new("git") .args(["-C", &dir_str, "add", "-A"]) .output(); if add.map(|o| !o.status.success()).unwrap_or(true) { eprintln!(" ~/{}: git add failed, skipping", repo.path); continue; } // Check if there's anything staged let has_changes = std::process::Command::new("git") .args(["-C", &dir_str, "diff", "--cached", "--quiet"]) .status() .map(|s| !s.success()) .unwrap_or(false); if has_changes { let commit = std::process::Command::new("git") .args(["-C", &dir_str, "commit", "-m", "Commiting for bread sync"]) .output(); match commit { Ok(o) if o.status.success() => {} Ok(o) => { eprintln!( " ~/{}: commit failed: {}", repo.path, String::from_utf8_lossy(&o.stderr).trim() ); continue; } Err(e) => { eprintln!(" ~/{}: commit failed: {}", repo.path, e); continue; } } } // Push eprint!(" ~/{}: pushing... ", repo.path); let push = std::process::Command::new("git") .args(["-C", &dir_str, "push"]) .output(); match push { Ok(o) if o.status.success() => eprintln!("ok"), Ok(o) => eprintln!("failed: {}", String::from_utf8_lossy(&o.stderr).trim()), Err(e) => eprintln!("failed: {}", e), } } } // ── find_git_repos ────────────────────────────────────────────────────────── /// Read ~/.config/Nextcloud/nextcloud.cfg and return all configured local sync roots. /// Always includes ~/Nextcloud if it exists, even without a config file. fn nextcloud_sync_dirs(home: &Path) -> Vec { let mut dirs: Vec = Vec::new(); let cfg = home.join(".config/Nextcloud/nextcloud.cfg"); if let Ok(content) = fs::read_to_string(&cfg) { for line in content.lines() { if let Some(raw) = line.trim().strip_prefix("localPath=") { let p = PathBuf::from(raw); let p = if p.is_absolute() { p } else { home.join(p) }; if !dirs.contains(&p) { dirs.push(p); } } } } // Always treat ~/Nextcloud as off-limits if it exists let default_nc = home.join("Nextcloud"); if default_nc.exists() && !dirs.contains(&default_nc) { dirs.push(default_nc); } dirs } fn find_git_repos(home: &Path) -> Vec { let nc_dirs = nextcloud_sync_dirs(home); let mut repos: Vec = Vec::new(); // Home root at depth 1 only (e.g. ~/bread, ~/yay, ~/colorshell) walk_repos(home, home, 0, 1, &mut repos, &nc_dirs); // Deeper search in common project directories for subdir in &[ "Projects", "Documents", "src", "dev", "code", "repos", "builds", ] { let p = home.join(subdir); if p.exists() { walk_repos(&p, home, 0, 3, &mut repos, &nc_dirs); } } // .config at depth 1 (e.g. ~/.config/hypr, ~/.config/wificonf) let config_dir = home.join(".config"); if config_dir.exists() { walk_repos(&config_dir, home, 0, 1, &mut repos, &nc_dirs); } // Deduplicate by path, sort for determinism repos.sort_by(|a, b| a.path.cmp(&b.path)); repos.dedup_by(|a, b| a.path == b.path); repos } fn walk_repos( dir: &Path, home: &Path, depth: u32, max_depth: u32, repos: &mut Vec, nc_dirs: &[PathBuf], ) { // Skip anything inside a Nextcloud sync root if nc_dirs.iter().any(|nc| dir.starts_with(nc)) { return; } if dir.join(".git").exists() { if let Ok(repo) = Repository::open(dir) { let remote_url = repo .find_remote("origin") .ok() .and_then(|r| r.url().map(str::to_string)); if let Some(remote) = remote_url { let branch = repo .head() .ok() .and_then(|h| h.shorthand().map(str::to_string)) .unwrap_or_else(|| "main".to_string()); let rel = dir .strip_prefix(home) .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|_| dir.to_string_lossy().to_string()); repos.push(GitRepoRecord { path: rel, remote, branch, }); } } return; // don't recurse into git repos (skip submodules) } if depth >= max_depth { return; } if let Ok(entries) = fs::read_dir(dir) { let mut entries: Vec<_> = entries.filter_map(|e| e.ok()).collect(); entries.sort_by_key(|e| e.file_name()); for entry in entries { let path = entry.path(); if !path.is_dir() { continue; } let name = path.file_name().unwrap_or_default().to_string_lossy(); if GIT_SKIP_DIRS.contains(&name.as_ref()) { continue; } walk_repos(&path, home, depth + 1, max_depth, repos, nc_dirs); } } } // ── package install ───────────────────────────────────────────────────────── fn install_packages_from(packages_dir: &Path) -> Result<()> { let pacman_file = packages_dir.join("pacman.txt"); if pacman_file.exists() { let pkgs = packages::parse_pacman(&fs::read_to_string(&pacman_file)?); if !pkgs.is_empty() { eprintln!("bread: installing {} pacman packages...", pkgs.len()); let _ = std::process::Command::new("sudo") .args(["pacman", "-S", "--needed"]) .args(&pkgs) .status(); } } let cargo_file = packages_dir.join("cargo.txt"); if cargo_file.exists() { for pkg in packages::parse_cargo(&fs::read_to_string(&cargo_file)?) { let _ = std::process::Command::new("cargo") .args(["install", &pkg]) .status(); } } let pip_file = packages_dir.join("pip.txt"); if pip_file.exists() { let _ = std::process::Command::new("pip") .args(["install", "--user", "-r"]) .arg(&pip_file) .status(); } let npm_file = packages_dir.join("npm.txt"); if npm_file.exists() { for pkg in packages::parse_npm(&fs::read_to_string(&npm_file)?) { let _ = std::process::Command::new("npm") .args(["install", "-g", &pkg]) .status(); } } Ok(()) } // ── restore.sh ─────────────────────────────────────────────────────────────── fn generate_restore_sh(manifest: &ExportManifest) -> String { let ts = &manifest.exported_at[..16]; let mut s = String::new(); s.push_str("#!/bin/bash\n"); s.push_str("set -e\n"); s.push_str("cd \"$(dirname \"$0\")\"\n"); s.push_str("RESTORE_DIR=\"$(pwd)\"\n\n"); s.push_str(&format!( "echo \"Restoring bread snapshot for {} ({})\"\n\n", manifest.machine, ts )); // Config dirs and dotfiles from path_map let dirs: Vec<&PathRecord> = manifest.path_map.iter().filter(|r| !r.is_file).collect(); let files: Vec<&PathRecord> = manifest.path_map.iter().filter(|r| r.is_file).collect(); if !dirs.is_empty() { s.push_str("# configs and directories\n"); for r in &dirs { let dst = &r.original; let src = &r.staging; s.push_str(&format!("if [ -e \"$RESTORE_DIR/{src}\" ]; then\n")); s.push_str(&format!(" mkdir -p \"{dst}\"\n")); s.push_str(&format!(" cp -r \"$RESTORE_DIR/{src}/.\" \"{dst}/\"\n")); if r.staging == "systemd" { s.push_str(" systemctl --user daemon-reload\n"); } if r.staging == "local-bin" { s.push_str(" chmod +x \"${dst}\"/*\n"); } s.push_str(&format!(" echo \"[OK] {dst}\"\n")); s.push_str("fi\n"); } s.push('\n'); } if !files.is_empty() { s.push_str("# dotfiles\n"); for r in &files { let dst = &r.original; let src = &r.staging; s.push_str(&format!("if [ -f \"$RESTORE_DIR/{src}\" ]; then\n")); if r.staging.contains("ssh_config") { s.push_str(" mkdir -p ~/.ssh && chmod 700 ~/.ssh\n"); } // Expand ~ in destination for shell let dst_shell = dst.replace('~', "$HOME"); s.push_str(&format!(" cp \"$RESTORE_DIR/{src}\" \"{dst_shell}\"\n")); s.push_str(&format!(" echo \"[OK] {dst}\"\n")); s.push_str("fi\n"); } s.push('\n'); } // Packages if !manifest.packages.is_empty() { s.push_str("echo \"\"\n"); s.push_str("echo \"--- Package restore commands (not run automatically) ---\"\n"); if manifest.packages.contains(&"pacman".to_string()) { s.push_str("echo \" pacman: awk '{print \\$1}' \\\"$RESTORE_DIR/packages/pacman.txt\\\" | sudo pacman -S --needed -\"\n"); } if manifest.packages.contains(&"cargo".to_string()) { s.push_str("echo \" cargo: grep -v '^ ' \\\"$RESTORE_DIR/packages/cargo.txt\\\" | awk '{print \\$1}' | xargs -I{} cargo install {}\"\n"); } if manifest.packages.contains(&"pip".to_string()) { s.push_str( "echo \" pip: pip install --user -r \\\"$RESTORE_DIR/packages/pip.txt\\\"\"\n", ); } if manifest.packages.contains(&"npm".to_string()) { s.push_str("echo \" npm: awk -F/ '{print \\$NF}' \\\"$RESTORE_DIR/packages/npm.txt\\\" | xargs npm install -g\"\n"); } s.push('\n'); } // System files if manifest.system { s.push_str("echo \"\"\n"); s.push_str("echo \"--- System files (require sudo, not applied automatically) ---\"\n"); s.push_str("if [ -d \"$RESTORE_DIR/system/udev\" ]; then\n"); s.push_str(" echo \" udev: sudo cp \\\"$RESTORE_DIR/system/udev/\\\"* /etc/udev/rules.d/ && sudo udevadm control --reload-rules\"\n"); s.push_str("fi\n"); s.push_str("if [ -d \"$RESTORE_DIR/system/modprobe\" ]; then\n"); s.push_str(" echo \" modprobe: sudo cp \\\"$RESTORE_DIR/system/modprobe/\\\"* /etc/modprobe.d/\"\n"); s.push_str("fi\n"); s.push_str("if [ -d \"$RESTORE_DIR/system/sysctl\" ]; then\n"); s.push_str(" echo \" sysctl: sudo cp \\\"$RESTORE_DIR/system/sysctl/\\\"* /etc/sysctl.d/ && sudo sysctl --system\"\n"); s.push_str("fi\n"); s.push_str("if [ -d \"$RESTORE_DIR/system/networkmanager\" ]; then\n"); s.push_str(" echo \" networkmanager: sudo cp \\\"$RESTORE_DIR/system/networkmanager/\\\"* /etc/NetworkManager/system-connections/ && sudo chmod 600 /etc/NetworkManager/system-connections/* && sudo systemctl restart NetworkManager\"\n"); s.push_str("fi\n"); s.push_str("if [ -d \"$RESTORE_DIR/system/bluetooth\" ]; then\n"); s.push_str(" echo \" bluetooth: sudo cp -r \\\"$RESTORE_DIR/system/bluetooth/\\\"* /var/lib/bluetooth/ && sudo systemctl restart bluetooth\"\n"); s.push_str("fi\n\n"); } // Git repos if !manifest.repos.is_empty() { s.push_str("echo \"\"\n"); s.push_str("echo \"--- Git repositories ---\"\n"); for repo in &manifest.repos { let dest = format!("$HOME/{}", repo.path); let branch = &repo.branch; let remote = &repo.remote; // Create parent dir and clone; skip if already present let parent = std::path::Path::new(&repo.path) .parent() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_default(); if !parent.is_empty() { s.push_str(&format!("mkdir -p \"$HOME/{parent}\"\n")); } s.push_str(&format!("if [ ! -d \"{dest}/.git\" ]; then\n")); s.push_str(&format!( " git clone --branch {branch} {remote} \"{dest}\" && echo \"[OK] ~/{}\"\n", repo.path )); s.push_str(&format!( "else\n echo \"[skip] ~/{} (already exists)\"\nfi\n", repo.path )); } } s }