|
<#
|
|
.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."
|