|
<# |
|
.SYNOPSIS |
|
Copy Git-untracked or Git-modified files from one Git repository, |
|
or from many Git repositories found at an exact folder depth. |
|
|
|
.DESCRIPTION |
|
- Copies files that are modified: staged or unstaged. |
|
- Copies untracked files. |
|
- Copies Git-ignored dotfiles, such as .env and .env.development. |
|
- Optionally includes ignored files via -IncludeIgnored. |
|
- Optionally skips files larger than -MaxFileSizeBytes. |
|
- Skips common cache/build/package directories: |
|
node_modules, bin, obj, __pycache__, vendor, dist, build, etc. |
|
- Original single-repo mode is preserved when -Depth is not provided. |
|
- Bulk mode is enabled when -Depth is provided. |
|
- In bulk mode, repositories are searched exactly at the given depth below -From. |
|
|
|
.EXAMPLE |
|
.\Copy-GitDirtyFiles.ps1 -From "C:\src\myrepo" -To "D:\backup\myrepo-dirty" |
|
|
|
.EXAMPLE |
|
.\Copy-GitDirtyFiles.ps1 -From "C:\src\myrepo" -To "D:\backup\myrepo-dirty" -IncludeIgnored |
|
|
|
.EXAMPLE |
|
.\Copy-GitDirtyFiles.ps1 -From "Z:\Dev" -To "D:\backup\Dev-dirty" -Depth 2 |
|
|
|
Searches exactly two directory levels below Z:\Dev and copies dirty files |
|
from every Git repository found there. |
|
|
|
.EXAMPLE |
|
.\Copy-GitDirtyFiles.ps1 -From "Z:\Dev" -To "D:\backup\Dev-dirty" -Depth 2 -IncludeIgnored |
|
|
|
.EXAMPLE |
|
.\Copy-GitDirtyFiles.ps1 -From "C:\src\myrepo" -To "D:\backup\myrepo-dirty" -MaxFileSizeBytes 1048576 |
|
#> |
|
|
|
[CmdletBinding()] |
|
param( |
|
[Parameter(Mandatory = $false)] |
|
[string] $From, |
|
|
|
[Parameter(Mandatory = $false)] |
|
[string] $To, |
|
|
|
[Parameter(Mandatory = $false)] |
|
[switch] $IncludeIgnored, |
|
|
|
# Optional. |
|
# If omitted, script behaves like the original single-repository script. |
|
# If provided, script searches for Git repositories exactly this many |
|
# directory levels below -From. |
|
# |
|
# Example: |
|
# -From Z:\Dev -Depth 1 => Z:\Dev\* |
|
# -From Z:\Dev -Depth 2 => Z:\Dev\*\* |
|
# -From Z:\Dev -Depth 3 => Z:\Dev\*\*\* |
|
[Parameter(Mandatory = $false)] |
|
[int] $Depth = -1, |
|
|
|
# Optional. |
|
# If set to 0 or greater, files larger than this many bytes are skipped. |
|
# If omitted or set to -1, no file-size limit is applied. |
|
[Parameter(Mandatory = $false)] |
|
[long] $MaxFileSizeBytes = -1 |
|
) |
|
|
|
Set-StrictMode -Version Latest |
|
$ErrorActionPreference = "Stop" |
|
|
|
if ($MaxFileSizeBytes -lt -1) { |
|
throw "MaxFileSizeBytes cannot be less than -1. Use -1 for unlimited, or a non-negative byte limit." |
|
} |
|
|
|
function Resolve-AbsolutePath { |
|
param([Parameter(Mandatory)] [string] $Path) |
|
|
|
$expanded = [Environment]::ExpandEnvironmentVariables($Path) |
|
$resolved = Resolve-Path -LiteralPath $expanded -ErrorAction Stop |
|
return $resolved.Path |
|
} |
|
|
|
function Ensure-Directory { |
|
param([Parameter(Mandatory)] [string] $Dir) |
|
|
|
if (-not (Test-Path -LiteralPath $Dir -PathType Container)) { |
|
New-Item -ItemType Directory -Path $Dir -Force | Out-Null |
|
} |
|
} |
|
|
|
function Assert-GitAvailable { |
|
$git = Get-Command git -ErrorAction SilentlyContinue |
|
if (-not $git) { |
|
throw "git executable not found in PATH." |
|
} |
|
} |
|
|
|
function Test-GitRepo { |
|
param([Parameter(Mandatory)] [string] $RepoRoot) |
|
|
|
$isRepo = & git -C $RepoRoot rev-parse --is-inside-work-tree 2>$null |
|
return ($LASTEXITCODE -eq 0 -and $isRepo -eq "true") |
|
} |
|
|
|
function Assert-GitRepo { |
|
param([Parameter(Mandatory)] [string] $RepoRoot) |
|
|
|
Assert-GitAvailable |
|
|
|
if (-not (Test-GitRepo -RepoRoot $RepoRoot)) { |
|
throw "No Git repository found at '$RepoRoot' or inside it. Initialize Git first or choose another source folder." |
|
} |
|
} |
|
|
|
function Get-GitTopLevel { |
|
param([Parameter(Mandatory)] [string] $RepoAnyPath) |
|
|
|
$top = & git -C $RepoAnyPath rev-parse --show-toplevel 2>$null |
|
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($top)) { |
|
throw "Failed to resolve Git top-level directory for '$RepoAnyPath'." |
|
} |
|
|
|
return $top.Trim() |
|
} |
|
|
|
# Common folders that should never be copied. |
|
# Match is path-segment based, so it catches nested occurrences. |
|
$IgnoredDirNames = @( |
|
# AWS |
|
".aws-sam", ".serverless", |
|
|
|
# Node / JS |
|
"node_modules", "bower_components", ".yarn", ".pnp", ".pnpm-store", ".npm", ".turbo", ".next", ".nuxt", ".output", |
|
"dist", "build", ".cache", "cache", ".parcel-cache", ".vite", ".svelte-kit", ".angular", "out" |
|
|
|
# Python |
|
"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ".nox", |
|
".venv", "venv", "env", "site-packages", ".eggs", "egg-info", |
|
|
|
# .NET |
|
"bin", "obj", ".vs", "TestResults", "packages", ".nuget", |
|
|
|
# Java / JVM |
|
"target", ".gradle", "build", ".idea", ".classpath", ".project", ".settings", |
|
|
|
# Go |
|
"vendor", |
|
|
|
# Rust |
|
"target", |
|
|
|
# PHP / Composer |
|
"vendor", |
|
|
|
# Ruby |
|
"vendor", ".bundle", |
|
|
|
# iOS / macOS |
|
"Pods", "DerivedData", |
|
|
|
# Misc VCS / tooling caches |
|
".git", ".svn", ".hg", |
|
|
|
# Visual Studio |
|
".vsdbg", ".vs", "TestResults" |
|
) | Sort-Object -Unique |
|
|
|
function Should-IgnorePath { |
|
param([Parameter(Mandatory)] [string] $RelativePath) |
|
|
|
$p = $RelativePath.Replace('\', '/') |
|
while ($p.StartsWith('./', [System.StringComparison]::Ordinal)) { |
|
$p = $p.Substring(2) |
|
} |
|
$p = $p.TrimStart('/') |
|
$segments = $p.Split('/', [System.StringSplitOptions]::RemoveEmptyEntries) |
|
|
|
foreach ($seg in $segments) { |
|
if ($IgnoredDirNames -contains $seg) { |
|
return $true |
|
} |
|
} |
|
|
|
return $false |
|
} |
|
|
|
function Get-RepoDotFilePaths { |
|
param([Parameter(Mandatory)] [string] $RepoRoot) |
|
|
|
$results = New-Object System.Collections.Generic.List[string] |
|
$stack = New-Object System.Collections.Generic.Stack[System.IO.DirectoryInfo] |
|
$stack.Push((Get-Item -LiteralPath $RepoRoot)) |
|
|
|
while ($stack.Count -gt 0) { |
|
$dir = $stack.Pop() |
|
$children = @(Get-ChildItem -LiteralPath $dir.FullName -Force -ErrorAction SilentlyContinue) |
|
|
|
foreach ($child in $children) { |
|
$relative = (Get-RelativePath -BasePath $RepoRoot -ChildPath $child.FullName).Replace('\', '/') |
|
|
|
if ($child.PSIsContainer) { |
|
if (-not (Should-IgnorePath -RelativePath $relative)) { |
|
$stack.Push($child) |
|
} |
|
|
|
continue |
|
} |
|
|
|
if ($child.Name.StartsWith(".", [System.StringComparison]::Ordinal) -and |
|
-not (Should-IgnorePath -RelativePath $relative)) { |
|
$results.Add($relative) |
|
} |
|
} |
|
} |
|
|
|
return @($results) |
|
} |
|
|
|
function Get-IgnoredDotFilePaths { |
|
param([Parameter(Mandatory)] [string] $RepoRoot) |
|
|
|
$dotFiles = @(Get-RepoDotFilePaths -RepoRoot $RepoRoot) |
|
if ($dotFiles.Count -eq 0) { |
|
return @() |
|
} |
|
|
|
$ignoredDotFiles = New-Object System.Collections.Generic.List[string] |
|
$batchSize = 100 |
|
|
|
for ($i = 0; $i -lt $dotFiles.Count; $i += $batchSize) { |
|
$last = [Math]::Min($i + $batchSize - 1, $dotFiles.Count - 1) |
|
$batch = @($dotFiles[$i..$last]) |
|
|
|
$batchIgnoredDotFiles = @(& git -C $RepoRoot check-ignore -- $batch 2>$null) |
|
if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne 1) { |
|
throw "Failed in '$RepoRoot': git check-ignore" |
|
} |
|
|
|
foreach ($p in $batchIgnoredDotFiles) { |
|
if (-not [string]::IsNullOrWhiteSpace($p)) { |
|
$ignoredDotFiles.Add($p.Trim()) |
|
} |
|
} |
|
} |
|
|
|
return @($ignoredDotFiles) |
|
} |
|
|
|
function Get-DirtyPaths { |
|
param( |
|
[Parameter(Mandatory)] [string] $RepoRoot, |
|
[Parameter(Mandatory)] [bool] $IncludeIgnoredFiles |
|
) |
|
|
|
$set = New-Object System.Collections.Generic.HashSet[string] |
|
|
|
# 1) Modified tracked files: unstaged |
|
$modifiedUnstaged = & git -C $RepoRoot diff --name-only 2>$null |
|
if ($LASTEXITCODE -ne 0) { |
|
throw "Failed in '$RepoRoot': git diff --name-only" |
|
} |
|
|
|
foreach ($p in $modifiedUnstaged) { |
|
if (-not [string]::IsNullOrWhiteSpace($p)) { |
|
[void] $set.Add($p.Trim()) |
|
} |
|
} |
|
|
|
# 2) Modified tracked files: staged |
|
$modifiedStaged = & git -C $RepoRoot diff --cached --name-only 2>$null |
|
if ($LASTEXITCODE -ne 0) { |
|
throw "Failed in '$RepoRoot': git diff --cached --name-only" |
|
} |
|
|
|
foreach ($p in $modifiedStaged) { |
|
if (-not [string]::IsNullOrWhiteSpace($p)) { |
|
[void] $set.Add($p.Trim()) |
|
} |
|
} |
|
|
|
# 3) Untracked, but not ignored |
|
$untracked = & git -C $RepoRoot ls-files -o --exclude-standard 2>$null |
|
if ($LASTEXITCODE -ne 0) { |
|
throw "Failed in '$RepoRoot': git ls-files -o --exclude-standard" |
|
} |
|
|
|
foreach ($p in $untracked) { |
|
if (-not [string]::IsNullOrWhiteSpace($p)) { |
|
[void] $set.Add($p.Trim()) |
|
} |
|
} |
|
|
|
# 4) Ignored dotfiles, such as .env and .env.development. |
|
$ignoredDotFiles = @(Get-IgnoredDotFilePaths -RepoRoot $RepoRoot) |
|
foreach ($p in $ignoredDotFiles) { |
|
if (-not [string]::IsNullOrWhiteSpace($p)) { |
|
[void] $set.Add($p.Trim()) |
|
} |
|
} |
|
|
|
# 5) Ignored files, if requested |
|
if ($IncludeIgnoredFiles) { |
|
$ignored = & git -C $RepoRoot ls-files -o -i --exclude-standard 2>$null |
|
if ($LASTEXITCODE -ne 0) { |
|
throw "Failed in '$RepoRoot': git ls-files -o -i --exclude-standard" |
|
} |
|
|
|
foreach ($p in $ignored) { |
|
if (-not [string]::IsNullOrWhiteSpace($p)) { |
|
[void] $set.Add($p.Trim()) |
|
} |
|
} |
|
} |
|
|
|
return @($set) |
|
} |
|
|
|
function Copy-DirtyFiles { |
|
param( |
|
[Parameter(Mandatory)] [string] $RepoRoot, |
|
[Parameter(Mandatory)] [string] $DestinationRoot, |
|
[Parameter(Mandatory)] [bool] $IncludeIgnoredFiles, |
|
[Parameter(Mandatory)] [long] $MaxFileSizeBytes |
|
) |
|
|
|
$dirty = @(Get-DirtyPaths -RepoRoot $RepoRoot -IncludeIgnoredFiles $IncludeIgnoredFiles) |
|
|
|
if ($dirty.Count -eq 0) { |
|
if ($IncludeIgnoredFiles) { |
|
Write-Host "No modified, untracked, or ignored files found. Nothing to copy." |
|
} else { |
|
Write-Host "No modified, untracked, or ignored dotfiles found. Other ignored files are hidden unless -IncludeIgnored is set." |
|
} |
|
|
|
return @{ |
|
Copied = 0 |
|
Skipped = 0 |
|
SkippedSize = 0 |
|
Missing = 0 |
|
Dirty = 0 |
|
} |
|
} |
|
|
|
$copied = 0 |
|
$skipped = 0 |
|
$skippedSize = 0 |
|
$missing = 0 |
|
|
|
foreach ($rel in $dirty) { |
|
if (Should-IgnorePath -RelativePath $rel) { |
|
$skipped++ |
|
continue |
|
} |
|
|
|
$src = Join-Path -Path $RepoRoot -ChildPath $rel |
|
|
|
$srcItem = Get-Item -LiteralPath $src -ErrorAction SilentlyContinue |
|
if (-not $srcItem -or $srcItem.PSIsContainer) { |
|
$missing++ |
|
continue |
|
} |
|
|
|
if ($MaxFileSizeBytes -ge 0 -and $srcItem.Length -gt $MaxFileSizeBytes) { |
|
$skippedSize++ |
|
continue |
|
} |
|
|
|
$dst = Join-Path -Path $DestinationRoot -ChildPath $rel |
|
$dstDir = Split-Path -Path $dst -Parent |
|
|
|
Ensure-Directory -Dir $dstDir |
|
|
|
Copy-Item -LiteralPath $srcItem.FullName -Destination $dst -Force |
|
$copied++ |
|
} |
|
|
|
Write-Host "Copied: $copied" |
|
Write-Host "Skipped by ignored dirs list: $skipped" |
|
Write-Host "Skipped by file size limit: $skippedSize" |
|
Write-Host "Missing or non-file: $missing" |
|
Write-Host "Done." |
|
|
|
return @{ |
|
Copied = $copied |
|
Skipped = $skipped |
|
SkippedSize = $skippedSize |
|
Missing = $missing |
|
Dirty = $dirty.Count |
|
} |
|
} |
|
|
|
function Get-RelativePath { |
|
param( |
|
[Parameter(Mandatory)] [string] $BasePath, |
|
[Parameter(Mandatory)] [string] $ChildPath |
|
) |
|
|
|
$baseFull = [System.IO.Path]::GetFullPath($BasePath) |
|
$childFull = [System.IO.Path]::GetFullPath($ChildPath) |
|
|
|
if (-not $baseFull.EndsWith([System.IO.Path]::DirectorySeparatorChar)) { |
|
$baseFull += [System.IO.Path]::DirectorySeparatorChar |
|
} |
|
|
|
$baseUri = [System.Uri]::new($baseFull) |
|
$childUri = [System.Uri]::new($childFull) |
|
|
|
$relativeUri = $baseUri.MakeRelativeUri($childUri) |
|
$relativePath = [System.Uri]::UnescapeDataString($relativeUri.ToString()) |
|
|
|
return ($relativePath -replace '/', [System.IO.Path]::DirectorySeparatorChar) |
|
} |
|
|
|
function Get-DirectoriesAtExactDepth { |
|
param( |
|
[Parameter(Mandatory)] [string] $Root, |
|
[Parameter(Mandatory)] [int] $ExactDepth |
|
) |
|
|
|
if ($ExactDepth -lt 0) { |
|
throw "Depth cannot be negative." |
|
} |
|
|
|
if ($ExactDepth -eq 0) { |
|
return @((Get-Item -LiteralPath $Root)) |
|
} |
|
|
|
$currentLevel = @((Get-Item -LiteralPath $Root)) |
|
|
|
for ($level = 1; $level -le $ExactDepth; $level++) { |
|
$nextLevel = @() |
|
|
|
foreach ($dir in $currentLevel) { |
|
# Do not descend into already detected Git working trees. |
|
# This keeps the search focused on project folders and avoids scanning |
|
# huge dependency/cache trees inside repositories. |
|
if ($level -gt 1 -and (Test-Path -LiteralPath (Join-Path $dir.FullName ".git"))) { |
|
continue |
|
} |
|
|
|
$children = Get-ChildItem -LiteralPath $dir.FullName -Directory -Force -ErrorAction SilentlyContinue | |
|
Where-Object { |
|
$IgnoredDirNames -notcontains $_.Name |
|
} |
|
|
|
foreach ($child in $children) { |
|
$nextLevel += $child |
|
} |
|
} |
|
|
|
$currentLevel = $nextLevel |
|
} |
|
|
|
return @($currentLevel) |
|
} |
|
|
|
function Find-GitReposAtExactDepth { |
|
param( |
|
[Parameter(Mandatory)] [string] $Root, |
|
[Parameter(Mandatory)] [int] $ExactDepth |
|
) |
|
|
|
Assert-GitAvailable |
|
|
|
$candidateDirs = @(Get-DirectoriesAtExactDepth -Root $Root -ExactDepth $ExactDepth) |
|
$repos = New-Object System.Collections.Generic.HashSet[string] |
|
|
|
foreach ($dir in $candidateDirs) { |
|
if (-not (Test-GitRepo -RepoRoot $dir.FullName)) { |
|
continue |
|
} |
|
|
|
$top = Get-GitTopLevel -RepoAnyPath $dir.FullName |
|
|
|
# Important: |
|
# Only accept repositories whose top-level is exactly the candidate path. |
|
# This prevents a child folder inside a repository from being counted as |
|
# the repository when searching a deeper level. |
|
$candidateFull = [System.IO.Path]::GetFullPath($dir.FullName).TrimEnd('\', '/') |
|
$topFull = [System.IO.Path]::GetFullPath($top).TrimEnd('\', '/') |
|
|
|
if ([string]::Equals($candidateFull, $topFull, [System.StringComparison]::OrdinalIgnoreCase)) { |
|
[void] $repos.Add($topFull) |
|
} |
|
} |
|
|
|
return @($repos) |
|
} |
|
|
|
# --- Main flow --- |
|
|
|
if ([string]::IsNullOrWhiteSpace($From)) { |
|
$From = Read-Host "Source folder" |
|
} |
|
|
|
if ([string]::IsNullOrWhiteSpace($To)) { |
|
$To = Read-Host "Destination folder" |
|
} |
|
|
|
$FromAbs = Resolve-AbsolutePath -Path $From |
|
|
|
$ToExpanded = [Environment]::ExpandEnvironmentVariables($To) |
|
if (-not (Test-Path -LiteralPath $ToExpanded)) { |
|
Ensure-Directory -Dir $ToExpanded |
|
} |
|
|
|
$ToAbs = Resolve-AbsolutePath -Path $ToExpanded |
|
|
|
# Single-repository mode: original behavior. |
|
if ($Depth -lt 0) { |
|
Assert-GitRepo -RepoRoot $FromAbs |
|
$repoTop = Get-GitTopLevel -RepoAnyPath $FromAbs |
|
|
|
Write-Host "Mode: Single repository" |
|
Write-Host "Git repo top-level: $repoTop" |
|
Write-Host "Copy destination: $ToAbs" |
|
Write-Host "Ignored dotfiles: Included" |
|
Write-Host ("Other ignored files: " + $(if ($IncludeIgnored.IsPresent) { "Included" } else { "Hidden" })) |
|
Write-Host ("File size limit: " + $(if ($MaxFileSizeBytes -ge 0) { "$MaxFileSizeBytes bytes" } else { "Unlimited" })) |
|
|
|
Copy-DirtyFiles ` |
|
-RepoRoot $repoTop ` |
|
-DestinationRoot $ToAbs ` |
|
-IncludeIgnoredFiles $IncludeIgnored.IsPresent ` |
|
-MaxFileSizeBytes $MaxFileSizeBytes | Out-Null |
|
|
|
exit 0 |
|
} |
|
|
|
# Bulk mode. |
|
Write-Host "Mode: Bulk repositories" |
|
Write-Host "Search root: $FromAbs" |
|
Write-Host "Search depth: $Depth" |
|
Write-Host "Copy destination: $ToAbs" |
|
Write-Host "Ignored dotfiles: Included" |
|
Write-Host ("Other ignored files: " + $(if ($IncludeIgnored.IsPresent) { "Included" } else { "Hidden" })) |
|
Write-Host ("File size limit: " + $(if ($MaxFileSizeBytes -ge 0) { "$MaxFileSizeBytes bytes" } else { "Unlimited" })) |
|
Write-Host "" |
|
|
|
$repos = @(Find-GitReposAtExactDepth -Root $FromAbs -ExactDepth $Depth | Sort-Object) |
|
|
|
if ($repos.Count -eq 0) { |
|
Write-Host "No Git repositories found exactly at depth $Depth below '$FromAbs'." |
|
exit 0 |
|
} |
|
|
|
Write-Host "Git repositories found: $($repos.Count)" |
|
Write-Host "" |
|
|
|
$totalCopied = 0 |
|
$totalSkipped = 0 |
|
$totalSkippedSize = 0 |
|
$totalMissing = 0 |
|
$totalDirty = 0 |
|
$processed = 0 |
|
$failed = 0 |
|
|
|
foreach ($repo in $repos) { |
|
$processed++ |
|
|
|
$relativeRepoPath = Get-RelativePath -BasePath $FromAbs -ChildPath $repo |
|
$repoDestination = Join-Path -Path $ToAbs -ChildPath $relativeRepoPath |
|
|
|
Write-Host "[$processed/$($repos.Count)] Repository: $repo" |
|
Write-Host "Destination: $repoDestination" |
|
|
|
try { |
|
Ensure-Directory -Dir $repoDestination |
|
|
|
$result = Copy-DirtyFiles ` |
|
-RepoRoot $repo ` |
|
-DestinationRoot $repoDestination ` |
|
-IncludeIgnoredFiles $IncludeIgnored.IsPresent ` |
|
-MaxFileSizeBytes $MaxFileSizeBytes |
|
|
|
$totalCopied += [int] $result.Copied |
|
$totalSkipped += [int] $result.Skipped |
|
$totalSkippedSize += [int] $result.SkippedSize |
|
$totalMissing += [int] $result.Missing |
|
$totalDirty += [int] $result.Dirty |
|
} |
|
catch { |
|
$failed++ |
|
Write-Warning "Failed to process repository '$repo': $($_.Exception.Message)" |
|
} |
|
|
|
Write-Host "" |
|
} |
|
|
|
Write-Host "Bulk copy summary" |
|
Write-Host "-----------------" |
|
Write-Host "Repositories found: $($repos.Count)" |
|
Write-Host "Repositories processed: $processed" |
|
Write-Host "Repositories failed: $failed" |
|
Write-Host "Dirty paths detected: $totalDirty" |
|
Write-Host "Files copied: $totalCopied" |
|
Write-Host "Skipped by ignore list: $totalSkipped" |
|
Write-Host "Skipped by size limit: $totalSkippedSize" |
|
Write-Host "Missing or non-file: $totalMissing" |
|
Write-Host "Done." |