Ca organizare pe disc, avem o structură de proiecte destul de, să zicem, variată. Unele au început ca mici experimente locale, promovate la repo-uri și dezvoltate oficial. Altele invers. Unele au rămas repo-uri locale în Mercurial, altele promovate la Git, proces de altfel în curs. Bun, trecând peste motivații și mecanisme, periodic facem câte un recensământ să vedem care ce are.

Aici odihnesc următoarele
Și, fiindcă în timp a crescut blănița, mi s-a făcut lene să mai caut ad labam și am compus un mic catren în PowerShell ce face fix asta: scanează un director rădăcină și-mi spune ce directoare sunt sub controlul Git, Mercurial sau în afara oricărei jurisdicții, scriind rezultatul într-un CSV. Dacă subarborele pornind de la un folder aflat imediat sub rădăcină nu are niciun repo, atunci este raportat doar dânsul, nu toată sub-structura.
[CmdletBinding()]
param (
# Stays current directory if omitted
[Parameter(Position = 0)]
[string] $Root = '.',
# CSV output path, current directory if omitted
[Parameter(Position = 1)]
[string] $OutputPath = '.\repository-list.csv',
# Don't include root directory in the report
[switch] $SkipRoot,
# Do not skip special directories such as node_modules
[switch] $DontSkipSpecial
)
$specialDirectories = @("node_modules")
$resolvedRoot = Resolve-Path -LiteralPath $Root -ErrorAction Stop
if (-not (
Test-Path `
-LiteralPath $resolvedRoot.ProviderPath `
-PathType Container
)) {
throw "'$Root' not a directory."
}
$rootPath = [System.IO.Path]::GetFullPath($resolvedRoot.ProviderPath)
function Get-RelativeFolderName {
param (
[Parameter(Mandatory)]
[string] $BasePath,
[Parameter(Mandatory)]
[string] $TargetPath
)
if ($BasePath -eq $TargetPath) {
return '.'
}
# PowerShell 7 / .NET Core (a wee bit of muscle flexing...)
$getRelativePath = [System.IO.Path].GetMethod(
'GetRelativePath',
[Type[]] @([string], [string])
)
if ($null -ne $getRelativePath) {
return [System.IO.Path]::GetRelativePath($BasePath, $TargetPath)
}
# Fallback PowerShell 5.1
$separator = [string] [System.IO.Path]::DirectorySeparatorChar
if (-not $BasePath.EndsWith($separator)) {
$BasePath += $separator
}
$baseUri = [Uri] $BasePath
$targetUri = [Uri] $TargetPath
$relativePath = [Uri]::UnescapeDataString(
$baseUri.MakeRelativeUri($targetUri).ToString()
)
return $relativePath.Replace(
'/',
[System.IO.Path]::DirectorySeparatorChar
)
}
function Get-RepositoryType {
param (
[Parameter(Mandatory)]
[string] $Path
)
# Test-Path detects both .git as dir and .git as file
if (Test-Path -LiteralPath (Join-Path $Path '.git')) {
return 'git'
}
if (Test-Path -LiteralPath (Join-Path $Path '.hg')) {
return 'mercurial'
}
return 'none'
}
$rows = [System.Collections.Generic.List[object]]::new()
$pending = [System.Collections.Generic.Stack[string]]::new()
$pending.Push($rootPath)
# Good ol' DFS
while ($pending.Count -gt 0) {
$directoryPath = $pending.Pop()
$repositoryType = Get-RepositoryType -Path $directoryPath
$directoryName = (Split-Path $directoryPath -Leaf).ToLower()
$isSpecialDirectory = $specialDirectories.Contains($directoryName)
if (-not $DontSkipSpecial -and $isSpecialDirectory) {
continue
}
if (-not ($SkipRoot -and $directoryPath -eq $rootPath)) {
[void] $rows.Add(
[PSCustomObject] @{
FolderName = Get-RelativeFolderName `
-BasePath $rootPath `
-TargetPath $directoryPath
RepositoryType = $repositoryType
}
)
}
# Found a repo, no longer interested in what's down below
if ($repositoryType -ne 'none') {
continue
}
try {
$children = @(
Get-ChildItem `
-LiteralPath $directoryPath `
-Directory `
-Force `
-ErrorAction Stop |
Where-Object {
# Avoid loops caused by junctions or symlinks
# (-band = bitwise and)
-not (
$_.Attributes `
-band [System.IO.FileAttributes]::ReparsePoint
)
} |
Sort-Object FullName -Descending
)
foreach ($child in $children) {
$pending.Push($child.FullName)
}
} catch {
Write-Warning "Cannot inspect '$directoryPath': $($_.Exception.Message)"
}
}
# Identify all ancestors for a folder that contains a repository
$foldersWithRepos = [System.Collections.Generic.HashSet[string]]::new()
foreach ($row in $rows) {
if ($row.RepositoryType -ne 'none' -and $row.FolderName -ne '.') {
$parentFolder = [System.IO.Path]::GetDirectoryName($row.FolderName)
while (-not [string]::IsNullOrEmpty($parentFolder)) {
# Previously visited ancestors have already been recorded.
if (-not $foldersWithRepos.Add($parentFolder)) {
break
}
$parentFolder = [System.IO.Path]::GetDirectoryName($parentFolder)
}
}
}
# Keep Root, its immediate children, and children
# of folders containing repos.
# A branch without repositories is therefore
# represented by its first folder.
$rows = @(
$rows | Where-Object {
$parent = [System.IO.Path]::GetDirectoryName($_.FolderName)
[string]::IsNullOrEmpty($parent) -or $foldersWithRepos.Contains($parent)
}
)
# This does not require that the path actually exists
$statePath = $ExecutionContext.SessionState.Path
$outputFullPath = ($statePath.GetUnresolvedProviderPathFromPSPath($OutputPath))
$outputDirectory = Split-Path -Parent $outputFullPath
if (-not (Test-Path -LiteralPath $outputDirectory -PathType Container)) {
New-Item -ItemType Directory `
-Path $outputDirectory `
-Force | Out-Null
}
$rows |
Export-Csv `
-LiteralPath $outputFullPath `
-NoTypeInformation `
-Encoding UTF8
Write-Host "Wrote $($rows.Count) records in '$outputFullPath'."
Există, firește, o serie de limitări asumate, ce-și vor găsi rezolvarea când și dacă va fi nevoie. În primul rând, raportul mai scurt nu înseamnă o scanare mai rapidă. Scriptul parcurge integral ramurile fără repo-uri și păstrează rândurile în memorie înainte de filtrare. Un director cu mulți descendenți va fi parcurs chiar dacă apare ulterior ca un singur rând. Este acceptabil, nu vreau să văd tot ce este dedesubt dacă nu-i nimic semnificativ, iar prețul plătit este rezonabil.
În al doilea rând, scanarea se oprește la primul repo întâlnit, fie că este găsit în directorul $Root însuși – situație-n care documentul rezultat va rămâne gol – fie în aval. În tot cazul, cele imbricate și submodulele nu apar separat și nici nu sunt descoperite. Detecția în sine, că tot suntem în cartier, este simplă: se uită după fișiere sau directoare .git, respectiv .hg. Atât.
Apoi, un director inaccesibil poate face raportul incomplet. Primești un avertisment în consolă, dar CSV-ul nu marchează scanarea incompletă. O ramură poate fi comprimată ca fiind fără repo-uri, deși există unul într-un director pe care scriptul nu l-a putut citi. Nu mă doare mâna să rulez ca administrator.
În sfârșit, directoarele de tip junction sau symlink sunt omise complet. Dacă unele proiecte sunt accesibile din $Root prin astfel de legături, nu vor apărea în raport. Ask no questions, hear no lies. Sau, cum zicea taica Flondor: putem să ne băgăm în asta și să ne căznim să ieșim, sau putem să nu ne băgăm deloc. Mai ales că am efectuat o căutare preliminară și-am văzut că nu avem, deci condiția e mai mult un paznic.

–
În ciuda aparenței de articol concis, insist să fiu și un pic pedant. Nu știu cum să traduc în română ideea de repository. Primele două variante ce vin în minte – depozit sau scremutul repozitoriu – au două beteșuguri: nu pot fi scrise fără scrâșnet de măsele și ne duc cu gândul la NIR-uri și marfă paletizată.
Termenul arhivă sugerează ceva static și este deja reclamat aproape exclusiv de fișierele folosite în general pentru comprimare și arhivare propriu-zisă. Director de cod, pe lângă faptul că dă ceasul înapoi în 1989 (oareșicum redundant, de altfel, politburo-ul de la Bruxelles s-a ocupat cu vădit succes de asta), este complet nesugestiv, că deja este un director ce are cod.
Deci rămâne repo, nu-i nici măcar o traducere, dar e cea mai puțin rea variantă. Nu-i nici moșnegesc, nici nu te face țeapăn ca un răcat la prezentarea armelor. Ce vreți mai mult?
–
Utilizarea este simplă:
.\repo-check.ps1 -Root "D:\Proiecte\Smuf\_src" # Console output: Wrote 16 records in 'D:\...\repository-list.csv'.
Rezultând un fișier CSV precum:
"FolderName","RepositoryType" ".","none" "Smuf","none" "Stuf","mercurial" "Zbuf","git" "SmufStuf","none" "SmufStuf\ProjectA","mercurial" "SmufStuf\ProjectB","none"