|
#!/usr/bin/env bash |
|
set -euo pipefail |
|
|
|
# Change the issue tag(s) cited in commits through a rebase starting at the given start commit. |
|
# - Replaces specific issue tags in the subject line (e.g., ABC-176 -> PROJ-96). |
|
# - Optionally prepends a default tag if no issue tag is detected in the subject. |
|
# - Scope: only the commits in <start>..[branch]. No git-filter-repo used. |
|
# |
|
# Usage: |
|
# git remap-issue-tags <start-commit> [branch] |
|
# --map SRC=DST # may be repeated (SRC/DST with or without brackets) |
|
# [--default TAG] # if no tag detected, prepend [TAG] |
|
# [--dry-run] # preview OLD -> NEW subjects; no history rewrite |
|
# [--bulk-edit] # open a SHA-keyed plan file to batch-edit NEW subjects |
|
# [--keep-plan] # do NOT delete the plan file after run |
|
# [--no-verify-hooks] # bypass commit-msg hooks during amend (default) |
|
# [--verify-hooks] # run commit-msg hooks during amend |
|
# [--help] |
|
# |
|
# Examples: |
|
# git remap-issue-tags ace0fba5e \ |
|
# --map ABC-176=PROJ-96 \ |
|
# --map ABC-180=PROJ-58 \ |
|
# --default PROJ-96 |
|
# |
|
# git remap-issue-tags ace0fba5e \ |
|
# feature/x \ |
|
# --map [ABC-180]=[PROJ-58] |
|
# |
|
# Notes: |
|
# - Recognized issue tags look like [PROJ-123] where PROJ is A-Z then A-Z0-9+. |
|
# - Only the *subject line* is edited; commit message bodies are unchanged. |
|
# - Preserves fixup!/squash! prefixes. |
|
# - Bulk-edit plan (block format only): |
|
# <sha> |
|
# OLD: <original subject> |
|
# NEW: <editable subject> |
|
|
|
print_usage() { |
|
awk ' |
|
NR==1 && /^#!/ { next } # skip shebang |
|
{ sub(/\r$/, "") } # normalize CRLF |
|
in_header == 0 && /^#/ { in_header = 1 } |
|
in_header == 1 { |
|
if (/^#/) { line = $0; sub(/^# ?/, "", line); print line; next } |
|
if (/^[[:space:]]*$/) { next } |
|
exit 0 |
|
} |
|
' "$0" |
|
} |
|
|
|
error() { |
|
echo "ERROR: $*" >&2 |
|
} |
|
|
|
error_and_usage() { |
|
local msg="$1" |
|
local code="${2:-2}" |
|
error "$msg" |
|
echo >&2 |
|
print_usage |
|
exit "$code" |
|
} |
|
|
|
# --- Parse args --- |
|
start="" |
|
branch="" |
|
maps=() |
|
default_tag="" |
|
no_verify=1 |
|
dry_run=0 |
|
bulk_edit=0 |
|
keep_plan=0 |
|
|
|
while [[ $# -gt 0 ]]; do |
|
case "$1" in |
|
--map) |
|
shift |
|
if [[ $# -lt 1 ]]; then |
|
error "--map requires an argument" |
|
exit 2 |
|
fi |
|
maps+=("$1") |
|
;; |
|
--default) |
|
shift |
|
if [[ $# -lt 1 ]]; then |
|
error "--default requires an argument" |
|
exit 2 |
|
fi |
|
default_tag="$1" |
|
;; |
|
--dry-run) |
|
dry_run=1 |
|
;; |
|
--bulk-edit) |
|
bulk_edit=1 |
|
;; |
|
--keep-plan) |
|
keep_plan=1 |
|
;; |
|
--no-verify-hooks) |
|
no_verify=1 |
|
;; |
|
--verify-hooks) |
|
no_verify=0 |
|
;; |
|
--help|-h) |
|
print_usage |
|
exit 0 |
|
;; |
|
-*) |
|
error_and_usage "Unknown option: $1" 2 |
|
;; |
|
*) |
|
if [[ -z "$start" ]]; then |
|
start="$1" |
|
elif [[ -z "$branch" ]]; then |
|
branch="$1" |
|
else |
|
error_and_usage "Unexpected positional arg: $1" 2 |
|
fi |
|
;; |
|
esac |
|
shift |
|
done |
|
|
|
if [[ -z "$start" ]]; then |
|
error_and_usage "<start-commit> is required." 2 |
|
fi |
|
|
|
if [[ -z "$branch" ]]; then |
|
branch="$(git rev-parse --abbrev-ref HEAD)" |
|
fi |
|
|
|
# --- Preconditions --- |
|
if ! command -v git >/dev/null; then |
|
error "git not found" |
|
exit 1 |
|
fi |
|
|
|
declare -a pycmd=() |
|
pick_python() { |
|
if command -v python3 >/dev/null 2>&1; then |
|
pycmd=(python3) |
|
return 0 |
|
fi |
|
|
|
if command -v py >/dev/null 2>&1; then |
|
if py -3 -c "import sys; sys.exit(0)" >/dev/null 2>&1; then |
|
pycmd=(py -3) |
|
return 0 |
|
fi |
|
fi |
|
|
|
if command -v wsl >/dev/null 2>&1 && wsl which python3 >/dev/null 2>&1; then |
|
pycmd=(wsl python3) |
|
return 0 |
|
fi |
|
|
|
return 1 |
|
} |
|
|
|
if ! pick_python; then |
|
error "Python 3 not found. Install python3, or ensure 'py -3' or 'wsl python3' is available in PATH." |
|
exit 1 |
|
fi |
|
|
|
pycmd_str=$(printf '%q ' "${pycmd[@]}") |
|
pycmd_str=${pycmd_str% } |
|
|
|
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then |
|
error "Not inside a Git repo" |
|
exit 1 |
|
fi |
|
|
|
if ! git rev-parse --verify "$start" >/dev/null 2>&1; then |
|
error "Start commit '$start' not found" |
|
exit 1 |
|
fi |
|
|
|
if ! git rev-parse --verify "$branch" >/dev/null 2>&1; then |
|
error "Branch '$branch' not found" |
|
exit 1 |
|
fi |
|
|
|
if ! git merge-base --is-ancestor "$start" "$branch"; then |
|
error "$start is not an ancestor of $branch" |
|
exit 1 |
|
fi |
|
|
|
if ! git diff-index --quiet HEAD --; then |
|
error "Working tree not clean. Commit or stash changes first." |
|
exit 1 |
|
fi |
|
|
|
# --- Backup --- |
|
backup_ref="refs/backup/${branch}-rewrite-$(date +%Y%m%d%H%M%S)" |
|
git update-ref "$backup_ref" "$(git rev-parse "$branch")" |
|
echo "Backup created at $backup_ref" |
|
|
|
# --- Paths/state under .git (persist across rebase --continue) --- |
|
gitdir="$(git rev-parse --git-dir)" |
|
stamp="$(date +%s)" |
|
step_script="$gitdir/rewrite-subject-during-rebase.$stamp.py" |
|
mappings_file="$gitdir/remap-mappings.$stamp.list" |
|
env_file="$gitdir/remap-env.$stamp.vars" |
|
queue_file="$gitdir/remap-queue.$stamp.list" |
|
plan_file="" |
|
|
|
# --- Gentle housekeeping: prune artifacts older than 14 days --- |
|
if command -v find >/dev/null 2>&1; then |
|
find "$gitdir" -maxdepth 1 -type f \ |
|
\( -name 'subject-remap-plan.*.txt' \ |
|
-o -name 'rewrite-subject-during-rebase.*.py' \ |
|
-o -name 'remap-*.vars' \ |
|
-o -name 'remap-*.list' \) \ |
|
-mtime +14 -print -delete >/dev/null 2>&1 || true |
|
fi |
|
|
|
# --- Write mappings file --- |
|
if ((${#maps[@]})); then |
|
printf '%s\n' "${maps[@]}" >"$mappings_file" |
|
else |
|
: >"$mappings_file" |
|
fi |
|
|
|
# --- Write env file (UPPERCASE keys by design) --- |
|
{ |
|
echo "DEFAULT_TAG=${default_tag}" |
|
if [[ $no_verify -eq 1 ]]; then |
|
echo "AMEND_VERIFY_FLAG=--no-verify" |
|
else |
|
echo "AMEND_VERIFY_FLAG=" |
|
fi |
|
} >"$env_file" |
|
|
|
# --- Build queue of original non-merge SHAs in pick order (first-parent only) --- |
|
" |
|
|
|
# --- Create helper script (plan generation & rebase step) --- |
|
cat >"$step_script" <<'PY' |
|
#!/usr/bin/env python3 |
|
import argparse |
|
import os |
|
import re |
|
import subprocess |
|
import sys |
|
import tempfile |
|
|
|
|
|
def sh(args): |
|
return subprocess.run( |
|
args, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True |
|
).stdout |
|
|
|
|
|
def read_kv(path): |
|
d = {} |
|
if not path or not os.path.exists(path): |
|
return d |
|
with open(path, "r", encoding="utf-8") as f: |
|
for line in f: |
|
line = line.rstrip("\r\n") |
|
if not line or line.startswith("#") or "=" not in line: |
|
continue |
|
k, v = line.split("=", 1) |
|
d[k.strip()] = v |
|
return d |
|
|
|
|
|
def read_lines(path): |
|
if not path or not os.path.exists(path): |
|
return [] |
|
with open(path, "r", encoding="utf-8") as f: |
|
return [ln.rstrip("\n") for ln in f] |
|
|
|
|
|
def transform_subject(core, mappings_lines, default_tag): |
|
for line in mappings_lines: |
|
s = line.strip() |
|
if not s or "=" not in s: |
|
continue |
|
src, dst = s.split("=", 1) |
|
src = src.strip() |
|
dst = dst.strip() |
|
if src.startswith("[") and src.endswith("]"): |
|
src = src[1:-1] |
|
if dst.startswith("[") and dst.endswith("]"): |
|
dst = dst[1:-1] |
|
core = core.replace(f"[{src}]", f"[{dst}]") |
|
|
|
if default_tag: |
|
dt = default_tag |
|
if dt.startswith("[") and dt.endswith("]"): |
|
dt = dt[1:-1] |
|
if not re.search(r"\[[A-Z][A-Z0-9]+-\d+\]", core): |
|
core = f"[{dt}] {core}".strip() |
|
|
|
return core |
|
|
|
|
|
def current_subject_and_body(): |
|
msg = sh(["git", "log", "-1", "--pretty=%B"]) |
|
lines = msg.splitlines() |
|
subject = lines[0] if lines else "" |
|
body = "\n".join(lines[1:]) if len(lines) > 1 else "" |
|
return subject, body |
|
|
|
|
|
def emit_plan(rev_range, mappings_lines, default_tag): |
|
out = sh( |
|
[ |
|
"git", "log", "-z", "--reverse", |
|
"--first-parent", "--no-merges", |
|
"--pretty=format:%H%x00%s", |
|
rev_range, |
|
] |
|
) |
|
toks = out.split("\x00") |
|
for i in range(0, len(toks) - 1, 2): |
|
sha = toks[i] |
|
if not sha: |
|
continue |
|
subject = toks[i + 1] |
|
m = re.match(r"^(?:fixup|squash)!\s+", subject) |
|
prefix = m.group(0) if m else "" |
|
core = subject[len(prefix):] if m else subject |
|
new = prefix + transform_subject(core, mappings_lines, default_tag) |
|
|
|
subject = subject.replace("\r", " ") |
|
new = new.replace("\r", " ") |
|
|
|
print(sha) |
|
print(f"OLD: {subject}") |
|
print(f"NEW: {new}\n") |
|
|
|
|
|
def plan_lookup(plan_path, sha): |
|
if not plan_path or not os.path.exists(plan_path): |
|
return None |
|
|
|
new_map = {} |
|
cur_sha = None |
|
|
|
with open(plan_path, "r", encoding="utf-8") as f: |
|
for raw in f: |
|
line = raw.rstrip("\r\n") |
|
if not line or line.lstrip().startswith("#"): |
|
continue |
|
|
|
m_sha = re.match(r"^([0-9a-f]{7,40})\s*$", line) |
|
if m_sha: |
|
cur_sha = m_sha.group(1) |
|
continue |
|
|
|
if cur_sha: |
|
m_new = re.match(r"^NEW:\s*(.*)$", line) |
|
if m_new: |
|
new_map[cur_sha] = m_new.group(1).strip() |
|
continue |
|
|
|
v = new_map.get(sha) |
|
if v: |
|
return v |
|
return None |
|
|
|
|
|
def find_gitdir(): |
|
try: |
|
out = subprocess.run( |
|
["git", "rev-parse", "--git-dir"], |
|
check=True, |
|
stdout=subprocess.PIPE, |
|
stderr=subprocess.PIPE, |
|
text=True, |
|
).stdout.strip() |
|
return out or ".git" |
|
except Exception: |
|
return ".git" |
|
|
|
|
|
def find_rebase_dir(gitdir): |
|
for d in ("rebase-merge", "rebase-apply"): |
|
path = os.path.join(gitdir, d) |
|
if os.path.isdir(path): |
|
return path |
|
return None |
|
|
|
|
|
_PICK_CMDS = ("pick", "reword", "edit", "fixup", "squash") |
|
|
|
|
|
def last_pick_sha(rebase_dir): |
|
if not rebase_dir: |
|
return None |
|
done = os.path.join(rebase_dir, "done") |
|
try: |
|
with open(done, "r", encoding="utf-8") as f: |
|
lines = [ln.rstrip("\r\n") for ln in f] |
|
except FileNotFoundError: |
|
return None |
|
|
|
i = len(lines) - 1 |
|
while i >= 0 and lines[i].startswith("exec "): |
|
i -= 1 |
|
if i < 0: |
|
return None |
|
|
|
m = re.match(r"^([a-z]+)\s+([0-9a-f]{7,40})\b", lines[i]) |
|
if not m: |
|
return None |
|
cmd, sha = m.group(1), m.group(2) |
|
if cmd in _PICK_CMDS: |
|
return sha |
|
return None |
|
|
|
|
|
def main(): |
|
ap = argparse.ArgumentParser(add_help=False) |
|
ap.add_argument("--dry-run", action="store_true") |
|
ap.add_argument("--emit-plan", action="store_true") |
|
ap.add_argument("--range", required=False) |
|
ap.add_argument("--env", required=False) |
|
ap.add_argument("--mappings", required=False) |
|
ap.add_argument("--plan", required=False) |
|
ap.add_argument("--queue", required=False) # accepted; not used |
|
ap.add_argument("--sha", required=False) |
|
ap.add_argument("--subject", required=False) |
|
args = ap.parse_args() |
|
|
|
env = read_kv(args.env) if args.env else {} |
|
mappings_lines = read_lines(args.mappings) |
|
default_tag = env.get("DEFAULT_TAG", "") |
|
|
|
if args.emit_plan: |
|
if not args.range: |
|
print("ERROR: --emit-plan requires --range", file=sys.stderr) |
|
sys.exit(2) |
|
emit_plan(args.range, mappings_lines, default_tag) |
|
return |
|
|
|
if args.dry_run: |
|
subject = args.subject or "" |
|
sha = args.sha or "" |
|
|
|
override = plan_lookup(args.plan, sha) |
|
if override is not None: |
|
print(override) |
|
return |
|
|
|
m = re.match(r"^(?:fixup|squash)!\s+", subject) |
|
prefix = m.group(0) if m else "" |
|
core = subject[len(prefix):] if m else subject |
|
new_subject = prefix + transform_subject(core, mappings_lines, default_tag) |
|
print(new_subject) |
|
return |
|
|
|
# Mutating mode during rebase: act ONLY after pick-like steps and bind to that SHA. |
|
subject, body = current_subject_and_body() |
|
|
|
gitdir = find_gitdir() |
|
rebase_dir = find_rebase_dir(gitdir) |
|
sha = last_pick_sha(rebase_dir) |
|
if not sha: |
|
return |
|
|
|
new_subject = plan_lookup(args.plan, sha) |
|
if new_subject is None: |
|
m = re.match(r"^(?:fixup|squash)!\s+", subject) |
|
prefix = m.group(0) if m else "" |
|
core = subject[len(prefix):] if m else subject |
|
new_subject = prefix + transform_subject(core, mappings_lines, default_tag) |
|
|
|
if new_subject == subject: |
|
return |
|
|
|
new_msg = new_subject + ("\n" + body if body else "") |
|
with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as tf: |
|
tf.write(new_msg) |
|
path = tf.name |
|
|
|
try: |
|
amend_flag = env.get("AMEND_VERIFY_FLAG", "") |
|
cmd = ["git", "commit", "--amend", "-F", path] |
|
if amend_flag: |
|
cmd.append(amend_flag) |
|
subprocess.run(cmd, check=True) |
|
finally: |
|
try: |
|
os.remove(path) |
|
except OSError: |
|
pass |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |
|
PY |
|
|
|
chmod +x "$step_script" |
|
|
|
# --- Optional bulk-edit plan (create & open before rebase) --- |
|
if [[ $bulk_edit -eq 1 ]]; then |
|
plan_file="$gitdir/subject-remap-plan.$stamp.txt" |
|
{ |
|
echo "# Edit ONLY the 'NEW:' line in each block." |
|
echo "# Blank NEW means: use automatic mapping for that commit." |
|
echo "# Lines starting with '#' are ignored." |
|
echo |
|
} > "$plan_file" |
|
|
|
"${pycmd[@]}" "$step_script" --emit-plan --range "$start..$branch" \ |
|
--env "$env_file" --mappings "$mappings_file" >> "$plan_file" |
|
|
|
editor_cmd="${VISUAL:-${GIT_EDITOR:-${EDITOR:-vi}}}" |
|
sh -c "$editor_cmd \"$plan_file\"" |
|
|
|
# Normalize CRLF to LF for parsing |
|
if grep -q $'\r' "$plan_file"; then |
|
tmp_norm="$plan_file.norm.$$" |
|
tr -d '\r' < "$plan_file" > "$tmp_norm" |
|
mv "$tmp_norm" "$plan_file" |
|
fi |
|
|
|
# Abort if plan is empty (no SHA headers) |
|
if ! LC_ALL=C grep -Eq '^[0-9a-f]{7,40}[[:space:]]*$' "$plan_file"; then |
|
error "Bulk-edit plan is empty; aborting." |
|
if [[ $keep_plan -ne 1 ]]; then |
|
rm -f "$plan_file" |
|
fi |
|
exit 1 |
|
fi |
|
|
|
# Sanity check: plan SHAs match first-parent pick list exactly (count & order) |
|
mapfile -t fp_arr < <(git rev-list --first-parent --reverse --no-merges "$start..$branch") |
|
mapfile -t plan_arr < <(LC_ALL=C grep -E '^[0-9a-f]{7,40}[[:space:]]*$' "$plan_file") |
|
|
|
fp_count="${#fp_arr[@]}" |
|
plan_count="${#plan_arr[@]}" |
|
|
|
if [[ "$plan_count" -ne "$fp_count" ]]; then |
|
error "Plan has $plan_count commit blocks but first-parent pick list has $fp_count. Aborting to avoid misapplied edits." |
|
if [[ $keep_plan -ne 1 ]]; then |
|
rm -f "$plan_file" |
|
fi |
|
exit 1 |
|
fi |
|
|
|
mismatch_index=-1 |
|
for ((i = 0; i < fp_count; i++)); do |
|
if [[ "${fp_arr[$i]}" != "${plan_arr[$i]}" ]]; then |
|
mismatch_index="$i" |
|
break |
|
fi |
|
done |
|
|
|
if [[ "$mismatch_index" -ge 0 ]]; then |
|
error "Plan SHA at position $((mismatch_index + 1)) does not match first-parent pick list." |
|
echo " Expected: ${fp_arr[$mismatch_index]}" >&2 |
|
echo " Found: ${plan_arr[$mismatch_index]}" >&2 |
|
if [[ $keep_plan -ne 1 ]]; then |
|
rm -f "$plan_file" |
|
fi |
|
exit 1 |
|
fi |
|
fi |
|
|
|
# --- DRY RUN PATH (preview only) --- |
|
if [[ $dry_run -eq 1 ]]; then |
|
echo "Dry run: showing subject rewrites for commits in $start..$branch" |
|
while IFS= read -r -d '' sha && IFS= read -r -d '' subject; do |
|
new_subject="$("${pycmd[@]}" "$step_script" --dry-run \ |
|
--env "$env_file" \ |
|
--mappings "$mappings_file" \ |
|
--plan "${plan_file:-}" \ |
|
--sha "$sha" \ |
|
--subject "$subject" || true)" |
|
|
|
if [[ "$new_subject" != "$subject" ]]; then |
|
printf '%s\n OLD: %s\n NEW: %s\n\n' "$sha" "$subject" "$new_subject" |
|
fi |
|
done < <(git log -z --reverse --first-parent --no-merges --pretty=format:%H%x00%s "$start..$branch") |
|
|
|
if [[ -n "${plan_file}" && $keep_plan -ne 1 ]]; then |
|
rm -f "$plan_file" |
|
fi |
|
|
|
exit 0 |
|
fi |
|
|
|
# --- Execute the rebase (mutating) --- |
|
current_branch="$(git rev-parse --abbrev-ref HEAD)" |
|
if [[ "$current_branch" != "$branch" ]]; then |
|
git checkout -q "$branch" |
|
fi |
|
|
|
set +e |
|
git rebase --rebase-merges=no-rebase-cousins -x \ |
|
"$pycmd_str '$step_script' --env '$env_file' --mappings '$mappings_file' --plan '${plan_file:-}'" \ |
|
"$start" |
|
status=$? |
|
set -e |
|
|
|
if [[ $status -ne 0 ]]; then |
|
echo |
|
error "Rebase paused or failed." |
|
echo "Options:" >&2 |
|
echo " - Resolve conflicts, then: git rebase --continue" >&2 |
|
echo " - Abort and restore: git rebase --abort && git reset --hard $backup_ref" >&2 |
|
echo |
|
echo "State files kept for resume/debug:" >&2 |
|
echo " $step_script" >&2 |
|
echo " $env_file" >&2 |
|
echo " $mappings_file" >&2 |
|
echo " $queue_file" >&2 |
|
if [[ -n "${plan_file}" ]]; then |
|
echo " $plan_file" >&2 |
|
fi |
|
exit $status |
|
fi |
|
|
|
# Cleanup on success |
|
rm -f "$step_script" "$env_file" "$mappings_file" "$queue_file" |
|
if [[ -n "${plan_file}" && $keep_plan -ne 1 ]]; then |
|
rm -f "$plan_file" |
|
fi |
|
|
|
cat <<EOF |
|
|
|
Done. Rebased '$branch' from $start and updated commit subjects. |
|
|
|
Rollback backup ref: |
|
git reset --hard "$backup_ref" |
|
|
|
Push (history changed): |
|
git push --force-with-lease origin "$branch" |
|
|
|
Scope: |
|
Only commits in the range $start..$branch were rewritten. Commit bodies were untouched. |
|
EOF |
|
if [[ -n "${plan_file}" && $keep_plan -eq 1 ]]; then |
|
echo "(Bulk-edit plan kept at: $plan_file)" |
|
fi |