#!/usr/bin/env bash set -euo pipefail script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) src_dir="$script_dir/config" dest_dir="${HOME}/.config" dry_run=false target_path="" usage() { cat <<'EOF' Usage: sync-config.sh [--dry-run] [path] path can be: config (full config) config/tmux (specific folder) config/tmux/config (specific file) .config (full config) .config/tmux (specific folder) .config/tmux/config (specific file) tmux (folder under .config) tmux/config (file under .config) EOF } for arg in "$@"; do case "$arg" in --dry-run) dry_run=true ;; -h|--help) usage exit 0 ;; *) if [[ -z "$target_path" ]]; then target_path="$arg" else echo "Error: too many arguments." >&2 usage >&2 exit 1 fi ;; esac done hash_cmd="sha256sum" if ! command -v "$hash_cmd" >/dev/null 2>&1; then echo "Error: sha256sum not found. Install coreutils or adjust hash_cmd." >&2 exit 1 fi file_hash() { "$hash_cmd" "$1" | awk '{print $1}' } copy_file() { local src="$1" local dst="$2" if $dry_run; then echo "copy: $src -> $dst" else mkdir -p "$(dirname "$dst")" cp -a "$src" "$dst" echo "copied: $dst" fi } delete_file() { local dst="$1" if $dry_run; then echo "delete: $dst" else rm -f "$dst" echo "deleted: $dst" fi } delete_dir() { local dst="$1" if $dry_run; then echo "delete dir: $dst" else rm -rf "$dst" echo "deleted dir: $dst" fi } if [[ ! -d "$src_dir" ]]; then echo "Error: source config directory not found: $src_dir" >&2 exit 1 fi mkdir -p "$dest_dir" target_rel="$target_path" if [[ -n "$target_rel" ]]; then if [[ "$target_rel" == "config" || "$target_rel" == ".config" ]]; then target_rel="" elif [[ "$target_rel" == config/* ]]; then target_rel=${target_rel#config/} elif [[ "$target_rel" == .config/* ]]; then target_rel=${target_rel#.config/} fi target_rel=${target_rel#./} if [[ "$target_rel" == "." ]]; then target_rel="" fi if [[ "$target_rel" == /* ]]; then echo "Error: absolute paths are not supported: $target_path" >&2 exit 1 fi fi base_path="$src_dir" if [[ -n "$target_rel" ]]; then base_path="$src_dir/$target_rel" fi if [[ ! -e "$base_path" ]]; then echo "Error: path not found in source: $base_path" >&2 exit 1 fi check_delete_marker() { local rel_path="$1" # Check centralized .delete folder: config/.delete/path/to/dir_or_file if [[ -f "$src_dir/.delete/$rel_path" ]] || [[ -d "$src_dir/.delete/$rel_path" ]]; then return 0 fi # Check inline .delete marker: config/path/to/.delete/dir_or_file local parent_path="$src_dir/$rel_path" local parent_dir=$(dirname "$parent_path") local base_name=$(basename "$parent_path") if [[ -f "$parent_dir/.delete/$base_name" ]] || [[ -d "$parent_dir/.delete/$base_name" ]]; then return 0 fi return 1 } # Check if a path is a .delete directory or inside one (should be skipped during traversal) is_in_delete_dir() { local rel_path="$1" # Check if the path itself is a .delete directory or any parent is .delete local current="$rel_path" while [[ "$current" != "." && "$current" != "/" && -n "$current" ]]; do local base base=$(basename "$current") if [[ "$base" == ".delete" ]]; then return 0 fi current=$(dirname "$current") done return 1 } sync_file() { local src_path="$1" local rel_path=${src_path#"$src_dir"/} local dst_path="$dest_dir/$rel_path" # Check if there's a corresponding .delete marker for this file if check_delete_marker "$rel_path"; then if [[ -f "$dst_path" ]]; then delete_file "$dst_path" deleted_files+=("$dst_path") fi return fi if [[ ! -f "$dst_path" ]]; then copy_file "$src_path" "$dst_path" return fi local src_hash local dst_hash src_hash=$(file_hash "$src_path") dst_hash=$(file_hash "$dst_path") if [[ "$src_hash" != "$dst_hash" ]]; then copy_file "$src_path" "$dst_path" fi } shopt -s globstar nullglob dotglob if [[ -f "$base_path" ]]; then sync_file "$base_path" elif [[ -d "$base_path" ]]; then for src_path in "$base_path"/**; do rel_path=${src_path#"$src_dir"/} if [[ "$rel_path" == ".git" || "$rel_path" == .git/* ]]; then continue fi # Check if this is a .delete directory - process its contents to delete in destination if [[ -d "$src_path" && "$(basename "$rel_path")" == ".delete" ]]; then # Get the parent directory of the .delete folder parent_rel_path=$(dirname "$rel_path") # Process all files in this .delete directory to delete corresponding files in destination for delete_marker in "$src_path"/**; do if [[ -e "$delete_marker" ]]; then # Get the basename of the marker (file/dir to delete) marker_basename=$(basename "$delete_marker") # Construct the target path in the parent directory target_rel_path="$parent_rel_path/$marker_basename" target_dst_path="$dest_dir/$target_rel_path" if [[ -d "$delete_marker" ]]; then if [[ -d "$target_dst_path" ]]; then delete_dir "$target_dst_path" fi elif [[ -f "$delete_marker" ]]; then if [[ -f "$target_dst_path" ]]; then delete_file "$target_dst_path" fi fi fi done # Delete the .delete folder in destination if it exists delete_dst_path="$dest_dir/$rel_path" if [[ -d "$delete_dst_path" ]]; then delete_dir "$delete_dst_path" fi continue fi # Skip anything inside a .delete directory - don't process or copy if is_in_delete_dir "$rel_path"; then continue fi dst_path="$dest_dir/$rel_path" if [[ -d "$src_path" ]]; then # Check if there's a .delete marker for this directory if check_delete_marker "$rel_path"; then if [[ -d "$dst_path" ]]; then delete_dir "$dst_path" deleted_dirs+=("$dst_path") fi continue fi if ! $dry_run; then mkdir -p "$dst_path" else echo "mkdir: $dst_path" fi continue fi if [[ ! -f "$src_path" ]]; then continue fi sync_file "$src_path" done fi if $dry_run; then echo "Dry run complete. Re-run without --dry-run to apply changes." else echo "Sync complete. Updated files where content differed." fi