Master Git cherry-pick and stash for surgical code management, selective commit application, and efficient context switching in complex development workflows.
Cherry-pick and stash are Git's precision tools. After years of untangling complex merge scenarios and managing multiple work streams, I've learned these commands are essential for surgical code management. Here's how to wield them effectively in real-world development.
Cherry-Pick Mastery
Advanced Cherry-Pick Strategies
# cherry_pick_manager.py
import subprocess
import re
from typing import List, Dict, Optional
from dataclasses import dataclass
@dataclass
class Commit:
sha: str
message: str
author: str
date: str
files: List[str]
class CherryPickManager:
def __init__(self, repo_path: str = "."):
self.repo_path = repo_path
def smart_cherry_pick(self, commits: List[str], target_branch: str):
"""Intelligently cherry-pick commits with dependency resolution"""
# Analyze commit dependencies
dependencies = self.analyze_dependencies(commits)
# Order commits by dependencies
ordered_commits = self.topological_sort(commits, dependencies)
# Switch to target branch
self.checkout(target_branch)
# Cherry-pick in order
results = []
for commit in ordered_commits:
result = self.cherry_pick_commit(commit)
results.append(result)
if not result['success']:
# Handle conflict
self.handle_cherry_pick_conflict(commit, result)
return results
def analyze_dependencies(self, commits: List[str]) -> Dict[str, List[str]]:
"""Analyze dependencies between commits"""
dependencies = {}
for i, commit in enumerate(commits):
deps = []
commit_files = self.get_commit_files(commit)
# Check previous commits for dependencies
for j in range(i):
prev_commit = commits[j]
prev_files = self.get_commit_files(prev_commit)
# If files overlap, there's a dependency
if set(commit_files) & set(prev_files):
deps.append(prev_commit)
dependencies[commit] = deps
return dependencies
def cherry_pick_range(self, start_commit: str, end_commit: str,
exclude: List[str] = None):
"""Cherry-pick a range of commits with exclusions"""
# Get commit range
commits = self.get_commit_range(start_commit, end_commit)
# Filter out excluded commits
if exclude:
commits = [c for c in commits if c not in exclude]
# Cherry-pick each
for commit in commits:
print(f"Cherry-picking {commit[:7]}")
result = subprocess.run(
['git', 'cherry-pick', commit],
cwd=self.repo_path,
capture_output=True,
text=True
)
if result.returncode != 0:
if self.has_conflicts():
print(f"Conflict in {commit[:7]}, resolving...")
self.auto_resolve_conflicts()
else:
print(f"Failed to cherry-pick {commit[:7]}")
subprocess.run(['git', 'cherry-pick', '--abort'],
cwd=self.repo_path)
def cherry_pick_interactive(self, commits: List[str]):
"""Interactive cherry-pick with squashing option"""
print("Interactive cherry-pick mode")
print("Commands: pick (p), squash (s), skip (x), edit (e), abort (a)")
temp_branch = "cherry-pick-temp"
self.create_branch(temp_branch)
for commit in commits:
commit_info = self.get_commit_info(commit)
print(f"\n{commit[:7]} - {commit_info.message}")
action = input("Action [p/s/x/e/a]: ").strip().lower()
if action == 'a':
print("Aborting cherry-pick")
self.checkout('-')
self.delete_branch(temp_branch)
return
elif action == 'x':
print(f"Skipping {commit[:7]}")
continue
elif action == 'p':
subprocess.run(['git', 'cherry-pick', commit],
cwd=self.repo_path)
elif action == 's':
# Squash with previous
subprocess.run(['git', 'cherry-pick', '-n', commit],
cwd=self.repo_path)
subprocess.run(['git', 'commit', '--amend', '--no-edit'],
cwd=self.repo_path)
elif action == 'e':
subprocess.run(['git', 'cherry-pick', '-e', commit],
cwd=self.repo_path)
Cherry-Pick Workflows
#!/bin/bash
# cherry_pick_workflows.sh
# Cherry-pick with conflict resolution
cherry_pick_with_resolution() {
COMMIT=$1
echo "Cherry-picking $COMMIT..."
if ! git cherry-pick $COMMIT; then
echo "Conflicts detected. Attempting automatic resolution..."
# Get conflicted files
CONFLICTS=$(git diff --name-only --diff-filter=U)
for file in $CONFLICTS; do
echo "Resolving $file..."
# Try different merge strategies
if [[ $file == *.json ]]; then
# For JSON files, try to merge objects
python3 -c "
import json
import sys
def merge_json(ours, theirs):
try:
ours_json = json.loads(ours)
theirs_json = json.loads(theirs)
if isinstance(ours_json, dict) and isinstance(theirs_json, dict):
merged = {**ours_json, **theirs_json}
return json.dumps(merged, indent=2)
except:
pass
return None
"
fi
# If auto-resolution fails, prompt user
if [ -f "$file" ]; then
echo "Please resolve $file manually"
$EDITOR $file
git add $file
fi
done
# Continue cherry-pick
git cherry-pick --continue
fi
}
# Cherry-pick multiple commits maintaining order
cherry_pick_sequence() {
# Read commits from stdin or arguments
if [ $# -eq 0 ]; then
COMMITS=$(cat)
else
COMMITS="$@"
fi
# Create backup branch
CURRENT_BRANCH=$(git branch --show-current)
git branch backup-$CURRENT_BRANCH
# Cherry-pick each commit
for commit in $COMMITS; do
echo "Cherry-picking $commit..."
if ! git cherry-pick $commit; then
echo "Failed to cherry-pick $commit"
echo "Options: [c]ontinue, [s]kip, [a]bort"
read -n 1 option
case $option in
c)
echo "Please resolve conflicts and run: git cherry-pick --continue"
return 1
;;
s)
git cherry-pick --skip
;;
a)
git cherry-pick --abort
return 1
;;
esac
fi
done
echo "Cherry-pick sequence complete!"
}
# Cherry-pick from another repository
cherry_pick_from_remote() {
REMOTE_URL=$1
COMMIT=$2
# Add remote if not exists
REMOTE_NAME="cherry-pick-source"
git remote add $REMOTE_NAME $REMOTE_URL 2>/dev/null || true
# Fetch the specific commit
git fetch $REMOTE_NAME $COMMIT
# Cherry-pick it
git cherry-pick FETCH_HEAD
# Clean up remote
git remote remove $REMOTE_NAME
}
Git Stash Advanced Usage
Stash Management System
# stash_manager.py
import subprocess
import json
import re
from datetime import datetime
from typing import List, Dict, Optional
class StashManager:
def __init__(self):
self.stash_metadata_file = '.git/stash_metadata.json'
self.load_metadata()
def load_metadata(self):
"""Load stash metadata"""
try:
with open(self.stash_metadata_file, 'r') as f:
self.metadata = json.load(f)
except FileNotFoundError:
self.metadata = {}
def save_metadata(self):
"""Save stash metadata"""
with open(self.stash_metadata_file, 'w') as f:
json.dump(self.metadata, f, indent=2)
def smart_stash(self, message: str, include_untracked: bool = True,
category: str = None):
"""Create a categorized stash with metadata"""
# Generate stash ID
stash_id = f"stash_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
# Get current branch and commit
branch = subprocess.check_output(
['git', 'branch', '--show-current'],
text=True
).strip()
commit = subprocess.check_output(
['git', 'rev-parse', 'HEAD'],
text=True
).strip()[:7]
# Create stash
cmd = ['git', 'stash', 'push', '-m', message]
if include_untracked:
cmd.append('-u')
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
# Store metadata
self.metadata[stash_id] = {
'message': message,
'branch': branch,
'commit': commit,
'category': category or 'general',
'created_at': datetime.now().isoformat(),
'files': self.get_stashed_files()
}
self.save_metadata()
print(f"Created stash: {stash_id}")
return stash_id
return None
def get_stashed_files(self) -> List[str]:
"""Get list of files in the most recent stash"""
output = subprocess.check_output(
['git', 'stash', 'show', '--name-only'],
text=True
)
return output.strip().split('\n')
def list_stashes_by_category(self, category: str = None):
"""List stashes filtered by category"""
stashes = subprocess.check_output(
['git', 'stash', 'list'],
text=True
).strip().split('\n')
filtered = []
for i, stash in enumerate(stashes):
if not stash:
continue
# Find matching metadata
for stash_id, meta in self.metadata.items():
if category and meta.get('category') != category:
continue
if meta['message'] in stash:
filtered.append({
'index': i,
'id': stash_id,
'message': meta['message'],
'branch': meta['branch'],
'category': meta['category'],
'created_at': meta['created_at']
})
return filtered
def apply_stash_to_branch(self, stash_index: int, target_branch: str):
"""Apply stash to a specific branch"""
# Save current branch
current_branch = subprocess.check_output(
['git', 'branch', '--show-current'],
text=True
).strip()
# Switch to target branch
subprocess.run(['git', 'checkout', target_branch])
# Apply stash
result = subprocess.run(
['git', 'stash', 'apply', f'stash@{{{stash_index}}}'],
capture_output=True,
text=True
)
if result.returncode != 0:
print(f"Failed to apply stash: {result.stderr}")
subprocess.run(['git', 'checkout', current_branch])
return False
return True
def create_patch_from_stash(self, stash_index: int, output_file: str):
"""Create a patch file from a stash"""
patch = subprocess.check_output(
['git', 'stash', 'show', '-p', f'stash@{{{stash_index}}}'],
text=True
)
with open(output_file, 'w') as f:
f.write(patch)
print(f"Created patch: {output_file}")
return output_file
def stash_partial(self, files: List[str], message: str):
"""Stash only specific files"""
# Stage files to keep
all_modified = subprocess.check_output(
['git', 'diff', '--name-only'],
text=True
).strip().split('\n')
files_to_keep = [f for f in all_modified if f not in files]
if files_to_keep:
subprocess.run(['git', 'add'] + files_to_keep)
# Stash everything else
subprocess.run(['git', 'stash', 'push', '-k', '-m', message])
# Unstage kept files
if files_to_keep:
subprocess.run(['git', 'reset'] + files_to_keep)
Advanced Stash Workflows
#!/bin/bash
# stash_workflows.sh
# Stash with automatic naming
auto_stash() {
# Generate descriptive stash name
BRANCH=$(git branch --show-current)
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
FILES_COUNT=$(git diff --name-only | wc -l)
MESSAGE="WIP on $BRANCH: $FILES_COUNT files @ $TIMESTAMP"
# Check what to stash
if [ $(git diff --staged --name-only | wc -l) -gt 0 ]; then
MESSAGE="$MESSAGE (staged)"
git stash push -m "$MESSAGE" --keep-index
else
git stash push -m "$MESSAGE" -u
fi
echo "Created stash: $MESSAGE"
}
# Interactive stash browser
browse_stashes() {
echo "Available stashes:"
echo "=================="
# List stashes with details
git stash list | while IFS= read -r stash; do
INDEX=$(echo $stash | cut -d: -f1 | sed 's/stash@{\(.*\)}/\1/')
MESSAGE=$(echo $stash | cut -d: -f2-)
echo ""
echo "[$INDEX] $MESSAGE"
# Show files in stash
echo " Files:"
git stash show --name-only stash@{$INDEX} | sed 's/^/ - /'
# Show stats
STATS=$(git stash show --stat stash@{$INDEX} | tail -1)
echo " Stats: $STATS"
done
echo ""
echo "Commands: apply <n>, pop <n>, drop <n>, show <n>, quit"
while true; do
read -p "> " cmd index
case $cmd in
apply)
git stash apply stash@{$index}
;;
pop)
git stash pop stash@{$index}
;;
drop)
read -p "Are you sure? (y/n) " confirm
[ "$confirm" = "y" ] && git stash drop stash@{$index}
;;
show)
git stash show -p stash@{$index} | less
;;
quit|q)
break
;;
*)
echo "Unknown command: $cmd"
;;
esac
done
}
# Stash queue system
stash_queue_push() {
MESSAGE=$1
QUEUE_FILE=".git/stash_queue"
# Create stash
git stash push -m "$MESSAGE" -u
# Add to queue
echo "$MESSAGE" >> $QUEUE_FILE
echo "Added to stash queue: $MESSAGE"
}
stash_queue_pop() {
QUEUE_FILE=".git/stash_queue"
if [ ! -f $QUEUE_FILE ] || [ ! -s $QUEUE_FILE ]; then
echo "Stash queue is empty"
return 1
fi
# Get last item from queue
LAST_MESSAGE=$(tail -1 $QUEUE_FILE)
# Find and apply matching stash
STASH_INDEX=$(git stash list | grep -n "$LAST_MESSAGE" | cut -d: -f1)
if [ ! -z "$STASH_INDEX" ]; then
git stash pop stash@{$((STASH_INDEX - 1))}
# Remove from queue
sed -i '$ d' $QUEUE_FILE
echo "Popped from queue: $LAST_MESSAGE"
else
echo "Could not find stash: $LAST_MESSAGE"
fi
}
Cherry-Pick and Stash Integration
Combined Workflows
# combined_workflows.py
class CherryPickStashWorkflow:
def __init__(self):
self.cherry_picker = CherryPickManager()
self.stash_manager = StashManager()
def cherry_pick_with_stash(self, commits: List[str],
preserve_working_tree: bool = True):
"""Cherry-pick commits while preserving working tree"""
stash_id = None
# Stash current changes if needed
if preserve_working_tree and self.has_changes():
print("Stashing current changes...")
stash_id = self.stash_manager.smart_stash(
"Auto-stash before cherry-pick",
category="cherry-pick"
)
try:
# Perform cherry-picks
for commit in commits:
print(f"Cherry-picking {commit[:7]}...")
result = subprocess.run(
['git', 'cherry-pick', commit],
capture_output=True,
text=True
)
if result.returncode != 0:
print(f"Cherry-pick failed for {commit[:7]}")
# Check if it's a conflict
if "conflict" in result.stderr.lower():
if not self.resolve_cherry_pick_conflict(commit):
raise Exception("Could not resolve conflict")
finally:
# Restore stashed changes
if stash_id:
print("Restoring stashed changes...")
subprocess.run(['git', 'stash', 'pop'])
def selective_cherry_pick(self, source_branch: str,
file_patterns: List[str]):
"""Cherry-pick only changes matching file patterns"""
# Get commits from source branch
commits = subprocess.check_output(
['git', 'log', '--oneline', f'HEAD..{source_branch}'],
text=True
).strip().split('\n')
relevant_commits = []
for commit_line in commits:
if not commit_line:
continue
commit_sha = commit_line.split()[0]
# Check if commit touches relevant files
files = subprocess.check_output(
['git', 'diff-tree', '--no-commit-id', '--name-only',
'-r', commit_sha],
text=True
).strip().split('\n')
for pattern in file_patterns:
if any(pattern in f for f in files):
relevant_commits.append(commit_sha)
break
# Cherry-pick relevant commits
for commit in relevant_commits:
print(f"Cherry-picking {commit} (matches pattern)")
subprocess.run(['git', 'cherry-pick', commit])
def extract_changes_to_patch(self, commits: List[str],
output_dir: str = "patches"):
"""Extract commits as patches for later application"""
import os
os.makedirs(output_dir, exist_ok=True)
patches = []
for i, commit in enumerate(commits):
# Get commit info
info = subprocess.check_output(
['git', 'show', '--format=%s', '-s', commit],
text=True
).strip()
# Create safe filename
safe_name = re.sub(r'[^\w\s-]', '', info)[:50]
filename = f"{output_dir}/{i:03d}-{safe_name}.patch"
# Generate patch
patch = subprocess.check_output(
['git', 'format-patch', '-1', commit, '--stdout'],
text=True
)
with open(filename, 'w') as f:
f.write(patch)
patches.append(filename)
print(f"Created patch: {filename}")
# Create apply script
script = f"{output_dir}/apply_all.sh"
with open(script, 'w') as f:
f.write("#!/bin/bash\n")
f.write("# Apply all patches in order\n\n")
for patch in patches:
f.write(f"echo 'Applying {os.path.basename(patch)}...'\n")
f.write(f"git am < {os.path.basename(patch)}\n")
f.write("if [ $? -ne 0 ]; then\n")
f.write(" echo 'Failed to apply patch'\n")
f.write(" exit 1\n")
f.write("fi\n\n")
os.chmod(script, 0o755)
print(f"Created apply script: {script}")
Conflict Resolution
Smart Conflict Resolution
# conflict_resolver.py
class CherryPickConflictResolver:
def __init__(self):
self.conflict_handlers = {
'.json': self.resolve_json_conflict,
'.yaml': self.resolve_yaml_conflict,
'.yml': self.resolve_yaml_conflict,
'.xml': self.resolve_xml_conflict,
'.md': self.resolve_markdown_conflict
}
def resolve_conflicts(self) -> bool:
"""Resolve all conflicts in current cherry-pick"""
# Get conflicted files
conflicted = subprocess.check_output(
['git', 'diff', '--name-only', '--diff-filter=U'],
text=True
).strip().split('\n')
if not conflicted or conflicted == ['']:
return True
resolved = []
for file in conflicted:
if self.auto_resolve_file(file):
resolved.append(file)
subprocess.run(['git', 'add', file])
else:
print(f"Could not auto-resolve: {file}")
# Check if all resolved
remaining = subprocess.check_output(
['git', 'diff', '--name-only', '--diff-filter=U'],
text=True
).strip()
if not remaining:
print("All conflicts resolved!")
return True
return False
def auto_resolve_file(self, filepath: str) -> bool:
"""Attempt to auto-resolve a conflicted file"""
# Get file extension
ext = os.path.splitext(filepath)[1]
# Use specific handler if available
if ext in self.conflict_handlers:
return self.conflict_handlers[ext](filepath)
# Default resolution strategy
return self.resolve_generic_conflict(filepath)
def resolve_json_conflict(self, filepath: str) -> bool:
"""Resolve JSON file conflicts"""
import json
with open(filepath, 'r') as f:
content = f.read()
# Extract conflict sections
pattern = r'<<<<<<< HEAD\n(.*?)\n=======\n(.*?)\n>>>>>>> .+'
matches = re.findall(pattern, content, re.DOTALL)
if not matches:
return False
for ours_str, theirs_str in matches:
try:
ours = json.loads(ours_str)
theirs = json.loads(theirs_str)
# Merge strategy: combine both
if isinstance(ours, dict) and isinstance(theirs, dict):
merged = {**ours, **theirs}
elif isinstance(ours, list) and isinstance(theirs, list):
merged = ours + [x for x in theirs if x not in ours]
else:
return False
merged_str = json.dumps(merged, indent=2)
# Replace conflict with merged content
conflict_block = f'<<<<<<< HEAD\n{ours_str}\n=======\n{theirs_str}\n>>>>>>> '
content = content.replace(conflict_block, merged_str)
except json.JSONDecodeError:
return False
# Write resolved content
with open(filepath, 'w') as f:
f.write(content)
return True
Recovery and Cleanup
Stash Recovery
#!/bin/bash
# stash_recovery.sh
# Recover dropped stash
recover_dropped_stash() {
echo "Searching for dropped stashes..."
# Find dangling commits (which include dropped stashes)
DANGLING=$(git fsck --unreachable | grep commit | cut -d' ' -f3)
for commit in $DANGLING; do
# Check if it looks like a stash
if git show --format="%s" -s $commit | grep -q "WIP on\|On .*:"; then
echo "Found potential stash: $commit"
git show --stat $commit
read -p "Recover this stash? (y/n) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
# Create new stash from commit
git stash store -m "Recovered stash" $commit
echo "Recovered as: $(git stash list | head -1)"
fi
fi
done
}
# Clean up old stashes
cleanup_old_stashes() {
DAYS_OLD=${1:-30}
echo "Cleaning stashes older than $DAYS_OLD days..."
# Get stash list with dates
git stash list --format="%gd %ci %s" | while read stash_ref commit_date message; do
# Parse date
stash_date=$(date -d "$commit_date" +%s 2>/dev/null || date -j -f "%Y-%m-%d %H:%M:%S" "$commit_date" +%s)
current_date=$(date +%s)
age_days=$(( (current_date - stash_date) / 86400 ))
if [ $age_days -gt $DAYS_OLD ]; then
echo "Dropping old stash: $stash_ref ($age_days days old)"
git stash drop $stash_ref
fi
done
}
# Export stashes before cleanup
export_stashes() {
OUTPUT_DIR="stash_export_$(date +%Y%m%d)"
mkdir -p $OUTPUT_DIR
echo "Exporting stashes to $OUTPUT_DIR..."
# Export each stash
git stash list | while IFS=: read stash_ref message; do
# Extract stash number
INDEX=$(echo $stash_ref | sed 's/stash@{\(.*\)}/\1/')
# Create patch file
FILENAME="$OUTPUT_DIR/stash_${INDEX}.patch"
git stash show -p $stash_ref > $FILENAME
# Add metadata
echo "# Stash: $message" | cat - $FILENAME > temp && mv temp $FILENAME
echo "# Date: $(date)" >> $FILENAME
echo "Exported: $FILENAME"
done
# Create import script
cat > $OUTPUT_DIR/import.sh << 'EOF'
#!/bin/bash
for patch in *.patch; do
echo "Importing $patch..."
git apply --3way $patch
git stash push -m "Imported from $patch"
done
EOF
chmod +x $OUTPUT_DIR/import.sh
echo "Created import script: $OUTPUT_DIR/import.sh"
}
Best Practices Checklist
Conclusion
Cherry-pick and stash are powerful tools for precise code management. Master them to handle complex scenarios like hotfixes, selective feature ports, and efficient context switching. The key is understanding when each tool is appropriate and maintaining good hygiene to prevent confusion. With these techniques, you can navigate any Git scenario with confidence.