mirror of
https://gh.wpcy.net/https://github.com/CaptainCore/do.git
synced 2026-04-17 21:52:19 +08:00
854 lines
34 KiB
Bash
854 lines
34 KiB
Bash
# ----------------------------------------------------
|
||
# 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
|
||
}
|