start 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629
  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 || true
  32. while IFS= read -r group; do
  33. cat "$script_dir/to_sync.@$group" 2>/dev/null || true
  34. done < <(get_groups)
  35. cat "$script_dir/to_sync.$hostname_id" 2>/dev/null || true
  36. } | grep -v '^\s*$' | sort -u || true
  37. }
  38. # Get combined package list (common + groups + host-specific, deduplicated).
  39. # Unlike dotfiles, package lists are ADDITIVE: every applicable list is unioned
  40. # rather than overridden, so a host list adds to the common one, never replaces it.
  41. get_pkg_list() {
  42. {
  43. cat "$script_dir/pkglist" 2>/dev/null || true
  44. while IFS= read -r group; do
  45. cat "$script_dir/pkglist.@$group" 2>/dev/null || true
  46. done < <(get_groups)
  47. cat "$script_dir/pkglist.$hostname_id" 2>/dev/null || true
  48. } | grep -v '^\s*#' | grep -v '^\s*$' | sort -u || true
  49. }
  50. # Explicitly-installed packages on this host.
  51. # -Qqe (explicit) keeps AUR *dependencies* out of the list; they get pulled in
  52. # automatically by their parent. The -debug filter drops artifacts makepkg
  53. # generates when building locally -- they only exist on the machine that built
  54. # them and mostly aren't installable elsewhere.
  55. installed_packages() {
  56. yay -Qqe | grep -v -- '-debug$' | sort -u || true
  57. }
  58. # Resolve source path: common -> groups (alpha) -> host-specific (last wins)
  59. resolve_source() {
  60. local p="$1"
  61. local result="$dots_dir/$p"
  62. while IFS= read -r group; do
  63. if [[ -e "$script_dir/dots.@$group/$p" ]]; then
  64. result="$script_dir/dots.@$group/$p"
  65. fi
  66. done < <(get_groups)
  67. if [[ -e "$dots_host_dir/$p" ]]; then
  68. result="$dots_host_dir/$p"
  69. fi
  70. echo "$result"
  71. }
  72. # Check if a file contains @host or @group markers
  73. file_has_markers() {
  74. grep -qE '@(host|group) ' "$1" 2>/dev/null
  75. }
  76. # Check if any file in a directory tree contains markers
  77. dir_has_markers() {
  78. grep -rqlE '@(host|group) ' "$1" 2>/dev/null
  79. }
  80. # Detect the comment style from the first marker line in a file
  81. # Returns "block" for /* */ style, or the line-comment prefix (# // ; --)
  82. detect_comment_style() {
  83. local src="$1"
  84. local marker_line
  85. marker_line=$(grep -m1 -E '@(host|group) ' "$src")
  86. # Extract everything before @host or @group
  87. local prefix="${marker_line%%@*}"
  88. # Trim trailing whitespace
  89. prefix="${prefix%"${prefix##*[![:space:]]}"}"
  90. # Check if it's a block comment opener
  91. if [[ "$prefix" == "/*" ]]; then
  92. echo "block"
  93. else
  94. echo "$prefix"
  95. fi
  96. }
  97. # Process a file with @host/@group markers, outputting only matching sections
  98. process_markers() {
  99. local src="$1"
  100. local in_block=0
  101. local include_block=0
  102. # Pre-load groups into an associative array for fast lookup
  103. declare -A host_groups
  104. while IFS= read -r g; do
  105. host_groups["$g"]=1
  106. done < <(get_groups)
  107. while IFS= read -r line || [[ -n "$line" ]]; do
  108. if [[ $in_block -eq 0 ]]; then
  109. if [[ "$line" =~ @host[[:space:]]+([^[:space:]]+) ]]; then
  110. in_block=1
  111. if [[ "${BASH_REMATCH[1]}" == "$hostname_id" ]]; then
  112. include_block=1
  113. else
  114. include_block=0
  115. fi
  116. elif [[ "$line" =~ @group[[:space:]]+([^[:space:]]+) ]]; then
  117. in_block=1
  118. if [[ -v "host_groups[${BASH_REMATCH[1]}]" ]]; then
  119. include_block=1
  120. else
  121. include_block=0
  122. fi
  123. elif [[ "$line" =~ @end ]]; then
  124. # Stray @end outside a block, skip it
  125. continue
  126. else
  127. printf '%s\n' "$line"
  128. fi
  129. else
  130. if [[ "$line" =~ @end ]]; then
  131. in_block=0
  132. include_block=0
  133. elif [[ $include_block -eq 1 ]]; then
  134. printf '%s\n' "$line"
  135. fi
  136. fi
  137. done < "$src"
  138. if [[ $in_block -eq 1 ]]; then
  139. log_error "Unclosed @host/@group block in $src"
  140. return 1
  141. fi
  142. }
  143. # Ensure we cleanup on exit
  144. trap 'cd "$HOME"' EXIT
  145. # Create checkout directory if needed
  146. mkdir -p "$checkout_dir"
  147. sub_help(){
  148. local groups
  149. groups=$(get_groups | tr '\n' ', ' | sed 's/,$//')
  150. cat << EOF
  151. Usage: $ProgName <subcommand> [options]
  152. Subcommands:
  153. sync [--machine|--group <name>] [dot_file]
  154. Sync dotfiles (optionally add a new dotfile)
  155. --machine: add to host-specific config ($hostname_id)
  156. --group <name>: add to group-specific config
  157. list List currently synced dotfiles
  158. status Show git status of dotfiles repo
  159. packages [install|diff|save] Sync packages (additive: pkglist + pkglist.@<group>
  160. + pkglist.$hostname_id). Not run by 'sync'.
  161. Host: $hostname_id
  162. Groups: ${groups:-none}
  163. Resolution order: common -> groups (alpha) -> host-specific
  164. Common dotfiles: dots/ + to_sync
  165. Group dotfiles: dots.@<group>/ + to_sync.@<group>
  166. Host overrides: dots.$hostname_id/ + to_sync.$hostname_id
  167. In-file markers:
  168. # @host <name> ... # @end (host-specific block)
  169. # @group <name> ... # @end (group-specific block)
  170. Files with markers are processed and written (not symlinked).
  171. For help with each subcommand run:
  172. $ProgName <subcommand> -h|--help
  173. EOF
  174. }
  175. sub_sync(){
  176. # Clone repo if it doesn't exist
  177. if [[ ! -d "$checkout_dir/dotfiles/.git" ]]; then
  178. log_info "Cloning dotfiles repository..."
  179. cd "$checkout_dir"
  180. if ! git clone "$repo"; then
  181. log_error "Failed to clone repository"
  182. exit 1
  183. fi
  184. cd "$script_dir"
  185. git submodule init
  186. git submodule update
  187. fi
  188. # Navigate to script directory
  189. cd "$script_dir"
  190. # Get current branch
  191. current_branch=$(git branch --show-current)
  192. log_info "Pulling latest changes from $current_branch..."
  193. git pull origin "$current_branch"
  194. git submodule update --init --recursive
  195. # Parse flags for adding new dotfiles
  196. local machine_specific=0
  197. local group_target=""
  198. if [[ "${1:-}" == "--machine" ]]; then
  199. machine_specific=1
  200. shift
  201. elif [[ "${1:-}" == "--group" ]]; then
  202. group_target="${2:-}"
  203. if [[ -z "$group_target" ]]; then
  204. log_error "--group requires a group name"
  205. exit 1
  206. fi
  207. shift 2
  208. fi
  209. # Add new dotfile if provided
  210. if [ -n "${1:-}" ]; then
  211. local file_to_add="$1"
  212. # Validate that file exists
  213. if [[ ! -e "$HOME/$file_to_add" ]]; then
  214. log_error "File $HOME/$file_to_add does not exist"
  215. exit 1
  216. fi
  217. log_info "Adding new dotfile: $file_to_add"
  218. real_path=$(realpath "$HOME/$file_to_add")
  219. to_copy=${real_path#"$HOME/"}
  220. if [[ $machine_specific -eq 1 ]]; then
  221. target_dots_dir="$dots_host_dir"
  222. sync_file="$script_dir/to_sync.$hostname_id"
  223. log_info "Adding as host-specific ($hostname_id)"
  224. elif [[ -n "$group_target" ]]; then
  225. target_dots_dir="$script_dir/dots.@$group_target"
  226. sync_file="$script_dir/to_sync.@$group_target"
  227. log_info "Adding as group-specific (@$group_target)"
  228. else
  229. target_dots_dir="$dots_dir"
  230. sync_file="$script_dir/to_sync"
  231. fi
  232. # Create parent directory structure
  233. mkdir -p "$(dirname "$target_dots_dir/$to_copy")"
  234. # Copy to dots directory
  235. if cp -R "$real_path" "$target_dots_dir/$to_copy"; then
  236. log_info "Copied $file_to_add to $target_dots_dir/$to_copy"
  237. else
  238. log_error "Failed to copy $file_to_add"
  239. exit 1
  240. fi
  241. # Add to sync file if not already there
  242. if ! grep -qxF "$to_copy" "$sync_file" 2>/dev/null; then
  243. echo "$to_copy" >> "$sync_file"
  244. log_info "Added $to_copy to $(basename "$sync_file")"
  245. else
  246. log_info "$to_copy already in $(basename "$sync_file")"
  247. fi
  248. # Remove original and create symlink
  249. rm -rf "$real_path"
  250. ln -s "$target_dots_dir/$to_copy" "$real_path"
  251. log_info "Created symlink: $real_path -> $target_dots_dir/$to_copy"
  252. fi
  253. # Stage tracked files from all source directories
  254. log_info "Staging changes..."
  255. get_sync_list | while IFS= read -r p; do
  256. if [[ -e "$dots_dir/$p" ]]; then
  257. git add "$dots_dir/$p"
  258. fi
  259. while IFS= read -r group; do
  260. if [[ -e "$script_dir/dots.@$group/$p" ]]; then
  261. git add "$script_dir/dots.@$group/$p"
  262. fi
  263. done < <(get_groups)
  264. if [[ -e "$dots_host_dir/$p" ]]; then
  265. git add "$dots_host_dir/$p"
  266. fi
  267. done
  268. git add start to_sync
  269. for f in bootstrap README.md; do
  270. [[ -f "$f" ]] && git add "$f" || true
  271. done
  272. # Stage host-specific, group-specific, and groups files, plus package lists
  273. for f in to_sync.* groups.* pkglist pkglist.*; do
  274. [[ -f "$f" ]] && git add "$f" || true
  275. done
  276. for d in dots.*/; do
  277. [[ -d "$d" ]] && git add "$d"
  278. done
  279. # Commit and push if there are changes
  280. if git diff --cached --quiet; then
  281. log_info "No changes to commit"
  282. else
  283. log_info "Committing changes..."
  284. if git commit -m "Sync: $(date '+%Y-%m-%d %H:%M:%S')"; then
  285. log_info "Pushing to remote..."
  286. git remote add origin "$repo" 2>/dev/null || true
  287. if git push -u origin "$current_branch"; then
  288. log_info "Successfully pushed changes"
  289. else
  290. log_error "Failed to push changes"
  291. exit 1
  292. fi
  293. else
  294. log_error "Failed to commit changes"
  295. exit 1
  296. fi
  297. fi
  298. # Deploy a single file: process markers or symlink
  299. deploy_file() {
  300. local src="$1"
  301. local dest="$2"
  302. mkdir -p "$(dirname "$dest")"
  303. if file_has_markers "$src"; then
  304. # File has markers — process and write (not symlink)
  305. if [[ -L "$dest" ]]; then
  306. rm "$dest"
  307. fi
  308. local comment_style
  309. comment_style=$(detect_comment_style "$src")
  310. {
  311. if [[ "$comment_style" == "block" ]]; then
  312. echo "/* DO NOT EDIT - Generated by dotsync from: $src"
  313. echo " Regenerate with: dotsync */"
  314. else
  315. echo "$comment_style DO NOT EDIT - Generated by dotsync from:"
  316. echo "$comment_style $src"
  317. echo "$comment_style Regenerate with: dotsync"
  318. fi
  319. echo ""
  320. process_markers "$src"
  321. } > "$dest.dotsync_tmp"
  322. # Only update if content changed
  323. if [[ -f "$dest" ]] && cmp -s "$dest" "$dest.dotsync_tmp"; then
  324. rm "$dest.dotsync_tmp"
  325. else
  326. [[ -e "$dest" ]] && rm "$dest"
  327. mv "$dest.dotsync_tmp" "$dest"
  328. chmod --reference="$src" "$dest"
  329. log_info "Processed: $dest (from $src)"
  330. fi
  331. else
  332. # No markers — symlink
  333. if [[ -L "$dest" ]] && [[ "$(readlink "$dest")" == "$src" ]]; then
  334. return # Already correctly linked
  335. fi
  336. if [[ -e "$dest" ]] || [[ -L "$dest" ]]; then
  337. log_warn "Removing existing $dest"
  338. rm -rf "$dest"
  339. fi
  340. if ln -s "$src" "$dest"; then
  341. log_info "Linked: $dest -> $src"
  342. else
  343. log_error "Failed to create symlink for $(basename "$dest")"
  344. return 1
  345. fi
  346. fi
  347. }
  348. # Deploy dotfiles (symlink or process markers)
  349. log_info "Deploying dotfiles for $hostname_id..."
  350. get_sync_list | while IFS= read -r p; do
  351. local source
  352. source=$(resolve_source "$p")
  353. local destination="$HOME/$p"
  354. if [[ ! -e "$source" ]]; then
  355. log_warn "Source $source does not exist, skipping"
  356. continue
  357. fi
  358. if [[ -d "$source" ]]; then
  359. if dir_has_markers "$source"; then
  360. # Directory contains markers — expand into per-file operations
  361. # Remove directory-level symlink if present
  362. if [[ -L "$destination" ]]; then
  363. log_warn "Replacing directory symlink $destination with expanded files"
  364. rm "$destination"
  365. fi
  366. mkdir -p "$destination"
  367. # Walk all files in the source directory
  368. while IFS= read -r src_file; do
  369. local rel="${src_file#"$source/"}"
  370. deploy_file "$src_file" "$destination/$rel"
  371. done < <(find "$source" -type f | sort)
  372. else
  373. # No markers in directory — symlink the whole directory
  374. if [[ -L "$destination" ]] && [[ "$(readlink "$destination")" == "$source" ]]; then
  375. continue # Already correctly linked
  376. fi
  377. if [[ -e "$destination" ]] || [[ -L "$destination" ]]; then
  378. log_warn "Removing existing $destination"
  379. rm -rf "$destination"
  380. fi
  381. mkdir -p "$(dirname "$destination")"
  382. if ln -s "$source" "$destination"; then
  383. log_info "Linked: $destination -> $source"
  384. else
  385. log_error "Failed to create symlink for $p"
  386. exit 1
  387. fi
  388. fi
  389. else
  390. # Single file entry
  391. deploy_file "$source" "$destination"
  392. fi
  393. done
  394. log_info "Sync complete!"
  395. }
  396. sub_list(){
  397. echo "Host: $hostname_id"
  398. local groups
  399. groups=$(get_groups | tr '\n' ', ' | sed 's/,$//')
  400. if [[ -n "$groups" ]]; then
  401. echo "Groups: $groups"
  402. fi
  403. echo ""
  404. if [[ -f "$script_dir/to_sync" ]]; then
  405. echo "Common dotfiles (to_sync):"
  406. while IFS= read -r p; do
  407. [[ -z "$p" ]] && continue
  408. local source
  409. source=$(resolve_source "$p")
  410. local tag=""
  411. if [[ "$source" == "$dots_host_dir/"* ]]; then
  412. tag=" [override: $hostname_id]"
  413. elif [[ "$source" == *"/dots.@"* ]]; then
  414. local gname
  415. gname=$(echo "$source" | sed 's|.*dots\.@\([^/]*\)/.*|\1|')
  416. tag=" [override: @$gname]"
  417. fi
  418. local marker_tag=""
  419. if [[ -f "$source" ]] && file_has_markers "$source"; then
  420. marker_tag=" [processed]"
  421. fi
  422. if [[ -L "$HOME/$p" ]] || [[ -f "$HOME/$p" ]] || [[ -d "$HOME/$p" ]]; then
  423. echo " + $p$tag$marker_tag"
  424. else
  425. echo " - $p (not deployed)$tag$marker_tag"
  426. fi
  427. done < "$script_dir/to_sync"
  428. fi
  429. # Group-specific dotfiles
  430. while IFS= read -r group; do
  431. local gsync="$script_dir/to_sync.@$group"
  432. if [[ -f "$gsync" ]] && [[ -s "$gsync" ]]; then
  433. echo ""
  434. echo "Group dotfiles (to_sync.@$group):"
  435. while IFS= read -r p; do
  436. [[ -z "$p" ]] && continue
  437. if [[ -L "$HOME/$p" ]] || [[ -f "$HOME/$p" ]] || [[ -d "$HOME/$p" ]]; then
  438. echo " + $p"
  439. else
  440. echo " - $p (not deployed)"
  441. fi
  442. done < "$gsync"
  443. fi
  444. done < <(get_groups)
  445. # Host-specific dotfiles
  446. if [[ -f "$script_dir/to_sync.$hostname_id" ]] && [[ -s "$script_dir/to_sync.$hostname_id" ]]; then
  447. echo ""
  448. echo "Host-specific dotfiles (to_sync.$hostname_id):"
  449. while IFS= read -r p; do
  450. [[ -z "$p" ]] && continue
  451. if [[ -L "$HOME/$p" ]] || [[ -f "$HOME/$p" ]] || [[ -d "$HOME/$p" ]]; then
  452. echo " + $p"
  453. else
  454. echo " - $p (not deployed)"
  455. fi
  456. done < "$script_dir/to_sync.$hostname_id"
  457. fi
  458. }
  459. sub_status(){
  460. if [[ ! -d "$script_dir/.git" ]]; then
  461. log_error "Dotfiles repository not found"
  462. exit 1
  463. fi
  464. cd "$script_dir"
  465. log_info "Host: $hostname_id"
  466. log_info "Git status:"
  467. git status
  468. }
  469. sub_packages(){
  470. local action=${1:-install}
  471. if [[ "$action" == "-h" || "$action" == "--help" ]]; then
  472. cat << EOF
  473. Usage: $ProgName packages [install|diff|save]
  474. install Install tracked packages missing on this host (default)
  475. diff Show drift in both directions, change nothing
  476. save Write this host's explicit packages to pkglist.$hostname_id
  477. Lists are additive and resolved as:
  478. pkglist + pkglist.@<group> + pkglist.$hostname_id
  479. Not run by '$ProgName sync': installing needs sudo and takes time, whereas
  480. sync is unprivileged and fast. Keep them separate.
  481. EOF
  482. return 0
  483. fi
  484. local tracked installed missing extra
  485. tracked=$(get_pkg_list)
  486. if [[ -z "$tracked" ]]; then
  487. log_error "No package lists found (pkglist, pkglist.@<group>, pkglist.$hostname_id)"
  488. log_error "Seed one with: $ProgName packages save"
  489. return 1
  490. fi
  491. installed=$(installed_packages)
  492. missing=$(comm -23 <(echo "$tracked") <(echo "$installed"))
  493. extra=$(comm -13 <(echo "$tracked") <(echo "$installed"))
  494. case $action in
  495. install)
  496. if [[ -z "$missing" ]]; then
  497. log_info "All $(echo "$tracked" | wc -l) tracked packages already installed"
  498. return 0
  499. fi
  500. log_info "Installing $(echo "$missing" | wc -l) missing package(s):"
  501. echo "$missing" | sed 's/^/ /'
  502. # Build an argv array and invoke yay directly -- no pipe, no xargs.
  503. # yay is interactive (cleanBuild, conflict and diff prompts), so it
  504. # needs to inherit the terminal's stdin. Piping to it steals stdin;
  505. # xargs is worse, because it points the child's stdin at /dev/null
  506. # and every prompt immediately reads EOF.
  507. local -a pkgs
  508. mapfile -t pkgs <<< "$missing"
  509. yay -S --needed "${pkgs[@]}"
  510. ;;
  511. diff)
  512. if [[ -n "$missing" ]]; then
  513. log_warn "Tracked but NOT installed ($(echo "$missing" | wc -l)):"
  514. echo "$missing" | sed 's/^/ /'
  515. fi
  516. if [[ -n "$extra" ]]; then
  517. log_warn "Installed but NOT tracked ($(echo "$extra" | wc -l)):"
  518. echo "$extra" | sed 's/^/ /'
  519. fi
  520. if [[ -z "$missing" && -z "$extra" ]]; then
  521. log_info "In sync: $(echo "$tracked" | wc -l) packages"
  522. fi
  523. ;;
  524. save)
  525. echo "$installed" > "$script_dir/pkglist.$hostname_id"
  526. log_info "Wrote $(echo "$installed" | wc -l) packages to pkglist.$hostname_id"
  527. log_warn "Move entries shared with other hosts into pkglist so they are not duplicated"
  528. ;;
  529. *)
  530. log_error "Unknown packages action: '$action'"
  531. log_error "Run '$ProgName packages --help' for usage."
  532. return 1
  533. ;;
  534. esac
  535. }
  536. subcommand=${1:-}
  537. case $subcommand in
  538. "" | "-h" | "--help")
  539. sub_help
  540. ;;
  541. sync|list|status|packages)
  542. shift
  543. "sub_${subcommand}" "$@"
  544. ;;
  545. *)
  546. log_error "'$subcommand' is not a known subcommand."
  547. echo " Run '$ProgName --help' for a list of known subcommands." >&2
  548. exit 1
  549. ;;
  550. esac