Skip to content

Instantly share code, notes, and snippets.

@disafronov
Last active December 20, 2025 11:17
Show Gist options
  • Select an option

  • Save disafronov/abc0b4d2cc5db04b4e5dea0dd43e8e8c to your computer and use it in GitHub Desktop.

Select an option

Save disafronov/abc0b4d2cc5db04b4e5dea0dd43e8e8c to your computer and use it in GitHub Desktop.
Sync one's ~/.dotsecrets with git & stow
#!/usr/bin/env sh
set -eu
# Base directories for encrypted and decrypted secrets
REPO_DIR="$HOME/.dotsecrets"
DECRYPTED_DIR="$HOME/.dotsecrets.decrypted"
# Age encryption keys
AGE_KEY="$HOME/.age.key"
AGE_PUB="$HOME/.age.pub"
# Compare encrypted file content with plain file content
# Returns 0 if contents match, non-zero otherwise
compare_encrypted_with_plain() {
enc_file="$1"
plain_file="$2"
tmp_dec="$(mktemp)" || exit 1
if age -i "$AGE_KEY" -d -o "$tmp_dec" "$enc_file" >/dev/null 2>&1 && cmp -s "$plain_file" "$tmp_dec"; then
result=0
else
result=1
fi
rm -f "$tmp_dec" 2>/dev/null
return "$result"
}
# Remove decrypted file if corresponding encrypted file no longer exists
cleanup_decrypted_file() {
dec_file="$1"
rel_path="${dec_file#$DECRYPTED_DIR/}"
enc_file="$REPO_DIR/${rel_path}.age"
[ -f "$enc_file" ] || rm -f "$dec_file" || exit 1
}
# Decrypt a single encrypted file if needed (compares content to avoid unnecessary re-decryption)
decrypt_file_if_needed() {
enc_file="$1"
rel_path="${enc_file#$REPO_DIR/}"
dec_file="$DECRYPTED_DIR/${rel_path%.age}"
# Skip if this would create a .gitignore file
[ "${dec_file##*/}" = '.gitignore' ] && return
mkdir -p "$(dirname "$dec_file")" || exit 1
# Decrypt if all-secrets flag is set, or if decrypted file does not exist, or if content changed
if [ "$ALL_SECRETS" = '1' ] || [ ! -f "$dec_file" ] || ! compare_encrypted_with_plain "$enc_file" "$dec_file"; then
age -i "$AGE_KEY" -d -o "$dec_file" "$enc_file" || exit 1
fi
}
# Decrypt secrets from the encrypted repository into the decrypted tree
decrypt_secrets() {
# Prepare decrypted filesystem tree
mkdir -p "$DECRYPTED_DIR" || exit 1
# Remove decrypted files that no longer exist in the encrypted repository (skip .gitignore)
find "$DECRYPTED_DIR" -type f ! -name ".gitignore" | while IFS= read -r dec_file; do
cleanup_decrypted_file "$dec_file"
done
# Decrypt all secrets recursively (respecting content comparison and all-secrets flag)
find "$REPO_DIR" -type f -name '*.age' | while IFS= read -r enc_file; do
decrypt_file_if_needed "$enc_file"
done
}
# Remove encrypted file if corresponding decrypted source no longer exists
cleanup_encrypted_file() {
enc_file="$1"
rel_path="${enc_file#$REPO_DIR/}"
src_file="$DECRYPTED_DIR/${rel_path%.age}"
# Skip .gitignore.age files
[ "${src_file##*/}" = '.gitignore' ] && return
[ -f "$src_file" ] || rm -f "$enc_file" || exit 1
}
# Encrypt a single file if needed (compares content to avoid unnecessary re-encryption)
encrypt_file_if_needed() {
file="$1"
rel_path="${file#$DECRYPTED_DIR/}"
enc_file="$REPO_DIR/${rel_path}.age"
mkdir -p "$(dirname "$enc_file")" || exit 1
# Encrypt if all-secrets flag is set, or if encrypted file does not exist, or if source content changed
if [ "$ALL_SECRETS" = '1' ] || [ ! -f "$enc_file" ] || ! compare_encrypted_with_plain "$enc_file" "$file"; then
age -R "$AGE_PUB" -a -e -o "$enc_file" "$file" || exit 1
fi
}
# Encrypt secrets from the decrypted tree back into the encrypted repository
encrypt_secrets() {
# Remove encrypted files that no longer exist in the decrypted source (skip .gitignore.age)
find "$REPO_DIR" -type f -name '*.age' | while IFS= read -r enc_file; do
cleanup_encrypted_file "$enc_file"
done
# Encrypt all secrets recursively (excluding .gitignore, respecting all-secrets flag)
find "$DECRYPTED_DIR" -type f ! -name '.gitignore' | while IFS= read -r file; do
encrypt_file_if_needed "$file"
done
}
# Parse arguments
ACTION=""
ALL_SECRETS=""
FORCE=""
for arg in "$@"; do
case "$arg" in
-a|--all)
ALL_SECRETS="1"
;;
-f|--force)
FORCE="1"
;;
pull|push)
ACTION="$arg"
;;
esac
done
if [ "$ACTION" = "pull" ]; then
if [ "$FORCE" = "1" ]; then
# Forcefully sync local repository history and working tree with upstream
git -C "$REPO_DIR" fetch || exit 1
git -C "$REPO_DIR" reset --hard @{u} || exit 1
git -C "$REPO_DIR" clean -fdx || exit 1
else
# Pull the latest files from git
git -C "$REPO_DIR" pull || exit 1
fi
# Secrets decrypt into decrypted tree
decrypt_secrets
# Save current working directory
OLDPWD="$PWD"
# Change into decrypted directory
cd "$DECRYPTED_DIR" || exit 1
# Remove old stow links safely (if any)
stow -D . 2>/dev/null || true
# Apply stow links
stow . || exit 1
# Return to original working directory
cd "$OLDPWD" || exit 1
elif [ "$ACTION" = "push" ]; then
# Validate that decrypted source tree exists
[ -d "$DECRYPTED_DIR" ] || { echo "Error: $DECRYPTED_DIR does not exist" >&2; exit 1; }
# Secrets encrypt back into encrypted repository
encrypt_secrets
# Add, commit and push all files
git -C "$REPO_DIR" add -A || exit 1
# Commit if there are staged changes
if ! git -C "$REPO_DIR" diff --cached --quiet; then
git -C "$REPO_DIR" commit -m "chore: update" || exit 1
fi
if [ "$FORCE" = "1" ]; then
# Forcefully overwrite remote history with local branch
git -C "$REPO_DIR" push --force || exit 1
else
# Ensure upstream is configured and fail loudly if there is nothing to push
git -C "$REPO_DIR" rev-parse --abbrev-ref --symbolic-full-name @{u} >/dev/null 2>&1 || exit 1
if git -C "$REPO_DIR" rev-list @{u}..HEAD --quiet; then
git -C "$REPO_DIR" push || exit 1
else
exit 1
fi
fi
else
echo "Usage: $0 {pull|push} [-a|--all] [-f|--force]" >&2
exit 1
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment