start 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  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. # 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. # Check if any file in a directory tree contains markers
  57. dir_has_markers() {
  58. grep -rqlE '@(host|group) ' "$1" 2>/dev/null
  59. }
  60. # Detect the comment style from the first marker line in a file
  61. # Returns "block" for /* */ style, or the line-comment prefix (# // ; --)
  62. detect_comment_style() {
  63. local src="$1"
  64. local marker_line
  65. marker_line=$(grep -m1 -E '@(host|group) ' "$src")
  66. # Extract everything before @host or @group
  67. local prefix="${marker_line%%@*}"
  68. # Trim trailing whitespace
  69. prefix="${prefix%"${prefix##*[![:space:]]}"}"
  70. # Check if it's a block comment opener
  71. if [[ "$prefix" == "/*" ]]; then
  72. echo "block"
  73. else
  74. echo "$prefix"
  75. fi
  76. }
  77. # Process a file with @host/@group markers, outputting only matching sections
  78. process_markers() {
  79. local src="$1"
  80. local in_block=0
  81. local include_block=0
  82. # Pre-load groups into an associative array for fast lookup
  83. declare -A host_groups
  84. while IFS= read -r g; do
  85. host_groups["$g"]=1
  86. done < <(get_groups)
  87. while IFS= read -r line || [[ -n "$line" ]]; do
  88. if [[ $in_block -eq 0 ]]; then
  89. if [[ "$line" =~ @host[[:space:]]+([^[:space:]]+) ]]; then
  90. in_block=1
  91. if [[ "${BASH_REMATCH[1]}" == "$hostname_id" ]]; then
  92. include_block=1
  93. else
  94. include_block=0
  95. fi
  96. elif [[ "$line" =~ @group[[:space:]]+([^[:space:]]+) ]]; then
  97. in_block=1
  98. if [[ -v "host_groups[${BASH_REMATCH[1]}]" ]]; then
  99. include_block=1
  100. else
  101. include_block=0
  102. fi
  103. elif [[ "$line" =~ @end ]]; then
  104. # Stray @end outside a block, skip it
  105. continue
  106. else
  107. printf '%s\n' "$line"
  108. fi
  109. else
  110. if [[ "$line" =~ @end ]]; then
  111. in_block=0
  112. include_block=0
  113. elif [[ $include_block -eq 1 ]]; then
  114. printf '%s\n' "$line"
  115. fi
  116. fi
  117. done < "$src"
  118. if [[ $in_block -eq 1 ]]; then
  119. log_error "Unclosed @host/@group block in $src"
  120. return 1
  121. fi
  122. }
  123. # Ensure we cleanup on exit
  124. trap 'cd "$HOME"' EXIT
  125. # Create checkout directory if needed
  126. mkdir -p "$checkout_dir"
  127. sub_help(){
  128. local groups
  129. groups=$(get_groups | tr '\n' ', ' | sed 's/,$//')
  130. cat << EOF
  131. Usage: $ProgName <subcommand> [options]
  132. Subcommands:
  133. sync [--machine|--group <name>] [dot_file]
  134. Sync dotfiles (optionally add a new dotfile)
  135. --machine: add to host-specific config ($hostname_id)
  136. --group <name>: add to group-specific config
  137. list List currently synced dotfiles
  138. status Show git status of dotfiles repo
  139. Host: $hostname_id
  140. Groups: ${groups:-none}
  141. Resolution order: common -> groups (alpha) -> host-specific
  142. Common dotfiles: dots/ + to_sync
  143. Group dotfiles: dots.@<group>/ + to_sync.@<group>
  144. Host overrides: dots.$hostname_id/ + to_sync.$hostname_id
  145. In-file markers:
  146. # @host <name> ... # @end (host-specific block)
  147. # @group <name> ... # @end (group-specific block)
  148. Files with markers are processed and written (not symlinked).
  149. For help with each subcommand run:
  150. $ProgName <subcommand> -h|--help
  151. EOF
  152. }
  153. sub_sync(){
  154. # Clone repo if it doesn't exist
  155. if [[ ! -d "$checkout_dir/dotfiles/.git" ]]; then
  156. log_info "Cloning dotfiles repository..."
  157. cd "$checkout_dir"
  158. if ! git clone "$repo"; then
  159. log_error "Failed to clone repository"
  160. exit 1
  161. fi
  162. cd "$script_dir"
  163. git submodule init
  164. git submodule update
  165. fi
  166. # Navigate to script directory
  167. cd "$script_dir"
  168. # Get current branch
  169. current_branch=$(git branch --show-current)
  170. log_info "Pulling latest changes from $current_branch..."
  171. git pull origin "$current_branch"
  172. git submodule update --init --recursive
  173. # Parse flags for adding new dotfiles
  174. local machine_specific=0
  175. local group_target=""
  176. if [[ "${1:-}" == "--machine" ]]; then
  177. machine_specific=1
  178. shift
  179. elif [[ "${1:-}" == "--group" ]]; then
  180. group_target="${2:-}"
  181. if [[ -z "$group_target" ]]; then
  182. log_error "--group requires a group name"
  183. exit 1
  184. fi
  185. shift 2
  186. fi
  187. # Add new dotfile if provided
  188. if [ -n "${1:-}" ]; then
  189. local file_to_add="$1"
  190. # Validate that file exists
  191. if [[ ! -e "$HOME/$file_to_add" ]]; then
  192. log_error "File $HOME/$file_to_add does not exist"
  193. exit 1
  194. fi
  195. log_info "Adding new dotfile: $file_to_add"
  196. real_path=$(realpath "$HOME/$file_to_add")
  197. to_copy=${real_path#"$HOME/"}
  198. if [[ $machine_specific -eq 1 ]]; then
  199. target_dots_dir="$dots_host_dir"
  200. sync_file="$script_dir/to_sync.$hostname_id"
  201. log_info "Adding as host-specific ($hostname_id)"
  202. elif [[ -n "$group_target" ]]; then
  203. target_dots_dir="$script_dir/dots.@$group_target"
  204. sync_file="$script_dir/to_sync.@$group_target"
  205. log_info "Adding as group-specific (@$group_target)"
  206. else
  207. target_dots_dir="$dots_dir"
  208. sync_file="$script_dir/to_sync"
  209. fi
  210. # Create parent directory structure
  211. mkdir -p "$(dirname "$target_dots_dir/$to_copy")"
  212. # Copy to dots directory
  213. if cp -R "$real_path" "$target_dots_dir/$to_copy"; then
  214. log_info "Copied $file_to_add to $target_dots_dir/$to_copy"
  215. else
  216. log_error "Failed to copy $file_to_add"
  217. exit 1
  218. fi
  219. # Add to sync file if not already there
  220. if ! grep -qxF "$to_copy" "$sync_file" 2>/dev/null; then
  221. echo "$to_copy" >> "$sync_file"
  222. log_info "Added $to_copy to $(basename "$sync_file")"
  223. else
  224. log_info "$to_copy already in $(basename "$sync_file")"
  225. fi
  226. # Remove original and create symlink
  227. rm -rf "$real_path"
  228. ln -s "$target_dots_dir/$to_copy" "$real_path"
  229. log_info "Created symlink: $real_path -> $target_dots_dir/$to_copy"
  230. fi
  231. # Stage tracked files from all source directories
  232. log_info "Staging changes..."
  233. get_sync_list | while IFS= read -r p; do
  234. if [[ -e "$dots_dir/$p" ]]; then
  235. git add "$dots_dir/$p"
  236. fi
  237. while IFS= read -r group; do
  238. if [[ -e "$script_dir/dots.@$group/$p" ]]; then
  239. git add "$script_dir/dots.@$group/$p"
  240. fi
  241. done < <(get_groups)
  242. if [[ -e "$dots_host_dir/$p" ]]; then
  243. git add "$dots_host_dir/$p"
  244. fi
  245. done
  246. git add start to_sync
  247. # Stage host-specific, group-specific, and groups files
  248. for f in to_sync.* groups.*; do
  249. [[ -f "$f" ]] && git add "$f"
  250. done
  251. for d in dots.*/; do
  252. [[ -d "$d" ]] && git add "$d"
  253. done
  254. # Commit and push if there are changes
  255. if git diff --cached --quiet; then
  256. log_info "No changes to commit"
  257. else
  258. log_info "Committing changes..."
  259. if git commit -m "Sync: $(date '+%Y-%m-%d %H:%M:%S')"; then
  260. log_info "Pushing to remote..."
  261. git remote add origin "$repo" 2>/dev/null || true
  262. if git push -u origin "$current_branch"; then
  263. log_info "Successfully pushed changes"
  264. else
  265. log_error "Failed to push changes"
  266. exit 1
  267. fi
  268. else
  269. log_error "Failed to commit changes"
  270. exit 1
  271. fi
  272. fi
  273. # Deploy a single file: process markers or symlink
  274. deploy_file() {
  275. local src="$1"
  276. local dest="$2"
  277. mkdir -p "$(dirname "$dest")"
  278. if file_has_markers "$src"; then
  279. # File has markers — process and write (not symlink)
  280. if [[ -L "$dest" ]]; then
  281. rm "$dest"
  282. fi
  283. local comment_style
  284. comment_style=$(detect_comment_style "$src")
  285. {
  286. if [[ "$comment_style" == "block" ]]; then
  287. echo "/* DO NOT EDIT - Generated by dotsync from: $src"
  288. echo " Regenerate with: dotsync */"
  289. else
  290. echo "$comment_style DO NOT EDIT - Generated by dotsync from:"
  291. echo "$comment_style $src"
  292. echo "$comment_style Regenerate with: dotsync"
  293. fi
  294. echo ""
  295. process_markers "$src"
  296. } > "$dest.dotsync_tmp"
  297. # Only update if content changed
  298. if [[ -f "$dest" ]] && cmp -s "$dest" "$dest.dotsync_tmp"; then
  299. rm "$dest.dotsync_tmp"
  300. else
  301. [[ -e "$dest" ]] && rm "$dest"
  302. mv "$dest.dotsync_tmp" "$dest"
  303. chmod --reference="$src" "$dest"
  304. log_info "Processed: $dest (from $src)"
  305. fi
  306. else
  307. # No markers — symlink
  308. if [[ -L "$dest" ]] && [[ "$(readlink "$dest")" == "$src" ]]; then
  309. return # Already correctly linked
  310. fi
  311. if [[ -e "$dest" ]] || [[ -L "$dest" ]]; then
  312. log_warn "Removing existing $dest"
  313. rm -rf "$dest"
  314. fi
  315. if ln -s "$src" "$dest"; then
  316. log_info "Linked: $dest -> $src"
  317. else
  318. log_error "Failed to create symlink for $(basename "$dest")"
  319. return 1
  320. fi
  321. fi
  322. }
  323. # Deploy dotfiles (symlink or process markers)
  324. log_info "Deploying dotfiles for $hostname_id..."
  325. get_sync_list | while IFS= read -r p; do
  326. local source
  327. source=$(resolve_source "$p")
  328. local destination="$HOME/$p"
  329. if [[ ! -e "$source" ]]; then
  330. log_warn "Source $source does not exist, skipping"
  331. continue
  332. fi
  333. if [[ -d "$source" ]]; then
  334. if dir_has_markers "$source"; then
  335. # Directory contains markers — expand into per-file operations
  336. # Remove directory-level symlink if present
  337. if [[ -L "$destination" ]]; then
  338. log_warn "Replacing directory symlink $destination with expanded files"
  339. rm "$destination"
  340. fi
  341. mkdir -p "$destination"
  342. # Walk all files in the source directory
  343. while IFS= read -r src_file; do
  344. local rel="${src_file#"$source/"}"
  345. deploy_file "$src_file" "$destination/$rel"
  346. done < <(find "$source" -type f | sort)
  347. else
  348. # No markers in directory — symlink the whole directory
  349. if [[ -L "$destination" ]] && [[ "$(readlink "$destination")" == "$source" ]]; then
  350. continue # Already correctly linked
  351. fi
  352. if [[ -e "$destination" ]] || [[ -L "$destination" ]]; then
  353. log_warn "Removing existing $destination"
  354. rm -rf "$destination"
  355. fi
  356. mkdir -p "$(dirname "$destination")"
  357. if ln -s "$source" "$destination"; then
  358. log_info "Linked: $destination -> $source"
  359. else
  360. log_error "Failed to create symlink for $p"
  361. exit 1
  362. fi
  363. fi
  364. else
  365. # Single file entry
  366. deploy_file "$source" "$destination"
  367. fi
  368. done
  369. log_info "Sync complete!"
  370. }
  371. sub_list(){
  372. echo "Host: $hostname_id"
  373. local groups
  374. groups=$(get_groups | tr '\n' ', ' | sed 's/,$//')
  375. if [[ -n "$groups" ]]; then
  376. echo "Groups: $groups"
  377. fi
  378. echo ""
  379. if [[ -f "$script_dir/to_sync" ]]; then
  380. echo "Common dotfiles (to_sync):"
  381. while IFS= read -r p; do
  382. [[ -z "$p" ]] && continue
  383. local source
  384. source=$(resolve_source "$p")
  385. local tag=""
  386. if [[ "$source" == "$dots_host_dir/"* ]]; then
  387. tag=" [override: $hostname_id]"
  388. elif [[ "$source" == *"/dots.@"* ]]; then
  389. local gname
  390. gname=$(echo "$source" | sed 's|.*dots\.@\([^/]*\)/.*|\1|')
  391. tag=" [override: @$gname]"
  392. fi
  393. local marker_tag=""
  394. if [[ -f "$source" ]] && file_has_markers "$source"; then
  395. marker_tag=" [processed]"
  396. fi
  397. if [[ -L "$HOME/$p" ]] || [[ -f "$HOME/$p" ]] || [[ -d "$HOME/$p" ]]; then
  398. echo " + $p$tag$marker_tag"
  399. else
  400. echo " - $p (not deployed)$tag$marker_tag"
  401. fi
  402. done < "$script_dir/to_sync"
  403. fi
  404. # Group-specific dotfiles
  405. while IFS= read -r group; do
  406. local gsync="$script_dir/to_sync.@$group"
  407. if [[ -f "$gsync" ]] && [[ -s "$gsync" ]]; then
  408. echo ""
  409. echo "Group dotfiles (to_sync.@$group):"
  410. while IFS= read -r p; do
  411. [[ -z "$p" ]] && continue
  412. if [[ -L "$HOME/$p" ]] || [[ -f "$HOME/$p" ]] || [[ -d "$HOME/$p" ]]; then
  413. echo " + $p"
  414. else
  415. echo " - $p (not deployed)"
  416. fi
  417. done < "$gsync"
  418. fi
  419. done < <(get_groups)
  420. # Host-specific dotfiles
  421. if [[ -f "$script_dir/to_sync.$hostname_id" ]] && [[ -s "$script_dir/to_sync.$hostname_id" ]]; then
  422. echo ""
  423. echo "Host-specific dotfiles (to_sync.$hostname_id):"
  424. while IFS= read -r p; do
  425. [[ -z "$p" ]] && continue
  426. if [[ -L "$HOME/$p" ]] || [[ -f "$HOME/$p" ]] || [[ -d "$HOME/$p" ]]; then
  427. echo " + $p"
  428. else
  429. echo " - $p (not deployed)"
  430. fi
  431. done < "$script_dir/to_sync.$hostname_id"
  432. fi
  433. }
  434. sub_status(){
  435. if [[ ! -d "$script_dir/.git" ]]; then
  436. log_error "Dotfiles repository not found"
  437. exit 1
  438. fi
  439. cd "$script_dir"
  440. log_info "Host: $hostname_id"
  441. log_info "Git status:"
  442. git status
  443. }
  444. subcommand=${1:-}
  445. case $subcommand in
  446. "" | "-h" | "--help")
  447. sub_help
  448. ;;
  449. sync|list|status)
  450. shift
  451. "sub_${subcommand}" "$@"
  452. ;;
  453. *)
  454. log_error "'$subcommand' is not a known subcommand."
  455. echo " Run '$ProgName --help' for a list of known subcommands." >&2
  456. exit 1
  457. ;;
  458. esac