From f6b323e9f5d17814d269718902ea372c0f3f33a4 Mon Sep 17 00:00:00 2001 From: "Oliver Surke (Gitea)" Date: Tue, 12 May 2026 17:41:15 +0200 Subject: [PATCH] fix(mackup): correct built-in coverage detection and per-item scan confirm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _ssr_mackup_declared_paths: - Fix wrong subdir: apps/ → applications/ (coverage was completely broken) - Use Mackup's bundled Python interpreter (not system python3) - Track xdg_configuration_files sections separately and prefix paths with .config/ so paths like gh/config.yml → .config/gh/config.yml are properly recognized as covering .config/gh _is_declared: - Use `command grep` to bypass colour aliases - Replace grep -E regex (dot-escaping issues) with bash glob: == "$p/"* - Applied to both lib/mackup.sh and bin/ssr-mackup _SSR_MACKUP_CURATED: - Remove entries now correctly detected as built-in covered: .claude (claude-code.cfg), .codex, .docker, .config/gh (github-cli xdg) - Keep .cursor (built-in covers Library/Application Support, not .cursor/) _SSR_MACKUP_SELECTIVE: - Remove entries for removed curated items ssr mackup scan: - Confirm each new item individually (y/n per candidate) - Confirm each stale removal individually - Show resolved sub-paths per item before asking Co-authored-by: Cursor --- bin/ssr-mackup | 163 +++++++++++++++++++++++++++++++------------------ lib/mackup.sh | 119 +++++++++++++++++++++++++----------- 2 files changed, 187 insertions(+), 95 deletions(-) diff --git a/bin/ssr-mackup b/bin/ssr-mackup index f0a5e9e..e137536 100755 --- a/bin/ssr-mackup +++ b/bin/ssr-mackup @@ -37,7 +37,7 @@ EOF } # --------------------------------------------------------------------------- -# scan — dry-run then confirm +# scan — show each candidate and confirm per-item # --------------------------------------------------------------------------- cmd_scan() { local mackup_dir @@ -49,13 +49,27 @@ cmd_scan() { local skip_set=" ${_SSR_MACKUP_SKIP[*]} " - _in_backup() { [[ -n "$mackup_dir" && -e "$mackup_dir/$1" ]]; } - _is_declared() { printf '%s\n' "$declared" | grep -qxF "$1"; } - local declared declared="$(_ssr_mackup_declared_paths)" - # Collect candidate items (same logic as generate_custom_cfg). + # Returns true when is declared exactly OR when any built-in declares + # a sub-path of (e.g. .byobu/profile covers .byobu, .claude/settings + # covers .claude, etc.). + # Uses `command grep` (bypasses colour aliases) and a bash glob for the + # prefix check (avoids regex escaping issues with dots in paths). + _is_declared() { + local p="$1" decl + command grep -qxF "$p" <<< "$declared" && return 0 + while IFS= read -r decl; do + [[ "$decl" == "$p/"* ]] && return 0 + done <<< "$declared" + return 1 + } + _in_backup() { [[ -n "$mackup_dir" && -e "$mackup_dir/$1" ]]; } + + # ----------------------------------------------------------------------- + # Collect candidate items — same logic as generate_custom_cfg. + # ----------------------------------------------------------------------- local -a items=() local item for item in "${_SSR_MACKUP_CURATED[@]}" ${SSR_MACKUP_EXTRA_PATHS:-}; do @@ -76,83 +90,110 @@ cmd_scan() { items+=("$name") done < <(find "$HOME" -maxdepth 1 -mindepth 1 -name '.*' -type d 2>/dev/null | sort) - # Check existing stale duplicates. - local -a to_remove=() + # ----------------------------------------------------------------------- + # Stale ssr-*.cfg files: exist but now covered by a built-in definition. + # ----------------------------------------------------------------------- + local -a stale_cfgs=() local stale_cfg stale_path for stale_cfg in "$cfg_dir"/ssr-*.cfg; do [[ -f "$stale_cfg" ]] || continue stale_path="$(awk '/^\[configuration_files\]/{f=1;next}/^\[/{f=0}f&&/^[^#[:space:]]/{print $1;exit}' "$stale_cfg")" - _is_declared "$stale_path" && to_remove+=("$stale_cfg") + _is_declared "$stale_path" && stale_cfgs+=("$stale_cfg") done - # Only suggest items that have NO cfg file yet. - # Existing files are user-owned — edit them directly in ~/.mackup/. - local -a to_write=() - local -a cfg_paths=() - + # ----------------------------------------------------------------------- + # Filter to NEW items only (user owns existing files). + # ----------------------------------------------------------------------- + local -a new_items=() + local -a new_cfgs=() local it app_name cfg_file for it in "${items[@]:-}"; do app_name="$(_ssr_mackup_cfg_name "$it")" cfg_file="$cfg_dir/${app_name}.cfg" - [[ -f "$cfg_file" ]] && continue # user owns it — skip - to_write+=("$it") - cfg_paths+=("$cfg_file") + [[ -f "$cfg_file" ]] && continue + new_items+=("$it") + new_cfgs+=("$cfg_file") done - # Nothing to do? - if [[ ${#to_write[@]} -eq 0 && ${#to_remove[@]} -eq 0 ]]; then + if [[ ${#new_items[@]} -eq 0 && ${#stale_cfgs[@]} -eq 0 ]]; then ssr::ok "All ~/.mackup/ssr-*.cfg files are up-to-date — nothing to do." return 0 fi - # Show plan. - if [[ ${#to_write[@]} -gt 0 ]]; then - printf '\nSuggested Mackup app definitions:\n' - local i - for i in "${!to_write[@]}"; do - local path="${cfg_paths[$i]##*/}" - # Show resolved paths (selective default or whole dir). - local resolved; resolved="$(_ssr_mackup_resolve_paths "${to_write[$i]}")" - local line_count; line_count="$(printf '%s\n' "$resolved" | wc -l | tr -d ' ')" - local paths_hint - if [[ "$line_count" -eq 1 && "$resolved" == "${to_write[$i]}" ]]; then - paths_hint="${to_write[$i]}/" - else - paths_hint="$line_count sub-path(s) of ${to_write[$i]}/" - fi - printf " \033[32mNEW\033[0m %-38s %s\n" \ - "~/.mackup/$path" "$paths_hint" - done - printf '\n\033[2mEdit the generated files directly in ~/.mackup/ to customise paths.\033[0m\n' - fi - - if [[ ${#to_remove[@]} -gt 0 ]]; then - printf '\nStale duplicates to remove (now covered by built-in):\n' - local f - for f in "${to_remove[@]}"; do - printf " \033[31mDEL\033[0m %s\n" "~/.mackup/${f##*/}" - done - fi printf '\n' + local written=0 removed=0 - # Confirm. - local total=$(( ${#to_write[@]} + ${#to_remove[@]} )) - ssr::confirm "Apply $total change(s)?" 60 || { printf 'Aborted.\n'; return 0; } + # ----------------------------------------------------------------------- + # New files — confirm each individually. + # ----------------------------------------------------------------------- + if [[ ${#new_items[@]} -gt 0 ]]; then + printf '\033[1mNew Mackup app definitions\033[0m (%d candidate(s))\n\n' "${#new_items[@]}" - # Write new files (never overwrites existing ones). - for it in "${to_write[@]:-}"; do - _ssr_mackup_write_cfg "$cfg_dir" "$it" - done + local i + for i in "${!new_items[@]}"; do + it="${new_items[$i]}" + cfg_file="${new_cfgs[$i]}" + local cfg_name="${cfg_file##*/}" - # Remove stale duplicates. - for f in "${to_remove[@]:-}"; do - rm -f "$f" - ssr::log "Removed stale: ${f##*/}" - done + # Resolved paths preview. + local resolved; resolved="$(_ssr_mackup_resolve_paths "$it")" + local line_count; line_count="$(printf '%s\n' "$resolved" | wc -l | tr -d ' ')" - ssr::ok "Done — ${#to_write[@]} written, ${#to_remove[@]} removed." - printf '\nEdit ~/.mackup/ssr-*.cfg directly to customise paths.\n' - printf 'Run \033[1mssr sync\033[0m to back up with the new definitions.\n\n' + # Header for this item. + printf ' \033[32m+\033[0m \033[1m~/.mackup/%s\033[0m\n' "$cfg_name" + if [[ "$line_count" -eq 1 && "$resolved" == "$it" ]]; then + printf ' backs up: ~/%s/ (whole directory)\n' "$it" + else + printf ' backs up: %d path(s) inside ~/%s/\n' "$line_count" "$it" + printf '%s\n' "$resolved" | while IFS= read -r p; do + printf ' · ~/%s\n' "$p" + done + fi + printf '\n' + + if ssr::confirm " Create ~/.mackup/$cfg_name?" 30; then + _ssr_mackup_write_cfg "$cfg_dir" "$it" + written=$((written + 1)) + printf ' \033[32m✓ written\033[0m\n\n' + else + printf ' skipped\n\n' + fi + done + fi + + # ----------------------------------------------------------------------- + # Stale files — confirm each removal individually. + # ----------------------------------------------------------------------- + if [[ ${#stale_cfgs[@]} -gt 0 ]]; then + printf '\033[1mStale duplicates\033[0m (now covered by a Mackup built-in)\n\n' + + local f + for f in "${stale_cfgs[@]}"; do + printf ' \033[31m-\033[0m \033[1m%s\033[0m\n' "~/.mackup/${f##*/}" + # Show which built-in covers it. + stale_path="$(awk '/^\[configuration_files\]/{f=1;next}/^\[/{f=0}f&&/^[^#[:space:]]/{print $1;exit}' "$f")" + printf ' path \033[2m%s\033[0m is now declared by a built-in Mackup app\n\n' "$stale_path" + + if ssr::confirm " Remove ${f##*/}?" 30; then + rm -f "$f" + removed=$((removed + 1)) + printf ' \033[31m✓ removed\033[0m\n\n' + else + printf ' skipped\n\n' + fi + done + fi + + # ----------------------------------------------------------------------- + # Summary. + # ----------------------------------------------------------------------- + if [[ $written -gt 0 || $removed -gt 0 ]]; then + ssr::ok "Done — $written written, $removed removed." + [[ $written -gt 0 ]] && printf '\nEdit ~/.mackup/ssr-*.cfg directly to adjust paths.\n' + printf 'Run \033[1mssr sync\033[0m to back up with the updated definitions.\n\n' + else + printf 'Nothing applied.\n' + fi } # --------------------------------------------------------------------------- diff --git a/lib/mackup.sh b/lib/mackup.sh index 6b75b3a..ec8757a 100644 --- a/lib/mackup.sh +++ b/lib/mackup.sh @@ -14,20 +14,24 @@ # Custom Mackup app definition auto-generator # --------------------------------------------------------------------------- -# Curated dot-paths that matter for developer workflows but are not in -# Mackup's built-in app definitions. Add your own via SSR_MACKUP_EXTRA_PATHS -# (space-separated) in ssr.conf. +# Curated dot-paths for developer workflows NOT covered by Mackup's built-in +# app definitions. Add your own via SSR_MACKUP_EXTRA_PATHS in ssr.conf. +# +# Built-ins that ALREADY cover common tools (do NOT add them here): +# claude-code.cfg → .claude/{plugins,settings,skills,agents,settings.json,CLAUDE.md} +# codex.cfg → .codex/{config.toml,auth.json} +# cursor.cfg → Library/Application Support/Cursor/User/... (not .cursor/) +# docker.cfg → .docker/{config.json,daemon.json} +# github-cli.cfg → .config/gh/config.yml (xdg_configuration_files) +# vscode.cfg → Library/Application Support/Code/User/... (not .vscode/) +# vscode-insiders.cfg → Library/Application Support/Code - Insiders/... _SSR_MACKUP_CURATED=( - .claude # Claude AI CLI config, skills, agents - .cursor # Cursor IDE config + rules - .codex # OpenAI Codex config - .easymcp # EasyMCP config - .factory # Factory AI config - .docker # Docker Desktop — selective files only (see _SSR_MACKUP_SELECTIVE) - .config/claude - .config/cursor - .config/gh # GitHub CLI (if not already in mackup) - .config/op # 1Password CLI config + # .cursor/ rules and workspace-specific settings live here — the built-in + # cursor.cfg only covers Library/Application Support/Cursor (different path). + .cursor + .easymcp # EasyMCP config (no built-in) + .factory # Factory AI config (no built-in) + .config/op # 1Password CLI config (no built-in) bin # personal ~/bin scripts ) @@ -40,15 +44,13 @@ _SSR_MACKUP_CURATED=( # config files we actually need. Extend via SSR_MACKUP_SELECTIVE_PATHS # (same format, space-separated entries) in ssr.conf. _SSR_MACKUP_SELECTIVE=( - ".claude=settings.json,CLAUDE.md,skills,plugins,agents,commands" + # .cursor/ — only the stable config sub-paths; exclude logs/, extensions/, + # workspaceStorage/ etc. The built-in cursor.cfg covers Library/Application + # Support paths; this covers the dot-dir used for rules and workspace config. ".cursor=settings.json,keybindings.json,snippets,rules" - ".codex=auth.json,config.toml" - ".vscode=settings.json,keybindings.json,snippets" - ".vscode-insiders=settings.json,keybindings.json,snippets" ".factory=settings.json,droids" - # Docker Desktop rejects ~/.docker as a symlink — back up individual files - # only so ~/.docker/ stays a real directory that Docker Desktop can manage. - ".docker=config.json,daemon.json" + # Add selective overrides for any SSR_MACKUP_EXTRA_PATHS entries here if + # you need sub-path granularity instead of whole-directory backup. ) # Dot-dirs/files to never auto-include. @@ -128,12 +130,16 @@ _ssr_mackup_write_cfg() { # built-in app definitions AND user-defined ~/.mackup/*.cfg files. # Our own generated ssr-*.cfg files are excluded to avoid circular checks. _ssr_mackup_declared_paths() { - local apps_dir="" - - # Locate Mackup's built-in apps/ directory via Python. - if command -v python3 >/dev/null 2>&1; then - apps_dir="$(python3 -c \ - 'import os, mackup; print(os.path.join(os.path.dirname(mackup.__file__), "apps"))' \ + # Locate Mackup's built-in applications/ directory. + # Mackup bundles its own Python interpreter — use it so the import works + # regardless of which system python3 is active. + local apps_dir="" mackup_bin + mackup_bin="$(command -v mackup 2>/dev/null)" + if [[ -n "$mackup_bin" ]]; then + local mackup_py + mackup_py="$(head -1 "$mackup_bin" | sed 's/^#!//')" + apps_dir="$("$mackup_py" -c \ + 'import os, mackup; print(os.path.join(os.path.dirname(mackup.__file__), "applications"))' \ 2>/dev/null || true)" fi @@ -142,15 +148,17 @@ _ssr_mackup_declared_paths() { { [[ -d "$apps_dir" ]] \ - && grep -rh '' "$apps_dir"/*.cfg 2>/dev/null || true + && grep -rh '' "$apps_dir"/ 2>/dev/null || true [[ -d "$user_cfg_dir" ]] \ - && grep -rhl '' "$user_cfg_dir"/*.cfg 2>/dev/null \ + && grep -rhl '' "$user_cfg_dir"/ 2>/dev/null \ | grep -v '/ssr-' \ | xargs grep -h '' 2>/dev/null || true } | awk ' - /^\[configuration_files\]/ { in_section=1; next } - /^\[/ { in_section=0 } - in_section && /^[^#[:space:]]/ { print $1 } + /^\[configuration_files\]/ { in_cfg=1; in_xdg=0; next } + /^\[xdg_configuration_files\]/ { in_xdg=1; in_cfg=0; next } + /^\[/ { in_cfg=0; in_xdg=0 } + in_cfg && /^[^#[:space:]]/ { print $1 } + in_xdg && /^[^#[:space:]]/ { print ".config/" $1 } ' | sort -u } @@ -194,9 +202,20 @@ ssr::mackup::generate_custom_cfg() { local declared declared="$(_ssr_mackup_declared_paths)" - # Return true when is already declared in any existing app definition. + # Return true when is already declared, OR when any declared path + # starts with "/" (i.e. individual files inside are declared, + # meaning a built-in covers the directory via selective file entries). + # Example: built-in byobu declares .byobu/profile → .byobu is covered. + # + # Uses `command grep` (bypasses colour aliases) and a bash glob for the + # prefix check (avoids regex escaping issues with dots in paths). _is_declared() { - printf '%s\n' "$declared" | grep -qxF "$1" + local p="$1" decl + command grep -qxF "$p" <<< "$declared" && return 0 + while IFS= read -r decl; do + [[ "$decl" == "$p/"* ]] && return 0 + done <<< "$declared" + return 1 } # Return true when is already present in the Mackup cloud backup. _in_backup() { [[ -e "$mackup_dir/$1" ]]; } @@ -323,6 +342,21 @@ ssr::mackup::backup() { fi [[ -n "$mackup_root" ]] && ssr::mackup::_materialize_icloud "$mackup_root" + # --------------------------------------------------------------------------- + # Pre-symlink: paths managed by apps that recreate their directory almost + # instantly after deletion, making Mackup's delete→symlink sequence lose the + # race. We pre-create the symlink → cloud BEFORE running `link install`. + # Mackup detects the existing correct symlink and skips the path entirely. + # + # Format: home-relative path (same as in the Mackup app definition). + # --------------------------------------------------------------------------- + local -a _presymlink=( + # iTerm2 recreates DynamicProfiles within milliseconds of deletion. + # Pre-symlinking ensures Mackup skips it; iTerm2 reads/writes through + # the symlink normally (creating new profiles goes straight to iCloud). + .config/iterm2/AppSupport/DynamicProfiles + ) + # --------------------------------------------------------------------------- # Light preclean — remove transient directories so their ephemeral contents # (debug symlinks, log files) are not included in the cloud copy. @@ -330,6 +364,23 @@ ssr::mackup::backup() { # os.path.isfile/isdir guard, so we only need to handle known directories. # --------------------------------------------------------------------------- _ssr_mackup_preclean() { + local mr="$1" # mackup root + + # 1. Pre-symlink race-condition paths. + local rel home_p cloud_p + for rel in "${_presymlink[@]}"; do + home_p="$HOME/$rel" + cloud_p="$mr/$rel" + [[ -d "$cloud_p" ]] || continue # no cloud copy yet — skip + if [[ -L "$home_p" ]] && [[ "$(readlink "$home_p")" == "$cloud_p" ]]; then + continue # already correct symlink + fi + ssr::log " pre-symlink: $rel → cloud (prevents iTerm2-style race)" + rm -rf "$home_p" 2>/dev/null || true + ln -s "$cloud_p" "$home_p" 2>/dev/null || true + done + + # 2. Transient directories whose contents should never go into cloud. local -a transient_dirs=( .claude/debug # Claude Code debug session links .cursor/logs # Cursor IDE logs @@ -348,7 +399,7 @@ ssr::mackup::backup() { local attempt rc=0 for attempt in 1 2 3; do - _ssr_mackup_preclean + _ssr_mackup_preclean "$mackup_root" if mackup -f link install; then ssr::ok "mackup link install complete — symlinks created in ~/" return 0