Created
January 21, 2026 05:08
-
-
Save mcmire/33f5b5e1a7c96049424c17fb3060a4e0 to your computer and use it in GitHub Desktop.
Script to sync Git-backed Obsidian vault
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
| #!/bin/bash | |
| ## Constants | |
| LOCKED_EXIT_CODE=-700 | |
| ## Parameters | |
| repo_directory="$PWD" | |
| primary_branch_name=main | |
| dry_run=false | |
| pid_file_directory= | |
| ## Helpers | |
| success() { | |
| echo "$@" | |
| } | |
| info() { | |
| echo "$@" | |
| } | |
| warn() { | |
| echo "WARNING:" "$@" >&2 | |
| } | |
| error() { | |
| echo "ERROR:" "$@" >&2 | |
| } | |
| success-with-tag() { | |
| success "[$1]" "${@:2}" | |
| } | |
| warn-with-tag() { | |
| warn "[$1]" "${@:2}" | |
| } | |
| error-with-tag() { | |
| error "[$1]" "${@:2}" | |
| } | |
| info-with-tag() { | |
| info "[$1]" "${@:2}" | |
| } | |
| is-git-repository() { | |
| git rev-parse --is-inside-work-tree >/dev/null 2>&1 | |
| } | |
| get-current-branch() { | |
| git rev-parse --abbrev-ref HEAD | |
| } | |
| has-staged-changes() { | |
| if git diff-index --quiet --cached HEAD >/dev/null 2>&1; then | |
| return 1 | |
| else | |
| return 0 | |
| fi | |
| } | |
| get-files-to-sync() { | |
| git status --porcelain --no-renames | cut -c 4- | |
| } | |
| remove-branch() { | |
| local branch_name="$1" | |
| git branch -D "$branch_name" && (git push origin --delete "$branch_name" || true) | |
| } | |
| run-command() { | |
| local command_executable="$1" | |
| local command_args=("${@:2}") | |
| local exit_status | |
| local tempfile | |
| tempfile="$(mktemp)" | |
| local command="$command_executable" | |
| for command_arg in "${command_args[@]}"; do | |
| command+=" " | |
| command+=$(printf "%q" "$command_arg") | |
| done | |
| if [[ $dry_run == "true" ]]; then | |
| echo "Would have run: $command" | |
| else | |
| eval "$command" >"$tempfile" 2>&1 | |
| exit_status=$? | |
| if [[ $exit_status -ne 0 ]]; then | |
| error "Command '${command[*]}' failed with exit status $exit_status." | |
| echo "Command output:" | |
| cat "$tempfile" | |
| fi | |
| return $exit_status | |
| fi | |
| } | |
| ## Actions | |
| print-usage() { | |
| echo "\ | |
| USAGE: $0 OPTIONS | |
| OPTIONS: | |
| -b, --primary-branch BRANCH | |
| Name of the primary branch to sync with. | |
| (Default: main) | |
| -p, --pid-file-directory DIRECTORY | |
| Path to the directory used to record a lockfile for this script. | |
| (Default: /tmp) | |
| -n, --dry-run | |
| Don't perform any Git operations; just print what would have happened. | |
| -h, --help | |
| Print this message and exit. | |
| " | |
| } | |
| parse-args() { | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| --primary-branch | -b) | |
| primary_branch_name="$2" | |
| shift 2 | |
| ;; | |
| --dry-run | -n) | |
| dry_run="true" | |
| shift | |
| ;; | |
| --pid-file-directory | -p) | |
| pid_file_directory="$2" | |
| shift 2 | |
| ;; | |
| --help | -h) | |
| print-usage | |
| exit | |
| ;; | |
| *) | |
| error "Unknown option or argument: $1" | |
| error | |
| print-usage >&2 | |
| return 1 | |
| ;; | |
| esac | |
| done | |
| if [[ -z $pid_file_directory ]]; then | |
| if ls /tmp &>/dev/null; then | |
| pid_file_directory="/tmp" | |
| else | |
| pid_file_directory="$HOME/tmp" | |
| fi | |
| fi | |
| return 0 | |
| } | |
| validate-args() { | |
| if [[ -z $primary_branch_name ]]; then | |
| error "Missing required option: --primary-branch-name" | |
| error | |
| print-usage >&2 | |
| return 1 | |
| fi | |
| return 0 | |
| } | |
| get-pid-file-path() { | |
| local command_hash | |
| command_hash=$(echo -n "$repo_directory" "$primary_branch_name" | md5sum | head -c 32) | |
| echo "$pid_file_directory/sync-obsidian-vault.$command_hash.pid" | |
| } | |
| lock() { | |
| local pid_file_path | |
| pid_file_path="$(get-pid-file-path)" | |
| if [[ -z "$pid_file_path" ]]; then | |
| error "Could not lock: Could not generate PID file path" | |
| return 1 | |
| fi | |
| if [[ -f "$pid_file_path" ]]; then | |
| if ps -p "$(cat "$pid_file_path")" >/dev/null; then | |
| error "This Obsidian vault is already in the process of being synced." | |
| return $LOCKED_EXIT_CODE | |
| else | |
| rm "$pid_file_path" | |
| fi | |
| else | |
| mkdir -p "$(dirname "$pid_file_path")" | |
| echo $$ > "$pid_file_path" | |
| fi | |
| return 0 | |
| } | |
| unlock() { | |
| local pid_file_path | |
| pid_file_path="$(get-pid-file-path)" | |
| if [[ -f "$pid_file_path" ]]; then | |
| rm "$pid_file_path" | |
| fi | |
| } | |
| enforce-git-repository() { | |
| if ! is-git-repository; then | |
| error "\`$PWD\` is not a Git repository." | |
| return 1 | |
| fi | |
| } | |
| enforce-primary-branch() { | |
| local current_branch | |
| current_branch="$(get-current-branch)" | |
| if [[ "$current_branch" != "$primary_branch_name" ]]; then | |
| error-with-tag "$PWD" "The current branch must be \`$primary_branch_name\` to continue." | |
| return 1 | |
| fi | |
| } | |
| pull-primary-branch() { | |
| info-with-tag "$PWD" "Fetching updates..." | |
| run-command git pull origin "$primary_branch_name" --rebase --prune | |
| } | |
| commit-files-to-sync() { | |
| local files_to_sync=("$@") | |
| local submodule_path | |
| local submodule_paths=() | |
| local is_submodule= | |
| local num_files_added=0 | |
| info-with-tag "$PWD" "Committing all files..." | |
| # shellcheck disable=SC2016 | |
| while read -r submodule_path; do | |
| submodule_paths+=("$submodule_path") | |
| done < <(git submodule --quiet foreach 'echo $sm_path') | |
| is_submodule="false" | |
| for file in "${files_to_sync[@]}"; do | |
| for submodule_path in "${submodule_paths[@]}"; do | |
| if [[ $submodule_path == "$file" ]]; then | |
| is_submodule="true" | |
| break | |
| fi | |
| done | |
| if [[ $file == *.md || $file == *.mdx || $file == .obsidian/* || "$is_submodule" == "true" ]]; then | |
| info-with-tag "$PWD" "- Adding file: $file" | |
| run-command git add "$file" | |
| fi | |
| done | |
| num_files_added="$(git diff --cached --name-only | wc -l)" | |
| if [[ $num_files_added -gt 0 ]]; then | |
| run-command git commit -m "Automatic sync" | |
| fi | |
| } | |
| push-primary-branch() { | |
| info-with-tag "$PWD" "Pushing \`$primary_branch_name\`..." | |
| run-command git push origin "$primary_branch_name" | |
| } | |
| sync-directory() { | |
| local directory="$1" | |
| local previous_directory="$2" | |
| local submodule_path | |
| local file | |
| local files_to_sync=() | |
| cd "$directory" || return $? | |
| enforce-git-repository || return $? | |
| enforce-primary-branch || return $? | |
| # shellcheck disable=SC2016 | |
| while read -r submodule_path; do | |
| sync-directory "${directory}/${submodule_path}" "$PWD" || return $? | |
| echo | |
| done < <(git submodule --quiet foreach 'echo $sm_path') | |
| while IFS= read -r file; do | |
| if [[ -n $file ]]; then | |
| files_to_sync+=("$file") | |
| fi | |
| done < <(get-files-to-sync) | |
| if [[ ${#files_to_sync[@]} -gt 0 ]]; then | |
| commit-files-to-sync "${files_to_sync[@]}" || return $? | |
| pull-primary-branch || return $? | |
| push-primary-branch || return $? | |
| if [[ $dry_run == "false" ]]; then | |
| success-with-tag "$directory" "Sync completed successfully!" | |
| fi | |
| else | |
| pull-primary-branch || return $? | |
| push-primary-branch || return $? | |
| if [[ $dry_run == "true" ]]; then | |
| success-with-tag "$directory" "No changes would have been synced." | |
| else | |
| success-with-tag "$directory" "No changes to sync, all good!" | |
| fi | |
| fi | |
| if [[ "$directory" != "$repo_directory" ]]; then | |
| cd "$previous_directory" >/dev/null || return $? | |
| fi | |
| return 0 | |
| } | |
| main() { | |
| local exitcode | |
| local current_branch | |
| parse-args "$@" || return $? | |
| validate-args || return $? | |
| lock || return $? | |
| trap unlock INT TERM | |
| sync-directory "$repo_directory" "$PWD" || return $? | |
| exitcode=$? | |
| unlock | |
| return $exitcode | |
| } | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment