Public/Repair-RepositoryLayout.ps1

function Repair-RepositoryLayout {
    <#
    .SYNOPSIS
    Conform git repositories to the New-Repository layout (`<org>\<repo>\<branch>`).
    .DESCRIPTION
    Scans repositories under the repos root and moves any that don't follow the
    standard `<root>\<org>\<repo>\<branch>` layout into place, where the branch
    directory mirrors the checked-out branch (with '/' as nested subdirectories,
    e.g. `release/main` -> `release\main`).
 
    Handles three non-conforming categories:
    - **git-at-repo-level**: a clone sitting directly at `<org>\<repo>` is moved
      down into a `<branch>` subdirectory.
    - **leaf-not-branch**: a working tree whose directory name doesn't match its
      checked-out branch is renamed (via `git worktree move` for linked worktrees,
      or a plain move for a standalone main clone with no dependent worktrees).
    - **detached / no-git**: reported only, never modified.
 
    Returns a RepositoryLayoutResult object per repository describing the action.
    Use -WhatIf to preview the full From->To plan without moving anything.
    .PARAMETER Root
    The repos root to scan. Defaults to $env:SOURCE_REPOS. Fails if neither is set.
    .PARAMETER Organization
    Optional organization-folder filter(s). Supports wildcards.
    .PARAMETER Name
    Optional repository-name filter(s). Supports wildcards.
    .EXAMPLE
    Repair-RepositoryLayout -WhatIf
    Previews the From->To plan for every repository without making changes.
    .EXAMPLE
    Repair-RepositoryLayout -Organization contoso -Name Platform.*
    Conforms only contoso/Platform.* repositories (prompting before each move).
    .EXAMPLE
    Repair-RepositoryLayout | Where-Object Status -like 'Skipped*'
    Lists repositories that were skipped (detached HEAD, no .git, or unsafe to move).
    #>

    [OutputType('RepositoryLayoutResult')]
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param(
        [string]$Root,

        [string[]]$Organization,

        [string[]]$Name
    )

    if (-not $Root) { $Root = $env:SOURCE_REPOS }
    if (-not $Root) {
        Write-Error 'No repository root specified. Pass -Root or set $env:SOURCE_REPOS.'
        return
    }
    if (-not (Test-Path $Root)) {
        Write-Error "Repos root not found: $Root"
        return
    }

    $cwd = (Get-Location).Path

    function New-Result {
        <#
        .SYNOPSIS
            Build a RepositoryLayoutResult object describing one repository's outcome.
        .PARAMETER Repo
            The repository path or identifier.
        .PARAMETER Category
            The non-conforming category (git-at-repo-level, leaf-not-branch, detached/no-git).
        .PARAMETER Action
            The action taken or planned (e.g. Move, Rename, None).
        .PARAMETER From
            The original path, when a move/rename applies.
        .PARAMETER To
            The destination path, when a move/rename applies.
        .PARAMETER Status
            The result status (e.g. Moved, Skipped, WhatIf, Reported).
        #>

        param($Repo, $Category, $Action, $From, $To, $Status)
        [PSCustomObject]@{
            PSTypeName = 'RepositoryLayoutResult'
            Repo       = $Repo
            Category   = $Category
            Action     = $Action
            From       = $From
            To         = $To
            Status     = $Status
        }
    }

    foreach ($org in Get-ChildItem $Root -Directory) {
        if ($Organization -and -not ($Organization | Where-Object { $org.Name -like $_ })) { continue }

        foreach ($repo in Get-ChildItem $org.FullName -Directory) {
            if ($Name -and -not ($Name | Where-Object { $repo.Name -like $_ })) { continue }

            $repoRel = "$($org.Name)/$($repo.Name)"

            # --- Category 1: .git directly at repo level (no branch dir) ---
            if (Test-Path (Join-Path $repo.FullName '.git')) {
                $branch = (git -C $repo.FullName rev-parse --abbrev-ref HEAD 2>$null)
                if (-not $branch -or $branch -eq 'HEAD') {
                    New-Result $repoRel 'git-at-repo-level' 'none' $repo.FullName '' 'Skipped-Detached'
                    continue
                }
                # Guard: don't move a repo we're sitting inside
                if ($cwd -eq $repo.FullName -or $cwd.StartsWith($repo.FullName + '\')) {
                    New-Result $repoRel 'git-at-repo-level' 'none' $repo.FullName '' 'Skipped-CwdInside'
                    continue
                }
                # Guard: multiple worktrees at repo level => complex, skip
                $wtCount = @(git -C $repo.FullName worktree list 2>$null).Count
                if ($wtCount -gt 1) {
                    New-Result $repoRel 'git-at-repo-level' 'none' $repo.FullName '' 'Skipped-HasWorktrees'
                    continue
                }

                $branchDir = $branch -replace '/', '\'
                $target = Join-Path $repo.FullName $branchDir

                if ($PSCmdlet.ShouldProcess($repo.FullName, "Move clone into '$branchDir' subdirectory")) {
                    try {
                        $tmp = Join-Path $org.FullName ("{0}__layout_tmp_{1}" -f $repo.Name, [guid]::NewGuid().ToString('N').Substring(0, 8))
                        Move-Item -LiteralPath $repo.FullName -Destination $tmp -ErrorAction Stop
                        # Recreate the repo dir and any multi-segment parent dirs
                        $targetParent = Split-Path $target -Parent
                        if (-not (Test-Path $targetParent)) {
                            New-Item -ItemType Directory -Path $targetParent -Force | Out-Null
                        }
                        Move-Item -LiteralPath $tmp -Destination $target -ErrorAction Stop
                        New-Result $repoRel 'git-at-repo-level' 'moved' $repo.FullName $target 'Converted'
                    } catch {
                        New-Result $repoRel 'git-at-repo-level' 'move-failed' $repo.FullName $target "Error: $($_.Exception.Message)"
                    }
                } else {
                    New-Result $repoRel 'git-at-repo-level' 'would-move' $repo.FullName $target 'WhatIf'
                }
                continue
            }

            # --- Category 2/3: branch-level working trees ---
            $gitItems = Get-ChildItem $repo.FullName -Force -Recurse -Depth 4 -Filter '.git' -ErrorAction SilentlyContinue |
                Where-Object { $_.FullName -notmatch '\\\.git\\' }

            if (-not $gitItems) {
                New-Result $repoRel 'no-git' 'none' $repo.FullName '' 'Skipped-NoGit'
                continue
            }

            foreach ($g in $gitItems) {
                $wt = Split-Path $g.FullName -Parent
                if (-not $wt) { continue }
                # Skip submodules — a non-empty superproject means this working tree
                # belongs to a parent repo's submodule, not a worktree of this repo.
                $super = (git -C $wt rev-parse --show-superproject-working-tree 2>$null)
                if ($super) {
                    New-Result $repoRel 'submodule' 'none' $wt '' 'Skipped-Submodule'
                    continue
                }
                # Skip foreign nested clones — the git common dir must resolve under this repo.
                $commonDir = (git -C $wt rev-parse --git-common-dir 2>$null)
                if ($commonDir) {
                    $commonFull = $commonDir
                    if (-not [System.IO.Path]::IsPathRooted($commonFull)) {
                        $commonFull = Join-Path $wt $commonDir
                    }
                    try { $commonFull = (Resolve-Path -LiteralPath $commonFull -ErrorAction Stop).Path } catch { }
                    if (-not $commonFull.Replace('/', '\').StartsWith($repo.FullName, [StringComparison]::OrdinalIgnoreCase)) {
                        New-Result $repoRel 'foreign-nested' 'none' $wt '' 'Skipped-ForeignClone'
                        continue
                    }
                }
                $rel = $wt.Substring($repo.FullName.Length).TrimStart('\')
                $branch = (git -C $wt rev-parse --abbrev-ref HEAD 2>$null)
                if (-not $branch -or $branch -eq 'HEAD') {
                    New-Result $repoRel 'detached' 'none' $wt '' 'Skipped-Detached'
                    continue
                }
                $branchDir = $branch -replace '/', '\'
                if ($rel -eq $branchDir) { continue }   # conforms

                $target = Join-Path $repo.FullName $branchDir
                # Is this a linked worktree (.git is a file) or a main clone (.git is a dir)?
                $isLinkedWorktree = -not $g.PSIsContainer

                if ($cwd -eq $wt -or $cwd.StartsWith($wt + '\')) {
                    New-Result $repoRel 'leaf-not-branch' 'none' $wt $target 'Skipped-CwdInside'
                    continue
                }

                if ($isLinkedWorktree) {
                    if ($PSCmdlet.ShouldProcess($wt, "git worktree move -> '$branchDir'")) {
                        try {
                            $targetParent = Split-Path $target -Parent
                            if (-not (Test-Path $targetParent)) { New-Item -ItemType Directory -Path $targetParent -Force | Out-Null }
                            git -C $wt worktree move $wt $target 2>&1 | Out-Null
                            if ($LASTEXITCODE -eq 0) {
                                New-Result $repoRel 'leaf-not-branch' 'worktree-move' $wt $target 'Converted'
                            } else {
                                New-Result $repoRel 'leaf-not-branch' 'worktree-move-failed' $wt $target "git exit $LASTEXITCODE"
                            }
                        } catch {
                            New-Result $repoRel 'leaf-not-branch' 'worktree-move-failed' $wt $target "Error: $($_.Exception.Message)"
                        }
                    } else {
                        New-Result $repoRel 'leaf-not-branch' 'would-worktree-move' $wt $target 'WhatIf'
                    }
                } else {
                    # Main clone: only safe to rename if it has no dependent worktrees
                    $wtCount = @(git -C $wt worktree list 2>$null).Count
                    if ($wtCount -gt 1) {
                        New-Result $repoRel 'leaf-not-branch' 'none' $wt $target 'Skipped-HasWorktrees'
                        continue
                    }
                    if ($PSCmdlet.ShouldProcess($wt, "Rename main clone dir -> '$branchDir'")) {
                        try {
                            $targetParent = Split-Path $target -Parent
                            if (-not (Test-Path $targetParent)) { New-Item -ItemType Directory -Path $targetParent -Force | Out-Null }
                            Move-Item -LiteralPath $wt -Destination $target -ErrorAction Stop
                            New-Result $repoRel 'leaf-not-branch' 'renamed' $wt $target 'Converted'
                        } catch {
                            New-Result $repoRel 'leaf-not-branch' 'rename-failed' $wt $target "Error: $($_.Exception.Message)"
                        }
                    } else {
                        New-Result $repoRel 'leaf-not-branch' 'would-rename' $wt $target 'WhatIf'
                    }
                }
            }
        }
    }
}