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.md
src/main.js
β lib/js/main.js
src/styles.css
β assets/css/main.css
docs/test.md
β documentation/guide.md
The 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.