start 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. #!/bin/bash
  2. # Dotfiles sync script
  3. set -euo pipefail
  4. # Configuration
  5. hostname_id=$(uname -n)
  6. checkout_dir=$HOME/.dofiles
  7. script_dir=$checkout_dir/dotfiles
  8. dots_dir=$checkout_dir/dotfiles/dots
  9. dots_host_dir=$checkout_dir/dotfiles/dots.$hostname_id
  10. repo="https://git.capella.pro/capella/dotfiles.git"
  11. ProgName=$(basename "$0")
  12. # Colors for output
  13. RED='\033[0;31m'
  14. GREEN='\033[0;32m'
  15. YELLOW='\033[1;33m'
  16. NC='\033[0m' # No Color
  17. # Logging functions
  18. log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
  19. log_warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; }
  20. log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
  21. # Get group names for the current host (sorted, one per line)
  22. get_groups() {
  23. local groups_file="$script_dir/groups.$hostname_id"
  24. if [[ -f "$groups_file" ]]; then
  25. grep -v '^\s*#' "$groups_file" | grep -v '^\s*$' | sort
  26. fi
  27. }
  28. # Get combined sync list (common + groups + host-specific, deduplicated)
  29. get_sync_list() {
  30. {
  31. cat "$script_dir/to_sync" 2>/dev/null
  32. while IFS= read -r group; do
  33. cat "$script_dir/to_sync.@$group" 2>/dev/null
  34. done < <(get_groups)
  35. cat "$script_dir/to_sync.$hostname_id" 2>/dev/null
  36. } | grep -v '^\s*$' | sort -u
  37. }
  38. # Resolve source path: common -> groups (alpha) -> host-specific (last wins)
  39. resolve_source() {
  40. local p="$1"
  41. local result="$dots_dir/$p"
  42. while IFS= read -r group; do
  43. if [[ -e "$script_dir/dots.@$group/$p" ]]; then
  44. result="$script_dir/dots.@$group/$p"
  45. fi
  46. done < <(get_groups)
  47. if [[ -e "$dots_host_dir/$p" ]]; then
  48. result="$dots_host_dir/$p"
  49. fi
  50. echo "$result"
  51. }
  52. # Check if a file contains @host or @group markers
  53. file_has_markers() {
  54. grep -qE '@(host|group) ' "$1" 2>/dev/null
  55. }
  56. # Detect the comment prefix from the first marker line in a file
  57. detect_comment_prefix() {
  58. local src="$1"
  59. local marker_line
  60. marker_line=$(grep -m1 -E '@(host|group) ' "$src")
  61. # Extract everything before @host or @group
  62. local prefix="${marker_line%%@*}"
  63. # Trim trailing whitespace
  64. prefix="${prefix%"${prefix##*[![:space:]]}"}"
  65. echo "$prefix"
  66. }
  67. # Process a file with @host/@group markers, outputting only matching sections
  68. process_markers() {
  69. local src="$1"
  70. local in_block=0
  71. local include_block=0
  72. # Pre-load groups into an associative array for fast lookup
  73. declare -A host_groups
  74. while IFS= read -r g; do
  75. host_groups["$g"]=1
  76. done < <(get_groups)
  77. while IFS= read -r line || [[ -n "$line" ]]; do
  78. if [[ $in_block -eq 0 ]]; then
  79. if [[ "$line" =~ @host[[:space:]]+([^[:space:]]+) ]]; then
  80. in_block=1
  81. if [[ "${BASH_REMATCH[1]}" == "$hostname_id" ]]; then
  82. include_block=1
  83. else
  84. include_block=0
  85. fi
  86. elif [[ "$line" =~ @group[[:space:]]+([^[:space:]]+) ]]; then
  87. in_block=1
  88. if [[ -v "host_groups[${BASH_REMATCH[1]}]" ]]; then
  89. include_block=1
  90. else
  91. include_block=0
  92. fi
  93. elif [[ "$line" =~ @end ]]; then
  94. # Stray @end outside a block, skip it
  95. continue
  96. else
  97. printf '%s\n' "$line"
  98. fi
  99. else
  100. if [[ "$line" =~ @end ]]; then
  101. in_block=0
  102. include_block=0
  103. elif [[ $include_block -eq 1 ]]; then
  104. printf '%s\n' "$line"
  105. fi
  106. fi
  107. done < "$src"
  108. if [[ $in_block -eq 1 ]]; then
  109. log_warn "Unclosed @host/@group block in $src"
  110. fi
  111. }
  112. # Ensure we cleanup on exit
  113. trap 'cd "$HOME"' EXIT
  114. # Create checkout directory if needed
  115. mkdir -p "$checkout_dir"
  116. sub_help(){
  117. local groups
  118. groups=$(get_groups | tr '\n' ', ' | sed 's/,$//')
  119. cat << EOF
  120. Usage: $ProgName <subcommand> [options]
  121. Subcommands:
  122. sync [--machine|--group <name>] [dot_file]
  123. Sync dotfiles (optionally add a new dotfile)
  124. --machine: add to host-specific config ($hostname_id)
  125. --group <name>: add to group-specific config
  126. list List currently synced dotfiles
  127. status Show git status of dotfiles repo
  128. Host: $hostname_id
  129. Groups: ${groups:-none}
  130. Resolution order: common -> groups (alpha) -> host-specific
  131. Common dotfiles: dots/ + to_sync
  132. Group dotfiles: dots.@<group>/ + to_sync.@<group>
  133. Host overrides: dots.$hostname_id/ + to_sync.$hostname_id
  134. In-file markers:
  135. # @host <name> ... # @end (host-specific block)
  136. # @group <name> ... # @end (group-specific block)
  137. Files with markers are processed and written (not symlinked).
  138. For help with each subcommand run:
  139. $ProgName <subcommand> -h|--help
  140. EOF
  141. }
  142. sub_sync(){
  143. # Clone repo if it doesn't exist
  144. if [[ ! -d "$checkout_dir/dotfiles/.git" ]]; then
  145. log_info "Cloning dotfiles repository..."
  146. cd "$checkout_dir"
  147. if ! git clone "$repo"; then
  148. log_error "Failed to clone repository"
  149. exit 1
  150. fi
  151. cd "$script_dir"
  152. git submodule init
  153. git submodule update
  154. fi
  155. # Navigate to script directory
  156. cd "$script_dir"
  157. # Get current branch
  158. current_branch=$(git branch --show-current)
  159. log_info "Pulling latest changes from $current_branch..."
  160. git pull origin "$current_branch" || log_warn "Git pull failed, continuing anyway"
  161. git submodule update --init --recursive
  162. # Parse flags for adding new dotfiles
  163. local machine_specific=0
  164. local group_target=""
  165. if [[ "${1:-}" == "--machine" ]]; then
  166. machine_specific=1
  167. shift
  168. elif [[ "${1:-}" == "--group" ]]; then
  169. group_target="${2:-}"
  170. if [[ -z "$group_target" ]]; then
  171. log_error "--group requires a group name"
  172. exit 1
  173. fi
  174. shift 2
  175. fi
  176. # Add new dotfile if provided
  177. if [ -n "${1:-}" ]; then
  178. local file_to_add="$1"
  179. # Validate that file exists
  180. if [[ ! -e "$HOME/$file_to_add" ]]; then
  181. log_error "File $HOME/$file_to_add does not exist"
  182. exit 1
  183. fi
  184. log_info "Adding new dotfile: $file_to_add"
  185. real_path=$(realpath "$HOME/$file_to_add")
  186. to_copy=${real_path#"$HOME/"}
  187. if [[ $machine_specific -eq 1 ]]; then
  188. target_dots_dir="$dots_host_dir"
  189. sync_file="$script_dir/to_sync.$hostname_id"
  190. log_info "Adding as host-specific ($hostname_id)"
  191. elif [[ -n "$group_target" ]]; then
  192. target_dots_dir="$script_dir/dots.@$group_target"
  193. sync_file="$script_dir/to_sync.@$group_target"
  194. log_info "Adding as group-specific (@$group_target)"
  195. else
  196. target_dots_dir="$dots_dir"
  197. sync_file="$script_dir/to_sync"
  198. fi
  199. # Create parent directory structure
  200. mkdir -p "$(dirname "$target_dots_dir/$to_copy")"
  201. # Copy to dots directory
  202. if cp -R "$real_path" "$target_dots_dir/$to_copy"; then
  203. log_info "Copied $file_to_add to $target_dots_dir/$to_copy"
  204. else
  205. log_error "Failed to copy $file_to_add"
  206. exit 1
  207. fi
  208. # Add to sync file if not already there
  209. if ! grep -qxF "$to_copy" "$sync_file" 2>/dev/null; then
  210. echo "$to_copy" >> "$sync_file"
  211. log_info "Added $to_copy to $(basename "$sync_file")"
  212. else
  213. log_info "$to_copy already in $(basename "$sync_file")"
  214. fi
  215. # Remove original and create symlink
  216. rm -rf "$real_path"
  217. ln -s "$target_dots_dir/$to_copy" "$real_path"
  218. log_info "Created symlink: $real_path -> $target_dots_dir/$to_copy"
  219. fi
  220. # Stage tracked files from all source directories
  221. log_info "Staging changes..."
  222. get_sync_list | while IFS= read -r p; do
  223. if [[ -e "$dots_dir/$p" ]]; then
  224. git add "$dots_dir/$p"
  225. fi
  226. while IFS= read -r group; do
  227. if [[ -e "$script_dir/dots.@$group/$p" ]]; then
  228. git add "$script_dir/dots.@$group/$p"
  229. fi
  230. done < <(get_groups)
  231. if [[ -e "$dots_host_dir/$p" ]]; then
  232. git add "$dots_host_dir/$p"
  233. fi
  234. done
  235. git add start to_sync
  236. # Stage host-specific, group-specific, and groups files
  237. for f in to_sync.* groups.*; do
  238. [[ -f "$f" ]] && git add "$f"
  239. done
  240. for d in dots.*/; do
  241. [[ -d "$d" ]] && git add "$d"
  242. done
  243. # Commit and push if there are changes
  244. if git diff --cached --quiet; then
  245. log_info "No changes to commit"
  246. else
  247. log_info "Committing changes..."
  248. if git commit -m "Sync: $(date '+%Y-%m-%d %H:%M:%S')"; then
  249. log_info "Pushing to remote..."
  250. git remote add origin "$repo" 2>/dev/null || true
  251. if git push -u origin "$current_branch"; then
  252. log_info "Successfully pushed changes"
  253. else
  254. log_error "Failed to push changes"
  255. exit 1
  256. fi
  257. else
  258. log_error "Failed to commit changes"
  259. exit 1
  260. fi
  261. fi
  262. # Deploy dotfiles (symlink or process markers)
  263. log_info "Deploying dotfiles for $hostname_id..."
  264. get_sync_list | while IFS= read -r p; do
  265. local source
  266. source=$(resolve_source "$p")
  267. local destination="$HOME/$p"
  268. if [[ ! -e "$source" ]]; then
  269. log_warn "Source file $source does not exist, skipping"
  270. continue
  271. fi
  272. mkdir -p "$(dirname "$destination")"
  273. if [[ -f "$source" ]] && file_has_markers "$source"; then
  274. # File has markers — process and write (not symlink)
  275. if [[ -L "$destination" ]]; then
  276. rm "$destination"
  277. fi
  278. local comment_prefix
  279. comment_prefix=$(detect_comment_prefix "$source")
  280. {
  281. echo "$comment_prefix DO NOT EDIT - Generated by dotsync from:"
  282. echo "$comment_prefix $source"
  283. echo ""
  284. process_markers "$source"
  285. } > "$destination.dotsync_tmp"
  286. # Only update if content changed
  287. if [[ -f "$destination" ]] && cmp -s "$destination" "$destination.dotsync_tmp"; then
  288. rm "$destination.dotsync_tmp"
  289. else
  290. [[ -e "$destination" ]] && rm "$destination"
  291. mv "$destination.dotsync_tmp" "$destination"
  292. chmod --reference="$source" "$destination"
  293. log_info "Processed: $destination (from $source)"
  294. fi
  295. else
  296. # No markers — symlink as before
  297. if [[ -L "$destination" ]] && [[ "$(readlink "$destination")" == "$source" ]]; then
  298. continue # Already correctly linked
  299. fi
  300. if [[ -e "$destination" ]] || [[ -L "$destination" ]]; then
  301. log_warn "Removing existing $destination"
  302. rm -rf "$destination"
  303. fi
  304. if ln -s "$source" "$destination"; then
  305. log_info "Linked: $destination -> $source"
  306. else
  307. log_error "Failed to create symlink for $p"
  308. fi
  309. fi
  310. done
  311. log_info "Sync complete!"
  312. }
  313. sub_list(){
  314. echo "Host: $hostname_id"
  315. local groups
  316. groups=$(get_groups | tr '\n' ', ' | sed 's/,$//')
  317. if [[ -n "$groups" ]]; then
  318. echo "Groups: $groups"
  319. fi
  320. echo ""
  321. if [[ -f "$script_dir/to_sync" ]]; then
  322. echo "Common dotfiles (to_sync):"
  323. while IFS= read -r p; do
  324. [[ -z "$p" ]] && continue
  325. local source
  326. source=$(resolve_source "$p")
  327. local tag=""
  328. if [[ "$source" == "$dots_host_dir/"* ]]; then
  329. tag=" [override: $hostname_id]"
  330. elif [[ "$source" == *"/dots.@"* ]]; then
  331. local gname
  332. gname=$(echo "$source" | sed 's|.*dots\.@\([^/]*\)/.*|\1|')
  333. tag=" [override: @$gname]"
  334. fi
  335. local marker_tag=""
  336. if [[ -f "$source" ]] && file_has_markers "$source"; then
  337. marker_tag=" [processed]"
  338. fi
  339. if [[ -L "$HOME/$p" ]] || [[ -f "$HOME/$p" ]] || [[ -d "$HOME/$p" ]]; then
  340. echo " + $p$tag$marker_tag"
  341. else
  342. echo " - $p (not deployed)$tag$marker_tag"
  343. fi
  344. done < "$script_dir/to_sync"
  345. fi
  346. # Group-specific dotfiles
  347. while IFS= read -r group; do
  348. local gsync="$script_dir/to_sync.@$group"
  349. if [[ -f "$gsync" ]] && [[ -s "$gsync" ]]; then
  350. echo ""
  351. echo "Group dotfiles (to_sync.@$group):"
  352. while IFS= read -r p; do
  353. [[ -z "$p" ]] && continue
  354. if [[ -L "$HOME/$p" ]] || [[ -f "$HOME/$p" ]] || [[ -d "$HOME/$p" ]]; then
  355. echo " + $p"
  356. else
  357. echo " - $p (not deployed)"
  358. fi
  359. done < "$gsync"
  360. fi
  361. done < <(get_groups)
  362. # Host-specific dotfiles
  363. if [[ -f "$script_dir/to_sync.$hostname_id" ]] && [[ -s "$script_dir/to_sync.$hostname_id" ]]; then
  364. echo ""
  365. echo "Host-specific dotfiles (to_sync.$hostname_id):"
  366. while IFS= read -r p; do
  367. [[ -z "$p" ]] && continue
  368. if [[ -L "$HOME/$p" ]] || [[ -f "$HOME/$p" ]] || [[ -d "$HOME/$p" ]]; then
  369. echo " + $p"
  370. else
  371. echo " - $p (not deployed)"
  372. fi
  373. done < "$script_dir/to_sync.$hostname_id"
  374. fi
  375. }
  376. sub_status(){
  377. if [[ ! -d "$script_dir/.git" ]]; then
  378. log_error "Dotfiles repository not found"
  379. exit 1
  380. fi
  381. cd "$script_dir"
  382. log_info "Host: $hostname_id"
  383. log_info "Git status:"
  384. git status
  385. }
  386. subcommand=${1:-}
  387. case $subcommand in
  388. "" | "-h" | "--help")
  389. sub_help
  390. ;;
  391. sync|list|status)
  392. shift
  393. "sub_${subcommand}" "$@"
  394. ;;
  395. *)
  396. log_error "'$subcommand' is not a known subcommand."
  397. echo " Run '$ProgName --help' for a list of known subcommands." >&2
  398. exit 1
  399. ;;
  400. esac