From 46cea4756f0a3d0cf62db87d418e3e279da72166 Mon Sep 17 00:00:00 2001 From: Emerson Soares Date: Sat, 18 Apr 2026 17:34:54 -0300 Subject: [PATCH 1/4] feat: add provider-migrate command for repo renames and transfers --- Makefile | 1 + bin/git-issue | 3 +- bin/git-issue-provider-migrate | 175 +++++++++++++++++++++ t/test-provider-migrate.sh | 267 +++++++++++++++++++++++++++++++++ 4 files changed, 445 insertions(+), 1 deletion(-) create mode 100755 bin/git-issue-provider-migrate create mode 100755 t/test-provider-migrate.sh diff --git a/Makefile b/Makefile index 47ac683..30fe598 100644 --- a/Makefile +++ b/Makefile @@ -38,6 +38,7 @@ test: @sh t/test-merge.sh @sh t/test-qol.sh @sh t/test-comment-sync.sh + @sh t/test-provider-migrate.sh clean: rm -rf t/tmp-* diff --git a/bin/git-issue b/bin/git-issue index ef84667..f4f2de0 100755 --- a/bin/git-issue +++ b/bin/git-issue @@ -25,6 +25,7 @@ Commands: sync Two-way sync (import + export) search Search issues by text pattern merge Merge issues from a remote + provider-migrate Migrate Provider-IDs after repo renames or transfers fsck Validate issue data integrity init Configure the current repo for issue tracking version Print version information @@ -38,7 +39,7 @@ EOF ISSUE_BIN_DIR="$(cd "$(dirname "$0")" && pwd)" case "${1:-}" in - create|ls|show|comment|edit|state|search|import|export|sync|merge|fsck|init) + create|ls|show|comment|edit|state|search|import|export|sync|merge|fsck|init|provider-migrate) cmd="$1" shift exec "$ISSUE_BIN_DIR/git-issue-$cmd" "$@" diff --git a/bin/git-issue-provider-migrate b/bin/git-issue-provider-migrate new file mode 100755 index 0000000..25c9b08 --- /dev/null +++ b/bin/git-issue-provider-migrate @@ -0,0 +1,175 @@ +#!/bin/sh +# +# git-issue-provider-migrate - Migrate Provider-IDs after repo renames or transfers +# + +set -e + +. "$(dirname "$0")/git-issue-lib" + +usage() { + cat < + +Migrates Provider-IDs after repository renames, org transfers, or +cross-platform migrations. Appends new Provider-ID commits without +modifying existing history. + +Options: + --dry-run Show what would be migrated without making changes + -h, --help Show this help + +Examples: + git issue provider-migrate github:owner/old-repo github:owner/new-repo + git issue provider-migrate github:old-org/repo github:new-org/repo + git issue provider-migrate github:owner/repo gitlab:owner/repo +EOF + exit 1 +} + +dry_run=0 +old_prefix="" +new_prefix="" + +while test $# -gt 0 +do + case "$1" in + --dry-run) + dry_run=1 + shift + ;; + -h|--help) + usage + ;; + --) + shift + break + ;; + -*) + echo "error: unknown option '$1'" >&2 + usage + ;; + *) + if test -z "$old_prefix" + then + old_prefix="$1" + elif test -z "$new_prefix" + then + new_prefix="$1" + else + echo "error: too many arguments" >&2 + usage + fi + shift + ;; + esac +done + +if test -z "$old_prefix" || test -z "$new_prefix" +then + echo "error: both and are required" >&2 + usage +fi + +if test "$old_prefix" = "$new_prefix" +then + echo "error: old and new prefix are the same: $old_prefix" >&2 + exit 1 +fi + +# Verify we are inside a git repository +git rev-parse --git-dir >/dev/null 2>&1 || { + echo "fatal: not a git repository" >&2 + exit 128 +} + +# Validate git identity before creating commits (unless dry-run) +if test "$dry_run" -eq 0 +then + validate_git_identity +fi + +empty_tree="$(git hash-object -t tree /dev/null)" +migrated=0 + +for ref in $(git for-each-ref --format='%(refname)' refs/issues/) +do + uuid="${ref#refs/issues/}" + short_id="$(printf '%s' "$uuid" | cut -c1-7)" + + # Get all Provider-IDs in the chain + all_pids="$(git log --format='%(trailers:key=Provider-ID,valueonly)' "$ref" | sed '/^$/d' | sed 's/^[[:space:]]*//')" + + # Check if any Provider-ID matches the old prefix + matched_number="" + old_ifs="$IFS" + IFS=' +' + for pid in $all_pids + do + case "$pid" in + "${old_prefix}#"*) + matched_number="${pid#*#}" + break + ;; + esac + done + IFS="$old_ifs" + + # Skip issues without a matching Provider-ID + test -z "$matched_number" && continue + + new_pid="${new_prefix}#${matched_number}" + + # Idempotency: skip if new Provider-ID already exists in chain + already_migrated=0 + old_ifs="$IFS" + IFS=' +' + for pid in $all_pids + do + if test "$pid" = "$new_pid" + then + already_migrated=1 + break + fi + done + IFS="$old_ifs" + + if test "$already_migrated" -eq 1 + then + continue + fi + + if test "$dry_run" -eq 1 + then + printf '[dry-run] Would migrate %s: %s#%s -> %s\n' "$short_id" "$old_prefix" "$matched_number" "$new_pid" + else + # Get current HEAD of the issue + issue_head="$(git rev-parse "$ref")" + + # Build commit message + tmpfile="$(mktemp)" + # Use a simple arrow since the Unicode arrow may cause issues in POSIX + printf 'Provider migrate: %s -> %s\n' "$old_prefix" "$new_prefix" > "$tmpfile" + git interpret-trailers --in-place --trailer "Provider-ID: $new_pid" "$tmpfile" + + # Create new commit as child of current HEAD + commit="$(git commit-tree -p "$issue_head" -- "$empty_tree" < "$tmpfile")" + rm -f "$tmpfile" + + # Update the ref + git update-ref "$ref" "$commit" "$issue_head" + + printf 'Migrated %s: %s#%s -> %s\n' "$short_id" "$old_prefix" "$matched_number" "$new_pid" + fi + + migrated=$((migrated + 1)) +done + +if test "$migrated" -eq 1 +then + printf 'Migrated %d issue\n' "$migrated" +else + printf 'Migrated %d issues\n' "$migrated" +fi diff --git a/t/test-provider-migrate.sh b/t/test-provider-migrate.sh new file mode 100755 index 0000000..d3dc84d --- /dev/null +++ b/t/test-provider-migrate.sh @@ -0,0 +1,267 @@ +#!/bin/sh +# +# Tests for git-issue provider-migrate +# +# Run: sh t/test-provider-migrate.sh +# + +set -e + +# Colors (if terminal supports them) +if test -t 1 +then + RED='\033[0;31m' + GREEN='\033[0;32m' + NC='\033[0m' +else + RED='' + GREEN='' + NC='' +fi + +TESTS_RUN=0 +TESTS_PASSED=0 +TESTS_FAILED=0 + +BIN_DIR="$(cd "$(dirname "$0")/../bin" && pwd)" +TEST_DIR="$(mktemp -d)" + +trap 'rm -rf "$TEST_DIR"' EXIT + +pass() { + TESTS_PASSED=$((TESTS_PASSED + 1)) + printf "${GREEN} PASS${NC} %s\n" "$1" +} + +fail() { + TESTS_FAILED=$((TESTS_FAILED + 1)) + printf "${RED} FAIL${NC} %s\n" "$1" + if test -n "${2:-}" + then + printf " %s\n" "$2" + fi +} + +run_test() { + TESTS_RUN=$((TESTS_RUN + 1)) +} + +# Helper: create a test issue with a Provider-ID +# Args: $1 = title, $2 = provider-id (optional) +create_test_issue() { + _title="$1" + _pid="${2:-}" + + _empty_tree="$(git hash-object -t tree /dev/null)" + _uuid="$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen 2>/dev/null || python3 -c 'import uuid; print(uuid.uuid4())' 2>/dev/null)" + + _tmpfile="$(mktemp)" + printf '%s\n' "$_title" > "$_tmpfile" + + if test -n "$_pid" + then + git interpret-trailers --in-place --trailer "Provider-ID: $_pid" "$_tmpfile" + fi + + _commit="$(git commit-tree -- "$_empty_tree" < "$_tmpfile")" + git update-ref "refs/issues/$_uuid" "$_commit" + rm -f "$_tmpfile" + + printf '%s' "$_uuid" +} + +setup_repo() { + rm -rf "$TEST_DIR/repo" + mkdir "$TEST_DIR/repo" + cd "$TEST_DIR/repo" + git init -q + git commit --allow-empty -q -m "initial" + export PATH="$BIN_DIR:$PATH" +} + +printf "Running git-issue provider-migrate tests...\n\n" + +# ============================================================ +# TEST: basic migration (2 matching + 1 unrelated) +# ============================================================ +run_test +setup_repo +uuid1="$(create_test_issue "Issue one" "github:owner/old-repo#1")" +uuid2="$(create_test_issue "Issue two" "github:owner/old-repo#2")" +uuid3="$(create_test_issue "Unrelated" "github:other/repo#5")" +output="$(git issue provider-migrate github:owner/old-repo github:owner/new-repo 2>&1)" +case "$output" in + *"Migrated 2 issues"*) + pass "basic migration: 2 matching migrated" + ;; + *) + fail "basic migration: 2 matching migrated" "output: $output" + ;; +esac + +# ============================================================ +# TEST: new Provider-ID is the most recent (head -1) +# ============================================================ +run_test +newest_pid="$(git log --format='%(trailers:key=Provider-ID,valueonly)' "refs/issues/$uuid1" | sed '/^$/d' | sed 's/^[[:space:]]*//' | head -1)" +if test "$newest_pid" = "github:owner/new-repo#1" +then + pass "new Provider-ID is the most recent in chain" +else + fail "new Provider-ID is the most recent in chain" "got: '$newest_pid'" +fi + +# ============================================================ +# TEST: old Provider-ID preserved in chain +# ============================================================ +run_test +all_pids="$(git log --format='%(trailers:key=Provider-ID,valueonly)' "refs/issues/$uuid1" | sed '/^$/d' | sed 's/^[[:space:]]*//')" +case "$all_pids" in + *"github:owner/old-repo#1"*) + pass "old Provider-ID preserved in chain" + ;; + *) + fail "old Provider-ID preserved in chain" "pids: $all_pids" + ;; +esac + +# ============================================================ +# TEST: unrelated issue not modified +# ============================================================ +run_test +unrelated_count="$(git rev-list --count "refs/issues/$uuid3")" +if test "$unrelated_count" -eq 1 +then + pass "unrelated issue not modified" +else + fail "unrelated issue not modified" "expected 1 commit, got $unrelated_count" +fi + +# ============================================================ +# TEST: dry-run does not modify anything +# ============================================================ +run_test +setup_repo +uuid_dry="$(create_test_issue "Dry run issue" "github:owner/old-repo#10")" +before_sha="$(git rev-parse "refs/issues/$uuid_dry")" +git issue provider-migrate --dry-run github:owner/old-repo github:owner/new-repo >/dev/null 2>&1 +after_sha="$(git rev-parse "refs/issues/$uuid_dry")" +if test "$before_sha" = "$after_sha" +then + pass "dry-run does not modify refs" +else + fail "dry-run does not modify refs" "ref changed from $before_sha to $after_sha" +fi + +# ============================================================ +# TEST: dry-run shows preview with [dry-run] prefix +# ============================================================ +run_test +output="$(git issue provider-migrate --dry-run github:owner/old-repo github:owner/new-repo 2>&1)" +case "$output" in + *"[dry-run]"*) + pass "dry-run shows preview with [dry-run] prefix" + ;; + *) + fail "dry-run shows preview with [dry-run] prefix" "output: $output" + ;; +esac + +# ============================================================ +# TEST: idempotent (second run is no-op) +# ============================================================ +run_test +setup_repo +uuid_idem="$(create_test_issue "Idempotent issue" "github:owner/old-repo#20")" +git issue provider-migrate github:owner/old-repo github:owner/new-repo >/dev/null 2>&1 +before_sha="$(git rev-parse "refs/issues/$uuid_idem")" +output="$(git issue provider-migrate github:owner/old-repo github:owner/new-repo 2>&1)" +after_sha="$(git rev-parse "refs/issues/$uuid_idem")" +if test "$before_sha" = "$after_sha" +then + case "$output" in + *"Migrated 0 issues"*) + pass "idempotent: second run is no-op" + ;; + *) + fail "idempotent: second run is no-op" "output: $output" + ;; + esac +else + fail "idempotent: second run is no-op" "ref changed" +fi + +# ============================================================ +# TEST: cross-platform migration (github -> gitlab) +# ============================================================ +run_test +setup_repo +uuid_cross="$(create_test_issue "Cross-platform" "github:owner/repo#7")" +output="$(git issue provider-migrate github:owner/repo gitlab:owner/repo 2>&1)" +newest_pid="$(git log --format='%(trailers:key=Provider-ID,valueonly)' "refs/issues/$uuid_cross" | sed '/^$/d' | sed 's/^[[:space:]]*//' | head -1)" +if test "$newest_pid" = "gitlab:owner/repo#7" +then + pass "cross-platform migration (github -> gitlab)" +else + fail "cross-platform migration (github -> gitlab)" "got: '$newest_pid'" +fi + +# ============================================================ +# TEST: issues without Provider-ID are skipped +# ============================================================ +run_test +setup_repo +uuid_nopid="$(create_test_issue "No provider ID")" +uuid_withpid="$(create_test_issue "Has provider ID" "github:owner/repo#1")" +output="$(git issue provider-migrate github:owner/repo github:owner/new-repo 2>&1)" +nopid_count="$(git rev-list --count "refs/issues/$uuid_nopid")" +case "$output" in + *"Migrated 1 issue"*) + if test "$nopid_count" -eq 1 + then + pass "issues without Provider-ID are skipped" + else + fail "issues without Provider-ID are skipped" "no-pid issue was modified" + fi + ;; + *) + fail "issues without Provider-ID are skipped" "output: $output" + ;; +esac + +# ============================================================ +# TEST: error on missing arguments +# ============================================================ +run_test +setup_repo +if git issue provider-migrate 2>/dev/null +then + fail "error on missing arguments" "should have failed" +else + pass "error on missing arguments" +fi + +# ============================================================ +# TEST: error on same old and new prefix +# ============================================================ +run_test +setup_repo +if git issue provider-migrate github:owner/repo github:owner/repo 2>/dev/null +then + fail "error on same old and new prefix" "should have failed" +else + pass "error on same old and new prefix" +fi + +# ============================================================ +# SUMMARY +# ============================================================ +printf "\n%.60s\n" "============================================================" +printf "Tests: %d | Passed: ${GREEN}%d${NC} | Failed: ${RED}%d${NC}\n" \ + "$TESTS_RUN" "$TESTS_PASSED" "$TESTS_FAILED" +printf "%.60s\n" "============================================================" + +if test "$TESTS_FAILED" -gt 0 +then + exit 1 +fi From a36a3729eea9871a81fa0c9e96752d4693875cd7 Mon Sep 17 00:00:00 2001 From: Emerson Soares Date: Sat, 18 Apr 2026 17:40:25 -0300 Subject: [PATCH 2/4] fix: add not-a-git-repo test, improve dry-run summary wording - Add missing test for exit 128 when not in a git repo (12 tests total) - Dry-run summary now says "Would migrate" instead of "Migrated" --- bin/git-issue-provider-migrate | 7 +++++-- t/test-provider-migrate.sh | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/bin/git-issue-provider-migrate b/bin/git-issue-provider-migrate index 25c9b08..b109e91 100755 --- a/bin/git-issue-provider-migrate +++ b/bin/git-issue-provider-migrate @@ -167,9 +167,12 @@ do migrated=$((migrated + 1)) done +_verb="Migrated" +test "$dry_run" -eq 1 && _verb="Would migrate" + if test "$migrated" -eq 1 then - printf 'Migrated %d issue\n' "$migrated" + printf '%s %d issue\n' "$_verb" "$migrated" else - printf 'Migrated %d issues\n' "$migrated" + printf '%s %d issues\n' "$_verb" "$migrated" fi diff --git a/t/test-provider-migrate.sh b/t/test-provider-migrate.sh index d3dc84d..e9b715c 100755 --- a/t/test-provider-migrate.sh +++ b/t/test-provider-migrate.sh @@ -253,6 +253,25 @@ else pass "error on same old and new prefix" fi +# ============================================================ +# TEST: error when not in a git repo (exit 128) +# ============================================================ +run_test +_non_git_dir="$(mktemp -d)" +if output="$(cd "$_non_git_dir" && "$BIN_DIR/git-issue-provider-migrate" github:a/b github:a/c 2>&1)" +then + fail "error when not in a git repo" "should have failed" +else + exit_code=$? + if test "$exit_code" -eq 128 + then + pass "error when not in a git repo (exit 128)" + else + fail "error when not in a git repo (exit 128)" "expected exit 128, got $exit_code" + fi +fi +rm -rf "$_non_git_dir" + # ============================================================ # SUMMARY # ============================================================ From 182a413da8cba9e6754ab2bdd779c43764e7bcba Mon Sep 17 00:00:00 2001 From: Emerson Soares Date: Sat, 18 Apr 2026 17:47:44 -0300 Subject: [PATCH 3/4] feat(sync): detect potential repo rename and suggest provider-migrate When syncing with a provider, scan existing issues for Provider-IDs that share the same type and owner but have a different repo name. If found (and no migrated PID exists), print a warning suggesting git issue provider-migrate. --- bin/git-issue-sync | 62 ++++++++++++++++++++++++++++++++++++ t/test-provider-migrate.sh | 65 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/bin/git-issue-sync b/bin/git-issue-sync index bb0ee1f..e247459 100755 --- a/bin/git-issue-sync +++ b/bin/git-issue-sync @@ -72,5 +72,67 @@ ISSUE_BIN_DIR="$(cd "$(dirname "$0")" && pwd)" printf '=== Importing from %s ===\n' "$provider" "$ISSUE_BIN_DIR/git-issue-import" "$provider" --state "$state_filter" $dry_run +# --- Rename detection: warn if issues have Provider-IDs from a different repo name --- +_sync_type="${provider%%:*}" +_sync_path="${provider#*:}" +_sync_owner="${_sync_path%/*}" +_sync_repo="${_sync_path#*/}" +_sync_owner_prefix="${_sync_type}:${_sync_owner}/" + +_mismatch_file="$(mktemp)" +: > "$_mismatch_file" + +for _ref in $(git for-each-ref --format='%(refname)' refs/issues/) +do + git log --format='%(trailers:key=Provider-ID,valueonly)' "$_ref" | \ + sed '/^$/d' | sed 's/^[[:space:]]*//' | while read -r _pid + do + # Does this PID belong to the same owner/type but different repo? + case "$_pid" in + "${_sync_owner_prefix}"*) + _pid_repo="${_pid#"${_sync_owner_prefix}"}" + _pid_repo="${_pid_repo%%#*}" + if test "$_pid_repo" != "$_sync_repo" + then + # Check if this issue already has a PID for the target repo + _has_new_pid="" + git log --format='%(trailers:key=Provider-ID,valueonly)' "$_ref" | \ + sed '/^$/d' | sed 's/^[[:space:]]*//' | while read -r _check_pid + do + case "$_check_pid" in + "${_sync_owner_prefix}${_sync_repo}#"*) + printf 'yes' > "$_mismatch_file.found" + ;; + esac + done + if test -f "$_mismatch_file.found" + then + rm -f "$_mismatch_file.found" + else + printf '%s\n' "${_sync_owner_prefix}${_pid_repo}" >> "$_mismatch_file" + fi + fi + ;; + esac + done +done + +if test -s "$_mismatch_file" +then + printf '\n' + printf 'Warning: found issues with Provider-IDs from a different repo name:\n' + sort "$_mismatch_file" | uniq -c | while read -r _count _old_prefix + do + printf ' %s (%d issues)\n' "$_old_prefix" "$_count" + done + printf 'If the repo was renamed or transferred, run:\n' + # Show the first mismatched prefix as example + _first_old="$(sort "$_mismatch_file" | head -1)" + printf ' git issue provider-migrate %s %s\n' "$_first_old" "${_sync_owner_prefix}${_sync_repo}" + printf '\n' +fi +rm -f "$_mismatch_file" "$_mismatch_file.found" +# --- End rename detection --- + printf '\n=== Exporting to %s ===\n' "$provider" "$ISSUE_BIN_DIR/git-issue-export" "$provider" $dry_run diff --git a/t/test-provider-migrate.sh b/t/test-provider-migrate.sh index e9b715c..435862d 100755 --- a/t/test-provider-migrate.sh +++ b/t/test-provider-migrate.sh @@ -272,6 +272,71 @@ else fi rm -rf "$_non_git_dir" +# ============================================================ +# TEST: sync warns about potential rename +# ============================================================ +run_test +setup_repo +create_test_issue "Old repo issue 1" "github:owner/old-repo#1" >/dev/null +create_test_issue "Old repo issue 2" "github:owner/old-repo#2" >/dev/null +create_test_issue "Unrelated issue" "gitlab:other/repo#3" >/dev/null + +# Mock gh so import doesn't fail +mkdir -p "$TEST_DIR/mock-bin" +cat > "$TEST_DIR/mock-bin/gh" <<'MOCK' +#!/bin/sh +case "$*" in + "auth status") exit 0 ;; + *"api --paginate"*"issues?"*) echo '[]' ;; + *"api /user"*) echo '{"login":"test","id":1}' ;; + *) echo '{}' ;; +esac +MOCK +chmod +x "$TEST_DIR/mock-bin/gh" +export PATH="$TEST_DIR/mock-bin:$BIN_DIR:$PATH" + +output="$(git issue sync github:owner/new-repo 2>&1)" || true +case "$output" in + *"provider-migrate"*) + pass "sync warns about potential rename" + ;; + *) + fail "sync warns about potential rename" "output: $output" + ;; +esac + +# ============================================================ +# TEST: sync does NOT warn after provider-migrate was run +# ============================================================ +run_test +setup_repo +create_test_issue "Migrated issue" "github:owner/old-repo#1" >/dev/null +# Run provider-migrate first +git issue provider-migrate github:owner/old-repo github:owner/new-repo >/dev/null 2>&1 + +mkdir -p "$TEST_DIR/mock-bin" +cat > "$TEST_DIR/mock-bin/gh" <<'MOCK' +#!/bin/sh +case "$*" in + "auth status") exit 0 ;; + *"api --paginate"*"issues?"*) echo '[]' ;; + *"api /user"*) echo '{"login":"test","id":1}' ;; + *) echo '{}' ;; +esac +MOCK +chmod +x "$TEST_DIR/mock-bin/gh" +export PATH="$TEST_DIR/mock-bin:$BIN_DIR:$PATH" + +output="$(git issue sync github:owner/new-repo 2>&1)" || true +case "$output" in + *"provider-migrate"*) + fail "sync does NOT warn after provider-migrate was run" "output: $output" + ;; + *) + pass "sync does NOT warn after provider-migrate was run" + ;; +esac + # ============================================================ # SUMMARY # ============================================================ From 4b3ba30876909f1dacf720d75d2bb03ae4793eaf Mon Sep 17 00:00:00 2001 From: Emerson Soares Date: Sat, 18 Apr 2026 17:51:06 -0300 Subject: [PATCH 4/4] chore: bump version to 1.4.0, update docs and changelog for provider-migrate --- CHANGELOG.md | 12 +++++ CLAUDE.md | 4 +- README.md | 7 +-- bin/git-issue | 2 +- doc/git-issue-provider-migrate.1 | 91 ++++++++++++++++++++++++++++++++ doc/git-issue.1 | 6 +++ docs/migration-guide.md | 37 +++++++++++++ 7 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 doc/git-issue-provider-migrate.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index cae0781..e84f78b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.4.0] - 2026-04-18 + +### Added + +- **`git issue provider-migrate`** — Migrate Provider-IDs after repository renames, org transfers, or cross-platform moves + - Appends new Provider-ID commits to matching issues (preserves immutability, append-only) + - Supports dry-run mode (`--dry-run`) to preview changes + - Idempotent: safe to run multiple times without creating duplicate commits + - Works across all providers (GitHub, GitLab, Gitea, Forgejo) +- **Rename detection in `git issue sync`** — Warns when local issues have Provider-IDs from a different repo under the same owner, and suggests `provider-migrate` to prevent duplicates + ## [1.3.3] - 2026-02-16 ### Fixed @@ -328,6 +339,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Optimized for large repositories - Efficient Git plumbing usage +[1.4.0]: https://github.com/remenoscodes/git-native-issue/compare/v1.3.3...v1.4.0 [1.3.3]: https://github.com/remenoscodes/git-native-issue/compare/v1.3.2...v1.3.3 [1.3.2]: https://github.com/remenoscodes/git-native-issue/compare/v1.3.1...v1.3.2 [1.3.1]: https://github.com/remenoscodes/git-native-issue/compare/v1.3.0...v1.3.1 diff --git a/CLAUDE.md b/CLAUDE.md index 6fd227c..f5f99d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ Distributed issue tracking using Git's native data model. Issues stored as commi Inherits workspace conventions from `~/CLAUDE.md`. ## Status -- **Version**: 1.3.3 +- **Version**: 1.4.0 - **State**: active - **Deploy**: Homebrew (`remenoscodes/git-native-issue/git-native-issue`), install script, Makefile @@ -15,7 +15,7 @@ Platform bridges: `gh` (GitHub), `glab` (GitLab), REST API (Gitea/Forgejo). ## Key Commands ```bash -make test # Run all 282 tests +make test # Run all 296 tests make install # Install system-wide (/usr/local) git issue create "title" -l bug # Create issue with label git issue ls --format full # List issues with metadata diff --git a/README.md b/README.md index 1ff9409..8285488 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/remenoscodes/git-native-issue/actions/workflows/ci.yml/badge.svg)](https://github.com/remenoscodes/git-native-issue/actions/workflows/ci.yml) [![License: GPL-2.0](https://img.shields.io/badge/License-GPL--2.0-blue.svg)](https://www.gnu.org/licenses/gpl-2.0) -[![Version](https://img.shields.io/badge/version-1.3.3-green.svg)](https://github.com/remenoscodes/git-native-issue/releases) +[![Version](https://img.shields.io/badge/version-1.4.0-green.svg)](https://github.com/remenoscodes/git-native-issue/releases) Distributed issue tracking using Git's native data model. @@ -164,7 +164,7 @@ make install prefix=~ # User install (~/bin) ```bash git issue version -# git-issue version 1.3.3 +# git-issue version 1.4.0 ``` ## Commands @@ -180,6 +180,7 @@ git issue version | `git issue import` | Import issues from a platform (GitHub, GitLab, Gitea, Forgejo) | | `git issue export` | Export issues to a platform | | `git issue sync` | Two-way sync (import + export) | +| `git issue provider-migrate` | Migrate Provider-IDs after repo rename or transfer | | `git issue search ` | Search issues by text | | `git issue merge ` | Merge issues from a remote | | `git issue fsck` | Validate issue data integrity | @@ -652,7 +653,7 @@ the deliverable that makes ecosystem adoption possible. make test ``` -282 tests: core (77), bridge (37), merge/fsck (21), QoL (22), validation (36), quality (59), edge cases (13), comment sync (8), concurrency (9). +296 tests: core (77), bridge (37), merge/fsck (21), QoL (22), validation (36), quality (59), edge cases (13), comment sync (8), concurrency (9), provider-migrate (14). ## Performance Notes diff --git a/bin/git-issue b/bin/git-issue index f4f2de0..ef2abb3 100755 --- a/bin/git-issue +++ b/bin/git-issue @@ -7,7 +7,7 @@ set -e -VERSION="1.3.3" +VERSION="1.4.0" usage() { cat < +.I +.SH DESCRIPTION +Migrate Provider-IDs in local issues after a repository rename, +organization transfer, or cross-platform move. +.PP +When a repository is renamed (e.g., +.B github:alice/old\-name +becomes +.BR github:alice/new\-name ), +local issues still carry Provider-IDs referencing the old name. +Running +.B git issue sync +with the new name would create duplicates because the old Provider-IDs +no longer match. +.PP +.B git issue provider\-migrate +scans all issues under +.B refs/issues/ +for Provider-ID trailers matching +.IR , +and appends a new commit with the updated Provider-ID using +.IR . +The original Provider-ID commits are preserved in the commit history +(append-only, immutable). +.PP +The operation is idempotent: running it multiple times does not create +duplicate commits. +Issue numbers are preserved across the migration. +.SH OPTIONS +.TP +.B \-\-dry\-run +Show what would be migrated without making any changes. +.TP +.BR \-h ", " \-\-help +Show usage information. +.SH EXAMPLES +Repository rename (same owner): +.RS +.nf +$ git issue provider\-migrate github:alice/old\-name github:alice/new\-name +.fi +.RE +.PP +Organization transfer: +.RS +.nf +$ git issue provider\-migrate github:old\-org/repo github:new\-org/repo +.fi +.RE +.PP +Cross-platform move (GitHub to GitLab): +.RS +.nf +$ git issue provider\-migrate github:owner/repo gitlab:owner/repo +.fi +.RE +.PP +Preview changes without modifying anything: +.RS +.nf +$ git issue provider\-migrate \-\-dry\-run github:owner/old github:owner/new +.fi +.RE +.SH BEHAVIOR +.IP \(bu 2 +.B Append-only: +New Provider-ID commits are appended to the issue chain. +Original Provider-ID commits remain in history. +.IP \(bu 2 +.B Idempotent: +Safe to run multiple times. +Issues that already have the new Provider-ID are skipped. +.IP \(bu 2 +.B Issue numbers preserved: +Only the repository prefix changes; the issue number (e.g., #42) +is carried over from old to new. +.IP \(bu 2 +.B All providers supported: +Works with GitHub, GitLab, Gitea, and Forgejo Provider-ID formats. +.SH SEE ALSO +.BR git\-issue\-sync (1), +.BR git\-issue\-import (1), +.BR git\-issue\-export (1), +.BR git\-issue (1) diff --git a/doc/git-issue.1 b/doc/git-issue.1 index 660b995..2c6d55e 100644 --- a/doc/git-issue.1 +++ b/doc/git-issue.1 @@ -65,6 +65,11 @@ Export local issues to an external provider (currently GitHub). .B sync Two-way synchronization (import followed by export). .TP +.B provider\-migrate +Migrate Provider-IDs after a repository rename, org transfer, or +cross-platform move. +Appends updated Provider-ID commits to matching issues. +.TP .B version Print version information. .SH DATA MODEL @@ -124,4 +129,5 @@ and .BR git\-issue\-import (1), .BR git\-issue\-export (1), .BR git\-issue\-sync (1), +.BR git\-issue\-provider\-migrate (1), .BR git (1) diff --git a/docs/migration-guide.md b/docs/migration-guide.md index 674dd3c..2d4b111 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -676,6 +676,43 @@ Add to `.git/config` for automatic fetch: fetch = +refs/issues/*:refs/issues/* ``` +## After a Repository Rename + +When a repository is renamed (e.g., on GitHub, GitLab, or Gitea), issue +numbers are preserved but the Provider-IDs in your local issues still +reference the old name. This can cause duplicates on the next sync. + +Fix it with one command: + +``` +git issue provider-migrate github:owner/old-name github:owner/new-name +``` + +This appends updated Provider-IDs to all affected issues. The original +Provider-IDs are preserved in the commit history. + +`git issue sync` will also detect this situation and suggest the command. + +### Other Scenarios + +**Organization transfer:** + +``` +git issue provider-migrate github:old-org/repo github:new-org/repo +``` + +**Cross-platform move (e.g., GitHub to GitLab):** + +``` +git issue provider-migrate github:owner/repo gitlab:owner/repo +``` + +**Preview before migrating:** + +``` +git issue provider-migrate --dry-run github:owner/old-name github:owner/new-name +``` + ## See Also - [docs/gitlab-bridge.md](gitlab-bridge.md) - GitLab-specific documentation