use crate::config::Config;
use crate::config_sync;
use crate::exec::{Cmd, ScanPolicy, SecretsArg};
use crate::lock;
use crate::logger::*;
use crate::paths::*;
use std::io;
const SCAN_BATCH: usize = 200;
pub fn resolve_config_drift() -> io::Result<()> {
if let config_sync::Repair::Failed(reason) = config_sync::ensure_current(None) {
log_warn(&("Could not regenerate the chezmoi config: ".to_owned() + &reason));
log_step("Continuing with the existing config at ~/.config/chezmoi/chezmoi.toml.");
}
Ok(())
}
fn read_tracking_file(path: &str, home: &str) -> io::Result<(Vec<String>, Vec<String>)> {
use std::io::BufRead;
let mut tracked = Vec::new();
let mut excludes = Vec::new();
if !std::path::Path::new(path).exists() {
return Ok((tracked, excludes));
}
let file = std::fs::File::open(path)?;
let reader = io::BufReader::new(file);
for line in reader.lines() {
let mut line = line?.trim().to_string();
if line.is_empty() || line.starts_with('#') {
continue;
}
let is_exclude = if line.starts_with('!') {
line = line[1..].to_string();
true
} else {
false
};
let expanded = if line.starts_with('/') {
line
} else if line.starts_with('~') {
line.replacen('~', home, 1)
} else {
concat_paths(home, &line)
};
if is_exclude {
excludes.push(expanded);
} else {
tracked.push(expanded);
}
}
Ok((tracked, excludes))
}
pub fn generate_chezmoi_run_config(home: &str) -> io::Result<String> {
let config_dir =
std::env::var("XDG_CONFIG_HOME").unwrap_or_else(|_| concat_paths(home, ".config"));
let config_path = concat_paths(&config_dir, "chezmoi/chezmoi.toml");
let mut output = String::new();
let path = std::path::Path::new(&config_path);
if path.exists() {
let content = std::fs::read_to_string(path)?;
let mut in_git = false;
let mut saw_git = false;
let mut wrote_git_keys = false;
let write_git_keys = |out: &mut String, wrote: &mut bool| {
if !*wrote {
out.push_str(" autoCommit = false\n");
out.push_str(" autoPush = false\n");
*wrote = true;
}
};
for line in content.lines() {
let trimmed = line.trim();
if trimmed == "[git]" {
if in_git {
let mut wrote = wrote_git_keys;
write_git_keys(&mut output, &mut wrote);
}
in_git = true;
saw_git = true;
wrote_git_keys = false;
output.push_str(line);
output.push('\n');
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
if in_git {
let mut wrote = wrote_git_keys;
write_git_keys(&mut output, &mut wrote);
wrote_git_keys = wrote;
in_git = false;
}
output.push_str(line);
output.push('\n');
continue;
}
if in_git && (trimmed.starts_with("autoCommit") || trimmed.starts_with("autoPush")) {
continue;
}
output.push_str(line);
output.push('\n');
}
if in_git {
let mut wrote = wrote_git_keys;
write_git_keys(&mut output, &mut wrote);
} else if !saw_git {
output.push_str("\n[git]\n autoCommit = false\n autoPush = false\n");
}
} else {
output.push_str("[git]\n autoCommit = false\n autoPush = false\n");
}
let config_parent = std::path::Path::new(&config_path)
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from("."));
std::fs::create_dir_all(&config_parent)?;
let run_path = config_parent.join(format!(
"chezmoi-fp-manager-{}.toml",
std::process::id()
));
std::fs::write(&run_path, output)?;
Ok(run_path.to_string_lossy().into_owned())
}
pub fn remove_chezmoi_run_config(path: &str) {
let _ = std::fs::remove_file(path);
}
fn visit_dirs(dir: &std::path::Path, files: &mut Vec<String>) -> io::Result<()> {
if dir.is_dir() {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
visit_dirs(&path, files)?;
} else if path.is_file() {
files.push(path.to_string_lossy().into_owned());
}
}
}
Ok(())
}
fn run_hook(hook_path: &str, hook_type: &str) -> io::Result<bool> {
let path = std::path::Path::new(hook_path);
if !path.is_file() {
return Ok(true); }
log_section(&format!("Executing {} hook...", hook_type));
log_step(&format!("Running: {}", hook_path));
let status = std::process::Command::new(hook_path).status()?;
if status.success() {
log_success(&format!("{} hook completed successfully.", hook_type));
Ok(true)
} else {
log_error(&format!(
"{} hook failed with exit status: {:?}",
hook_type,
status.code()
));
Ok(false)
}
}
fn run_add_command(
cmd: &Cmd,
policy: ScanPolicy,
subcommand: &str,
paths: &[String],
extra_args: &[&str],
dry_run: bool,
seen: &mut Vec<String>,
) -> io::Result<()> {
for batch in paths.chunks(SCAN_BATCH) {
let mut argv: Vec<&str> = extra_args.to_vec();
if dry_run {
argv.push("--dry-run");
}
argv.push("--force");
argv.extend(batch.iter().map(|p| p.as_str()));
let outcome = cmd.run(subcommand, &argv, cmd.secrets_arg(subcommand, policy))?;
outcome.report();
outcome.report_summary();
if !outcome.success {
log_error(&format!(
"chezmoi {} failed for {} path(s).",
subcommand,
batch.len()
));
}
for path in outcome.secret_paths(seen) {
seen.push(path);
}
}
Ok(())
}
fn audit_managed_secrets(
cmd: &Cmd,
policy: ScanPolicy,
already_seen: &[String],
) -> io::Result<Vec<String>> {
if policy != ScanPolicy::Enforce {
return Ok(Vec::new());
}
let managed = cmd.run(
"managed",
&[
"--include=files,symlinks",
"--exclude=encrypted",
"--path-style",
"absolute",
"-0",
],
SecretsArg::Unsupported,
)?;
managed.report();
let mut candidates: Vec<String> = Vec::new();
for chunk in managed.stdout.as_bytes().split(|&b| b == 0) {
if chunk.is_empty() {
continue;
}
let path = String::from_utf8_lossy(chunk).into_owned();
if std::path::Path::new(&path).is_file() {
candidates.push(path);
}
}
if candidates.is_empty() {
return Ok(Vec::new());
}
log_section("Auditing managed files for leaked secrets...");
log_step(&format!("Scanning {} plaintext file(s)...", candidates.len()));
let mut flagged: Vec<String> = Vec::new();
for batch in candidates.chunks(SCAN_BATCH) {
let mut argv: Vec<&str> = vec!["--dry-run", "--force"];
argv.extend(batch.iter().map(|p| p.as_str()));
let outcome = cmd.run("add", &argv, cmd.secrets_arg("add", policy))?;
outcome.report_excluding(already_seen);
for path in outcome.secret_paths(already_seen) {
if !flagged.contains(&path) {
flagged.push(path);
}
}
}
Ok(flagged)
}
pub fn run_sync(config: Config, dry_run: bool) -> io::Result<()> {
let lock_dir =
std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| concat_paths(&config.home, ".cache"));
std::fs::create_dir_all(&lock_dir)?;
let lock_file_path = concat_paths(&lock_dir, "dotfiles-sync.lock.dir");
let Some(mut guard) = lock::acquire_or_report(&lock_file_path) else {
return Ok(());
};
if !dry_run {
if !run_hook(&config.pre_sync_hook, "pre-sync")? {
log_error("Pre-sync hook failed. Aborting synchronization.");
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"Pre-sync hook abort",
));
}
} else {
log_section("Running sync engine in simulation mode...");
let path = std::path::Path::new(&config.pre_sync_hook);
if path.is_file() {
println!(
" [dry-run] Would execute pre-sync hook: {}",
config.pre_sync_hook
);
}
}
log_section("Starting dotfiles sync engine...");
resolve_config_drift()?;
let temp_config = generate_chezmoi_run_config(&config.home)?;
let scan_policy = config.scan_policy();
let chezmoi = Cmd::new().with_config(&temp_config);
let mut flagged_secrets: Vec<String> = Vec::new();
let (tracked_list, exclude_list) = read_tracking_file(&config.tracked_file, &config.home)?;
let (enc_list, enc_exclude_list) = read_tracking_file(&config.tracked_enc_file, &config.home)?;
let mut all_excludes = Vec::new();
all_excludes.extend(exclude_list);
all_excludes.extend(enc_exclude_list);
log_section("Running chezmoi re-add...");
let status_output = chezmoi.run(
"status",
&[
"--include=files,symlinks",
"--path-style",
"absolute",
],
SecretsArg::Unsupported,
)?;
status_output.report();
let mut to_readd = Vec::new();
for line in status_output.stdout.lines() {
if line.len() >= 4 {
let target_status = &line[1..2];
let target_path = &line[3..];
let path = std::path::Path::new(target_path);
if target_status == "M" && (path.is_file() || path.is_symlink()) {
to_readd.push(target_path.to_string());
}
}
}
if !to_readd.is_empty() {
log_step(
&("Re-adding ".to_owned()
+ &to_readd.len().to_string()
+ " modified managed file(s)..."),
);
if dry_run {
for f in &to_readd {
println!(" [dry-run] Would re-add modified file: {}", f);
}
} else {
run_add_command(
&chezmoi,
scan_policy,
"re-add",
&to_readd,
&[],
false,
&mut flagged_secrets,
)?;
}
} else {
log_success("No managed files needed re-adding.");
}
log_section("Discovering and evaluating files...");
let managed_output = chezmoi.run(
"managed",
&[
"--include=files,symlinks",
"--path-style",
"absolute",
"-0",
],
SecretsArg::Unsupported,
)?;
managed_output.report();
let mut managed_list = Vec::new();
for m_bytes in managed_output.stdout.as_bytes().split(|&b| b == 0) {
if m_bytes.is_empty() {
continue;
}
let m = String::from_utf8_lossy(m_bytes).into_owned();
managed_list.push(normalize_path(&m));
}
let mut discovered_files = Vec::new();
let mut discovered_map = Vec::new();
let all_patterns = enc_list.iter().chain(tracked_list.iter());
for pattern in all_patterns {
let expanded = expand_pattern_paths(pattern);
for target in expanded {
let path = std::path::Path::new(&target);
if path.is_dir() {
let mut dir_files = Vec::new();
let _ = visit_dirs(path, &mut dir_files);
for found_path in dir_files {
if !discovered_map.contains(&found_path) {
discovered_map.push(found_path.clone());
discovered_files.push(found_path);
}
}
} else if path.is_file() {
let target_str = target.to_string();
if !discovered_map.contains(&target_str) {
discovered_map.push(target_str.clone());
discovered_files.push(target_str);
}
}
}
}
let mut to_add_std = Vec::new();
let mut to_add_enc = Vec::new();
for full_path in discovered_files {
let canon = normalize_path(&full_path);
if managed_list.contains(&canon) {
continue;
}
if path_is_excluded(&full_path, &all_excludes) {
continue;
}
if path_is_encrypted(&full_path, &enc_list) {
to_add_enc.push(full_path.clone());
} else {
to_add_std.push(full_path.clone());
}
}
if !to_add_enc.is_empty() {
log_step(
&("Encrypting & adding ".to_owned()
+ &to_add_enc.len().to_string()
+ " new file(s)..."),
);
if dry_run {
for f in &to_add_enc {
println!(" [dry-run] Would encrypt & add: {}", f);
}
} else {
run_add_command(
&chezmoi,
scan_policy,
"add",
&to_add_enc,
&["--encrypt"],
false,
&mut flagged_secrets,
)?;
}
}
if !to_add_std.is_empty() {
log_step(&("Adding ".to_owned() + &to_add_std.len().to_string() + " new file(s)..."));
if dry_run {
for f in &to_add_std {
println!(" [dry-run] Would add new path: {}", f);
}
} else {
run_add_command(
&chezmoi,
scan_policy,
"add",
&to_add_std,
&[],
false,
&mut flagged_secrets,
)?;
}
}
if to_add_enc.is_empty() && to_add_std.is_empty() {
log_success("No new files to add.");
}
log_section("Cleaning up orphaned and untracked files...");
let mut valid_prefixes = Vec::new();
valid_prefixes.extend(tracked_list.clone());
valid_prefixes.extend(enc_list.clone());
valid_prefixes.push(concat_paths(&config.home, ".config/chezmoi"));
let mut to_forget = Vec::new();
let managed_output = chezmoi.run(
"managed",
&[
"--include=files,symlinks",
"--path-style",
"absolute",
"-0",
],
SecretsArg::Unsupported,
)?;
managed_output.report();
let managed_stdout = managed_output.stdout;
for m_bytes in managed_stdout.as_bytes().split(|&b| b == 0) {
if m_bytes.is_empty() {
continue;
}
let full_path = String::from_utf8_lossy(m_bytes).into_owned();
let path_exists = std::path::Path::new(&full_path).exists();
if !path_exists {
to_forget.push(full_path);
continue;
}
let is_ignored = path_is_excluded(&full_path, &all_excludes);
let is_valid = !is_ignored && path_has_valid_prefix(&full_path, &valid_prefixes);
if !is_valid || is_ignored {
to_forget.push(full_path);
}
}
if !to_forget.is_empty() {
log_step(
&("Forgetting ".to_owned()
+ &to_forget.len().to_string()
+ " untracked/excluded file(s)..."),
);
if dry_run {
for f in &to_forget {
println!(" [dry-run] Would forget untracked/excluded path: {}", f);
}
} else {
let mut argv: Vec<&str> = vec!["--force"];
argv.extend(to_forget.iter().map(|p| p.as_str()));
let outcome = chezmoi.run("forget", &argv, SecretsArg::Unsupported)?;
outcome.report();
}
} else {
log_success("No orphaned or excluded files to forget.");
}
if !dry_run {
let pre_existing = audit_managed_secrets(&chezmoi, scan_policy, &flagged_secrets)?;
if !pre_existing.is_empty() {
log_warn(&format!(
"{} already-managed file(s) contain potential secrets and were committed in plaintext.",
pre_existing.len()
));
log_step("Run `fp-dotfiles-manager audit-secrets --fix` to move them into tracked_encrypted.");
}
}
log_section("Committing and pushing changes...");
let git_status = chezmoi.run("git", &["--", "status", "--porcelain"], SecretsArg::Unsupported)?;
git_status.report();
let has_changes = !git_status.stdout.is_empty();
if has_changes {
if dry_run {
println!(" [dry-run] Git changes detected in chezmoi source repository:");
for line in git_status.stdout.lines() {
println!(" [dry-run] {}", line);
}
println!(" [dry-run] Would stage changes: chezmoi git add .");
println!(
" [dry-run] Would commit changes: chezmoi git commit -m \"chore: auto-sync dotfiles\""
);
println!(" [dry-run] Would push branch to remote origin");
} else {
log_step("Staging changes...");
let staged = chezmoi.run("git", &["--", "add", "."], SecretsArg::Unsupported)?;
staged.report();
let staged_diff =
chezmoi.run("git", &["--", "diff", "--cached", "--name-only"], SecretsArg::Unsupported)?;
let has_staged = !staged_diff.stdout.is_empty();
if has_staged {
let branch_output =
chezmoi.run("git", &["--", "branch", "--show-current"], SecretsArg::Unsupported)?;
let mut branch_name = branch_output.stdout.trim().to_string();
log_step("Creating commit...");
let commit_res = chezmoi.run(
"git",
&["--", "commit", "--quiet", "-m", "chore: auto-sync dotfiles"],
SecretsArg::Unsupported,
)?;
if !commit_res.success {
commit_res.report();
guard.release();
std::process::exit(1);
}
let sha_output =
chezmoi.run("git", &["--", "rev-parse", "--short", "HEAD"], SecretsArg::Unsupported)?;
let commit_sha = sha_output.stdout.trim().to_string();
let subject_output =
chezmoi.run("git", &["--", "log", "-1", "--pretty=%s"], SecretsArg::Unsupported)?;
let commit_subject = subject_output.stdout.trim().to_string();
log_success(&("Created commit ".to_owned() + &commit_sha + " " + &commit_subject));
log_step("Pushing to remote...");
let push_res = chezmoi.run(
"git",
&["--", "push", "--quiet", "-u", "origin", "HEAD"],
SecretsArg::Unsupported,
)?;
if !push_res.success {
push_res.report();
guard.release();
std::process::exit(1);
}
if branch_name.is_empty() {
branch_name = "HEAD".to_string();
}
log_success(&("Pushed to origin branch ".to_owned() + &branch_name));
} else {
log_success("No changes staged for commit.");
}
}
} else {
log_success("No new changes to commit.");
}
remove_chezmoi_run_config(&temp_config);
if !dry_run {
let _ = run_hook(&config.post_sync_hook, "post-sync");
} else {
let path = std::path::Path::new(&config.post_sync_hook);
if path.is_file() {
println!(
" [dry-run] Would execute post-sync hook: {}",
config.post_sync_hook
);
}
}
log_done("Sync engine finished.");
Ok(())
}