Skip to content

Instantly share code, notes, and snippets.

@allen-munsch
Created March 18, 2025 05:23
Show Gist options
  • Save allen-munsch/95af0cb78d1373d8a2f80c1c3ce53562 to your computer and use it in GitHub Desktop.
Save allen-munsch/95af0cb78d1373d8a2f80c1c3ce53562 to your computer and use it in GitHub Desktop.
bashbox - a git / ssh implemention sort of like dropbox
#!/usr/bin/env bash
#
# BashBox - Git-based File Synchronization Tool
# A lightweight solution for bi-directional file synchronization using Git
#
# Author: James Munsch <[email protected]>
# License: MIT
set -euo pipefail # Strict mode: exit on error, unbound variable, and piping failures
# Constants
readonly VERSION="1.0.0"
readonly PROGRAM_NAME=$(basename "$0")
readonly DEFAULT_POLL_INTERVAL=60 # seconds
# Configuration with defaults
CONFIG=(
["log_file"]="log.bashbox"
["log_level"]="WARN" # DEBUG, INFO, WARN, ERROR
["quiet"]=false
["lock_file"]="/tmp/bashbox.lock"
["standard_suffix"]=false
["poll_interval"]=$DEFAULT_POLL_INTERVAL
["git_branch"]="main"
)
# Global state variables
declare FS_CHANGED=true
declare NEED_PULL=true
declare NEXT_PULL=0
declare -i LOCK_FD
# Color constants
if [[ -t 2 ]] && [[ -z "${NO_COLOR:-}" ]]; then
readonly RED='\033[0;31m'
readonly YELLOW='\033[0;33m'
readonly GREEN='\033[0;32m'
readonly BLUE='\033[0;34m'
readonly NC='\033[0m' # No Color
else
readonly RED=""
readonly YELLOW=""
readonly GREEN=""
readonly BLUE=""
readonly NC=""
fi
# Print usage information
usage() {
cat >&2 <<EOF
${BLUE}BashBox v${VERSION}${NC} - Git-based file synchronization tool
${GREEN}Usage:${NC}
$PROGRAM_NAME <path> <server> [options]
${GREEN}Arguments:${NC}
path Local directory to synchronize
server Remote SSH server (e.g., [email protected])
${GREEN}Options:${NC}
--standard-suffix Use .git suffix for remote repository
--log FILE Log file path (default: log.bashbox)
--log-level LEVEL Log level: DEBUG, INFO, WARN, ERROR (default: WARN)
--quiet Suppress stdout/stderr output
--lock-file FILE Lock file path (default: /tmp/bashbox.lock)
--poll-interval SEC How often to poll remote (default: 60 seconds)
--git-branch BRANCH Git branch to use (default: main)
--help Show this help message and exit
--version Show version information and exit
${GREEN}Examples:${NC}
$PROGRAM_NAME ~/Documents/notes [email protected]
$PROGRAM_NAME /data/project [email protected] --log-level DEBUG
EOF
}
# Print version information
version() {
echo "BashBox v${VERSION}"
}
# Logging functions
log() {
local level="$1"
local message="$2"
local timestamp=$(date -u +"%Y-%m-%d %H:%M:%S")
# Add to log file
echo "$timestamp $level $message" >> "${CONFIG[log_file]}"
# Print to stderr if not quiet
if [[ "${CONFIG[quiet]}" == false ]]; then
local color=""
case "$level" in
DEBUG) color="$BLUE";;
INFO) color="$GREEN";;
WARN) color="$YELLOW";;
ERROR) color="$RED";;
esac
echo -e "${timestamp} ${color}${level}${NC} ${message}" >&2
fi
}
debug() { [[ "${CONFIG[log_level]}" == "DEBUG" ]] && log "DEBUG" "$1"; }
info() { [[ "${CONFIG[log_level]}" == "DEBUG" || "${CONFIG[log_level]}" == "INFO" ]] && log "INFO" "$1"; }
warn() { [[ "${CONFIG[log_level]}" == "DEBUG" || "${CONFIG[log_level]}" == "INFO" || "${CONFIG[log_level]}" == "WARN" ]] && log "WARN" "$1"; }
error() { log "ERROR" "$1"; }
# Safe command execution with error handling
run_cmd() {
local cmd="$1"
local ignore_codes="${2:-0}"
local desc="${3:-command}"
debug "Running $desc: $cmd"
# Create a temporary file for output
local tmp_out
tmp_out=$(mktemp) || { error "Failed to create temporary file"; return 1; }
# Run the command
set +e # Temporarily disable exit on error
eval "$cmd" > "$tmp_out" 2>&1
local exit_code=$?
set -e # Re-enable exit on error
# Check if exit code should be ignored
if [[ $exit_code -ne 0 ]]; then
if [[ "$ignore_codes" == *"$exit_code"* ]]; then
debug "$desc completed with ignored exit code $exit_code"
if [[ "${CONFIG[log_level]}" == "DEBUG" ]]; then
debug "Output: $(cat "$tmp_out")"
fi
else
error "$desc failed with exit code $exit_code: $cmd"
error "Output: $(cat "$tmp_out")"
rm -f "$tmp_out"
return $exit_code
fi
else
debug "$desc completed successfully"
fi
# Clean up
rm -f "$tmp_out"
return 0
}
# Check if dependencies are installed
check_dependencies() {
local deps=("$@")
local missing=()
for dep in "${deps[@]}"; do
if ! command -v "$dep" &> /dev/null; then
missing+=("$dep")
fi
done
if [[ ${#missing[@]} -gt 0 ]]; then
error "Missing required dependencies: ${missing[*]}"
return 1
fi
return 0
}
# Check if required binaries are available locally
check_local_binaries() {
debug "Checking local dependencies"
check_dependencies "git" "ssh" || { error "Required local binaries missing"; return 1; }
}
# Check if required binaries are available on the remote server
check_remote_binaries() {
debug "Checking remote dependencies on ${SERVER}"
local deps=("git" "inotifywait")
for dep in "${deps[@]}"; do
if ! run_cmd "ssh ${SERVER} which ${dep} > /dev/null 2>&1" "" "remote dependency check (${dep})"; then
error "Required remote binary not found or could not connect to server: ${dep}"
return 1
fi
done
}
# Ensure the remote git repository exists
ensure_remote_repo() {
debug "Ensuring remote repository exists: ${REMOTE_NAME}"
run_cmd "ssh ${SERVER} git init --bare ${REMOTE_NAME}" "" "remote repository initialization"
}
# Initialize the local git repository
init_local_repo() {
debug "Initializing local repository: ${PATH_DIR}"
# Create parent directory if it doesn't exist
local base_dir=$(dirname "${PATH_DIR}")
mkdir -p "${base_dir}"
local current_dir=$(pwd)
# Clone the repository
cd "${base_dir}"
run_cmd "git clone ${SERVER}:${REMOTE_NAME}" "" "repository clone"
# Add a README file if the repo is empty
cd "${PATH_DIR}"
if [[ -z "$(ls -A | grep -v '\.git')" ]]; then
info "Adding initial README.md to empty repository"
cat > README.md <<EOF
# BashBox Sync Directory
This directory is synchronized using BashBox v${VERSION}.
- Local path: ${PATH_DIR}
- Remote: ${SERVER}:${REMOTE_NAME}
- Created: $(date -u +"%Y-%m-%d %H:%M:%S UTC")
Files added to this directory will be automatically synchronized.
EOF
run_cmd "git add README.md" "" "adding README"
run_cmd "git commit -m 'Initial commit by BashBox'" "" "initial commit"
run_cmd "git push origin ${CONFIG[git_branch]}" "" "initial push"
fi
cd "${current_dir}"
}
# Pull changes from the remote repository
pull_changes() {
# If the next pull time hasn't been reached yet, skip
if [[ ${NEXT_PULL} -gt $(date +%s) ]]; then
return 0
fi
# If the path doesn't exist, initialize it
if [[ ! -d "${PATH_DIR}" ]]; then
init_local_repo
return 0
fi
debug "Pulling changes from remote repository"
local current_dir=$(pwd)
cd "${PATH_DIR}"
# Check if we have any uncommitted changes before pulling
local has_changes=false
if git status --porcelain | grep -q '^.'; then
has_changes=true
debug "Found uncommitted local changes, saving them before pull"
push_changes
fi
# Pull changes from remote
if run_cmd "git pull origin ${CONFIG[git_branch]}" "0 1" "git pull"; then
info "Successfully pulled changes from remote"
fi
cd "${current_dir}"
# Schedule next pull
NEXT_PULL=$(($(date +%s) + CONFIG[poll_interval]))
debug "Next remote check scheduled for $(date -d "@${NEXT_PULL}" +"%H:%M:%S")"
}
# Push local changes to the remote repository
push_changes() {
# If there are no filesystem changes, skip
if [[ "${FS_CHANGED}" == false ]]; then
return 0
fi
debug "Preparing to push changes to remote repository"
local current_dir=$(pwd)
cd "${PATH_DIR}"
# Check for actual git changes
if ! git status --porcelain | grep -q '^.'; then
debug "No git changes detected, skipping push"
FS_CHANGED=false
cd "${current_dir}"
return 0
fi
# Add all changes
run_cmd "git add --all" "" "git add"
# Create commit with timestamp
local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%S")
local msg="Auto-commit by BashBox at ${timestamp}"
if run_cmd "git commit -m \"${msg}\"" "0 1" "git commit"; then
# Only push if commit was successful or there were already staged changes
if run_cmd "git push origin ${CONFIG[git_branch]}" "0 1" "git push"; then
info "Successfully pushed changes to remote"
fi
fi
cd "${current_dir}"
FS_CHANGED=false
}
# Watch for local filesystem changes
watch_local_changes() {
debug "Starting local filesystem watcher for: ${PATH_DIR}"
# Use inotifywait if available, otherwise fall back to polling
if command -v inotifywait &> /dev/null; then
debug "Using inotifywait for file change detection"
(
while true; do
inotifywait -r -q -e modify,create,delete,move,attrib \
--exclude "(/\.git/|\.swp$|\.swx$|\.tmp$|~$)" \
"${PATH_DIR}" 2>/dev/null
# Throttle immediate multiple change events
sleep 0.5
debug "Local change detected, marking for push"
FS_CHANGED=true
done
) &
else
# Fallback to basic polling with find and md5sum
debug "inotifywait not found, using polling for file change detection"
(
local last_hash=""
while true; do
# Get hash of all non-git files
local current_hash
current_hash=$(find "${PATH_DIR}" -type f \
-not -path "*/\.git/*" \
-not -name "*.swp" \
-not -name "*.swx" \
-not -name "*.tmp" \
-not -name "*~" \
-print0 2>/dev/null | sort -z | xargs -0 sha256sum 2>/dev/null | sort | sha256sum)
if [[ "${current_hash}" != "${last_hash}" ]]; then
if [[ -n "${last_hash}" ]]; then
debug "Local change detected (hash changed), marking for push"
FS_CHANGED=true
fi
last_hash="${current_hash}"
fi
sleep 2
done
) &
fi
}
# Watch for remote repository changes
watch_remote_changes() {
debug "Starting remote repository watcher"
(
while true; do
if run_cmd "ssh ${SERVER} \"cd ${REMOTE_NAME} && inotifywait -rqq -e modify,create,delete,move,attrib .\"" "0 1 255" "remote change detection"; then
debug "Remote change detected, triggering pull"
NEED_PULL=true
else
# If inotifywait fails, wait a bit before retrying
warn "Remote watcher error, retrying in 10 seconds"
sleep 10
fi
done
) &
}
# Main pull thread
pull_loop() {
debug "Starting pull synchronization loop"
(
while true; do
if [[ "${NEED_PULL}" == true ]]; then
pull_changes
NEED_PULL=false
elif [[ $(date +%s) -ge ${NEXT_PULL} ]]; then
pull_changes
fi
sleep 1
done
) &
}
# Main push thread
push_loop() {
debug "Starting push synchronization loop"
(
while true; do
push_changes
sleep 2
done
) &
}
# Cleanup function to be called on exit
cleanup() {
local exit_code=$?
info "BashBox shutting down (exit code: ${exit_code})"
# Kill all background processes
local pids=$(jobs -p)
if [[ -n "${pids}" ]]; then
kill ${pids} &>/dev/null || true
fi
# Release lock file if we have one
if [[ -v LOCK_FD ]] && [[ ${LOCK_FD} -gt 0 ]]; then
exec {LOCK_FD}>&-
fi
info "BashBox terminated"
exit ${exit_code}
}
# Set up trap for cleanup on exit
trap cleanup EXIT INT TERM
# Parse command line arguments
parse_args() {
# Require at least two arguments (path and server)
if [[ $# -lt 2 ]]; then
usage
return 1
fi
# Set positional arguments
PATH_DIR=$(realpath "$1")
SERVER="$2"
shift 2
# Process options
while [[ $# -gt 0 ]]; do
case "$1" in
--standard-suffix)
CONFIG[standard_suffix]=true
shift
;;
--log)
CONFIG[log_file]="$2"
shift 2
;;
--log-level)
CONFIG[log_level]="${2^^}" # Convert to uppercase
shift 2
;;
--quiet)
CONFIG[quiet]=true
shift
;;
--lock-file)
CONFIG[lock_file]="$2"
shift 2
;;
--poll-interval)
CONFIG[poll_interval]="$2"
shift 2
;;
--git-branch)
CONFIG[git_branch]="$2"
shift 2
;;
--help)
usage
exit 0
;;
--version)
version
exit 0
;;
*)
echo -e "${RED}Error:${NC} Unknown option: $1" >&2
usage
return 1
;;
esac
done
# Validate log level
case "${CONFIG[log_level]}" in
DEBUG|INFO|WARN|ERROR) ;;
*)
echo -e "${RED}Error:${NC} Invalid log level: ${CONFIG[log_level]}" >&2
echo "Valid options: DEBUG, INFO, WARN, ERROR" >&2
return 1
;;
esac
# Set remote repository name
REMOTE_NAME=$(basename "${PATH_DIR}")
if [[ "${CONFIG[standard_suffix]}" == true ]]; then
REMOTE_NAME="${REMOTE_NAME}.git"
fi
return 0
}
# Acquire lock file to prevent multiple instances
acquire_lock() {
debug "Acquiring lock file: ${CONFIG[lock_file]}"
# Create directory for lock file if it doesn't exist
mkdir -p "$(dirname "${CONFIG[lock_file]}")"
# Open file descriptor for the lock file
exec {LOCK_FD}>"${CONFIG[lock_file]}"
# Try to get exclusive lock
if ! flock -n ${LOCK_FD}; then
echo -e "${RED}Error:${NC} Another instance of BashBox is already running" >&2
return 1
fi
# Write PID to lock file
echo $$ > "${CONFIG[lock_file]}"
debug "Lock acquired with PID $$"
return 0
}
# Main function
main() {
# Parse command line arguments
parse_args "$@" || return 1
# Acquire lock file
acquire_lock || return 1
# Print startup banner
if [[ "${CONFIG[quiet]}" == false ]]; then
echo -e "${BLUE}BashBox v${VERSION}${NC} - Git-based File Synchronization Tool"
echo -e "${GREEN}Path:${NC} ${PATH_DIR}"
echo -e "${GREEN}Server:${NC} ${SERVER}:${REMOTE_NAME}"
echo -e "${GREEN}Started at:${NC} $(date)"
echo "--------------------------------------------"
fi
# Initialize log file
mkdir -p "$(dirname "${CONFIG[log_file]}")"
touch "${CONFIG[log_file]}"
# Initialize state variables
FS_CHANGED=true
NEED_PULL=true
NEXT_PULL=$(date +%s)
# Log startup
info "BashBox v${VERSION} starting up"
info "Configuration: path=${PATH_DIR}, server=${SERVER}, repo=${REMOTE_NAME}"
# Check dependencies
check_local_binaries || return 1
check_remote_binaries || return 1
# Ensure remote repository exists
ensure_remote_repo || return 1
# Initial pull to sync with remote
pull_changes || return 1
# Start background processes
info "Starting file monitoring and synchronization"
watch_local_changes
watch_remote_changes
pull_loop
push_loop
# Keep the main process running until interrupted
info "BashBox is running. Press Ctrl+C to stop."
while true; do
sleep 10
done
}
# Prevent script from running in source mode
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@" || exit $?
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment