Skip to content
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
172 changes: 100 additions & 72 deletions src/cli/commands/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>,
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,
Expand All @@ -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");
Expand Down Expand Up @@ -746,87 +770,91 @@ 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.
// 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 {
if let (Some(lc), Some(lh)) =
(local_content.as_ref(), local_hash.as_ref())
{
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(),
));
}
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 => {
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
let should_write = if first_sync {
local_hash.as_ref() != Some(&remote_hash)
} else {
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)?;
}
write_decrypted(&local_file, &plaintext)?;
#[cfg(unix)]
preserve_executable_bit(&enc_file, &local_file);
}
conflict_state.remove_conflict(&file);
local_unchanged && local_hash.as_ref() != Some(&remote_hash)
};

if should_write {
backup_and_write_dotfile(
&mut backup_dir,
&file,
&local_file,
&enc_file,
&plaintext,
)?;
}
conflict_state.remove_conflict(&file);
}
Err(e) => {
Output::warning(&format!(" {} (failed to decrypt: {})", file, e));
Expand Down Expand Up @@ -1460,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));
Expand Down
81 changes: 73 additions & 8 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -708,11 +708,17 @@ impl Config {
pub fn effective_dotfiles(&self, machine_id: &str) -> Vec<DotfileEntry> {
if let Some(profile) = self.machine_profile(machine_id) {
if !profile.dotfiles.is_empty() {
return profile
let mut entries: Vec<DotfileEntry> = profile
.dotfiles
.iter()
.map(|e| e.to_dotfile_entry())
.collect();
for global in &self.dotfiles.files {
if !entries.iter().any(|e| e.path() == global.path()) {
entries.push(global.clone());
}
}
return entries;
}
}
self.dotfiles.files.clone()
Expand All @@ -728,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<String> {
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.
Expand Down Expand Up @@ -1383,7 +1396,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)
Expand All @@ -1392,6 +1407,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();
Expand Down Expand Up @@ -1495,8 +1560,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"));
}
Expand Down
Loading
Loading