A cross-platform PowerShell script that intelligently detects moved/renamed files in Git repositories and preserves their history using git-filter-repo.
- PowerShell Core (Windows/Linux/macOS)
- Git
- git-filter-repo (
pip install git-filter-repo)
A cross-platform PowerShell script that intelligently detects moved/renamed files in Git repositories and preserves their history using git-filter-repo.
pip install git-filter-repo)| #!/usr/bin/env pwsh | |
| <# | |
| .SYNOPSIS | |
| Intelligently detects and handles Git file moves while preserving history. | |
| .DESCRIPTION | |
| This script analyzes a Git repository to find apparent file moves/renames, | |
| verifies them through multiple methods, and uses git-filter-repo to | |
| properly preserve their history. | |
| .PARAMETER Path | |
| The path to the Git repository. Defaults to current directory. | |
| .PARAMETER MinSimilarity | |
| Minimum similarity percentage to consider files as moved/renamed. | |
| Default is 60 (same as Git's default). | |
| .PARAMETER DryRun | |
| If specified, shows what would be done without making changes. | |
| .EXAMPLE | |
| ./git-smart-move.ps1 -Path ./my-repo | |
| ./git-smart-move.ps1 -MinSimilarity 80 -DryRun | |
| #> | |
| [CmdletBinding()] | |
| param( | |
| [Parameter()] | |
| [string]$Path = ".", | |
| [Parameter()] | |
| [int]$MinSimilarity = 60, | |
| [Parameter()] | |
| [switch]$DryRun | |
| ) | |
| # Class to represent a potential move | |
| class FileMove { | |
| [string]$OldPath | |
| [string]$NewPath | |
| [int]$Similarity | |
| [string]$Method | |
| [bool]$Confirmed | |
| FileMove([string]$old, [string]$new, [int]$sim, [string]$method) { | |
| $this.OldPath = $old | |
| $this.NewPath = $new | |
| $this.Similarity = $sim | |
| $this.Method = $method | |
| $this.Confirmed = $false | |
| } | |
| [string] ToString() { | |
| return "$($this.OldPath) β $($this.NewPath) ($($this.Similarity)% similar, detected by $($this.Method))" | |
| } | |
| } | |
| # Function to verify Git repository | |
| function Test-GitRepository { | |
| param([string]$Path) | |
| try { | |
| Push-Location $Path | |
| $gitDir = git rev-parse --git-dir 2>$null | |
| $isRepo = $LASTEXITCODE -eq 0 | |
| Pop-Location | |
| return $isRepo | |
| } | |
| catch { | |
| return $false | |
| } | |
| } | |
| # Function to check for git-filter-repo | |
| function Test-GitFilterRepo { | |
| try { | |
| $null = git-filter-repo --version 2>$null | |
| return $true | |
| } | |
| catch { | |
| Write-Host "git-filter-repo not found. Please install it first:" -ForegroundColor Red | |
| Write-Host "pip install git-filter-repo" -ForegroundColor Yellow | |
| return $false | |
| } | |
| } | |
| # Function to get deleted files | |
| function Get-DeletedFiles { | |
| $deletedFiles = @() | |
| git ls-files --deleted 2>$null | ForEach-Object { | |
| $deletedFiles += $_ | |
| } | |
| return $deletedFiles | |
| } | |
| # Function to get untracked files | |
| function Get-UntrackedFiles { | |
| $untrackedFiles = @() | |
| git ls-files --others --exclude-standard 2>$null | ForEach-Object { | |
| $untrackedFiles += $_ | |
| } | |
| return $untrackedFiles | |
| } | |
| # Function to find potential moves by filename | |
| function Find-PotentialMovesByName { | |
| param( | |
| [string[]]$DeletedFiles, | |
| [string[]]$UntrackedFiles | |
| ) | |
| $moves = @() | |
| foreach ($deleted in $DeletedFiles) { | |
| $baseName = Split-Path -Leaf $deleted | |
| $similar = $UntrackedFiles | Where-Object { | |
| (Split-Path -Leaf $_) -eq $baseName | |
| } | |
| foreach ($match in $similar) { | |
| $moves += [FileMove]::new($deleted, $match, 100, "exact_name_match") | |
| } | |
| } | |
| return $moves | |
| } | |
| # Function to find potential moves by content similarity | |
| function Find-PotentialMovesByContent { | |
| param( | |
| [string[]]$DeletedFiles, | |
| [string[]]$UntrackedFiles, | |
| [int]$MinSimilarity | |
| ) | |
| $moves = @() | |
| # Get the last content of deleted files | |
| foreach ($deleted in $DeletedFiles) { | |
| $oldContent = git show "HEAD:$deleted" 2>$null | |
| if (-not $oldContent) { continue } | |
| foreach ($untracked in $UntrackedFiles) { | |
| if (-not (Test-Path $untracked)) { continue } | |
| $newContent = Get-Content $untracked -Raw | |
| # Calculate similarity using git's hash-object | |
| $oldHash = $oldContent | git hash-object --stdin | |
| $newHash = $newContent | git hash-object --stdin | |
| if ($oldHash -eq $newHash) { | |
| $moves += [FileMove]::new($deleted, $untracked, 100, "content_hash") | |
| continue | |
| } | |
| # Use git's similarity index | |
| $similarity = git diff --no-index --percentage $deleted $untracked 2>$null | |
| if ($similarity -match "similarity index (\d+)%") { | |
| $simValue = [int]$Matches[1] | |
| if ($simValue -ge $MinSimilarity) { | |
| $moves += [FileMove]::new($deleted, $untracked, $simValue, "content_similarity") | |
| } | |
| } | |
| } | |
| } | |
| return $moves | |
| } | |
| # Function to generate filter-repo script | |
| function New-FilterRepoScript { | |
| param([FileMove[]]$Moves) | |
| $scriptPath = Join-Path ([System.IO.Path]::GetTempPath()) "git-moves-$(New-Guid).py" | |
| $script = @" | |
| import fastimport.commands | |
| def adjust_path(path): | |
| path_str = path.decode('utf-8') | |
| moves = { | |
| $(foreach ($move in $Moves) { | |
| " '$($move.OldPath)': '$($move.NewPath)'," | |
| }) | |
| } | |
| return moves.get(path_str, path_str).encode('utf-8') | |
| def filter_commit(commit): | |
| for change in commit.file_changes: | |
| change.path = adjust_path(change.path) | |
| if hasattr(change, 'new_path'): | |
| change.new_path = adjust_path(change.new_path) | |
| "@ | |
| $script | Out-File -FilePath $scriptPath -Encoding utf8 | |
| return $scriptPath | |
| } | |
| # Main execution | |
| if (-not (Test-GitRepository $Path)) { | |
| Write-Host "Error: Not a git repository: $Path" -ForegroundColor Red | |
| exit 1 | |
| } | |
| if (-not (Test-GitFilterRepo)) { | |
| exit 1 | |
| } | |
| Push-Location $Path | |
| try { | |
| # Get deleted and untracked files | |
| $deletedFiles = Get-DeletedFiles | |
| $untrackedFiles = Get-UntrackedFiles | |
| if (-not $deletedFiles) { | |
| Write-Host "No deleted files found in the repository." -ForegroundColor Yellow | |
| exit 0 | |
| } | |
| Write-Host "Analyzing potential file moves..." -ForegroundColor Cyan | |
| # Find potential moves | |
| $moves = @() | |
| $moves += Find-PotentialMovesByName $deletedFiles $untrackedFiles | |
| $moves += Find-PotentialMovesByContent $deletedFiles $untrackedFiles $MinSimilarity | |
| if (-not $moves) { | |
| Write-Host "No potential moves detected." -ForegroundColor Yellow | |
| exit 0 | |
| } | |
| # Group and sort moves by similarity | |
| $moves = $moves | Sort-Object -Property Similarity -Descending | |
| # Display findings | |
| Write-Host "`nDetected potential moves:" -ForegroundColor Cyan | |
| foreach ($move in $moves) { | |
| Write-Host $move -ForegroundColor $(if ($move.Similarity -eq 100) { "Green" } else { "Yellow" }) | |
| } | |
| if ($DryRun) { | |
| Write-Host "`nDry run - no changes made." -ForegroundColor Yellow | |
| exit 0 | |
| } | |
| # Confirm moves | |
| Write-Host "`nDo you want to proceed with these moves? (Y/N)" -ForegroundColor Cyan | |
| $confirm = Read-Host | |
| if ($confirm -notmatch '^[Yy]') { | |
| Write-Host "Operation cancelled." -ForegroundColor Yellow | |
| exit 0 | |
| } | |
| # Generate and execute filter-repo script | |
| $scriptPath = New-FilterRepoScript $moves | |
| Write-Host "`nExecuting git-filter-repo..." -ForegroundColor Cyan | |
| # Build path-rename arguments | |
| $renameArgs = $moves | ForEach-Object { "--path-rename", "$($_.OldPath):$($_.NewPath)" } | |
| # Execute git-filter-repo with path-rename arguments | |
| git filter-repo --force $renameArgs | |
| Write-Host "`nMoves completed successfully!" -ForegroundColor Green | |
| Write-Host @" | |
| Next steps: | |
| 1. Review the changes using 'git log' or 'git status' | |
| 2. If satisfied, commit any remaining changes | |
| 3. Push your changes | |
| "@ -ForegroundColor Cyan | |
| } | |
| finally { | |
| Pop-Location | |
| if ($scriptPath -and (Test-Path $scriptPath)) { | |
| Remove-Item $scriptPath | |
| } | |
| } |
Created a test repository with the following initial structure:
.
βββ README.txt
βββ docs/
β βββ test.md
βββ src/
βββ main.js
βββ styles.css
Performed the following file moves:
README.txt β README.mdsrc/main.js β lib/js/main.jssrc/styles.css β assets/css/main.cssdocs/test.md β documentation/guide.mdThe script correctly detected and preserved history for:
README.txt β README.md (100% match, content hash)src/main.js β lib/js/main.js (100% match, detected by both content hash and exact name match)docs/test.md β documentation/guide.md (100% match, content hash)src/styles.css β assets/css/main.css
For Windows cmd.exe users, recommended to set up the following alias:
doskey ps=powershell.exe -ExecutionPolicy Bypass -Command "& $1 $2 $3 $4 $5"Then use:
ps git-smart-move.ps1 -Path . [-DryRun]-DryRun first to preview detected moves
relevant discussions here and here.