Last active
May 2, 2026 12:01
-
-
Save nemtsov/8eaeaf04ae540d65e73a91ea61b294d9 to your computer and use it in GitHub Desktop.
Move folder and maintain Claude history (original: curiouslychase/dotfiles/scripts/claude-mv; patched to move dotfiles)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| set -e | |
| # Escape a string for safe use as a sed BRE pattern (delimiter |). | |
| sed_escape_pattern() { | |
| printf '%s' "$1" | sed 's/[][\\/.^$*|]/\\&/g' | |
| } | |
| # Escape a string for safe use as a sed replacement (delimiter |). | |
| sed_escape_replacement() { | |
| printf '%s' "$1" | sed 's/[\\&|/]/\\&/g' | |
| } | |
| # Usage: claude-mv <old_directory> <new_directory> | |
| if [ $# -ne 2 ]; then | |
| echo "Usage: claude-mv <old_directory> <new_directory>" | |
| echo "Example: claude-mv ~/old-project ~/new-project" | |
| exit 1 | |
| fi | |
| OLD_DIR="$1" | |
| NEW_DIR="$2" | |
| # Resolve old directory to absolute path | |
| OLD_ABS=$(cd "$OLD_DIR" 2>/dev/null && pwd || echo "") | |
| if [ -z "$OLD_ABS" ]; then | |
| echo "Error: Old directory does not exist: $OLD_DIR" | |
| exit 1 | |
| fi | |
| # Check if new directory already exists | |
| if [ -e "$NEW_DIR" ]; then | |
| echo "Error: New directory already exists: $NEW_DIR" | |
| exit 1 | |
| fi | |
| # Convert paths to Claude's format (replace / and . with -) | |
| # Need to do this BEFORE moving the directory to check for existing context | |
| OLD_ENCODED=$(echo "$OLD_ABS" | sed 's/[\/.]/-/g') | |
| # Figure out what the new absolute path will be | |
| if [[ "$NEW_DIR" = /* ]]; then | |
| # NEW_DIR is already absolute | |
| NEW_ABS_FUTURE="$NEW_DIR" | |
| else | |
| # NEW_DIR is relative, resolve it | |
| NEW_PARENT=$(dirname "$NEW_DIR") | |
| NEW_BASENAME=$(basename "$NEW_DIR") | |
| if [ "$NEW_PARENT" = "." ]; then | |
| NEW_ABS_FUTURE="$(pwd)/$NEW_BASENAME" | |
| else | |
| NEW_ABS_FUTURE="$(cd "$NEW_PARENT" 2>/dev/null && pwd)/$NEW_BASENAME" | |
| fi | |
| fi | |
| NEW_ENCODED=$(echo "$NEW_ABS_FUTURE" | sed 's/[\/.]/-/g') | |
| CLAUDE_DIR="$HOME/.claude" | |
| # Check if destination Claude context already exists BEFORE moving directory | |
| EXISTING_CONTEXT=() | |
| for subdir in projects file-history todos shell-snapshots debug; do | |
| NEW_PATH="$CLAUDE_DIR/$subdir/$NEW_ENCODED" | |
| if [ -d "$NEW_PATH" ] || [ -f "$NEW_PATH" ]; then | |
| EXISTING_CONTEXT+=("$subdir") | |
| fi | |
| done | |
| # If destination context exists, prompt user BEFORE moving anything | |
| if [ ${#EXISTING_CONTEXT[@]} -gt 0 ]; then | |
| echo "Warning: Claude context already exists for $NEW_ABS_FUTURE:" | |
| for item in "${EXISTING_CONTEXT[@]}"; do | |
| COUNT="" | |
| if [ "$item" = "projects" ] && [ -d "$CLAUDE_DIR/$item/$NEW_ENCODED" ]; then | |
| SESSION_COUNT=$(ls -1 "$CLAUDE_DIR/$item/$NEW_ENCODED"/*.jsonl 2>/dev/null | wc -l | tr -d ' ') | |
| COUNT=" ($SESSION_COUNT sessions)" | |
| fi | |
| echo " - $item$COUNT" | |
| done | |
| echo "" | |
| echo "Options:" | |
| echo " [c] Clean out existing context and continue" | |
| echo " [m] Merge old context into existing context" | |
| echo " [n] Abort (default)" | |
| echo "" | |
| read -p "Choose [c/m/N]: " -n 1 -r | |
| echo | |
| if [[ $REPLY =~ ^[Cc]$ ]]; then | |
| echo "Cleaning out existing context for $NEW_ABS_FUTURE..." | |
| for item in "${EXISTING_CONTEXT[@]}"; do | |
| NEW_PATH="$CLAUDE_DIR/$item/$NEW_ENCODED" | |
| rm -rf "$NEW_PATH" | |
| echo " ✓ Removed $item" | |
| done | |
| echo "" | |
| elif [[ $REPLY =~ ^[Mm]$ ]]; then | |
| echo "Will merge contexts..." | |
| else | |
| echo "Aborted." | |
| exit 1 | |
| fi | |
| fi | |
| echo "Moving Claude context from:" | |
| echo " $OLD_ABS" | |
| echo " → $NEW_ABS_FUTURE" | |
| echo "" | |
| MOVED=0 | |
| # Rename/merge in each location if exists (BEFORE moving directory) | |
| for subdir in projects file-history todos shell-snapshots debug; do | |
| OLD_PATH="$CLAUDE_DIR/$subdir/$OLD_ENCODED" | |
| NEW_PATH="$CLAUDE_DIR/$subdir/$NEW_ENCODED" | |
| if [ -d "$OLD_PATH" ]; then | |
| if [ -d "$NEW_PATH" ]; then | |
| # Merge: move all files (including dotfiles) from old to new, | |
| # skipping any name collisions and surfacing them to the user. | |
| echo "Merging $subdir/$OLD_ENCODED → $subdir/$NEW_ENCODED" | |
| ( | |
| shopt -s dotglob nullglob | |
| for f in "$OLD_PATH"/*; do | |
| base=$(basename "$f") | |
| if [ -e "$NEW_PATH/$base" ]; then | |
| echo " ⚠ collision, kept existing: $base" >&2 | |
| else | |
| mv -- "$f" "$NEW_PATH/" | |
| fi | |
| done | |
| ) | |
| rmdir "$OLD_PATH" 2>/dev/null || echo " ⚠ kept (collisions remain): $OLD_PATH" >&2 | |
| else | |
| # Simple rename | |
| echo "Moving $subdir/$OLD_ENCODED → $subdir/$NEW_ENCODED" | |
| mv "$OLD_PATH" "$NEW_PATH" | |
| fi | |
| MOVED=$((MOVED + 1)) | |
| elif [ -f "$OLD_PATH" ]; then | |
| echo "Moving $subdir/$OLD_ENCODED → $subdir/$NEW_ENCODED" | |
| mv "$OLD_PATH" "$NEW_PATH" | |
| MOVED=$((MOVED + 1)) | |
| fi | |
| done | |
| if [ $MOVED -eq 0 ]; then | |
| echo "No Claude context found for $OLD_ABS" | |
| else | |
| echo "✓ Moved $MOVED context location(s)" | |
| fi | |
| echo "" | |
| # Update session file contents to reference new path | |
| if [ -d "$CLAUDE_DIR/projects/$NEW_ENCODED" ]; then | |
| SESSION_DIR="$CLAUDE_DIR/projects/$NEW_ENCODED" | |
| SESSION_FILES=$(find "$SESSION_DIR" -name "*.jsonl" 2>/dev/null) | |
| if [ -n "$SESSION_FILES" ]; then | |
| echo "Updating session file references..." | |
| BACKUP_DIR="$CLAUDE_DIR/.claude-mv-backups" | |
| mkdir -p "$BACKUP_DIR" | |
| TS=$(date +%Y%m%d%H%M%S) | |
| BACKUP_TAR="$BACKUP_DIR/projects-$NEW_ENCODED-$TS.tar.gz" | |
| ( cd "$CLAUDE_DIR/projects" && tar -czf "$BACKUP_TAR" -- "./$NEW_ENCODED" ) | |
| echo " ✓ backup: $BACKUP_TAR" | |
| OLD_PAT=$(sed_escape_pattern "$OLD_ABS") | |
| NEW_REP=$(sed_escape_replacement "$NEW_ABS_FUTURE") | |
| # Only rewrite files that actually contain the old path, and preserve | |
| # mtime so unrelated path-rewrites don't reshuffle session ordering. | |
| UPDATED=0 | |
| find "$SESSION_DIR" -name "*.jsonl" -print0 | while IFS= read -r -d '' f; do | |
| if grep -qF -- "$OLD_ABS" "$f"; then | |
| ref=$(mktemp -t claude-mv-mtime) | |
| touch -r "$f" "$ref" | |
| sed -i '' "s|$OLD_PAT|$NEW_REP|g" "$f" | |
| touch -r "$ref" "$f" | |
| rm -f "$ref" | |
| UPDATED=$((UPDATED + 1)) | |
| fi | |
| done | |
| echo "✓ Updated session files (mtime preserved)" | |
| echo "" | |
| fi | |
| fi | |
| # Move the actual directory AFTER Claude context is moved | |
| echo "Moving directory:" | |
| echo " $OLD_ABS" | |
| echo " → $NEW_DIR" | |
| mv "$OLD_ABS" "$NEW_DIR" | |
| # Resolve new directory to absolute path | |
| NEW_ABS=$(cd "$NEW_DIR" 2>/dev/null && pwd) | |
| echo "✓ Directory moved" | |
| echo "" | |
| # Update history.jsonl to reference new path | |
| HISTORY_FILE="$CLAUDE_DIR/history.jsonl" | |
| if [ -f "$HISTORY_FILE" ]; then | |
| echo "" | |
| echo "Updating history.jsonl references..." | |
| HIST_TS=$(date +%Y%m%d%H%M%S) | |
| HISTORY_BACKUP="$HISTORY_FILE.backup.$HIST_TS" | |
| cp -p "$HISTORY_FILE" "$HISTORY_BACKUP" | |
| OLD_PAT=$(sed_escape_pattern "$OLD_ABS") | |
| NEW_REP=$(sed_escape_replacement "$NEW_ABS") | |
| if grep -qF -- "$OLD_ABS" "$HISTORY_FILE"; then | |
| ref=$(mktemp -t claude-mv-mtime) | |
| touch -r "$HISTORY_FILE" "$ref" | |
| sed -i '' "s|$OLD_PAT|$NEW_REP|g" "$HISTORY_FILE" | |
| touch -r "$ref" "$HISTORY_FILE" | |
| rm -f "$ref" | |
| echo "✓ Updated history.jsonl (backup: $HISTORY_BACKUP, mtime preserved)" | |
| else | |
| echo "✓ history.jsonl unchanged (no references found)" | |
| rm -f "$HISTORY_BACKUP" | |
| fi | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment