Created
August 4, 2026 14:59
-
-
Save dzogrim/e14f5a4e77906758beb466731ea30e7a to your computer and use it in GitHub Desktop.
Safely converts macOS Finder alias files below ROOT into POSIX symlinks
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 | |
| # | |
| # convert-finder-aliases.sh | |
| # --------------------------------------------------------------------------- | |
| # Safely converts macOS Finder alias files below ROOT into POSIX symlinks. | |
| # | |
| # Internal aliases, whose targets are inside ROOT, are converted to relative | |
| # symlinks so the complete tree can be moved or mounted elsewhere. External | |
| # aliases are reported by default and can be converted to absolute symlinks only | |
| # with --apply --include-external. | |
| # | |
| # The original Finder alias is retained as "<name>.finder-alias" before any | |
| # symlink is created. Broken aliases, missing targets, self-targets, and | |
| # conflicts are never modified. | |
| # | |
| # Required configuration: | |
| # ADM_CLASSEUR_PATH_PERSO="/path/to/search/root" in ~/.config/AdminHelpers/env | |
| # | |
| # Usage: | |
| # ./convert-finder-aliases.sh [--dry-run] [--log FILE] | |
| # ./convert-finder-aliases.sh --apply [--include-external] [--log FILE] | |
| # ./convert-finder-aliases.sh --restore [--log FILE] | |
| # ./convert-finder-aliases.sh --cleanup --confirm-delete-backups [--log FILE] | |
| # ./convert-finder-aliases.sh --root PATH [options] | |
| # ./convert-finder-aliases.sh --help | |
| # --------------------------------------------------------------------------- | |
| set -Eeuo pipefail | |
| # shellcheck disable=SC2034 | |
| readonly VERSION="0.1.1 (2026-08-04)" | |
| readonly SCRIPT_NAME="${0##*/}" | |
| readonly customScriptsEnv="${HOME}/.config/AdminHelpers/env" | |
| readonly BACKUP_SUFFIX=".finder-alias" | |
| MODE="dry-run" | |
| ROOT="" | |
| ROOT_REAL="" | |
| LOG_FILE="" | |
| INCLUDE_EXTERNAL=false | |
| CONFIRM_DELETE_BACKUPS=false | |
| TMP_DIR="" | |
| RESOLVER="" | |
| aliases_found=0 | |
| internal_count=0 | |
| external_count=0 | |
| broken_count=0 | |
| self_target_count=0 | |
| missing_target_count=0 | |
| already_converted_count=0 | |
| converted_count=0 | |
| restored_count=0 | |
| cleaned_count=0 | |
| skipped_count=0 | |
| error_count=0 | |
| FILES_INSPECTED=0 | |
| METADATA_FAILURES=0 | |
| usage() { | |
| cat <<EOF | |
| ${SCRIPT_NAME} ${VERSION} | |
| Usage: | |
| ${SCRIPT_NAME} [--dry-run] [--log FILE] | |
| ${SCRIPT_NAME} --apply [--include-external] [--log FILE] | |
| ${SCRIPT_NAME} --restore [--log FILE] | |
| ${SCRIPT_NAME} --cleanup --confirm-delete-backups [--log FILE] | |
| ${SCRIPT_NAME} --root PATH [options] | |
| ${SCRIPT_NAME} --help | |
| Modes: | |
| --dry-run Analyze only. This is the default. | |
| --apply Convert safe aliases. | |
| --restore Restore retained Finder aliases and remove generated links. | |
| --cleanup Delete retained backups after validating matching links. | |
| Options: | |
| --include-external | |
| With --apply, also convert external aliases to absolute links. | |
| --confirm-delete-backups | |
| Required with --cleanup before deleting .finder-alias files. | |
| --root PATH Override ROOT after loading ADM_CLASSEUR_PATH_PERSO. | |
| --log FILE Write the complete report to FILE and category reports beside it. | |
| -v, --version Print version. | |
| -h, --help Show this help. | |
| Configuration: | |
| ADM_CLASSEUR_PATH_PERSO must be defined in: | |
| ${customScriptsEnv} | |
| EOF | |
| } | |
| die_args() { | |
| printf 'ERROR: %s\n' "$1" >&2 | |
| printf 'Usage: %s --help\n' "${SCRIPT_NAME}" >&2 | |
| exit 2 | |
| } | |
| load_config() { | |
| if [[ ! -r "${customScriptsEnv}" ]]; then | |
| printf 'ERROR: AdminHelpers environment is missing or unreadable: %s\n' "${customScriptsEnv}" >&2 | |
| exit 3 | |
| fi | |
| # shellcheck source=/dev/null | |
| source "${customScriptsEnv}" | |
| : "${ADM_CLASSEUR_PATH_PERSO:?Missing ADM_CLASSEUR_PATH_PERSO in ${customScriptsEnv}}" | |
| ROOT="${ADM_CLASSEUR_PATH_PERSO}" | |
| } | |
| parse_args() { | |
| local selected_modes=0 | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| --dry-run) | |
| MODE="dry-run" | |
| selected_modes=$((selected_modes + 1)) | |
| shift | |
| ;; | |
| --apply) | |
| MODE="apply" | |
| selected_modes=$((selected_modes + 1)) | |
| shift | |
| ;; | |
| --restore) | |
| MODE="restore" | |
| selected_modes=$((selected_modes + 1)) | |
| shift | |
| ;; | |
| --cleanup) | |
| MODE="cleanup" | |
| selected_modes=$((selected_modes + 1)) | |
| shift | |
| ;; | |
| --include-external) | |
| INCLUDE_EXTERNAL=true | |
| shift | |
| ;; | |
| --confirm-delete-backups) | |
| CONFIRM_DELETE_BACKUPS=true | |
| shift | |
| ;; | |
| --root) | |
| [[ $# -ge 2 ]] || die_args "--root requires a path" | |
| ROOT="$2" | |
| shift 2 | |
| ;; | |
| --log) | |
| [[ $# -ge 2 ]] || die_args "--log requires a file" | |
| LOG_FILE="$2" | |
| shift 2 | |
| ;; | |
| -v|--version) | |
| printf '%s %s\n' "${SCRIPT_NAME}" "${VERSION}" | |
| exit 0 | |
| ;; | |
| -h|--help) | |
| usage | |
| exit 0 | |
| ;; | |
| *) | |
| die_args "unknown argument: $1" | |
| ;; | |
| esac | |
| done | |
| [[ "${selected_modes}" -le 1 ]] || die_args "--apply, --restore, and --cleanup are mutually exclusive" | |
| if [[ "${INCLUDE_EXTERNAL}" == true && "${MODE}" != "apply" ]]; then | |
| die_args "--include-external is only valid with --apply" | |
| fi | |
| if [[ "${CONFIRM_DELETE_BACKUPS}" == true && "${MODE}" != "cleanup" ]]; then | |
| die_args "--confirm-delete-backups is only valid with --cleanup" | |
| fi | |
| if [[ "${MODE}" == "cleanup" && "${CONFIRM_DELETE_BACKUPS}" != true ]]; then | |
| die_args "--cleanup requires --confirm-delete-backups" | |
| fi | |
| } | |
| require_root() { | |
| if [[ -z "${ROOT}" || ! -d "${ROOT}" ]]; then | |
| printf 'ERROR: ROOT missing or inaccessible: %s\n' "${ROOT:-<unset>}" >&2 | |
| exit 3 | |
| fi | |
| } | |
| require_tools() { | |
| local missing=() | |
| local tool | |
| for tool in /usr/bin/find /usr/bin/mdls /usr/bin/mktemp /usr/bin/xcrun /usr/bin/perl /bin/mkdir /bin/mv /bin/ln /bin/rm; do | |
| [[ -x "${tool}" ]] || missing+=("${tool}") | |
| done | |
| if [[ "${#missing[@]}" -gt 0 ]]; then | |
| printf 'ERROR: required macOS tool unavailable:\n' >&2 | |
| printf ' %s\n' "${missing[@]}" >&2 | |
| exit 4 | |
| fi | |
| } | |
| setup_logging() { | |
| local log_dir | |
| [[ -z "${LOG_FILE}" ]] && return 0 | |
| log_dir="${LOG_FILE%/*}" | |
| [[ "${log_dir}" == "${LOG_FILE}" ]] && log_dir="." | |
| [[ -d "${log_dir}" ]] || { | |
| printf 'ERROR: log directory missing: %s\n' "${log_dir}" >&2 | |
| exit 1 | |
| } | |
| : >"${LOG_FILE}" || { | |
| printf 'ERROR: cannot write log: %s\n' "${LOG_FILE}" >&2 | |
| exit 1 | |
| } | |
| } | |
| log() { | |
| printf '%s\n' "$*" | |
| [[ -z "${LOG_FILE}" ]] || printf '%s\n' "$*" >>"${LOG_FILE}" | |
| } | |
| escape_field() { | |
| local value="$1" | |
| value=${value//\\/\\\\} | |
| value=${value//$'\t'/\\t} | |
| value=${value//$'\n'/\\n} | |
| value=${value//$'\r'/\\r} | |
| printf '%s' "${value}" | |
| } | |
| report_path() { | |
| local category="$1" | |
| local base dir stem | |
| [[ -n "${LOG_FILE}" ]] || return 1 | |
| base="${LOG_FILE##*/}" | |
| dir="${LOG_FILE%/*}" | |
| [[ "${dir}" == "${LOG_FILE}" ]] && dir="." | |
| stem="${base%.*}" | |
| printf '%s/%s-%s.tsv' "${dir}" "${stem}" "${category}" | |
| } | |
| write_report() { | |
| local category="$1" | |
| local status="$2" | |
| local alias_path="$3" | |
| local target_path="$4" | |
| local message="$5" | |
| local file | |
| file="$(report_path "${category}")" || return 0 | |
| if [[ ! -e "${file}" ]]; then | |
| printf 'status\talias_path\tresolved_target\tmessage\n' >"${file}" | |
| fi | |
| printf '%s\t%s\t%s\t%s\n' \ | |
| "$(escape_field "${status}")" \ | |
| "$(escape_field "${alias_path}")" \ | |
| "$(escape_field "${target_path}")" \ | |
| "$(escape_field "${message}")" >>"${file}" | |
| } | |
| cleanup_tmp() { | |
| [[ -z "${TMP_DIR}" ]] || /bin/rm -rf -- "${TMP_DIR}" | |
| } | |
| build_resolver() { | |
| TMP_DIR="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/convert-finder-aliases.XXXXXX")" | |
| RESOLVER="${TMP_DIR}/resolve-finder-alias" | |
| /bin/mkdir -p "${TMP_DIR}/module-cache" | |
| cat >"${TMP_DIR}/resolve-finder-alias.swift" <<'SWIFT' | |
| import Foundation | |
| enum ResolveError: Error, CustomStringConvertible { | |
| case missingArgument | |
| case notAlias | |
| case unreadable(URLResourceKey) | |
| var description: String { | |
| switch self { | |
| case .missingArgument: | |
| return "missing alias path" | |
| case .notAlias: | |
| return "not a Finder alias file" | |
| case .unreadable(let key): | |
| return "unable to read resource key \(key.rawValue)" | |
| } | |
| } | |
| } | |
| func fail(_ message: String, _ code: Int32) -> Never { | |
| FileHandle.standardError.write(Data((message + "\n").utf8)) | |
| exit(code) | |
| } | |
| do { | |
| guard CommandLine.arguments.count == 2 else { | |
| throw ResolveError.missingArgument | |
| } | |
| let aliasURL = URL(fileURLWithPath: CommandLine.arguments[1]) | |
| let values = try aliasURL.resourceValues(forKeys: [.isAliasFileKey]) | |
| guard let isAlias = values.isAliasFile else { | |
| throw ResolveError.unreadable(.isAliasFileKey) | |
| } | |
| guard isAlias else { | |
| throw ResolveError.notAlias | |
| } | |
| let resolvedURL = try URL( | |
| resolvingAliasFileAt: aliasURL, | |
| options: [.withoutUI] | |
| ) | |
| print(resolvedURL.path) | |
| } catch let error as ResolveError { | |
| fail(error.description, 10) | |
| } catch { | |
| fail(error.localizedDescription, 11) | |
| } | |
| SWIFT | |
| CLANG_MODULE_CACHE_PATH="${TMP_DIR}/module-cache" \ | |
| /usr/bin/xcrun swiftc "${TMP_DIR}/resolve-finder-alias.swift" -o "${RESOLVER}" || { | |
| printf 'ERROR: failed to build Swift alias resolver\n' >&2 | |
| exit 4 | |
| } | |
| } | |
| is_finder_alias() { | |
| local item="$1" | |
| [[ ! -L "${item}" ]] || return 1 | |
| [[ "${item}" != *"${BACKUP_SUFFIX}" ]] || return 1 | |
| [[ "$(/usr/bin/mdls -raw -name kMDItemContentType "${item}" 2>/dev/null)" == "com.apple.alias-file" ]] | |
| } | |
| is_retained_finder_alias() { | |
| local item="$1" | |
| [[ ! -L "${item}" ]] || return 1 | |
| [[ "$(/usr/bin/mdls -raw -name kMDItemContentType "${item}" 2>/dev/null)" == "com.apple.alias-file" ]] | |
| } | |
| resolve_alias() { | |
| "${RESOLVER}" "$1" | |
| } | |
| canonical_path() { | |
| /usr/bin/perl -MCwd=abs_path -e ' | |
| my $path = abs_path($ARGV[0]); | |
| exit 1 unless defined $path; | |
| print $path; | |
| ' "$1" | |
| } | |
| relative_path() { | |
| /usr/bin/perl -MFile::Spec -e 'print File::Spec->abs2rel($ARGV[1], $ARGV[0])' "$1" "$2" | |
| } | |
| link_target_canonical() { | |
| /usr/bin/perl -MCwd=abs_path -e ' | |
| my $path = abs_path($ARGV[0]); | |
| exit 1 unless defined $path; | |
| print $path; | |
| ' "$1" | |
| } | |
| target_is_inside_root() { | |
| local root_real="$1" | |
| local target_real="$2" | |
| [[ "${target_real}" == "${root_real}" || "${target_real}" == "${root_real}/"* ]] | |
| } | |
| backup_for() { | |
| printf '%s%s' "$1" "${BACKUP_SUFFIX}" | |
| } | |
| classify_existing_conversion() { | |
| local alias_file="$1" | |
| local target="$2" | |
| local backup="$3" | |
| local backup_target="" | |
| [[ -L "${alias_file}" || -e "${backup}" || -L "${backup}" ]] || return 1 | |
| if [[ -e "${backup}" && ! -L "${backup}" ]] && is_retained_finder_alias "${backup}"; then | |
| backup_target="$(resolve_alias "${backup}" 2>/dev/null || true)" | |
| fi | |
| if [[ -L "${alias_file}" && -n "${backup_target}" && "${backup_target}" == "${target}" ]]; then | |
| return 0 | |
| fi | |
| if [[ -e "${backup}" || -L "${backup}" ]]; then | |
| return 0 | |
| fi | |
| return 1 | |
| } | |
| convert_alias() { | |
| local alias_file="$1" | |
| local target="$2" | |
| local link_target="$3" | |
| local classification="$4" | |
| local backup | |
| local link_real | |
| local target_real | |
| backup="$(backup_for "${alias_file}")" | |
| if [[ -e "${backup}" || -L "${backup}" ]]; then | |
| log "[SKIP] [ALREADY_CONVERTED] backup exists: ${backup}" | |
| already_converted_count=$((already_converted_count + 1)) | |
| skipped_count=$((skipped_count + 1)) | |
| write_report "errors" "ALREADY_CONVERTED" "${alias_file}" "${target}" "backup already exists" | |
| return 0 | |
| fi | |
| log "[OK] [${classification}] converting: ${alias_file} -> ${link_target}" | |
| if [[ "${MODE}" == "dry-run" ]]; then | |
| log "[DRY-RUN] mv -- ${alias_file} ${backup}" | |
| log "[DRY-RUN] ln -s -- ${link_target} ${alias_file}" | |
| return 0 | |
| fi | |
| /bin/mv -- "${alias_file}" "${backup}" || { | |
| log "[ERROR] cannot move Finder alias to backup: ${alias_file}" | |
| error_count=$((error_count + 1)) | |
| write_report "errors" "ERROR" "${alias_file}" "${target}" "failed to move alias to backup" | |
| return 1 | |
| } | |
| if ! /bin/ln -s -- "${link_target}" "${alias_file}"; then | |
| /bin/rm -f -- "${alias_file}" | |
| /bin/mv -- "${backup}" "${alias_file}" | |
| log "[ERROR] failed to create symlink, original restored: ${alias_file}" | |
| error_count=$((error_count + 1)) | |
| write_report "errors" "ERROR" "${alias_file}" "${target}" "failed to create symlink" | |
| return 1 | |
| fi | |
| if [[ ! -L "${alias_file}" || ! -e "${alias_file}" ]]; then | |
| /bin/rm -f -- "${alias_file}" | |
| /bin/mv -- "${backup}" "${alias_file}" | |
| log "[ERROR] invalid symlink, original restored: ${alias_file}" | |
| error_count=$((error_count + 1)) | |
| write_report "errors" "ERROR" "${alias_file}" "${target}" "symlink validation failed" | |
| return 1 | |
| fi | |
| link_real="$(link_target_canonical "${alias_file}" 2>/dev/null || true)" | |
| target_real="$(canonical_path "${target}" 2>/dev/null || true)" | |
| if [[ -z "${link_real}" || -z "${target_real}" || "${link_real}" != "${target_real}" ]]; then | |
| /bin/rm -f -- "${alias_file}" | |
| /bin/mv -- "${backup}" "${alias_file}" | |
| log "[ERROR] symlink resolves to unexpected target, original restored: ${alias_file}" | |
| error_count=$((error_count + 1)) | |
| write_report "errors" "ERROR" "${alias_file}" "${target}" "symlink target mismatch" | |
| return 1 | |
| fi | |
| converted_count=$((converted_count + 1)) | |
| log "[OK ${classification}] ${alias_file} -> ${link_target}" | |
| } | |
| process_alias() { | |
| local alias_file="$1" | |
| local target="" | |
| local target_real="" | |
| local alias_parent="" | |
| local link_target="" | |
| local backup="" | |
| aliases_found=$((aliases_found + 1)) | |
| backup="$(backup_for "${alias_file}")" | |
| if ! target="$(resolve_alias "${alias_file}" 2>/dev/null)"; then | |
| broken_count=$((broken_count + 1)) | |
| skipped_count=$((skipped_count + 1)) | |
| log "[SKIP] [BROKEN] ${alias_file}" | |
| write_report "broken" "BROKEN" "${alias_file}" "" "Finder/Foundation could not resolve alias" | |
| return 0 | |
| fi | |
| if [[ -z "${target}" || "${target}" == "${alias_file}" ]]; then | |
| self_target_count=$((self_target_count + 1)) | |
| skipped_count=$((skipped_count + 1)) | |
| log "[SKIP] [SELF_TARGET] ${alias_file} -> ${target}" | |
| write_report "broken" "SELF_TARGET" "${alias_file}" "${target}" "resolver returned the alias path itself" | |
| return 0 | |
| fi | |
| if [[ ! -e "${target}" ]]; then | |
| missing_target_count=$((missing_target_count + 1)) | |
| skipped_count=$((skipped_count + 1)) | |
| log "[SKIP] [MISSING_TARGET] ${alias_file} -> ${target}" | |
| write_report "broken" "MISSING_TARGET" "${alias_file}" "${target}" "resolved target does not exist" | |
| return 0 | |
| fi | |
| target_real="$(canonical_path "${target}" 2>/dev/null || true)" | |
| if [[ -z "${target_real}" ]]; then | |
| error_count=$((error_count + 1)) | |
| skipped_count=$((skipped_count + 1)) | |
| log "[ERROR] cannot canonicalize target: ${alias_file} -> ${target}" | |
| write_report "errors" "ERROR" "${alias_file}" "${target}" "cannot canonicalize target" | |
| return 0 | |
| fi | |
| if classify_existing_conversion "${alias_file}" "${target}" "${backup}"; then | |
| already_converted_count=$((already_converted_count + 1)) | |
| skipped_count=$((skipped_count + 1)) | |
| log "[SKIP] [ALREADY_CONVERTED] ${alias_file}" | |
| write_report "errors" "ALREADY_CONVERTED" "${alias_file}" "${target}" "matching link or retained backup already exists" | |
| return 0 | |
| fi | |
| if target_is_inside_root "${ROOT_REAL}" "${target_real}"; then | |
| internal_count=$((internal_count + 1)) | |
| alias_parent="${alias_file%/*}" | |
| link_target="$(relative_path "${alias_parent}" "${target}")" | |
| log "[INTERNAL] ${alias_file} -> ${target}" | |
| write_report "internal" "INTERNAL" "${alias_file}" "${target}" "target is inside ROOT" | |
| convert_alias "${alias_file}" "${target}" "${link_target}" "INTERNAL" | |
| else | |
| external_count=$((external_count + 1)) | |
| log "[WARNING] [EXTERNAL] ${alias_file} -> ${target}" | |
| log "[WARNING] External absolute links may break on backup mounts." | |
| write_report "external" "EXTERNAL" "${alias_file}" "${target}" "target is outside ROOT" | |
| if [[ "${MODE}" == "apply" && "${INCLUDE_EXTERNAL}" == true ]]; then | |
| convert_alias "${alias_file}" "${target}" "${target}" "EXTERNAL" | |
| else | |
| skipped_count=$((skipped_count + 1)) | |
| fi | |
| fi | |
| } | |
| scan_aliases() { | |
| local path | |
| local content_type | |
| while IFS= read -r -d '' path; do | |
| ((FILES_INSPECTED += 1)) | |
| [[ "$path" == *.finder-alias ]] && continue | |
| content_type=$( | |
| /usr/bin/mdls \ | |
| -raw \ | |
| -name kMDItemContentType \ | |
| "$path" 2>/dev/null | |
| ) || { | |
| ((METADATA_FAILURES += 1)) | |
| continue | |
| } | |
| if [[ "$content_type" == "com.apple.alias-file" ]]; then | |
| process_alias "$path" | |
| fi | |
| done < <(/usr/bin/find "$ROOT" -type f -print0) | |
| } | |
| restore_backups() { | |
| local backup original | |
| while IFS= read -r -d '' backup; do | |
| original="${backup%"${BACKUP_SUFFIX}"}" | |
| if [[ ! -L "${original}" ]]; then | |
| log "[SKIP] restore conflict, original is not a generated symlink: ${original}" | |
| skipped_count=$((skipped_count + 1)) | |
| continue | |
| fi | |
| if ! is_retained_finder_alias "${backup}"; then | |
| log "[SKIP] restore backup is not a Finder alias: ${backup}" | |
| skipped_count=$((skipped_count + 1)) | |
| continue | |
| fi | |
| /bin/rm -f -- "${original}" || { | |
| log "[ERROR] cannot remove generated symlink: ${original}" | |
| error_count=$((error_count + 1)) | |
| continue | |
| } | |
| /bin/mv -- "${backup}" "${original}" || { | |
| log "[ERROR] cannot restore Finder alias: ${backup}" | |
| error_count=$((error_count + 1)) | |
| continue | |
| } | |
| restored_count=$((restored_count + 1)) | |
| log "[OK] restored Finder alias: ${original}" | |
| done < <(/usr/bin/find "${ROOT}" -type f -name "*${BACKUP_SUFFIX}" -print0) | |
| } | |
| cleanup_backups() { | |
| local backup original backup_target link_real backup_real | |
| while IFS= read -r -d '' backup; do | |
| original="${backup%"${BACKUP_SUFFIX}"}" | |
| if [[ ! -L "${original}" || ! -e "${original}" ]]; then | |
| log "[SKIP] cleanup needs a working symlink: ${original}" | |
| skipped_count=$((skipped_count + 1)) | |
| continue | |
| fi | |
| if ! is_retained_finder_alias "${backup}"; then | |
| log "[SKIP] cleanup backup is not a Finder alias: ${backup}" | |
| skipped_count=$((skipped_count + 1)) | |
| continue | |
| fi | |
| backup_target="$(resolve_alias "${backup}" 2>/dev/null || true)" | |
| link_real="$(link_target_canonical "${original}" 2>/dev/null || true)" | |
| backup_real="$(canonical_path "${backup_target}" 2>/dev/null || true)" | |
| if [[ -z "${backup_target}" || -z "${link_real}" || -z "${backup_real}" || "${link_real}" != "${backup_real}" ]]; then | |
| log "[SKIP] cleanup target mismatch, retaining backup: ${backup}" | |
| skipped_count=$((skipped_count + 1)) | |
| continue | |
| fi | |
| /bin/rm -f -- "${backup}" || { | |
| log "[ERROR] cannot delete retained backup: ${backup}" | |
| error_count=$((error_count + 1)) | |
| continue | |
| } | |
| cleaned_count=$((cleaned_count + 1)) | |
| log "[OK] deleted retained backup: ${backup}" | |
| done < <(/usr/bin/find "${ROOT}" -type f -name "*${BACKUP_SUFFIX}" -print0) | |
| } | |
| print_summary() { | |
| log "" | |
| log "Files inspected: ${FILES_INSPECTED}" | |
| log "Metadata failures: ${METADATA_FAILURES}" | |
| log "Finder aliases found: ${aliases_found}" | |
| log "Internal resolvable: ${internal_count}" | |
| log "External resolvable: ${external_count}" | |
| log "Broken: ${broken_count}" | |
| log "Self-target: ${self_target_count}" | |
| log "Missing target: ${missing_target_count}" | |
| log "Already converted: ${already_converted_count}" | |
| log "Converted successfully: ${converted_count}" | |
| log "Restored successfully: ${restored_count}" | |
| log "Backups cleaned: ${cleaned_count}" | |
| log "Skipped: ${skipped_count}" | |
| log "Errors: ${error_count}" | |
| log "Mode: ${MODE}" | |
| } | |
| main() { | |
| trap cleanup_tmp EXIT | |
| case "${1:-}" in | |
| -h|--help) | |
| usage | |
| exit 0 | |
| ;; | |
| -v|--version) | |
| printf '%s %s\n' "${SCRIPT_NAME}" "${VERSION}" | |
| exit 0 | |
| ;; | |
| esac | |
| load_config | |
| parse_args "$@" | |
| require_root | |
| require_tools | |
| setup_logging | |
| build_resolver | |
| ROOT_REAL="$(canonical_path "${ROOT}" 2>/dev/null || true)" | |
| if [[ -z "${ROOT_REAL}" ]]; then | |
| printf 'ERROR: cannot canonicalize ROOT: %s\n' "${ROOT}" >&2 | |
| exit 3 | |
| fi | |
| log "${SCRIPT_NAME} ${VERSION}" | |
| log "ROOT: ${ROOT}" | |
| log "Mode: ${MODE}" | |
| [[ "${MODE}" == "apply" && "${INCLUDE_EXTERNAL}" == true ]] && \ | |
| log "[WARNING] --include-external selected; external links will be absolute." | |
| case "${MODE}" in | |
| dry-run|apply) | |
| scan_aliases | |
| ;; | |
| restore) | |
| restore_backups | |
| ;; | |
| cleanup) | |
| cleanup_backups | |
| ;; | |
| *) | |
| die_args "invalid mode: ${MODE}" | |
| ;; | |
| esac | |
| print_summary | |
| [[ "${error_count}" -eq 0 ]] || exit 1 | |
| } | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment