do/commands/checkpoint
2025-06-21 20:51:04 -04:00

854 lines
34 KiB
Bash
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ----------------------------------------------------
# Checkpoint Commands
# Manages versioned checkpoints of the site's manifest.
# ----------------------------------------------------
# --- Checkpoint Base Directory ---
CHECKPOINT_BASE_DIR=""
CHECKPOINT_REPO_DIR=""
CHECKPOINT_LIST_FILE=""
# ----------------------------------------------------
# (Helper) Reverts a specific item's files to a given checkpoint hash.
# ----------------------------------------------------
function _revert_item_to_hash() {
local item_type="$1" # "plugin" or "theme"
local item_name="$2" # e.g., "akismet"
local target_hash="$3" # The git hash to revert to
local wp_content_dir="$4" # The live wp-content directory
local repo_dir="$5" # The path to the checkpoint git repo
local current_hash="$6" # The hash we are reverting FROM (for cleanup)
# Define paths
local item_path_in_repo="${item_type}s/${item_name}"
local restored_source_path="${repo_dir}/${item_path_in_repo}/"
local live_item_path="${wp_content_dir}/${item_type}s/${item_name}"
echo "Reverting '$item_name' files to state from checkpoint ${target_hash:0:7}..."
# Use git to restore the files *within the checkpoint repo*
"$GIT_CMD" -C "$repo_dir" checkout "$target_hash" -- "$item_path_in_repo" &>/dev/null
if [ $? -ne 0 ]; then
echo "❌ Error: git checkout failed. Could not restore files from checkpoint." >&2
# Clean up by checking out the original state before we messed with it
"$GIT_CMD" -C "$repo_dir" checkout "$current_hash" -- "$item_path_in_repo" &>/dev/null
return 1
fi
# Sync the restored files from the repo back to the live site
echo "Syncing restored files to the live site..."
# If the path no longer exists in the reverted repo state, it should be deleted from the live site.
if [ ! -e "${repo_dir}/${item_path_in_repo}" ]; then
echo " - Item did not exist in target checkpoint. Removing from live site..."
if [ -e "$live_item_path" ]; then
rm -rf "$live_item_path"
echo " ✅ Removed '$live_item_path'."
else
echo " - Already absent from live site. No action needed."
fi
else
# The item existed. Sync it to the live site. This handles both updates and re-additions.
echo " - Item existed in target checkpoint. Syncing files..."
rsync -a --delete "$restored_source_path" "$live_item_path/"
echo " ✅ Synced files to '$live_item_path/'."
fi
# IMPORTANT: Revert the repo back to the original hash so it remains consistent.
# This resets the state of the repo, leaving only the live files changed.
"$GIT_CMD" -C "$repo_dir" checkout "$current_hash" -- "$item_path_in_repo" &>/dev/null
echo "✅ Revert complete for '$item_name'."
echo "💡 Note: This action reverts files only. Database or activation status changes are not affected."
}
# ----------------------------------------------------
# Ensures checkpoint directories and lists exist.
# ----------------------------------------------------
function _ensure_checkpoint_setup() {
# Exit if already initialized
if [[ -n "$CHECKPOINT_BASE_DIR" ]]; then return 0; fi
local private_dir
if ! private_dir=$(_get_private_dir); then
return 1
fi
CHECKPOINT_BASE_DIR="${private_dir}/checkpoints"
CHECKPOINT_REPO_DIR="$CHECKPOINT_BASE_DIR/repo"
CHECKPOINT_LIST_FILE="$CHECKPOINT_BASE_DIR/list.json"
mkdir -p "$CHECKPOINT_REPO_DIR"
if [ ! -f "$CHECKPOINT_LIST_FILE" ]; then
echo "[]" > "$CHECKPOINT_LIST_FILE"
fi
}
# ----------------------------------------------------
# Generates a JSON manifest of the current WP state and saves it to a file.
# ----------------------------------------------------
function _generate_manifest() {
local output_file="$1"
if [ -z "$output_file" ]; then
echo "Error: No output file provided to _generate_manifest." >&2
return 1
fi
local core_version; core_version=$("$WP_CLI_CMD" core version --skip-plugins --skip-themes)
local plugins; plugins=$("$WP_CLI_CMD" plugin list --fields=name,title,status,version,auto_update --format=json --skip-plugins --skip-themes)
local themes; themes=$("$WP_CLI_CMD" theme list --fields=name,title,status,version,auto_update --format=json --skip-plugins --skip-themes)
# Manually create JSON to avoid extra dependencies
cat <<EOF > "$output_file"
{
"core": "$core_version",
"plugins": $plugins,
"themes": $themes
}
EOF
}
# ----------------------------------------------------
# Creates a new checkpoint.
# ----------------------------------------------------
function checkpoint_create() {
if ! setup_git; then return 1; fi
if ! setup_wp_cli; then echo "❌ Error: WP-CLI not found." >&2; return 1; fi
if ! command -v rsync &>/dev/null; then echo "❌ Error: rsync command not found." >&2; return 1; fi
_ensure_checkpoint_setup
echo "🚀 Creating new checkpoint..."
# Get wp-content path
local wp_content_dir
wp_content_dir=$("$WP_CLI_CMD" eval "echo rtrim(WP_CONTENT_DIR, '/');" --skip-plugins --skip-themes 2>/dev/null)
if [ -z "$wp_content_dir" ] || [ ! -d "$wp_content_dir" ]; then
echo "❌ Error: Could not determine wp-content directory." >&2
return 1
fi
echo " - Found wp-content at: $wp_content_dir"
# Sync files
echo " - Syncing themes, plugins, and mu-plugins..."
mkdir -p "$CHECKPOINT_REPO_DIR/themes" "$CHECKPOINT_REPO_DIR/plugins" "$CHECKPOINT_REPO_DIR/mu-plugins"
# Use rsync to copy directories. The trailing slashes are important.
rsync -a --delete --exclude='*.zip' --exclude='logs/' --exclude='.git/' "$wp_content_dir/themes/" "$CHECKPOINT_REPO_DIR/themes/"
rsync -a --delete --exclude='*.zip' --exclude='logs/' --exclude='.git/' "$wp_content_dir/plugins/" "$CHECKPOINT_REPO_DIR/plugins/"
if [ -d "$wp_content_dir/mu-plugins" ]; then
rsync -a --delete --exclude='*.zip' --exclude='logs/' --exclude='.git/' "$wp_content_dir/mu-plugins/" "$CHECKPOINT_REPO_DIR/mu-plugins/"
fi
local manifest_file="$CHECKPOINT_REPO_DIR/manifest.json"
echo " - Generating manifest..."
if ! _generate_manifest "$manifest_file"; then
echo "❌ Error: Failed to generate manifest file." >&2
return 1
fi
# Initialize git repo if it doesn't exist
if [ ! -d "$CHECKPOINT_REPO_DIR/.git" ]; then
echo " - Initializing checkpoint repository..."
"$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" init -b main > /dev/null
fi
echo " - Committing changes to repository..."
# Add all changes (manifest + files)
"$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" add .
# Check if there are changes to commit
if "$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" diff --staged --quiet; then
echo "✅ No changes detected. Checkpoint is up-to-date."
local latest_hash; latest_hash=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" rev-parse HEAD 2>/dev/null)
if [ -n "$latest_hash" ]; then
echo " Latest Hash: $latest_hash"
fi
return 0
fi
# Set a default author identity for the commit to prevent errors on remote systems
# where git might not be configured. This is a local config for this repo only.
"$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" config user.email "script@captaincore.io"
"$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" config user.name "_do Script"
local timestamp; timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
"$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" commit -m "Checkpoint $timestamp" > /dev/null
if [ $? -ne 0 ]; then
echo "❌ Error: Failed to commit checkpoint changes." >&2
return 1
fi
local commit_hash; commit_hash=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" rev-parse HEAD)
if [ -z "$commit_hash" ]; then
echo "❌ Error: Could not retrieve commit hash after creating checkpoint." >&2
return 1
fi
local checkpoint_file="$CHECKPOINT_BASE_DIR/$commit_hash.json"
echo " - Saving checkpoint details..."
printf '{\n "hash": "%s",\n "timestamp": "%s"\n}\n' "$commit_hash" "$timestamp" > "$checkpoint_file"
# Safely update the JSON list file using a PHP script
local php_code_template='
<?php
$list_file = "%s";
$hash = "%s";
$timestamp = "%s";
$list = file_exists($list_file) ? json_decode(file_get_contents($list_file), true) : [];
if (!is_array($list)) { $list = []; }
$new_entry = ["hash" => $hash, "timestamp" => $timestamp];
array_unshift($list, $new_entry);
echo json_encode($list, JSON_PRETTY_PRINT);
'
local php_script; php_script=$(printf "$php_code_template" "$CHECKPOINT_LIST_FILE" "$commit_hash" "$timestamp")
local temp_list_file; temp_list_file=$(mktemp)
if echo "$php_script" | "$WP_CLI_CMD" eval-file - > "$temp_list_file"; then
mv "$temp_list_file" "$CHECKPOINT_LIST_FILE"
else
echo "❌ Error: Failed to update checkpoint list." >&2
rm "$temp_list_file"
fi
echo "✅ Checkpoint created successfully."
echo " Hash: $commit_hash"
# Automatically regenerate the detailed checkpoint list
echo " - Regenerating detailed checkpoint list..."
checkpoint_list_generate > /dev/null
}
# ----------------------------------------------------
# Generates a detailed list of checkpoints for faster access.
# ----------------------------------------------------
function checkpoint_list_generate() {
if ! setup_gum || ! setup_git; then return 1; fi
if ! setup_wp_cli; then return 1; fi
_ensure_checkpoint_setup
# Read the potentially simple list created by `checkpoint create`
local php_script_read_list='
<?php
$list_file = "%s";
if (!file_exists($list_file)) { return; }
$list = json_decode(file_get_contents($list_file), true);
if (!is_array($list) || empty($list)) { return; }
foreach($list as $item) {
if (isset($item["timestamp"]) && isset($item["hash"])) {
echo $item["timestamp"] . "|" . $item["hash"] . "\n";
}
}
'
local php_script; php_script=$(printf "$php_script_read_list" "$CHECKPOINT_LIST_FILE")
local checkpoint_entries; checkpoint_entries=$(echo "$php_script" | "$WP_CLI_CMD" eval-file -)
if [ -z "$checkpoint_entries" ]; then
echo " No checkpoints found to generate a list from."
return 0
fi
echo "🔎 Generating detailed checkpoint list... (This may take a moment)"
local detailed_items=()
while IFS='|' read -r timestamp hash; do
hash=$(echo "$hash" | tr -d '[:space:]')
if [ -z "$hash" ]; then continue; fi
local parent_hash=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" log -n 1 --pretty=format:%P "$hash" 2>/dev/null)
local manifest_current; manifest_current=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" show "$hash:manifest.json" 2>/dev/null)
if [ -z "$manifest_current" ]; then continue; fi
local php_get_counts='
<?php
$manifest_json = <<<'EOT'
%s
EOT;
$data = json_decode($manifest_json, true);
$theme_count = isset($data["themes"]) && is_array($data["themes"]) ? count($data["themes"]) : 0;
$plugin_count = isset($data["plugins"]) && is_array($data["plugins"]) ? count($data["plugins"]) : 0;
echo "$theme_count Themes, $plugin_count Plugins";
'
local counts_script; counts_script=$(printf "$php_get_counts" "$manifest_current")
local counts_str; counts_str=$(echo "$counts_script" | "$WP_CLI_CMD" eval-file -)
local diff_stats="Initial checkpoint"
if [ -n "$parent_hash" ]; then
diff_stats=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" diff --shortstat "$parent_hash" "$hash" -- 'plugins/' 'themes/' 'mu-plugins/' | sed 's/^[ \t]*//')
if [ -z "$diff_stats" ]; then diff_stats="No file changes"; fi
fi
local formatted_timestamp
if [[ "$(uname)" == "Darwin" ]]; then
formatted_timestamp=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" -u "$timestamp" "+%a, %b %d, %Y, %-I:%M %p")
else
formatted_timestamp=$(date -d "$timestamp" "+%a, %b %d, %Y, %-I:%M %p")
fi
# Create a JSON object for this item
local json_item
json_item=$(printf '{"hash": "%s", "timestamp": "%s", "formatted_timestamp": "%s", "counts_str": "%s", "diff_stats": "%s"}' \
"$hash" "$timestamp" "$formatted_timestamp" "$counts_str" "$diff_stats")
detailed_items+=("$json_item")
done <<< "$checkpoint_entries"
# Write the detailed list back to the file
local full_json="["
full_json+=$(IFS=,; echo "${detailed_items[*]}")
full_json+="]"
# Use PHP to pretty-print the JSON to the file
local php_write_script='
<?php
$list_file = "%s";
$json_data = <<<'EOT'
%s
EOT;
$data = json_decode($json_data, true);
file_put_contents($list_file, json_encode($data, JSON_PRETTY_PRINT));
'
local write_script; write_script=$(printf "$php_write_script" "$CHECKPOINT_LIST_FILE" "$full_json")
if echo "$write_script" | "$WP_CLI_CMD" eval-file -; then
echo "✅ Detailed checkpoint list saved to $CHECKPOINT_LIST_FILE"
else
echo "❌ Error: Failed to write detailed list."
fi
}
# ----------------------------------------------------
# (Helper) Lets the user select a checkpoint hash from the list.
# ----------------------------------------------------
function _select_checkpoint_hash() {
_ensure_checkpoint_setup
if ! setup_wp_cli; then return 1; fi
if [ ! -s "$CHECKPOINT_LIST_FILE" ]; then
echo " No checkpoints found. Run '_do checkpoint create' to make one." >&2
exit 1
fi
# Use PHP to read the detailed list and check if it's in the new format
local php_script_read_list='
<?php
$list_file = "%s";
$list = json_decode(file_get_contents($list_file), true);
if (!is_array($list) || empty($list)) {
echo "EMPTY";
return;
}
// Check if the first item has the detailed format.
if (!isset($list[0]["formatted_timestamp"])) {
echo "NEEDS_GENERATE";
return;
}
foreach($list as $item) {
if (isset($item["formatted_timestamp"]) && isset($item["hash"]) && isset($item["counts_str"]) && isset($item["diff_stats"])) {
// Output format: formatted_timestamp|hash|counts_str|diff_stats
echo $item["formatted_timestamp"] . "|" . $item["hash"] . "|" . $item["counts_str"] . "|" . $item["diff_stats"] . "\n";
}
}
'
local php_script; php_script=$(printf "$php_script_read_list" "$CHECKPOINT_LIST_FILE")
local checkpoint_entries; checkpoint_entries=$(echo "$php_script" | "$WP_CLI_CMD" eval-file -)
if [[ "$checkpoint_entries" == "EMPTY" ]]; then
echo " No checkpoints available to select." >&2
exit 1 # Use exit 1 to guarantee a non-zero exit code from the subshell
elif [[ "$checkpoint_entries" == "NEEDS_GENERATE" ]]; then
echo "⚠️ The checkpoint list needs to be generated for faster display." >&2
echo "Please run: _do checkpoint list-generate" >&2
exit 1 # Use exit 1
fi
local display_items=()
local data_items=()
# Get terminal width for dynamic padding
local term_width; term_width=$(tput cols)
local hash_col_width=9 # "xxxxxxx |"
local counts_col_width=20 # "xx Themes, xx Plugins |"
# Calculate available width for the timestamp column
local timestamp_col_width=$((term_width - hash_col_width - counts_col_width - 5)) # 5 for buffers
# Set a reasonable minimum and maximum
if [ "$timestamp_col_width" -lt 20 ]; then timestamp_col_width=20; fi
if [ "$timestamp_col_width" -gt 30 ]; then timestamp_col_width=30; fi
while IFS='|' read -r formatted_timestamp hash counts_str diff_stats; do
hash=$(echo "$hash" | tr -d '[:space:]')
if [ -z "$hash" ]; then continue; fi
# Use the new dynamic width for the timestamp
local display_string
display_string=$(printf "%-${timestamp_col_width}s | %s | %-18s | %s" \
"$formatted_timestamp" "${hash:0:7}" "$counts_str" "$diff_stats")
display_items+=("$display_string")
data_items+=("$hash")
done <<< "$checkpoint_entries"
if [ ${#display_items[@]} -eq 0 ]; then
echo "❌ No valid checkpoints to display." >&2
exit 1
fi
local prompt_text="${1:-Select a checkpoint to inspect}"
local selected_display
selected_display=$(printf "%s\n" "${display_items[@]}" | "$GUM_CMD" filter --height=20 --prompt="👇 $prompt_text" --indicator="→" --placeholder="")
if [ -z "$selected_display" ]; then
echo "" # Return empty for cancellation
return 0
fi
local selected_index=-1
for i in "${!display_items[@]}"; do
if [[ "${display_items[$i]}" == "$selected_display" ]]; then
selected_index=$i
break
fi
done
if [ "$selected_index" -ne -1 ]; then
echo "${data_items[$selected_index]}"
return 0
else
echo "❌ Error: Could not find selected checkpoint." >&2
exit 1
fi
}
# ----------------------------------------------------
# Lists all checkpoints from the pre-generated list and allows selection.
# ----------------------------------------------------
function checkpoint_list() {
if ! setup_gum || ! setup_git; then return 1; fi
local selected_hash
selected_hash=$(_select_checkpoint_hash "Select a checkpoint to inspect")
local exit_code=$?
if [ $exit_code -ne 0 ]; then
return 1
fi
if [ -z "$selected_hash" ]; then
echo "No checkpoint selected."
return 0
fi
checkpoint_show "$selected_hash"
}
# ----------------------------------------------------
# Reverts all files to a specific checkpoint hash.
# ----------------------------------------------------
function checkpoint_revert() {
local target_hash="$1"
if ! setup_gum || ! setup_git; then return 1; fi
if ! setup_wp_cli; then return 1; fi
# If no hash is provided, let the user pick one from a detailed list.
if [ -z "$target_hash" ]; then
target_hash=$(_select_checkpoint_hash "Select a checkpoint to revert to")
if [ -z "$target_hash" ]; then
echo "Revert cancelled."
return 0
fi
# Check the exit code of the helper
if [ $? -ne 0 ]; then
return 1 # Error was already printed by the helper
fi
fi
_ensure_checkpoint_setup
# Validate the hash to ensure it exists in the repo before proceeding
if ! "$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" cat-file -e "${target_hash}^{commit}" &>/dev/null; then
echo "❌ Error: Checkpoint hash '$target_hash' not found." >&2
return 1
fi
# Final confirmation before the revert
echo "🚨 You are about to revert ALL themes, plugins, and mu-plugins to the state from checkpoint ${target_hash:0:7}."
echo "This will overwrite any changes made since that checkpoint was created."
"$GUM_CMD" confirm "Are you sure you want to proceed?" || { echo "Revert cancelled."; return 0; }
# Get wp-content path for rsync destination
local wp_content_dir
wp_content_dir=$("$WP_CLI_CMD" eval "echo rtrim(WP_CONTENT_DIR, '/');" --skip-plugins --skip-themes 2>/dev/null)
if [ -z "$wp_content_dir" ] || [ ! -d "$wp_content_dir" ]; then
echo "❌ Error: Could not determine wp-content directory." >&2
return 1
fi
local current_hash=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" rev-parse HEAD)
# Revert all three directories within the git repo
echo "Reverting all tracked files to checkpoint ${target_hash:0:7}..."
"$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" checkout "$target_hash" -- 'plugins/' 'themes/' 'mu-plugins/' &>/dev/null
# Sync the reverted files from the repo to the live site directories
echo "Syncing restored files to the live site..."
rsync -a --delete "$CHECKPOINT_REPO_DIR/plugins/" "$wp_content_dir/plugins/"
rsync -a --delete "$CHECKPOINT_REPO_DIR/themes/" "$wp_content_dir/themes/"
rsync -a --delete "$CHECKPOINT_REPO_DIR/mu-plugins/" "$wp_content_dir/mu-plugins/"
# IMPORTANT: Reset the repo's state back to the original `HEAD`
"$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" checkout "$current_hash" -- 'plugins/' 'themes/' 'mu-plugins/' &>/dev/null
echo "✅ Full file revert to checkpoint ${target_hash:0:7} is complete."
echo "💡 Note: This action reverts files only. Database changes, plugin/theme activation status, and WordPress core version are not affected."
}
# ----------------------------------------------------
# Shows the diff between two checkpoints or one checkpoint and its parent.
# ----------------------------------------------------
function checkpoint_show() {
local hash_after="$1"
local hash_before="$2"
if [ -z "$hash_after" ]; then
echo "❌ Error: No hash provided." >&2
show_command_help "checkpoint"
return 1
fi
if ! setup_gum || ! setup_git; then return 1; fi
if ! setup_wp_cli; then return 1; fi
_ensure_checkpoint_setup
# If 'before' hash is not provided, find the parent of the 'after' hash.
if [ -z "$hash_before" ]; then
hash_before=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" log -n 1 --pretty=format:%P "$hash_after" 2>/dev/null)
fi
local manifest_after
manifest_after=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" show "$hash_after:manifest.json" 2>/dev/null)
if [ -z "$manifest_after" ]; then
echo "❌ Error: Could not find manifest for 'after' hash '$hash_after'." >&2
return 1
fi
local manifest_before="{}"
if [ -n "$hash_before" ]; then
manifest_before=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" show "$hash_before:manifest.json" 2>/dev/null)
if [ -z "$manifest_before" ]; then
echo "⚠️ Warning: Could not find manifest for 'before' hash '$hash_before'. Comparing against an empty state." >&2
manifest_before="{}"
fi
fi
# Get a list of themes and plugins that have actual file changes.
local changed_files_list
if [ -z "$hash_before" ]; then
# This is the initial commit, compare against the empty tree.
changed_files_list=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" diff-tree --no-commit-id --name-only -r "$hash_after" -- 'plugins/' 'themes/' 'mu-plugins/')
else
changed_files_list=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" diff --name-only "$hash_before" "$hash_after" -- 'plugins/' 'themes/' 'mu-plugins/')
fi
local items_with_file_changes=()
while IFS= read -r file_path; do
if [[ -n "$file_path" ]]; then
local item_name
item_name=$(echo "$file_path" | cut -d'/' -f2)
items_with_file_changes+=("$item_name")
fi
done <<< "$changed_files_list"
local changed_items_str
changed_items_str=$(printf "%s\n" "${items_with_file_changes[@]}" | sort -u | tr '\n' ',' | sed 's/,$//')
# --- 1. Generate list of ALL items from PHP ---
export MANIFEST_AFTER_JSON="$manifest_after"
export MANIFEST_BEFORE_JSON="$manifest_before"
export CHANGED_ITEMS_STR="$changed_items_str"
local php_script
read -r -d '' php_script <<'PHP'
<?php
// This PHP script compares two manifest files and outputs pipe-delimited data for shell processing.
$manifest_after_json = getenv('MANIFEST_AFTER_JSON');
$manifest_before_json = getenv('MANIFEST_BEFORE_JSON');
$changed_items_str = getenv('CHANGED_ITEMS_STR');
$after_data = json_decode($manifest_after_json, true);
$before_data = json_decode($manifest_before_json, true);
$items_with_file_changes = !empty($changed_items_str) ? explode(',', $changed_items_str) : [];
function process_item_diff($item_type, $after_items, $before_items, $items_with_file_changes) {
$after_map = [];
if (is_array($after_items)) { foreach ($after_items as $item) { if(isset($item["name"])) $after_map[$item["name"]] = $item; } }
$before_map = [];
if (is_array($before_items)) { foreach ($before_items as $item) { if(isset($item["name"])) $before_map[$item["name"]] = $item; } }
$all_names = array_unique(array_merge(array_keys($after_map), array_keys($before_map)));
sort($all_names);
$output_lines = [];
foreach ($all_names as $name) {
if(empty($name)) continue;
$has_changed = false;
$change_parts = [];
$after_item = $after_map[$name] ?? null;
$before_item = $before_map[$name] ?? null;
if ($after_item && $before_item) {
// Check for status changes first for correct ordering
if (($after_item["status"] ?? null) !== ($before_item["status"] ?? null)) {
$change_parts[] = "status " . ($before_item["status"] ?? 'N/A') . " -> " . ($after_item["status"] ?? 'N/A');
$has_changed = true;
}
if (($after_item["version"] ?? null) !== ($before_item["version"] ?? null)) {
$change_parts[] = "version " . ($before_item["version"] ?? 'N/A') . " -> " . ($after_item["version"] ?? 'N/A');
$has_changed = true;
}
if (in_array($name, $items_with_file_changes, true)) {
$change_parts[] = "files changed";
$has_changed = true;
}
} elseif ($after_item) {
$change_parts[] = "installed";
if(isset($after_item["version"])) $change_parts[] = "v" . $after_item["version"];
$has_changed = true;
} else {
$change_parts[] = "deleted";
$has_changed = true;
}
$item_for_details = $after_item ?: $before_item;
$title = $item_for_details["title"] ?? $name;
if ($has_changed) {
$details_string = implode(", ", array_unique($change_parts));
} else {
$version = $item_for_details["version"] ?? 'N/A';
$status = $item_for_details["status"] ?? 'N/A';
// Format with status first, then version
$details_string = "$status, v$version";
}
$output_lines[] = [
'has_changed' => $has_changed,
'type' => $item_type,
'slug' => $name,
'title' => $title,
'details' => $details_string,
];
}
// Sort to show changed items first
usort($output_lines, function ($a, $b) {
if ($a['has_changed'] !== $b['has_changed']) {
return $b['has_changed'] <=> $a['has_changed'];
}
return strcmp($a['slug'], $b['slug']);
});
// Output as pipe-delimited data
foreach ($output_lines as $line) {
echo implode("|", [
$line['has_changed'] ? 'true' : 'false',
$line['type'],
$line['slug'],
$line['title'],
$line['details']
]) . "\n";
}
}
process_item_diff('Theme', $after_data['themes'] ?? [], $before_data['themes'] ?? [], $items_with_file_changes);
process_item_diff('Plugin', $after_data['plugins'] ?? [], $before_data['plugins'] ?? [], $items_with_file_changes);
PHP
local php_output
php_output=$(echo "$php_script" | "$WP_CLI_CMD" eval-file - 2>/dev/null)
# Unset the environment variables
unset MANIFEST_AFTER_JSON
unset MANIFEST_BEFORE_JSON
unset CHANGED_ITEMS_STR
if [ -z "$php_output" ]; then
echo "✅ No manifest changes found between checkpoints ${hash_before:0:7} and ${hash_after:0:7}."
return 0
fi
local display_items=()
local data_items=()
# Use fixed-width columns for consistent alignment
local slug_width=30
local title_width=42
# Read the pipe-delimited output from PHP and format it for display
while IFS='|' read -r has_changed item_type slug title details; do
local icon
if [[ "$has_changed" == "true" ]];
then
icon="+"
else
icon=$(printf '\xE2\xA0\x80\xE2\xA0\x80')
fi
# Use "%-2s" to create a 2-character wide column for the icon.
# This ensures consistent padding for both the "+" and " " cases.
# Note the space between "%-2s" and "%-8s" is preserved.
local display_line
display_line=$(printf "%-2s%-8s %-*.*s %-*.*s %s" "$icon" "$item_type" "$slug_width" "$slug_width" "$slug" "$title_width" "$title_width" "$title" "$details")
display_items+=("$display_line")
data_items+=("$item_type|$slug")
done <<< "$php_output"
# Start a loop to allow returning to the item selection.
while true; do
# --- 2. Interactive Item Selection ---
local selected_display_text
selected_display_text=$(printf "%s\n" "${display_items[@]}" | "$GUM_CMD" filter --prompt="? Select item to inspect (checkpoints ${hash_before:0:7} -> ${hash_after:0:7}). Press Esc to exit." --height=20 --indicator="→" --placeholder="")
if [ -z "$selected_display_text" ]; then
# User pressed Esc, so break the loop and exit the function.
break
fi
local selected_index=-1
for i in "${!display_items[@]}"; do
if [[ "${display_items[$i]}" == "$selected_display_text" ]]; then
selected_index=$i
break
fi
done
if [ "$selected_index" -eq -1 ]; then
# Should not happen, but as a safeguard
continue
fi
local item_data="${data_items[$selected_index]}"
local item_type; item_type=$(echo "$item_data" | cut -d'|' -f1)
local item_name; item_name=$(echo "$item_data" | cut -d'|' -f2)
# --- 3. Get wp-content Path ---
local wp_content_dir
wp_content_dir=$("$WP_CLI_CMD" eval "echo rtrim(WP_CONTENT_DIR, '/');" --skip-plugins --skip-themes 2>/dev/null)
if [ -z "$wp_content_dir" ] || [ ! -d "$wp_content_dir" ]; then
echo "❌ Error: Could not determine wp-content directory." >&2
return 1
fi
# --- 4. Interactive Action Selection ---
local choices=("Show File Changes" "Revert Files to 'After' State (${hash_after:0:7})")
if [ -n "$hash_before" ]; then
choices+=("Revert Files to 'Before' State (${hash_before:0:7})")
fi
choices+=("Back to item list")
local action; action=$("$GUM_CMD" choose "${choices[@]}")
# --- 5. Execute Action ---
case "$action" in
"Show File Changes")
local item_path_in_repo
case "$item_type" in
"Theme")
item_path_in_repo="themes/${item_name}"
;;
"Plugin")
item_path_in_repo="plugins/${item_name}"
;;
esac
# Get the list of files that have changed for the selected item.
local changed_files
if [ -z "$hash_before" ]; then
changed_files=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" diff-tree --no-commit-id --name-only -r "$hash_after" -- "$item_path_in_repo")
else
changed_files=$("$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" diff --name-only "$hash_before" "$hash_after" -- "$item_path_in_repo")
fi
if [ -z "$changed_files" ]; then
"$GUM_CMD" spin --spinner dot --title "No file changes found for '$item_name'." -- sleep 3
else
echo "Showing file changes for '$item_name' between ${hash_before:0:7} and ${hash_after:0:7}."
# Loop to allow viewing multiple diffs
while true; do
clear
local selected_file
selected_file=$(echo "$changed_files" | "$GUM_CMD" filter --prompt="? Select a file to view its diff (Press Esc to exit)" --height=20 --indicator="→" --placeholder="")
if [ -z "$selected_file" ]; then
break
fi
# Show the diff for the selected file, piped to `less`.
if [ -z "$hash_before" ]; then
# For the initial commit, just show the file content as it was added.
"$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" --no-pager show --color=always "$hash_after" -- "$selected_file" | less -RX
else
"$GIT_CMD" -C "$CHECKPOINT_REPO_DIR" --no-pager diff --color=always "$hash_before" "$hash_after" -- "$selected_file" | less -RX
fi
done
fi
;;
"Revert Files to 'After' State ("*)
_revert_item_to_hash "$item_type" "$item_name" "$hash_after" "$wp_content_dir" "$CHECKPOINT_REPO_DIR" "$hash_after"
;;
"Revert Files to 'Before' State ("*)
if [ -z "$hash_before" ]; then
echo "❌ Cannot revert: 'Before' state does not exist (likely the first checkpoint)." >&2
return 1
fi
_revert_item_to_hash "$item_type" "$item_name" "$hash_before" "$wp_content_dir" "$CHECKPOINT_REPO_DIR" "$hash_after"
;;
"Back to item list"|*)
# Do nothing, the loop will continue to the next iteration.
continue
;;
esac
done
}
# ----------------------------------------------------
# Gets the latest checkpoint hash.
# ----------------------------------------------------
function checkpoint_latest() {
_ensure_checkpoint_setup
if ! setup_wp_cli; then return 1; fi
if [ ! -s "$CHECKPOINT_LIST_FILE" ]; then
echo " No checkpoints found."
return
fi
local php_code_template='
<?php
$list_file = "%s";
if (!file_exists($list_file)) { return; }
$list = json_decode(file_get_contents($list_file));
if (!empty($list) && isset($list[0]->hash)) {
echo $list[0]->hash;
}
'
local php_script; php_script=$(printf "$php_code_template" "$CHECKPOINT_LIST_FILE")
local latest_hash
latest_hash=$(echo "$php_script" | "$WP_CLI_CMD" eval-file -)
if [ -z "$latest_hash" ]; then
echo " No checkpoints found."
else
echo "$latest_hash"
fi
}