Created
May 9, 2025 10:38
-
-
Save nicholaswmin/edc82812555311dbb231b004751092c6 to your computer and use it in GitHub Desktop.
git-based dotfiles management
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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