|
<# |
|
.SYNOPSIS |
|
Copy only Git-untracked or Git-modified files from one folder to another, skipping common cache/package folders. |
|
|
|
.DESCRIPTION |
|
- Copies files that are modified (staged or unstaged) and untracked. |
|
- Optionally includes ignored files (gitignored) via -IncludeIgnored. |
|
- Skips common cache/build/package directories (node_modules, bin/obj, __pycache__, etc.) even if they contain changes. |
|
- Throws if the source folder is not a Git repo. |
|
- Supports -From and -To parameters; prompts if missing. |
|
|
|
.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 |
|
#> |
|
|
|
[CmdletBinding()] |
|
param( |
|
[Parameter(Mandatory = $false)] |
|
[string] $From, |
|
|
|
[Parameter(Mandatory = $false)] |
|
[string] $To, |
|
|
|
[Parameter(Mandatory = $false)] |
|
[switch] $IncludeIgnored |
|
) |
|
|
|
Set-StrictMode -Version Latest |
|
$ErrorActionPreference = "Stop" |
|
|
|
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-GitRepo { |
|
param([Parameter(Mandatory)] [string] $RepoRoot) |
|
|
|
$git = Get-Command git -ErrorAction SilentlyContinue |
|
if (-not $git) { |
|
throw "git executable not found in PATH." |
|
} |
|
|
|
$isRepo = & git -C $RepoRoot rev-parse --is-inside-work-tree 2>$null |
|
if ($LASTEXITCODE -ne 0 -or $isRepo -ne "true") { |
|
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 (cache/build/package artifacts). |
|
# Match is path-segment based, so it catches nested occurrences. |
|
$IgnoredDirNames = @( |
|
# Node / JS |
|
"node_modules", "bower_components", ".yarn", ".pnp", ".pnpm-store", ".npm", ".turbo", ".next", ".nuxt", ".output", |
|
"dist", "build", ".cache", ".parcel-cache", ".vite", ".svelte-kit", |
|
|
|
# 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" |
|
) | Sort-Object -Unique |
|
|
|
function Should-IgnorePath { |
|
param([Parameter(Mandatory)] [string] $RelativePath) |
|
|
|
# Normalize separators to forward slashes for consistent checks. |
|
$p = ($RelativePath -replace '\\', '/').TrimStart('./') |
|
|
|
$segments = $p.Split('/', [System.StringSplitOptions]::RemoveEmptyEntries) |
|
foreach ($seg in $segments) { |
|
if ($IgnoredDirNames -contains $seg) { |
|
return $true |
|
} |
|
} |
|
return $false |
|
} |
|
|
|
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: 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: git diff --cached --name-only" } |
|
foreach ($p in $modifiedStaged) { |
|
if (-not [string]::IsNullOrWhiteSpace($p)) { [void]$set.Add($p.Trim()) } |
|
} |
|
|
|
# 3) Untracked (NOT ignored) |
|
$untracked = & git -C $RepoRoot ls-files -o --exclude-standard 2>$null |
|
if ($LASTEXITCODE -ne 0) { throw "Failed: git ls-files -o --exclude-standard" } |
|
foreach ($p in $untracked) { |
|
if (-not [string]::IsNullOrWhiteSpace($p)) { [void]$set.Add($p.Trim()) } |
|
} |
|
|
|
# 4) Ignored (gitignored) files, if requested |
|
if ($IncludeIgnoredFiles) { |
|
$ignored = & git -C $RepoRoot ls-files -o -i --exclude-standard 2>$null |
|
if ($LASTEXITCODE -ne 0) { throw "Failed: 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 |
|
) |
|
|
|
$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 or untracked files found. (Ignored files are hidden unless -IncludeIgnored is set.)" |
|
} |
|
return |
|
} |
|
|
|
$copied = 0 |
|
$skipped = 0 |
|
$missing = 0 |
|
|
|
foreach ($rel in $dirty) { |
|
if (Should-IgnorePath -RelativePath $rel) { |
|
$skipped++ |
|
continue |
|
} |
|
|
|
$src = Join-Path -Path $RepoRoot -ChildPath $rel |
|
if (-not (Test-Path -LiteralPath $src -PathType Leaf)) { |
|
$missing++ |
|
continue |
|
} |
|
|
|
$dst = Join-Path -Path $DestinationRoot -ChildPath $rel |
|
$dstDir = Split-Path -Path $dst -Parent |
|
Ensure-Directory -Dir $dstDir |
|
|
|
Copy-Item -LiteralPath $src -Destination $dst -Force |
|
$copied++ |
|
} |
|
|
|
Write-Host "Copied: $copied" |
|
Write-Host "Skipped (ignored dirs list): $skipped" |
|
Write-Host "Missing (deleted/non-file): $missing" |
|
Write-Host "Done." |
|
} |
|
|
|
# --- Main flow --- |
|
|
|
if ([string]::IsNullOrWhiteSpace($From)) { |
|
$From = Read-Host "Source folder (Git repo path)" |
|
} |
|
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 |
|
|
|
Assert-GitRepo -RepoRoot $FromAbs |
|
$repoTop = Get-GitTopLevel -RepoAnyPath $FromAbs |
|
|
|
Write-Host "Git repo top-level: $repoTop" |
|
Write-Host "Copy destination: $ToAbs" |
|
Write-Host ("Include ignored: " + $IncludeIgnored.IsPresent) |
|
|
|
Copy-DirtyFiles -RepoRoot $repoTop -DestinationRoot $ToAbs -IncludeIgnoredFiles $IncludeIgnored.IsPresent |