Created
March 18, 2025 05:23
-
-
Save allen-munsch/95af0cb78d1373d8a2f80c1c3ce53562 to your computer and use it in GitHub Desktop.
bashbox - a git / ssh implemention sort of like dropbox
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 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