Last active
December 20, 2025 11:17
-
-
Save disafronov/abc0b4d2cc5db04b4e5dea0dd43e8e8c to your computer and use it in GitHub Desktop.
Sync one's ~/.dotsecrets with git & stow
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 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