|
|
@@ -4,9 +4,11 @@
|
|
|
set -euo pipefail
|
|
|
|
|
|
# Configuration
|
|
|
+hostname_id=$(uname -n)
|
|
|
checkout_dir=$HOME/.dofiles
|
|
|
script_dir=$checkout_dir/dotfiles
|
|
|
dots_dir=$checkout_dir/dotfiles/dots
|
|
|
+dots_host_dir=$checkout_dir/dotfiles/dots.$hostname_id
|
|
|
repo="https://git.capella.pro/capella/dotfiles.git"
|
|
|
ProgName=$(basename "$0")
|
|
|
|
|
|
@@ -21,6 +23,109 @@ log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
|
|
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; }
|
|
|
log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
|
|
|
|
|
+# Get group names for the current host (sorted, one per line)
|
|
|
+get_groups() {
|
|
|
+ local groups_file="$script_dir/groups.$hostname_id"
|
|
|
+ if [[ -f "$groups_file" ]]; then
|
|
|
+ grep -v '^\s*#' "$groups_file" | grep -v '^\s*$' | sort
|
|
|
+ fi
|
|
|
+}
|
|
|
+
|
|
|
+# Get combined sync list (common + groups + host-specific, deduplicated)
|
|
|
+get_sync_list() {
|
|
|
+ {
|
|
|
+ cat "$script_dir/to_sync" 2>/dev/null
|
|
|
+ while IFS= read -r group; do
|
|
|
+ cat "$script_dir/to_sync.@$group" 2>/dev/null
|
|
|
+ done < <(get_groups)
|
|
|
+ cat "$script_dir/to_sync.$hostname_id" 2>/dev/null
|
|
|
+ } | grep -v '^\s*$' | sort -u
|
|
|
+}
|
|
|
+
|
|
|
+# Resolve source path: common -> groups (alpha) -> host-specific (last wins)
|
|
|
+resolve_source() {
|
|
|
+ local p="$1"
|
|
|
+ local result="$dots_dir/$p"
|
|
|
+
|
|
|
+ while IFS= read -r group; do
|
|
|
+ if [[ -e "$script_dir/dots.@$group/$p" ]]; then
|
|
|
+ result="$script_dir/dots.@$group/$p"
|
|
|
+ fi
|
|
|
+ done < <(get_groups)
|
|
|
+
|
|
|
+ if [[ -e "$dots_host_dir/$p" ]]; then
|
|
|
+ result="$dots_host_dir/$p"
|
|
|
+ fi
|
|
|
+
|
|
|
+ echo "$result"
|
|
|
+}
|
|
|
+
|
|
|
+# Check if a file contains @host or @group markers
|
|
|
+file_has_markers() {
|
|
|
+ grep -qE '@(host|group) ' "$1" 2>/dev/null
|
|
|
+}
|
|
|
+
|
|
|
+# Detect the comment prefix from the first marker line in a file
|
|
|
+detect_comment_prefix() {
|
|
|
+ local src="$1"
|
|
|
+ local marker_line
|
|
|
+ marker_line=$(grep -m1 -E '@(host|group) ' "$src")
|
|
|
+ # Extract everything before @host or @group
|
|
|
+ local prefix="${marker_line%%@*}"
|
|
|
+ # Trim trailing whitespace
|
|
|
+ prefix="${prefix%"${prefix##*[![:space:]]}"}"
|
|
|
+ echo "$prefix"
|
|
|
+}
|
|
|
+
|
|
|
+# Process a file with @host/@group markers, outputting only matching sections
|
|
|
+process_markers() {
|
|
|
+ local src="$1"
|
|
|
+ local in_block=0
|
|
|
+ local include_block=0
|
|
|
+
|
|
|
+ # Pre-load groups into an associative array for fast lookup
|
|
|
+ declare -A host_groups
|
|
|
+ while IFS= read -r g; do
|
|
|
+ host_groups["$g"]=1
|
|
|
+ done < <(get_groups)
|
|
|
+
|
|
|
+ while IFS= read -r line || [[ -n "$line" ]]; do
|
|
|
+ if [[ $in_block -eq 0 ]]; then
|
|
|
+ if [[ "$line" =~ @host[[:space:]]+([^[:space:]]+) ]]; then
|
|
|
+ in_block=1
|
|
|
+ if [[ "${BASH_REMATCH[1]}" == "$hostname_id" ]]; then
|
|
|
+ include_block=1
|
|
|
+ else
|
|
|
+ include_block=0
|
|
|
+ fi
|
|
|
+ elif [[ "$line" =~ @group[[:space:]]+([^[:space:]]+) ]]; then
|
|
|
+ in_block=1
|
|
|
+ if [[ -v "host_groups[${BASH_REMATCH[1]}]" ]]; then
|
|
|
+ include_block=1
|
|
|
+ else
|
|
|
+ include_block=0
|
|
|
+ fi
|
|
|
+ elif [[ "$line" =~ @end ]]; then
|
|
|
+ # Stray @end outside a block, skip it
|
|
|
+ continue
|
|
|
+ else
|
|
|
+ printf '%s\n' "$line"
|
|
|
+ fi
|
|
|
+ else
|
|
|
+ if [[ "$line" =~ @end ]]; then
|
|
|
+ in_block=0
|
|
|
+ include_block=0
|
|
|
+ elif [[ $include_block -eq 1 ]]; then
|
|
|
+ printf '%s\n' "$line"
|
|
|
+ fi
|
|
|
+ fi
|
|
|
+ done < "$src"
|
|
|
+
|
|
|
+ if [[ $in_block -eq 1 ]]; then
|
|
|
+ log_warn "Unclosed @host/@group block in $src"
|
|
|
+ fi
|
|
|
+}
|
|
|
+
|
|
|
# Ensure we cleanup on exit
|
|
|
trap 'cd "$HOME"' EXIT
|
|
|
|
|
|
@@ -29,13 +134,31 @@ mkdir -p "$checkout_dir"
|
|
|
|
|
|
|
|
|
sub_help(){
|
|
|
+ local groups
|
|
|
+ groups=$(get_groups | tr '\n' ', ' | sed 's/,$//')
|
|
|
cat << EOF
|
|
|
Usage: $ProgName <subcommand> [options]
|
|
|
|
|
|
Subcommands:
|
|
|
- sync [dot_file_to_add] Sync dotfiles (optionally add a new dotfile to track)
|
|
|
- list List currently synced dotfiles
|
|
|
- status Show git status of dotfiles repo
|
|
|
+ sync [--machine|--group <name>] [dot_file]
|
|
|
+ Sync dotfiles (optionally add a new dotfile)
|
|
|
+ --machine: add to host-specific config ($hostname_id)
|
|
|
+ --group <name>: add to group-specific config
|
|
|
+ list List currently synced dotfiles
|
|
|
+ status Show git status of dotfiles repo
|
|
|
+
|
|
|
+Host: $hostname_id
|
|
|
+Groups: ${groups:-none}
|
|
|
+ Resolution order: common -> groups (alpha) -> host-specific
|
|
|
+
|
|
|
+ Common dotfiles: dots/ + to_sync
|
|
|
+ Group dotfiles: dots.@<group>/ + to_sync.@<group>
|
|
|
+ Host overrides: dots.$hostname_id/ + to_sync.$hostname_id
|
|
|
+
|
|
|
+In-file markers:
|
|
|
+ # @host <name> ... # @end (host-specific block)
|
|
|
+ # @group <name> ... # @end (group-specific block)
|
|
|
+ Files with markers are processed and written (not symlinked).
|
|
|
|
|
|
For help with each subcommand run:
|
|
|
$ProgName <subcommand> -h|--help
|
|
|
@@ -66,6 +189,21 @@ sub_sync(){
|
|
|
git pull origin "$current_branch" || log_warn "Git pull failed, continuing anyway"
|
|
|
git submodule update --init --recursive
|
|
|
|
|
|
+ # Parse flags for adding new dotfiles
|
|
|
+ local machine_specific=0
|
|
|
+ local group_target=""
|
|
|
+ if [[ "${1:-}" == "--machine" ]]; then
|
|
|
+ machine_specific=1
|
|
|
+ shift
|
|
|
+ elif [[ "${1:-}" == "--group" ]]; then
|
|
|
+ group_target="${2:-}"
|
|
|
+ if [[ -z "$group_target" ]]; then
|
|
|
+ log_error "--group requires a group name"
|
|
|
+ exit 1
|
|
|
+ fi
|
|
|
+ shift 2
|
|
|
+ fi
|
|
|
+
|
|
|
# Add new dotfile if provided
|
|
|
if [ -n "${1:-}" ]; then
|
|
|
local file_to_add="$1"
|
|
|
@@ -81,44 +219,68 @@ sub_sync(){
|
|
|
real_path=$(realpath "$HOME/$file_to_add")
|
|
|
to_copy=${real_path#"$HOME/"}
|
|
|
|
|
|
- # Create parent directory structure in dots_dir
|
|
|
- mkdir -p "$(dirname "$dots_dir/$to_copy")"
|
|
|
+ if [[ $machine_specific -eq 1 ]]; then
|
|
|
+ target_dots_dir="$dots_host_dir"
|
|
|
+ sync_file="$script_dir/to_sync.$hostname_id"
|
|
|
+ log_info "Adding as host-specific ($hostname_id)"
|
|
|
+ elif [[ -n "$group_target" ]]; then
|
|
|
+ target_dots_dir="$script_dir/dots.@$group_target"
|
|
|
+ sync_file="$script_dir/to_sync.@$group_target"
|
|
|
+ log_info "Adding as group-specific (@$group_target)"
|
|
|
+ else
|
|
|
+ target_dots_dir="$dots_dir"
|
|
|
+ sync_file="$script_dir/to_sync"
|
|
|
+ fi
|
|
|
+
|
|
|
+ # Create parent directory structure
|
|
|
+ mkdir -p "$(dirname "$target_dots_dir/$to_copy")"
|
|
|
|
|
|
# Copy to dots directory
|
|
|
- if cp -R "$real_path" "$dots_dir/$to_copy"; then
|
|
|
- log_info "Copied $file_to_add to $dots_dir/$to_copy"
|
|
|
+ if cp -R "$real_path" "$target_dots_dir/$to_copy"; then
|
|
|
+ log_info "Copied $file_to_add to $target_dots_dir/$to_copy"
|
|
|
else
|
|
|
log_error "Failed to copy $file_to_add"
|
|
|
exit 1
|
|
|
fi
|
|
|
|
|
|
- # Add to to_sync file if not already there
|
|
|
- if ! grep -qxF "$to_copy" "$script_dir/to_sync" 2>/dev/null; then
|
|
|
- echo "$to_copy" >> "$script_dir/to_sync"
|
|
|
- log_info "Added $to_copy to sync list"
|
|
|
+ # Add to sync file if not already there
|
|
|
+ if ! grep -qxF "$to_copy" "$sync_file" 2>/dev/null; then
|
|
|
+ echo "$to_copy" >> "$sync_file"
|
|
|
+ log_info "Added $to_copy to $(basename "$sync_file")"
|
|
|
else
|
|
|
- log_info "$to_copy already in sync list"
|
|
|
+ log_info "$to_copy already in $(basename "$sync_file")"
|
|
|
fi
|
|
|
|
|
|
# Remove original and create symlink
|
|
|
rm -rf "$real_path"
|
|
|
- ln -s "$dots_dir/$to_copy" "$real_path"
|
|
|
- log_info "Created symlink: $real_path -> $dots_dir/$to_copy"
|
|
|
+ ln -s "$target_dots_dir/$to_copy" "$real_path"
|
|
|
+ log_info "Created symlink: $real_path -> $target_dots_dir/$to_copy"
|
|
|
fi
|
|
|
|
|
|
- # Add tracked files to git
|
|
|
+ # Stage tracked files from all source directories
|
|
|
log_info "Staging changes..."
|
|
|
- while IFS= read -r p; do
|
|
|
- [[ -z "$p" ]] && continue # Skip empty lines
|
|
|
- file="$dots_dir/$p"
|
|
|
- if [[ -e "$file" ]]; then
|
|
|
- git add "$file"
|
|
|
- else
|
|
|
- log_warn "Tracked file $file does not exist"
|
|
|
+ get_sync_list | while IFS= read -r p; do
|
|
|
+ if [[ -e "$dots_dir/$p" ]]; then
|
|
|
+ git add "$dots_dir/$p"
|
|
|
+ fi
|
|
|
+ while IFS= read -r group; do
|
|
|
+ if [[ -e "$script_dir/dots.@$group/$p" ]]; then
|
|
|
+ git add "$script_dir/dots.@$group/$p"
|
|
|
+ fi
|
|
|
+ done < <(get_groups)
|
|
|
+ if [[ -e "$dots_host_dir/$p" ]]; then
|
|
|
+ git add "$dots_host_dir/$p"
|
|
|
fi
|
|
|
- done < "$script_dir/to_sync"
|
|
|
+ done
|
|
|
|
|
|
git add start to_sync
|
|
|
+ # Stage host-specific, group-specific, and groups files
|
|
|
+ for f in to_sync.* groups.*; do
|
|
|
+ [[ -f "$f" ]] && git add "$f"
|
|
|
+ done
|
|
|
+ for d in dots.*/; do
|
|
|
+ [[ -d "$d" ]] && git add "$d"
|
|
|
+ done
|
|
|
|
|
|
# Commit and push if there are changes
|
|
|
if git diff --cached --quiet; then
|
|
|
@@ -140,58 +302,133 @@ sub_sync(){
|
|
|
fi
|
|
|
fi
|
|
|
|
|
|
- # Create symlinks for all tracked dotfiles
|
|
|
- log_info "Creating symlinks..."
|
|
|
- while IFS= read -r p; do
|
|
|
- [[ -z "$p" ]] && continue # Skip empty lines
|
|
|
- destination="$HOME/$p"
|
|
|
- source="$dots_dir/$p"
|
|
|
+ # Deploy dotfiles (symlink or process markers)
|
|
|
+ log_info "Deploying dotfiles for $hostname_id..."
|
|
|
+ get_sync_list | while IFS= read -r p; do
|
|
|
+ local source
|
|
|
+ source=$(resolve_source "$p")
|
|
|
+ local destination="$HOME/$p"
|
|
|
|
|
|
if [[ ! -e "$source" ]]; then
|
|
|
log_warn "Source file $source does not exist, skipping"
|
|
|
continue
|
|
|
fi
|
|
|
|
|
|
- # Check if destination is already a correct symlink
|
|
|
- if [[ -L "$destination" ]] && [[ "$(readlink "$destination")" == "$source" ]]; then
|
|
|
- continue # Already correctly linked
|
|
|
- fi
|
|
|
+ mkdir -p "$(dirname "$destination")"
|
|
|
|
|
|
- # Remove destination if it exists
|
|
|
- if [[ -e "$destination" ]] || [[ -L "$destination" ]]; then
|
|
|
- log_warn "Removing existing $destination"
|
|
|
- rm -rf "$destination"
|
|
|
- fi
|
|
|
+ if [[ -f "$source" ]] && file_has_markers "$source"; then
|
|
|
+ # File has markers — process and write (not symlink)
|
|
|
+ if [[ -L "$destination" ]]; then
|
|
|
+ rm "$destination"
|
|
|
+ fi
|
|
|
|
|
|
- # Create parent directory if needed
|
|
|
- mkdir -p "$(dirname "$destination")"
|
|
|
+ local comment_prefix
|
|
|
+ comment_prefix=$(detect_comment_prefix "$source")
|
|
|
|
|
|
- # Create symlink
|
|
|
- if ln -s "$source" "$destination"; then
|
|
|
- log_info "Linked: $destination -> $source"
|
|
|
+ {
|
|
|
+ echo "$comment_prefix DO NOT EDIT - Generated by dotsync from:"
|
|
|
+ echo "$comment_prefix $source"
|
|
|
+ echo ""
|
|
|
+ process_markers "$source"
|
|
|
+ } > "$destination.dotsync_tmp"
|
|
|
+
|
|
|
+ # Only update if content changed
|
|
|
+ if [[ -f "$destination" ]] && cmp -s "$destination" "$destination.dotsync_tmp"; then
|
|
|
+ rm "$destination.dotsync_tmp"
|
|
|
+ else
|
|
|
+ [[ -e "$destination" ]] && rm "$destination"
|
|
|
+ mv "$destination.dotsync_tmp" "$destination"
|
|
|
+ chmod --reference="$source" "$destination"
|
|
|
+ log_info "Processed: $destination (from $source)"
|
|
|
+ fi
|
|
|
else
|
|
|
- log_error "Failed to create symlink for $p"
|
|
|
+ # No markers — symlink as before
|
|
|
+ if [[ -L "$destination" ]] && [[ "$(readlink "$destination")" == "$source" ]]; then
|
|
|
+ continue # Already correctly linked
|
|
|
+ fi
|
|
|
+
|
|
|
+ if [[ -e "$destination" ]] || [[ -L "$destination" ]]; then
|
|
|
+ log_warn "Removing existing $destination"
|
|
|
+ rm -rf "$destination"
|
|
|
+ fi
|
|
|
+
|
|
|
+ if ln -s "$source" "$destination"; then
|
|
|
+ log_info "Linked: $destination -> $source"
|
|
|
+ else
|
|
|
+ log_error "Failed to create symlink for $p"
|
|
|
+ fi
|
|
|
fi
|
|
|
- done < "$script_dir/to_sync"
|
|
|
+ done
|
|
|
|
|
|
log_info "Sync complete!"
|
|
|
}
|
|
|
|
|
|
sub_list(){
|
|
|
- if [[ ! -f "$script_dir/to_sync" ]]; then
|
|
|
- log_error "No to_sync file found"
|
|
|
- exit 1
|
|
|
+ echo "Host: $hostname_id"
|
|
|
+
|
|
|
+ local groups
|
|
|
+ groups=$(get_groups | tr '\n' ', ' | sed 's/,$//')
|
|
|
+ if [[ -n "$groups" ]]; then
|
|
|
+ echo "Groups: $groups"
|
|
|
+ fi
|
|
|
+ echo ""
|
|
|
+
|
|
|
+ if [[ -f "$script_dir/to_sync" ]]; then
|
|
|
+ echo "Common dotfiles (to_sync):"
|
|
|
+ while IFS= read -r p; do
|
|
|
+ [[ -z "$p" ]] && continue
|
|
|
+ local source
|
|
|
+ source=$(resolve_source "$p")
|
|
|
+ local tag=""
|
|
|
+ if [[ "$source" == "$dots_host_dir/"* ]]; then
|
|
|
+ tag=" [override: $hostname_id]"
|
|
|
+ elif [[ "$source" == *"/dots.@"* ]]; then
|
|
|
+ local gname
|
|
|
+ gname=$(echo "$source" | sed 's|.*dots\.@\([^/]*\)/.*|\1|')
|
|
|
+ tag=" [override: @$gname]"
|
|
|
+ fi
|
|
|
+ local marker_tag=""
|
|
|
+ if [[ -f "$source" ]] && file_has_markers "$source"; then
|
|
|
+ marker_tag=" [processed]"
|
|
|
+ fi
|
|
|
+ if [[ -L "$HOME/$p" ]] || [[ -f "$HOME/$p" ]] || [[ -d "$HOME/$p" ]]; then
|
|
|
+ echo " + $p$tag$marker_tag"
|
|
|
+ else
|
|
|
+ echo " - $p (not deployed)$tag$marker_tag"
|
|
|
+ fi
|
|
|
+ done < "$script_dir/to_sync"
|
|
|
fi
|
|
|
|
|
|
- echo "Currently synced dotfiles:"
|
|
|
- while IFS= read -r p; do
|
|
|
- [[ -z "$p" ]] && continue
|
|
|
- if [[ -L "$HOME/$p" ]]; then
|
|
|
- echo " ✓ $p"
|
|
|
- else
|
|
|
- echo " ✗ $p (not linked)"
|
|
|
+ # Group-specific dotfiles
|
|
|
+ while IFS= read -r group; do
|
|
|
+ local gsync="$script_dir/to_sync.@$group"
|
|
|
+ if [[ -f "$gsync" ]] && [[ -s "$gsync" ]]; then
|
|
|
+ echo ""
|
|
|
+ echo "Group dotfiles (to_sync.@$group):"
|
|
|
+ while IFS= read -r p; do
|
|
|
+ [[ -z "$p" ]] && continue
|
|
|
+ if [[ -L "$HOME/$p" ]] || [[ -f "$HOME/$p" ]] || [[ -d "$HOME/$p" ]]; then
|
|
|
+ echo " + $p"
|
|
|
+ else
|
|
|
+ echo " - $p (not deployed)"
|
|
|
+ fi
|
|
|
+ done < "$gsync"
|
|
|
fi
|
|
|
- done < "$script_dir/to_sync"
|
|
|
+ done < <(get_groups)
|
|
|
+
|
|
|
+ # Host-specific dotfiles
|
|
|
+ if [[ -f "$script_dir/to_sync.$hostname_id" ]] && [[ -s "$script_dir/to_sync.$hostname_id" ]]; then
|
|
|
+ echo ""
|
|
|
+ echo "Host-specific dotfiles (to_sync.$hostname_id):"
|
|
|
+ while IFS= read -r p; do
|
|
|
+ [[ -z "$p" ]] && continue
|
|
|
+ if [[ -L "$HOME/$p" ]] || [[ -f "$HOME/$p" ]] || [[ -d "$HOME/$p" ]]; then
|
|
|
+ echo " + $p"
|
|
|
+ else
|
|
|
+ echo " - $p (not deployed)"
|
|
|
+ fi
|
|
|
+ done < "$script_dir/to_sync.$hostname_id"
|
|
|
+ fi
|
|
|
}
|
|
|
|
|
|
sub_status(){
|
|
|
@@ -201,6 +438,7 @@ sub_status(){
|
|
|
fi
|
|
|
|
|
|
cd "$script_dir"
|
|
|
+ log_info "Host: $hostname_id"
|
|
|
log_info "Git status:"
|
|
|
git status
|
|
|
}
|