Skip to content

Instantly share code, notes, and snippets.

@nicholaswmin
Created May 9, 2025 10:38
Show Gist options
  • Save nicholaswmin/edc82812555311dbb231b004751092c6 to your computer and use it in GitHub Desktop.
Save nicholaswmin/edc82812555311dbb231b004751092c6 to your computer and use it in GitHub Desktop.
git-based dotfiles management
#!/usr/bin/env zsh
# a git-based, no-tools dotfiles manager
# authors: @nicholaswmin, The MIT License
set -e
# TODO:
# - [ ] think about Spotlight filetype exclusion
# - [ ] consider ohmyzsh
# - [ ] automate git clone of work projects
# This script serves as both the entry point (init command)
# and provides helper functions for other dotfiles scripts
# (like setup.sh) by being sourced.
# --- Framework Overview ---
# Dit is a simple, Git-based dotfiles management
# framework for macOS. It helps you set up a new
# machine by cloning or initializing your dotfiles
# repository in your home directory and running
# a setup script.
#
# How it works:
#
# dit.sh init <empty-repo-url>
# > creates essential files like:
# > `.gitignore`, `dit.sh`, `setup.sh`
# > and starts tracking your home
# > directory linked to an empty remote repo.
#
# dit.sh restore <existing-repo-url>
# > imports configuration and installs app from
# > a previously backed up dot files repository.
#
# note: this file also provides helper functions
# to other files so keep it in your home directory
# --- Environment Variables ---
# The script's behavior can be influenced by the
# following environment variables:
#
# NO_COLOR : If set (to any value), disables
# colored output from the script.
# FORCE_COLOR : If set (to any value), forces
# colored output, even if the terminal
# might not fully support it according
# to tput.
# FORCE : If set (to any value), skips
# interactive prompts (user_choose,
# user_answer) and uses default values
# or the first option.
# --- Constants ---
readonly DOTFILES_GIT_DIR="$HOME/.git"
readonly SETUP_SCRIPT_NAME="setup.sh"
readonly SETUP_SCRIPT_PATH="$HOME/$SETUP_SCRIPT_NAME"
# --- Helper Functions ---
# Args: $1=tput color code, $@=text arguments
_color_log() {
local code="$1"
shift
local text="$@"
if { [ -z "$NO_COLOR" ] &&
[ "$(tput colors 2>/dev/null)" -gt 2 ] } ||
[ -n "$FORCE_COLOR" ]; then
tput setaf "$code" 2>/dev/null
printf "%s\n" "$text" >&2
tput sgr0 2>/dev/null
else
printf "%s\n" "$text" >&2
fi
}
log() { _color_log 8 "$@"; } # Dim
log_info() { _color_log 6 "$@"; } # Cyan
log_warn() {
local msg="$1"
shift
_color_log 3 "Warning: $msg" # Yellow
if [ "$#" -gt 0 ]; then
log "$@"
fi
}
log_done() { _color_log 2 "$@"; } # Green
log_error() {
local msg="$1"
shift
_color_log 1 "Error: $msg" # Red
if [ "$#" -gt 0 ]; then
log "$@"
fi
}
# Args: $1 (optional, default 1) = number of blank lines
log_pads() {
local num=${1:-1}
if ! [[ "$num" =~ ^[0-9]+$ ]]; then
log_error "log_pads requires a non-negative integer argument."
num=1 # Fallback to 1
fi
for i in $(seq 1 "$num"); do
log ""
done
}
log_header() {
log_pads
_color_log 5 "--- $@ ---" # Magenta
log_pads
}
# Args: $1=env_name, $2=default_value
# Output: value from env var or default to stdout
user_value() {
local env_name="$1"
local default="$2"
if [ $# -lt 2 ]; then
log_error "user_value function called without a default value for"
log_error "environment variable '$env_name'."
exit 1
fi
# Zsh specific: get value of env var whose name is in $env_name
local env_value="${(P)env_name:-}"
if [ -n "$env_value" ]; then
log "Using value from environment variable '$env_name':"
log "'$env_value'"
echo "$env_value" # Output to stdout
else
log "Environment variable '$env_name' not set or empty."
log "Using default: '$default'"
echo "$default" # Output to stdout
fi
}
# Args: $@=array of choices
# Output: selected choice string to stdout
user_choose() {
local choices=("$@")
local num_choices=${#choices[@]}
local index
local selected
if [ -n "$FORCE" ]; then
if [ "$num_choices" -gt 0 ]; then
log_info "FORCE set. Auto-selecting: ${choices[0]}"
echo "${choices[0]}" # Output to stdout
return 0
else
log_error "(user_choose): No choices provided."
return 1
fi
fi
if [ "$num_choices" -eq 0 ]; then
log_error "(user_choose): No choices provided."
return 1
fi
log_pads
log "Please choose from the following options:"
for i in "${!choices[@]}"; do
log "$((i+1)). ${choices[$i]}"
done
log_pads
while true; do
log "Enter the number of your choice:"
log "Press Ctrl+C to exit"
read -r input
local trimmed=$(echo "$input" | tr -d '[:space:]')
if [ -z "$trimmed" ]; then
log_error "Input cannot be empty."
log "Please enter a number, e.g: 3"
continue
fi
if [[ "$trimmed" =~ ^[0-9]+$ ]]; then
index=$((trimmed-1))
if [ "$index" -ge 0 ] && [ "$index" -lt "$num_choices" ]; then
selected="${choices[$index]}"
log_info "Selected: $selected"
echo "$selected" # Output to stdout
return 0
else
log_error "Invalid choice number '$trimmed'."
log "Enter 1-$num_choices, e.g: $((num_choices > 0 ? 1 : 0))"
fi
else
log_error "Invalid input '$input'."
log "Enter a number."
fi
done
}
# Args: $1=prompt_message, $2=default_value
# Output: trimmed user input to stdout
user_answer() {
local prompt="$1"
local default="$2"
local input
local trimmed
if [ -n "$FORCE" ]; then
if [ -n "$default" ]; then
log_info "FORCE set. Auto-using provided default: '$default'"
echo "$default" # Output to stdout
return 0
else
log_info "FORCE set. No default provided. Auto-using empty string."
echo "" # Output to stdout
return 0
fi
fi
while true; do
if [ -n "$default" ]; then
log "$prompt (Default: '$default'):"
log "Press Ctrl+C to exit"
else
log "$prompt:"
log "Press Ctrl+C to exit"
fi
read -r input
# Note: `tr -d '[:space:]'` removes ALL spaces, not just leading/trailing.
# This is a specific behavior of this script; alternative methods exist
# for standard leading/trailing trim if needed elsewhere.
trimmed=$(echo "$input" | tr -d '[:space:]')
if [ -z "$trimmed" ] && [ -n "$input" ] && [ -z "$default" ]; then
log_error "Input cannot be only whitespace."
log "Please provide a non-whitespace value or use Ctrl+C to exit."
continue
elif [ -z "$trimmed" ] && [ -n "$default" ]; then
log_info "No input provided or only whitespace. Using default value: '$default'"
echo "$default" # Output to stdout
return 0
elif [ -z "$trimmed" ] && [ -z "$default" ]; then
log_error "Input cannot be empty."
log "Please provide a value, e.g: John Doe"
continue
fi
log_info "Received input: '$trimmed'"
echo "$trimmed" # Output to stdout
return 0
done
}
# Output: normalized string to stdout
normalize_string_to_hyphens() {
local input_string="$1"
echo "$input_string" | tr -s '[:space:]' | tr '[:space:]' '-'
}
# Output: normalized GitHub username to stdout
gh_username() {
local username="$1"
local normalized=$(normalize_string_to_hyphens "${username:l}") # Zsh specific: lowercase
echo "$normalized"
}
is_valid_git_repo() {
local repo_url="$1"
git ls-remote "$repo_url" &> /dev/null
}
is_empty_git_repo() {
local repo_url="$1"
[ "$(git ls-remote --heads "$repo_url" | wc -l)" -eq 0 ]
}
is_not_empty_git_repo() {
local repo_url="$1"
[ "$(git ls-remote --heads "$repo_url" | wc -l)" -gt 0 ]
}
# --- Initial Setup Functions ---
check_home_dir() {
if [ "$PWD" != "$HOME" ]; then
log_error "This script must be run from your home directory (~)."
log_info "Please navigate to your home directory and run the"
log_info "script again:"
log " cd ~"
log " ./dit.sh init [giturl]"
exit 1
fi
}
clone_repo() {
local repo_url="$1"
log_info "Attempting to clone existing dotfiles repository from"
log_info "$repo_url..."
if [ -d "$DOTFILES_GIT_DIR" ]; then
log_warn "Standard Git repository already exists at $DOTFILES_GIT_DIR."
log_warn "Skipping clone. Assuming it's already set up."
else
log "Cloning repository from $repo_url into $HOME..."
if git clone "$repo_url" "$HOME" >&2; then
log_done "Repository cloned successfully."
else
log_error "Failed to clone the repository."
log_info "Please check the URL and your network connection."
return 1
fi
}
log_info "Checking for $SETUP_SCRIPT_NAME after cloning..."
if [ ! -f "$SETUP_SCRIPT_PATH" ]; then
log_warn "$SETUP_SCRIPT_NAME not found in the cloned repository."
log_warn "You will need to manually run your setup steps."
else
log_done "$SETUP_SCRIPT_PATH found."
fi
return 0
}
init_new_repo() {
log_header "Initialize New Dotfiles Repository"
if [ -d "$DOTFILES_GIT_DIR" ]; then
log_warn "Standard Git repository already exists at $DOTFILES_GIT_DIR."
log_warn "Skipping initialization. Assuming it's already set up."
else
log "Initializing repository in $HOME..."
if git init . >&2; then
log_done "Repository initialized successfully."
else
log_error "Error: Failed to initialize the repository."
return 1
fi
}
log_info "Creating a basic .gitignore file..."
local gitignore_content='
# Ignore everything by default
*
# But do not ignore the .gitignore file itself
!.gitignore
# Add other files/directories you want to track with
# dotfiles here, e.g.:
# !.zshrc
# !.gitconfig
# !.config/mytool/config.yml
# !Projects/
'
log "Writing .gitignore content to $HOME/.gitignore..."
printf "%s\n" "$gitignore_content" > "$HOME/.gitignore"
log_done ".gitignore created."
log_info "Adding .gitignore to the repository..."
if git add "$HOME/.gitignore" >&2; then
log_done ".gitignore added to repository."
else
log_error "Error: Failed to add .gitignore to repository."
# set -e will handle script exit
}
log_info "Creating a placeholder $SETUP_SCRIPT_NAME script..."
local setup_script_content='#!/usr/bin/env zsh
# @nicholaswmin dit dotfiles setup script
# This script is intended to be run from the user''s home
# directory after the dotfiles Git repository has been
# cloned or initialized. It installs necessary tools and
# applies configurations.
# --- Source Dit Helpers ---
# Source the main dit.sh script to get helper functions
# This assumes dit.sh is located in the home directory.
if [ -f "$HOME/dit.sh" ]; then
source "$HOME/dit.sh"
else
echo "Error: dit.sh not found at $HOME/dit.sh." >&2
echo "This script requires dit.sh to be sourced for" >&2
echo "helper functions." >&2
exit 1
fi
# --- Script Start ---
log_header "Dotfiles Setup"
# Example: Install Homebrew (if not already done by init)
# if ! command -v brew &> /dev/null; then
# /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# fi
# Example: Install applications from a Brewfile
# if [ -f "$HOME/Brewfile" ]; then
# log_info "Installing applications and tools from Brewfile..."
# brew bundle --file="$HOME/Brewfile" >&2 # Send brew output to stderr to align with dit''s logging
# log_info "Application and tool installation complete."
# fi
# Example: Configure Git (if not already done by init)
# log_info "Configuring Git..."
# git config --global user.name "Your Name"
# git config --global user.email "[email protected]"
# log_info "Git configuration complete."
# Remember to make this script executable: chmod +x setup.sh
# --- Script Completion ---
log_done "Dotfiles setup process complete."
'
log "Writing placeholder content to $SETUP_SCRIPT_PATH..."
printf "%s\n" "$setup_script_content" > "$HOME/$SETUP_SCRIPT_NAME"
chmod +x "$HOME/$SETUP_SCRIPT_NAME"
log_done "Placeholder $SETUP_SCRIPT_NAME created and made executable."
log_info "Adding $SETUP_SCRIPT_NAME to the repository..."
if git add "$HOME/$SETUP_SCRIPT_NAME" >&2; then
log_done "$SETUP_SCRIPT_NAME added to repository."
else
log_error "Error: Failed to add $SETUP_SCRIPT_NAME to repository."
# set -e will handle script exit
}
log_info "New repository initialized. Next steps:"
log "1. Review and edit the $HOME/.gitignore file to include"
log " the dotfiles you want to track (un-ignore them)."
log "2. Review and edit the $SETUP_SCRIPT_PATH script to"
log " automate your setup tasks."
log "3. Use standard git commands from your home directory to"
log " add any other dotfiles you want to track:"
log " git add <path/to/your/dotfile>"
log " (e.g., git add ~/.zshrc)"
log "4. Commit your initial changes:"
log " git commit -m \"Initial dotfiles commit\""
log "5. Create a new empty Git repository on GitHub, GitLab,"
log " etc. (if you haven''t already provided its URL)."
log "6. Add the remote origin to your local repository (if not done automatically):"
log " git remote add origin <url to your remote repo>"
log "7. Push your initial commit to the remote repository:"
log " git push -u origin main"
log_warn "Manual steps might be required to add, commit, and push"
log_warn "your dotfiles."
return 0
}
# --- Main Script Logic ---
# This block runs ONLY if the script is executed directly (not sourced)
if [[ "$ZSH_EVAL_CONTEXT" != *:* ]]; then
log_header "Dit Dotfiles Manager"
if [ "$#" -lt 1 ]; then
log_error "Usage: ./dit.sh init [<empty-repo-url>] | restore <existing-repo-url>"
exit 1
fi
local command="$1"
local repo_url="$2"
check_home_dir
case "$command" in
"init")
if [ "$#" -eq 1 ]; then
local current_user
current_user=$(gh_username "${USER:-$(whoami)}")
repo_url="https://github.com/$current_user/.dotfiles.git"
log_info "No URL provided for 'init'. Assuming default GitHub repo:"
log_info "$repo_url"
elif [ "$#" -ne 2 ]; then
log_error "Usage: ./dit.sh init [<empty-repo-url>]"
log_info "If providing a URL, ensure it's the second argument."
exit 1
fi
if is_valid_git_repo "$repo_url"; then
if is_empty_git_repo "$repo_url"; then
if init_new_repo; then
log_info "Adding remote origin '$repo_url'..."
if git remote add origin "$repo_url" >&2; then
log_done "Remote origin added successfully."
log_info "Remember to create this repository on the remote if it doesn't exist."
else
log_warn "Failed to add remote origin."
log_info "You may need to add it manually: git remote add origin $repo_url"
fi
else
log_error "Dotfiles repository initialization failed."
# set -e handles exit
exit 1 # Explicit exit for clarity
fi
else
log_error "Repository at $repo_url is not empty."
log_info "Please use 'dit.sh restore <existing-repo-url>' instead,"
log_info "or provide a URL to a truly empty repository for 'init'."
exit 1
fi
else
log_error "Error: '$repo_url' is not a valid or accessible Git repository URL."
log_info "Please provide a valid URL for an empty repository."
log_info "If using the default URL, ensure you can access it."
exit 1
fi
;;
"restore")
if [ "$#" -ne 2 ]; then
log_error "Error: Usage: ./dit.sh restore <existing-repo-url>"
exit 1
fi
if is_valid_git_repo "$repo_url"; then
if is_not_empty_git_repo "$repo_url"; then
clone_repo "$repo_url"
else
log_error "Error: Repository at $repo_url appears empty or has no branches."
log_info "Please use 'dit.sh init [<empty-repo-url>]' instead,"
log_info "or ensure the existing repository has committed branches."
exit 1
fi
else
log_error "Error: '$repo_url' is not a valid or accessible Git repository URL."
log_info "Please provide a valid URL for an existing repository."
exit 1
fi
;;
*)
log_error "Error: Invalid command '$command'."
log_error "Usage: ./dit.sh init [<empty-repo-url>] | restore <existing-repo-url>"
exit 1
;;
esac
if [ -f "$SETUP_SCRIPT_PATH" ] && [ -x "$SETUP_SCRIPT_PATH" ]; then
log_info "Running $SETUP_SCRIPT_PATH script..."
if "$SETUP_SCRIPT_PATH" >&2; then
log_done "$SETUP_SCRIPT_NAME executed successfully."
else
log_error "Error: $SETUP_SCRIPT_NAME failed during execution."
exit 1 # Explicit exit
fi
elif [ -f "$SETUP_SCRIPT_PATH" ]; then
log_warn "$SETUP_SCRIPT_PATH found but is not executable."
log_info "Attempting to make it executable..."
chmod +x "$SETUP_SCRIPT_PATH"
if [ -x "$SETUP_SCRIPT_PATH" ]; then
log_info "Now running $SETUP_SCRIPT_PATH script..."
if "$SETUP_SCRIPT_PATH" >&2; then
log_done "$SETUP_SCRIPT_NAME executed successfully."
else
log_error "Error: $SETUP_SCRIPT_NAME failed during execution."
exit 1 # Explicit exit
fi
else
log_error "$SETUP_SCRIPT_PATH could not be made executable."
log_error "Cannot run setup."
exit 1
fi
else
log_warn "$SETUP_SCRIPT_PATH not found."
log_info "Skipping setup script execution."
fi
log_done "Dit dotfiles process complete."
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment