From f0bc8b8561232da7a9316f0071fdcbbcedd356ae Mon Sep 17 00:00:00 2001 From: Stefan Zvonar Date: Sat, 4 Apr 2026 14:17:50 +1000 Subject: [PATCH 1/9] fix: detect local dotfile changes after sync and merge profile+global dotfiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs that prevented locally-edited dotfiles from being pushed: 1. When a dotfile has no sync history (newly added to config) and local differs from remote, is_true_conflict() returned true. In daemon mode, conflicts can't be resolved interactively, so the file was skipped in both import AND export — stuck indefinitely. Fix: treat no-sync-history as "local is authoritative" (not a conflict), letting the export step push to establish a baseline. 2. effective_dotfiles() returned ONLY profile dotfiles when a profile existed, silently excluding any dotfiles only in the global [dotfiles] config. Fix: merge profile entries with global entries (profile takes priority for duplicates). Closes #7 --- src/config.rs | 19 +++++++++++++++---- src/sync/conflict.rs | 14 ++++++++++---- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/config.rs b/src/config.rs index aff60f6..1508110 100644 --- a/src/config.rs +++ b/src/config.rs @@ -708,11 +708,20 @@ impl Config { pub fn effective_dotfiles(&self, machine_id: &str) -> Vec { if let Some(profile) = self.machine_profile(machine_id) { if !profile.dotfiles.is_empty() { - return profile + let mut entries: Vec = profile .dotfiles .iter() .map(|e| e.to_dotfile_entry()) .collect(); + // Merge global dotfiles that aren't already in the profile list + let profile_paths: std::collections::HashSet = + entries.iter().map(|e| e.path().to_string()).collect(); + for global in &self.dotfiles.files { + if !profile_paths.contains(global.path()) { + entries.push(global.clone()); + } + } + return entries; } } self.dotfiles.files.clone() @@ -1383,7 +1392,9 @@ files = [] .insert("my-server".to_string(), "server".to_string()); let files = config.effective_dotfiles("my-server"); - assert_eq!(files.len(), 1); + // Profile dotfiles are merged with global — .zshrc is in both so no duplicate + assert_eq!(files.len(), config.dotfiles.files.len()); + // Profile entry comes first assert_eq!(files[0].path(), ".zshrc"); // Unassigned machines get "dev" profile (which may or may not exist) @@ -1495,8 +1506,8 @@ packages = ["brew"] assert_eq!(profile.dotfiles.len(), 1); assert_eq!(profile.packages, vec!["brew"]); - // Helpers work - assert_eq!(parsed.effective_dotfiles("my-server").len(), 1); + // Helpers work — profile dotfiles merge with global (profile has .zshrc, global adds .gitconfig) + assert_eq!(parsed.effective_dotfiles("my-server").len(), 2); assert!(!parsed.is_manager_enabled("my-server", "npm")); assert!(parsed.is_manager_enabled("my-server", "brew")); } diff --git a/src/sync/conflict.rs b/src/sync/conflict.rs index dd529de..14ed811 100644 --- a/src/sync/conflict.rs +++ b/src/sync/conflict.rs @@ -41,8 +41,12 @@ impl FileConflict { self.local_hash != *last && self.remote_hash != *last } None => { - // No sync history - conflict if they differ - self.local_hash != self.remote_hash + // No sync history — local is authoritative. The export step will + // push it to establish a baseline. Treating this as a conflict + // causes the file to be skipped in both import AND export (since + // the daemon can't resolve conflicts interactively), leaving the + // file stuck indefinitely. + false } } } @@ -422,7 +426,9 @@ mod tests { } #[test] - fn test_is_conflict_no_sync_history_different() { + fn test_no_conflict_no_sync_history_different() { + // No sync history means local is authoritative — not a conflict. + // The export step will push to establish a baseline. let conflict = FileConflict { file_path: ".zshrc".to_string(), local_hash: "aaa".to_string(), @@ -431,7 +437,7 @@ mod tests { local_content: vec![], remote_content: vec![], }; - assert!(conflict.is_true_conflict()); + assert!(!conflict.is_true_conflict()); } #[test] From 542973917cf7f763f1e4d75ea12d3d8d521104c6 Mon Sep 17 00:00:00 2001 From: Stefan Zvonar Date: Sat, 4 Apr 2026 14:22:08 +1000 Subject: [PATCH 2/9] fix: broken test and add missing test cases - Fix test_detect_conflict_returns_some_when_differ_no_history: was asserting is_some() but fix makes it return None. Renamed and updated. - Add test for profile-overrides-global semantics (create_if_missing) - Add test for disjoint profile/global dotfile sets (union behavior) --- src/config.rs | 50 ++++++++++++++++++++++++++++++++++++++++++++ src/sync/conflict.rs | 5 +++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/config.rs b/src/config.rs index 1508110..651174b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1403,6 +1403,56 @@ files = [] assert_eq!(other.len(), config.dotfiles.files.len()); } + #[test] + fn test_effective_dotfiles_profile_overrides_global() { + let mut config = Config::default(); + // Global has .zshrc with create_if_missing=false (from default) + // Profile has .zshrc with create_if_missing=true — profile should win + config.profiles.insert( + "server".to_string(), + ProfileConfig { + dotfiles: vec![ProfileDotfileEntry::WithOptions { + path: ".zshrc".to_string(), + shared: false, + create_if_missing: true, + }], + dirs: vec![], + packages: vec![], + }, + ); + config + .machine_profiles + .insert("my-server".to_string(), "server".to_string()); + + let files = config.effective_dotfiles("my-server"); + let zshrc = files.iter().find(|e| e.path() == ".zshrc").unwrap(); + // Profile version takes priority (create_if_missing=true) + assert!(zshrc.create_if_missing()); + } + + #[test] + fn test_effective_dotfiles_disjoint_sets() { + let mut config = Config::default(); + config.dotfiles.files = vec![DotfileEntry::Simple(".gitconfig".to_string())]; + config.profiles.insert( + "server".to_string(), + ProfileConfig { + dotfiles: vec![ProfileDotfileEntry::Simple(".vimrc".to_string())], + dirs: vec![], + packages: vec![], + }, + ); + config + .machine_profiles + .insert("my-server".to_string(), "server".to_string()); + + let files = config.effective_dotfiles("my-server"); + // Profile has .vimrc, global has .gitconfig — should get both + assert_eq!(files.len(), 2); + assert_eq!(files[0].path(), ".vimrc"); // profile first + assert_eq!(files[1].path(), ".gitconfig"); // global appended + } + #[test] fn test_is_manager_enabled_with_profile() { let mut config = Config::default(); diff --git a/src/sync/conflict.rs b/src/sync/conflict.rs index 14ed811..123c4d4 100644 --- a/src/sync/conflict.rs +++ b/src/sync/conflict.rs @@ -466,13 +466,14 @@ mod tests { } #[test] - fn test_detect_conflict_returns_some_when_differ_no_history() { + fn test_detect_conflict_returns_none_when_differ_no_history() { + // No sync history means local is authoritative — not a conflict. let temp = TempDir::new().unwrap(); let local_path = temp.path().join(".zshrc"); std::fs::write(&local_path, b"local content").unwrap(); let result = detect_conflict(".zshrc", &local_path, b"remote content", None); - assert!(result.is_some()); + assert!(result.is_none()); } #[test] From e46f591a916f1ab33a12f88cf1d1d87c7ad9191a Mon Sep 17 00:00:00 2001 From: Stefan Zvonar Date: Sun, 5 Apr 2026 09:05:19 +1000 Subject: [PATCH 3/9] fix(sync): remote wins on first sync for create_if_missing files When a dotfile has create_if_missing=true and no sync history, skip conflict detection and let remote content win. This handles the case where an app creates a default file before tether runs (e.g. Claude Code writing {} to settings.json on startup). For files without create_if_missing, local remains authoritative when there's no sync history (from the is_true_conflict fix). Incorporates the fix from PR #6. --- src/cli/commands/sync.rs | 160 ++++++++++++++++++++++----------------- 1 file changed, 89 insertions(+), 71 deletions(-) diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index d4fd10f..bfb4800 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -8,6 +8,7 @@ use crate::sync::{ import_packages, sync_packages, GitBackend, MachineState, SyncEngine, SyncState, }; use anyhow::Result; +use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -746,87 +747,104 @@ pub fn decrypt_from_repo( let last_synced_hash = state.files.get(&file).map(|f| f.hash.as_str()); - // Check for conflict - if let Some(conflict) = - detect_conflict(&file, &local_file, &plaintext, last_synced_hash) - { - if interactive { - // Interactive mode: prompt user - conflict.show_diff()?; - let resolution = conflict.prompt_resolution()?; - - match resolution { - ConflictResolution::KeepLocal => {} - ConflictResolution::UseRemote => { - // Backup before overwriting - if local_file.exists() { - if backup_dir.is_none() { - backup_dir = Some(create_backup_dir()?); + // First-time sync for create_if_missing files: remote wins. + // This handles the case where an app creates a default file + // (e.g. Claude Code writes `{}` to settings.json on startup) + // before tether runs — that default shouldn't block the + // synced content from being applied. + let first_sync = last_synced_hash.is_none() && create_if_missing; + + if !first_sync { + // Normal sync: check for conflict + if let Some(conflict) = + detect_conflict(&file, &local_file, &plaintext, last_synced_hash) + { + if interactive { + conflict.show_diff()?; + let resolution = conflict.prompt_resolution()?; + + match resolution { + ConflictResolution::KeepLocal => {} + ConflictResolution::UseRemote => { + if local_file.exists() { + if backup_dir.is_none() { + backup_dir = Some(create_backup_dir()?); + } + backup_file( + backup_dir.as_ref().unwrap(), + "dotfiles", + &file, + &local_file, + )?; } - backup_file( - backup_dir.as_ref().unwrap(), - "dotfiles", - &file, - &local_file, - )?; + write_decrypted(&local_file, &plaintext)?; + #[cfg(unix)] + preserve_executable_bit(&enc_file, &local_file); + conflict_state.remove_conflict(&file); + } + ConflictResolution::Merged => { + conflict.launch_merge_tool(&config.merge, home)?; + conflict_state.remove_conflict(&file); + } + ConflictResolution::Skip => { + new_conflicts.push(( + file.to_string(), + conflict.local_hash.clone(), + conflict.remote_hash.clone(), + )); } - write_decrypted(&local_file, &plaintext)?; - #[cfg(unix)] - preserve_executable_bit(&enc_file, &local_file); - conflict_state.remove_conflict(&file); - } - ConflictResolution::Merged => { - conflict.launch_merge_tool(&config.merge, home)?; - conflict_state.remove_conflict(&file); - } - ConflictResolution::Skip => { - new_conflicts.push(( - file.to_string(), - conflict.local_hash.clone(), - conflict.remote_hash.clone(), - )); } + continue; + } else { + Output::warning(&format!( + " {} (conflict - skipped)", + file + )); + new_conflicts.push(( + file.to_string(), + conflict.local_hash.clone(), + conflict.remote_hash.clone(), + )); + continue; } - } else { - // Non-interactive (daemon): save conflict for later - Output::warning(&format!(" {} (conflict - skipped)", file)); - new_conflicts.push(( - file.to_string(), - conflict.local_hash.clone(), - conflict.remote_hash.clone(), - )); } - } else { - // No true conflict - but preserve local-only changes - let remote_hash = crate::sha256_hex(&plaintext); - let local_hash = std::fs::read(&local_file) - .ok() - .map(|c| crate::sha256_hex(&c)); + } - // Only write if local unchanged from last sync AND remote differs + // Apply remote content if local is unchanged or this is a first sync + let remote_hash = format!("{:x}", Sha256::digest(&plaintext)); + let local_hash = std::fs::read(&local_file) + .ok() + .map(|c| format!("{:x}", Sha256::digest(&c))); + + let should_write = if first_sync { + // First sync: write unless local already matches remote + local_hash.as_ref() != Some(&remote_hash) + } else { + // Normal sync: only write if local unchanged from last sync let local_unchanged = local_hash.as_deref() == last_synced_hash; - if local_unchanged && local_hash.as_ref() != Some(&remote_hash) { - // Backup before overwriting - if local_file.exists() { - if backup_dir.is_none() { - backup_dir = Some(create_backup_dir()?); - } - backup_file( - backup_dir.as_ref().unwrap(), - "dotfiles", - &file, - &local_file, - )?; - } - if let Some(parent) = local_file.parent() { - std::fs::create_dir_all(parent)?; + local_unchanged && local_hash.as_ref() != Some(&remote_hash) + }; + + if should_write { + if local_file.exists() { + if backup_dir.is_none() { + backup_dir = Some(create_backup_dir()?); } - write_decrypted(&local_file, &plaintext)?; - #[cfg(unix)] - preserve_executable_bit(&enc_file, &local_file); + backup_file( + backup_dir.as_ref().unwrap(), + "dotfiles", + &file, + &local_file, + )?; + } + if let Some(parent) = local_file.parent() { + std::fs::create_dir_all(parent)?; } - conflict_state.remove_conflict(&file); + write_decrypted(&local_file, &plaintext)?; + #[cfg(unix)] + preserve_executable_bit(&enc_file, &local_file); } + conflict_state.remove_conflict(&file); } Err(e) => { Output::warning(&format!(" {} (failed to decrypt: {})", file, e)); From 70b67ebfe23187ffa52edeb9ffb18f8446e37197 Mon Sep 17 00:00:00 2001 From: Stefan Zvonar Date: Sun, 5 Apr 2026 15:11:44 +1000 Subject: [PATCH 4/9] fix: clear conflict state when user chooses KeepLocal KeepLocal resolution wasn't calling remove_conflict, causing the user to be re-prompted on every sync until the export step ran. --- src/cli/commands/sync.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index bfb4800..85395fe 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -764,7 +764,9 @@ pub fn decrypt_from_repo( let resolution = conflict.prompt_resolution()?; match resolution { - ConflictResolution::KeepLocal => {} + ConflictResolution::KeepLocal => { + conflict_state.remove_conflict(&file); + } ConflictResolution::UseRemote => { if local_file.exists() { if backup_dir.is_none() { From 39bc68b40ba794581e1d40c4e69c31823c5c6990 Mon Sep 17 00:00:00 2001 From: Paddo <653385+paddo@users.noreply.github.com> Date: Wed, 8 Apr 2026 01:52:27 +1000 Subject: [PATCH 5/9] fix: use sha256_hex instead of inline Sha256::digest for consistency --- src/cli/commands/sync.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index 85395fe..e1e1ad3 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -8,7 +8,6 @@ use crate::sync::{ import_packages, sync_packages, GitBackend, MachineState, SyncEngine, SyncState, }; use anyhow::Result; -use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -813,10 +812,10 @@ pub fn decrypt_from_repo( } // Apply remote content if local is unchanged or this is a first sync - let remote_hash = format!("{:x}", Sha256::digest(&plaintext)); + let remote_hash = crate::sha256_hex(&plaintext); let local_hash = std::fs::read(&local_file) .ok() - .map(|c| format!("{:x}", Sha256::digest(&c))); + .map(|c| crate::sha256_hex(&c)); let should_write = if first_sync { // First sync: write unless local already matches remote From 719856bfd962048da6b0c9a6c58e0f0a3bc5dc6d Mon Sep 17 00:00:00 2001 From: Paddo <653385+paddo@users.noreply.github.com> Date: Wed, 8 Apr 2026 02:40:37 +1000 Subject: [PATCH 6/9] fix: simplify dotfile merge and conflict comments --- src/config.rs | 5 +---- src/sync/conflict.rs | 15 +++------------ 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/src/config.rs b/src/config.rs index 651174b..aa70a57 100644 --- a/src/config.rs +++ b/src/config.rs @@ -713,11 +713,8 @@ impl Config { .iter() .map(|e| e.to_dotfile_entry()) .collect(); - // Merge global dotfiles that aren't already in the profile list - let profile_paths: std::collections::HashSet = - entries.iter().map(|e| e.path().to_string()).collect(); for global in &self.dotfiles.files { - if !profile_paths.contains(global.path()) { + if !entries.iter().any(|e| e.path() == global.path()) { entries.push(global.clone()); } } diff --git a/src/sync/conflict.rs b/src/sync/conflict.rs index 123c4d4..cfe9f81 100644 --- a/src/sync/conflict.rs +++ b/src/sync/conflict.rs @@ -36,18 +36,9 @@ impl FileConflict { /// Check if there's actually a conflict (both sides changed since last sync) pub fn is_true_conflict(&self) -> bool { match &self.last_synced_hash { - Some(last) => { - // Both changed: local != last AND remote != last - self.local_hash != *last && self.remote_hash != *last - } - None => { - // No sync history — local is authoritative. The export step will - // push it to establish a baseline. Treating this as a conflict - // causes the file to be skipped in both import AND export (since - // the daemon can't resolve conflicts interactively), leaving the - // file stuck indefinitely. - false - } + Some(last) => self.local_hash != *last && self.remote_hash != *last, + // No sync history — local is authoritative; export establishes baseline + None => false, } } From a331f606db385aec028e480297313784c455d92c Mon Sep 17 00:00:00 2001 From: Paddo <653385+paddo@users.noreply.github.com> Date: Thu, 9 Apr 2026 07:36:38 +1000 Subject: [PATCH 7/9] style: run cargo fmt --- src/cli/commands/sync.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index e1e1ad3..7809688 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -797,10 +797,7 @@ pub fn decrypt_from_repo( } continue; } else { - Output::warning(&format!( - " {} (conflict - skipped)", - file - )); + Output::warning(&format!(" {} (conflict - skipped)", file)); new_conflicts.push(( file.to_string(), conflict.local_hash.clone(), From 44d0f42872e6331b9eb0215403646f01487b9e67 Mon Sep 17 00:00:00 2001 From: Paddo <653385+paddo@users.noreply.github.com> Date: Thu, 9 Apr 2026 08:03:49 +1000 Subject: [PATCH 8/9] refactor: extract backup helper, dedupe hash work, align effective_dirs - Extract backup_and_write_dotfile helper to remove two identical backup+ write+preserve_executable_bit blocks in decrypt_from_repo - detect_conflict now takes pre-computed local content and hashes so the caller can read and hash the file once instead of twice per sync - effective_dirs merges profile + global dirs like effective_dotfiles --- src/cli/commands/sync.rs | 164 +++++++++++++++++++++------------------ src/config.rs | 15 +++- src/sync/conflict.rs | 50 ++++-------- 3 files changed, 114 insertions(+), 115 deletions(-) diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index 7809688..933f828 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -654,6 +654,32 @@ fn write_decrypted(path: &Path, contents: &[u8]) -> Result<()> { crate::security::write_owner_only(path, contents) } +/// Back up an existing dotfile (if present), ensure parent dir exists, +/// write the decrypted content, and preserve the executable bit from the +/// encrypted source file. +fn backup_and_write_dotfile( + backup_dir: &mut Option, + file: &str, + local_file: &Path, + enc_file: &Path, + plaintext: &[u8], +) -> Result<()> { + use crate::sync::{backup_file, create_backup_dir}; + if local_file.exists() { + if backup_dir.is_none() { + *backup_dir = Some(create_backup_dir()?); + } + backup_file(backup_dir.as_ref().unwrap(), "dotfiles", file, local_file)?; + } + if let Some(parent) = local_file.parent() { + std::fs::create_dir_all(parent)?; + } + write_decrypted(local_file, plaintext)?; + #[cfg(unix)] + preserve_executable_bit(enc_file, local_file); + Ok(()) +} + pub fn decrypt_from_repo( config: &Config, sync_path: &Path, @@ -662,9 +688,7 @@ pub fn decrypt_from_repo( machine_state: &MachineState, interactive: bool, ) -> Result<()> { - use crate::sync::{ - backup_file, create_backup_dir, detect_conflict, ConflictResolution, ConflictState, - }; + use crate::sync::{detect_conflict, ConflictResolution, ConflictState}; let key = crate::security::get_encryption_key()?; let dotfiles_dir = sync_path.join("dotfiles"); @@ -747,100 +771,88 @@ pub fn decrypt_from_repo( let last_synced_hash = state.files.get(&file).map(|f| f.hash.as_str()); // First-time sync for create_if_missing files: remote wins. - // This handles the case where an app creates a default file - // (e.g. Claude Code writes `{}` to settings.json on startup) - // before tether runs — that default shouldn't block the - // synced content from being applied. + // Handles apps that create defaults (e.g. Claude Code writes + // `{}` to settings.json) before tether has a chance to sync. let first_sync = last_synced_hash.is_none() && create_if_missing; + let local_content = std::fs::read(&local_file).ok(); + let local_hash = local_content.as_ref().map(|c| crate::sha256_hex(c)); + let remote_hash = crate::sha256_hex(&plaintext); + if !first_sync { - // Normal sync: check for conflict - if let Some(conflict) = - detect_conflict(&file, &local_file, &plaintext, last_synced_hash) + if let (Some(lc), Some(lh)) = + (local_content.as_ref(), local_hash.as_ref()) { - if interactive { - conflict.show_diff()?; - let resolution = conflict.prompt_resolution()?; - - match resolution { - ConflictResolution::KeepLocal => { - conflict_state.remove_conflict(&file); - } - ConflictResolution::UseRemote => { - if local_file.exists() { - if backup_dir.is_none() { - backup_dir = Some(create_backup_dir()?); - } - backup_file( - backup_dir.as_ref().unwrap(), - "dotfiles", + if let Some(conflict) = detect_conflict( + &file, + lc, + lh, + &plaintext, + &remote_hash, + last_synced_hash, + ) { + if interactive { + conflict.show_diff()?; + let resolution = conflict.prompt_resolution()?; + + match resolution { + ConflictResolution::KeepLocal => { + conflict_state.remove_conflict(&file); + } + ConflictResolution::UseRemote => { + backup_and_write_dotfile( + &mut backup_dir, &file, &local_file, + &enc_file, + &plaintext, )?; + conflict_state.remove_conflict(&file); + } + ConflictResolution::Merged => { + conflict.launch_merge_tool(&config.merge, home)?; + conflict_state.remove_conflict(&file); + } + ConflictResolution::Skip => { + new_conflicts.push(( + file.to_string(), + conflict.local_hash.clone(), + conflict.remote_hash.clone(), + )); } - write_decrypted(&local_file, &plaintext)?; - #[cfg(unix)] - preserve_executable_bit(&enc_file, &local_file); - conflict_state.remove_conflict(&file); - } - ConflictResolution::Merged => { - conflict.launch_merge_tool(&config.merge, home)?; - conflict_state.remove_conflict(&file); - } - ConflictResolution::Skip => { - new_conflicts.push(( - file.to_string(), - conflict.local_hash.clone(), - conflict.remote_hash.clone(), - )); } + continue; + } else { + Output::warning(&format!( + " {} (conflict - skipped)", + file + )); + new_conflicts.push(( + file.to_string(), + conflict.local_hash.clone(), + conflict.remote_hash.clone(), + )); + continue; } - continue; - } else { - Output::warning(&format!(" {} (conflict - skipped)", file)); - new_conflicts.push(( - file.to_string(), - conflict.local_hash.clone(), - conflict.remote_hash.clone(), - )); - continue; } } } - // Apply remote content if local is unchanged or this is a first sync - let remote_hash = crate::sha256_hex(&plaintext); - let local_hash = std::fs::read(&local_file) - .ok() - .map(|c| crate::sha256_hex(&c)); - let should_write = if first_sync { - // First sync: write unless local already matches remote local_hash.as_ref() != Some(&remote_hash) } else { - // Normal sync: only write if local unchanged from last sync let local_unchanged = local_hash.as_deref() == last_synced_hash; local_unchanged && local_hash.as_ref() != Some(&remote_hash) }; if should_write { - if local_file.exists() { - if backup_dir.is_none() { - backup_dir = Some(create_backup_dir()?); - } - backup_file( - backup_dir.as_ref().unwrap(), - "dotfiles", - &file, - &local_file, - )?; - } - if let Some(parent) = local_file.parent() { - std::fs::create_dir_all(parent)?; - } - write_decrypted(&local_file, &plaintext)?; - #[cfg(unix)] - preserve_executable_bit(&enc_file, &local_file); + backup_and_write_dotfile( + &mut backup_dir, + &file, + &local_file, + &enc_file, + &plaintext, + )?; } conflict_state.remove_conflict(&file); } @@ -1476,7 +1488,7 @@ pub fn sync_directories( let configs_dir = sync_path.join("configs"); std::fs::create_dir_all(&configs_dir)?; - for dir_path in config.effective_dirs(machine_id) { + for dir_path in &config.effective_dirs(machine_id) { // Validate path is safe (security: prevents path traversal via synced config) if !crate::config::is_safe_dotfile_path(dir_path) { Output::warning(&format!(" {} (unsafe path, skipping)", dir_path)); diff --git a/src/config.rs b/src/config.rs index aa70a57..b0d0a39 100644 --- a/src/config.rs +++ b/src/config.rs @@ -734,14 +734,21 @@ impl Config { } } - /// Get effective dirs for a machine (profile takes priority, then global) - pub fn effective_dirs(&self, machine_id: &str) -> &[String] { + /// Get effective dirs for a machine. Profile dirs merge with global dirs; + /// profile entries take priority on duplicates. + pub fn effective_dirs(&self, machine_id: &str) -> Vec { if let Some(profile) = self.machine_profile(machine_id) { if !profile.dirs.is_empty() { - return &profile.dirs; + let mut dirs = profile.dirs.clone(); + for global in &self.dotfiles.dirs { + if !dirs.contains(global) { + dirs.push(global.clone()); + } + } + return dirs; } } - &self.dotfiles.dirs + self.dotfiles.dirs.clone() } /// Check if a package manager is enabled for a machine. diff --git a/src/sync/conflict.rs b/src/sync/conflict.rs index cfe9f81..a41a3ed 100644 --- a/src/sync/conflict.rs +++ b/src/sync/conflict.rs @@ -223,33 +223,26 @@ fn diff_lines<'a>(old: &[&'a str], new: &[&'a str]) -> Vec> { result } -/// Detect conflicts for a file +/// Detect conflicts for a file. Caller supplies pre-computed hashes to avoid +/// re-reading and re-hashing in the common non-conflict path. pub fn detect_conflict( file_path: &str, - local_path: &Path, + local_content: &[u8], + local_hash: &str, remote_content: &[u8], + remote_hash: &str, last_synced_hash: Option<&str>, ) -> Option { - let local_content = match std::fs::read(local_path) { - Ok(c) => c, - Err(_) => return None, // Local doesn't exist, no conflict - }; - - let local_hash = crate::sha256_hex(&local_content); - let remote_hash = crate::sha256_hex(remote_content); - - // No conflict if hashes match if local_hash == remote_hash { return None; } - // Check if it's a true conflict (both changed since last sync) let conflict = FileConflict { file_path: file_path.to_string(), - local_hash, + local_hash: local_hash.to_string(), last_synced_hash: last_synced_hash.map(|s| s.to_string()), - remote_hash, - local_content, + remote_hash: remote_hash.to_string(), + local_content: local_content.to_vec(), remote_content: remote_content.to_vec(), }; @@ -374,7 +367,6 @@ pub fn notify_deferred_casks(casks: &[String]) -> Result<()> { #[cfg(test)] mod tests { use super::*; - use tempfile::TempDir; // is_true_conflict tests #[test] @@ -447,32 +439,20 @@ mod tests { // detect_conflict tests #[test] fn test_detect_conflict_returns_none_when_equal() { - let temp = TempDir::new().unwrap(); - let local_path = temp.path().join(".zshrc"); let content = b"same content"; - std::fs::write(&local_path, content).unwrap(); - - let result = detect_conflict(".zshrc", &local_path, content, None); + let hash = crate::sha256_hex(content); + let result = detect_conflict(".zshrc", content, &hash, content, &hash, None); assert!(result.is_none()); } #[test] fn test_detect_conflict_returns_none_when_differ_no_history() { // No sync history means local is authoritative — not a conflict. - let temp = TempDir::new().unwrap(); - let local_path = temp.path().join(".zshrc"); - std::fs::write(&local_path, b"local content").unwrap(); - - let result = detect_conflict(".zshrc", &local_path, b"remote content", None); - assert!(result.is_none()); - } - - #[test] - fn test_detect_conflict_returns_none_when_local_missing() { - let temp = TempDir::new().unwrap(); - let local_path = temp.path().join("nonexistent"); - - let result = detect_conflict("nonexistent", &local_path, b"remote", None); + let local = b"local content"; + let remote = b"remote content"; + let local_hash = crate::sha256_hex(local); + let remote_hash = crate::sha256_hex(remote); + let result = detect_conflict(".zshrc", local, &local_hash, remote, &remote_hash, None); assert!(result.is_none()); } From 29bd80025354f18afe3694c671ea8c30f886818f Mon Sep 17 00:00:00 2001 From: Paddo <653385+paddo@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:20:14 +1000 Subject: [PATCH 9/9] chore: release v1.11.10 --- CHANGELOG.md | 15 +++++++++++++++ Cargo.toml | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfc0839..3f8ddce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.11.10] - 2026-04-08 + +### Fixed + +- Dotfiles with no sync history no longer treated as conflicts (prevented daemon from syncing new files) +- `create_if_missing` dotfiles now receive remote content on first sync even when an app created a default locally +- `effective_dotfiles()` merges profile + global dotfiles instead of replacing (global entries no longer silently dropped) +- `effective_dirs()` now merges profile + global dirs to match `effective_dotfiles` behavior +- `KeepLocal` conflict resolution now clears conflict state (no longer re-prompts on every sync) + +### Changed + +- `detect_conflict` takes pre-computed hashes so the local file is read once per sync instead of twice +- Extracted `backup_and_write_dotfile` helper to dedupe backup+write logic in `decrypt_from_repo` + ## [1.11.9] - 2026-04-07 ### Fixed diff --git a/Cargo.toml b/Cargo.toml index 3536e19..333ada7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tether" -version = "1.11.9" +version = "1.11.10" edition = "2021" authors = ["Paddo Tech"] description = "Sync your development environment across machines automatically"