Skip to content

Instantly share code, notes, and snippets.

@tayyebi
Created October 22, 2025 14:37
Show Gist options
  • Save tayyebi/fcdf3fe4611c63c21446189bbc76a7de to your computer and use it in GitHub Desktop.
Save tayyebi/fcdf3fe4611c63c21446189bbc76a7de to your computer and use it in GitHub Desktop.
#!/bin/bash
# Usage: ./usr/local/bin/cherry-pick <second_branch> [author-or-username] [--commit] [--commit-separate]
# Examples:
# ./usr/local/bin/cherry-pick feature-branch
# ./usr/local/bin/cherry-pick feature-branch "[email protected]" --commit
# ./usr/local/bin/cherry-pick feature-branch --commit-separate
# Description:
# Find commits in <second_branch> newer than the last commit on the current
# branch by the same author (or provided author/username). By default the script
# stages the combined changes without creating commits. Use --commit to create
# a single combined commit, or --commit-separate to replay each found commit
# as an individual commit preserving original author and message.
set -euo pipefail
if [ -z "${1-}" ]; then
echo "Usage: $0 <second_branch> [author-or-username] [--commit] [--commit-separate]"
exit 1
fi
SECOND_BRANCH="$1"
ARG2="${2-}"
COMMIT_FLAG=false
COMMIT_SEPARATE=false
# parse remaining args
for arg in "${@:2}"; do
case "$arg" in
--commit) COMMIT_FLAG=true ;;
--commit-separate) COMMIT_SEPARATE=true ;;
*) AUTHOR_ARG="$arg" ;;
esac
done
# ensure repo
if ! git rev-parse --git-dir >/dev/null 2>&1; then
echo "Error: not a git repository"
exit 2
fi
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
LAST_COMMIT_DATE=$(git log -1 --format="%ct" "$CURRENT_BRANCH" 2>/dev/null || echo 0)
# Determine author identifier (prefer provided arg). Use username (email local-part) from last commit when not provided.
if [ -n "${AUTHOR_ARG-}" ]; then
AUTHOR_IDENTIFIER="$AUTHOR_ARG"
else
LAST_AUTHOR_EMAIL=$(git log -1 --format="%ae" "$CURRENT_BRANCH" 2>/dev/null || echo "")
if [ -n "$LAST_AUTHOR_EMAIL" ]; then
AUTHOR_IDENTIFIER="${LAST_AUTHOR_EMAIL%%@*}" # username (local-part)
else
AUTHOR_IDENTIFIER=$(git log -1 --format="%an" "$CURRENT_BRANCH" 2>/dev/null || echo "")
fi
fi
echo "Current branch: $CURRENT_BRANCH"
echo "Second branch: $SECOND_BRANCH"
echo "Author identifier (username/email/regex): $AUTHOR_IDENTIFIER"
echo "Last commit date (epoch): $LAST_COMMIT_DATE"
echo "Create combined commit: $COMMIT_FLAG"
echo "Replay as separate commits: $COMMIT_SEPARATE"
# check branch exists
if ! git show-ref --verify --quiet "refs/heads/$SECOND_BRANCH" && ! git rev-parse --verify --quiet "$SECOND_BRANCH" >/dev/null 2>&1; then
echo "Error: branch '$SECOND_BRANCH' not found"
exit 3
fi
# collect qualifying commits (oldest->newest)
mapfile -t NEW_COMMITS < <(git log --reverse "$SECOND_BRANCH" --author="$AUTHOR_IDENTIFIER" --format="%H %ct" | awk -v ref="$LAST_COMMIT_DATE" '$2 > ref {print $1}')
if [ "${#NEW_COMMITS[@]}" -eq 0 ]; then
echo "No newer commits matching '$AUTHOR_IDENTIFIER' found in '$SECOND_BRANCH'."
exit 0
fi
echo "Found ${#NEW_COMMITS[@]} commit(s) to apply."
ORIG_HEAD=$(git rev-parse --verify HEAD 2>/dev/null || echo "none")
# Helper: list unmerged files
unmerged_files() {
git diff --name-only --diff-filter=U || true
}
# Iterate commits
for COMMIT in "${NEW_COMMITS[@]}"; do
echo "Processing commit: $COMMIT"
PREV_HEAD=$(git rev-parse --verify HEAD 2>/dev/null || echo "none")
if [ "$COMMIT_SEPARATE" = true ]; then
# Replay as individual commit: do normal cherry-pick (creates commit unless conflicts)
if git cherry-pick --allow-empty "$COMMIT"; then
echo "Cherry-picked $COMMIT as a separate commit without conflicts."
continue
fi
echo "Conflict detected while cherry-picking $COMMIT; accepting theirs for conflicts."
# Resolve conflicts by taking theirs, stage and continue
while IFS= read -r file; do
[ -z "$file" ] && continue
echo "Resolving conflict by taking theirs for: $file"
git checkout --theirs -- "$file" || true
git add -- "$file"
done < <(unmerged_files)
# If CHERRY_PICK_HEAD exists, continue; otherwise abort and restore
GIT_DIR=$(git rev-parse --git-dir)
if [ -e "$GIT_DIR/CHERRY_PICK_HEAD" ]; then
if git cherry-pick --continue; then
echo "cherry-pick --continue succeeded for $COMMIT."
continue
else
echo "Error: failed to continue cherry-pick for $COMMIT. Aborting and restoring."
git cherry-pick --abort || true
if [ "$PREV_HEAD" != "none" ]; then git reset --hard "$PREV_HEAD" || true; fi
exit 4
fi
else
echo "Warning: no cherry-pick in progress after resolving conflicts for $COMMIT."
git add -A || true
if git diff --name-only --diff-filter=U | read -r _; then
echo "Remaining unmerged paths found. Restoring previous HEAD ($PREV_HEAD)."
git reset --hard "$PREV_HEAD" || true
exit 5
fi
# create a commit using original commit metadata
AUTHOR_LINE=$(git show -s --format='%an <%ae>' "$COMMIT")
COMMIT_MSG=$(git show -s --format='%B' "$COMMIT")
COMMIT_DATE=$(git show -s --format='%aI' "$COMMIT")
GIT_COMMITTER_DATE="$COMMIT_DATE" git commit --author="$AUTHOR_LINE" -m "$COMMIT_MSG"
echo "Committed resolved result for $COMMIT preserving original metadata."
fi
else
# Default behavior: apply with --no-commit to stage changes (single combined commit later)
if git cherry-pick --no-commit --allow-empty "$COMMIT"; then
echo "Applied $COMMIT without conflicts (changes staged)."
continue
fi
echo "Conflict detected while applying $COMMIT. Accepting incoming changes (theirs) for all conflicts."
while IFS= read -r file; do
[ -z "$file" ] && continue
echo "Resolving conflict by taking theirs for: $file"
git checkout --theirs -- "$file" || true
git add -- "$file"
done < <(unmerged_files)
GIT_DIR=$(git rev-parse --git-dir)
if [ -e "$GIT_DIR/CHERRY_PICK_HEAD" ]; then
if git cherry-pick --continue; then
# cherry-pick --continue created a commit; we reset soft to PREV_HEAD to leave changes staged as intended
if [ "$PREV_HEAD" = "none" ]; then
git reset --soft HEAD^ || true
else
git reset --soft "$PREV_HEAD" || true
fi
echo "Uncommitted the cherry-picked changes and left them staged."
else
echo "Error: failed to continue cherry-pick after resolving conflicts for $COMMIT."
git cherry-pick --abort || true
if [ "$PREV_HEAD" != "none" ]; then git reset --hard "$PREV_HEAD" || true; fi
exit 4
fi
else
echo "Warning: no cherry-pick in progress after resolving conflicts for $COMMIT."
git add -A || true
if git diff --name-only --diff-filter=U | read -r _; then
echo "Remaining unmerged paths found. Aborting and restoring previous HEAD ($PREV_HEAD)."
git reset --hard "$PREV_HEAD" || true
exit 5
fi
fi
fi
done
# After applying all commits:
if [ "$COMMIT_SEPARATE" = true ]; then
echo "All commits replayed as separate commits. Review the log to see new commits."
exit 0
fi
if [ "$COMMIT_FLAG" = true ]; then
if git diff --staged --quiet; then
echo "No staged changes to commit."
else
git commit -m "Cherry-picked ${#NEW_COMMITS[@]} commit(s) from $SECOND_BRANCH by $AUTHOR_IDENTIFIER"
echo "Created combined commit."
fi
else
echo "All cherry-picks applied and changes staged. No commit has been made."
echo "To see these changes in your branch log, create a commit."
echo "Inspect staged changes with: git diff --staged"
echo 'Create a single commit with: git commit -m "<your combined message>"'
echo "Or replay commits as separate commits by re-running with --commit-separate."
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment