Public/Set-SLGitAttributes.ps1
|
function Set-SLGitAttributes { <# .SYNOPSIS Writes or completes a repo's .gitattributes from a profile, then renormalizes. .DESCRIPTION Compares the repo's .gitattributes with its profile (see Test-SLRepo for how the profile is chosen), then: MISSING writes the profile as .gitattributes DIFFERS, missing appends the missing rules under '# Added by SecondLine', keeping every existing line and comment (a missing '*' catch-all goes at the top, so it can't override the rules after it) DIFFERS, conflict refuses, unless -Force: then replaces the conflicting lines in place (keeping any extra attributes on them) MATCHES/EXTENDS writes nothing The file is written as UTF-8 without BOM, with LF line endings. A repo's extra rules are never removed. Then, unless -SkipRenormalize, runs git -C <repo> add .gitattributes git -C <repo> add --renormalize . and shows what is staged. It NEVER commits: review the staged changes and commit them yourself, on their own, so the line-ending change doesn't hide inside a real diff. Before renormalizing it refuses to run on a repo with uncommitted changes to tracked files (unless -AllowDirty), because the line-ending changes would be mixed into them. Untracked files don't count. .PARAMETER Path The repo's top folder. Accepts Test-SLRepo -PassThru objects from the pipeline. .PARAMETER Profile The profile to apply. Default: as Test-SLRepo picks it. .PARAMETER Standard The standard file, for its GitAttributes section. See Get-SecondLineStandard. .PARAMETER Force Replace lines whose attributes conflict with the profile. .PARAMETER AllowDirty Renormalize even with uncommitted changes to tracked files. .PARAMETER SkipRenormalize Only write .gitattributes; run no git commands. .EXAMPLE Set-SLGitAttributes -Path C:\src\MyApp -WhatIf Shows the rules it would write and the git commands it would run. .EXAMPLE Set-SLGitAttributes C:\src\Contoso.Api -Profile dotnet -Force .EXAMPLE Test-SLRepo C:\src -PassThru -Quiet | Where-Object Status -eq 'MISSING' | Set-SLGitAttributes #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidAssignmentToAutomaticVariable', '', Justification = '-Profile is the documented parameter name; it shadows $PROFILE only inside this function, which never uses it.')] [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] [OutputType([pscustomobject])] param( [Parameter(Mandatory, Position = 0, ValueFromPipelineByPropertyName)] [string] $Path, [Parameter(ValueFromPipelineByPropertyName)] [string] $Profile, [string] $Standard, [switch] $Force, [switch] $AllowDirty, [switch] $SkipRenormalize ) begin { [void] (Get-SLGitPath) # fail early, with an install hint, if git is missing $std = Get-SLOptionalStandard -Path $Standard $profiles = Get-SLProfiles -Standard $std } process { if (-not (Test-Path -LiteralPath $Path -PathType Container)) { Write-Error "Folder not found: $Path." return } $repo = (Resolve-Path -LiteralPath $Path).ProviderPath if (-not (Test-SLGitRepo $repo)) { Write-Error "$repo is not the top folder of a git repo (no .git in it). Point -Path at a repo; Test-SLRepo finds them." return } try { $check = Get-SLRepoCheck -RepoPath $repo -Standard $std -ProfileName $Profile -Profiles $profiles } catch { Write-Error $_.Exception.Message; return } $file = Join-Path $repo '.gitattributes' $lines = @() if ($check.Status -ne 'MISSING') { $lines = @(Read-SLTextLines -Path $file) } $cmp = Compare-SLGitAttributes -Lines $lines -ProfileLines $profiles[$check.Profile] -FileMissing:($check.Status -eq 'MISSING') $quoted = "'" + $repo.Replace("'", "''") + "'" # ------------------------------------------------------ the plan -- $action = 'Unchanged' $newLines = $null $added = @() $replaced = @() if ($cmp.Status -eq 'MISSING') { $action = 'Created' $newLines = @($profiles[$check.Profile]) $added = @($cmp.MissingRules) } elseif ($cmp.Status -eq 'DIFFERS') { if (@($cmp.Conflicts).Count -gt 0 -and -not $Force) { Write-Error ("$repo`: .gitattributes conflicts with profile '$($check.Profile)':`n " + (@($cmp.ConflictingRules) -join "`n ") + "`nNothing was changed. Re-run with -Force to replace those lines (every other line is kept), or choose another -Profile.") return } $list = New-Object System.Collections.Generic.List[string] foreach ($l in $lines) { $list.Add($l) } foreach ($c in @($cmp.Conflicts)) { # Keep attributes the repo adds that the profile doesn't mention. $keep = @(@($c.Have.Attributes.Keys) | Sort-Object | Where-Object { -not $c.Want.Attributes.ContainsKey($_) } | ForEach-Object { $c.Have.Attributes[$_] }) $ruleLine = Format-SLRuleLine -Pattern $c.Want.Pattern -Tokens (@($c.Want.Tokens) + $keep) foreach ($idx in $c.Have.Lines) { $replaced += "$($list[$idx].Trim()) -> $ruleLine" $list[$idx] = $ruleLine } } $top = @($cmp.Missing | Where-Object { $_.Pattern -eq '*' }) $bottom = @($cmp.Missing | Where-Object { $_.Pattern -ne '*' }) if ($bottom.Count -gt 0) { if ($list.Count -gt 0 -and $list[$list.Count - 1].Trim()) { $list.Add('') } $list.Add("# Added by SecondLine (profile '$($check.Profile)')") foreach ($r in $bottom) { $list.Add((Format-SLRuleLine -Pattern $r.Pattern -Tokens $r.Tokens)) } } if ($top.Count -gt 0) { # Later lines win in git, so a catch-all must come first. $head = @("# Added by SecondLine (profile '$($check.Profile)')") foreach ($r in $top) { $head += (Format-SLRuleLine -Pattern $r.Pattern -Tokens $r.Tokens) } $head += '' $list.InsertRange(0, [string[]] $head) } foreach ($r in @($cmp.Missing)) { $added += "$($r.Pattern) $($r.Tokens -join ' ')" } $newLines = $list.ToArray() if ($replaced.Count -gt 0) { $action = 'Replaced' } else { $action = 'Appended' } } $renormalize = -not $SkipRenormalize -and ($action -ne 'Unchanged' -or $check.FilesToRenormalize -gt 0) $result = [pscustomobject]@{ PSTypeName = 'SecondLine.GitAttributesChange' Repo = $check.Repo Path = $repo Profile = $check.Profile ProfileReason = $check.ProfileReason Action = $action RulesAdded = $added RulesReplaced = $replaced FilesStaged = @() Applied = $false } Write-Host '' Write-Host " SecondLine - .gitattributes for $($check.Repo)" -ForegroundColor White Write-Host " $repo (profile $($check.Profile): $($check.ProfileReason))" -ForegroundColor DarkGray Write-Host '' if ($action -eq 'Unchanged' -and -not $renormalize) { $msg = " .gitattributes $($check.Status.ToLowerInvariant()) the profile" if ($check.FilesToRenormalize -gt 0) { $msg += "; $($check.FilesToRenormalize) file(s) need renormalizing (-SkipRenormalize given)." } else { $msg += ' and no file needs renormalizing. Nothing to do.' } Write-Host $msg -ForegroundColor Green Write-Host '' return $result } # Check before changing anything, so a refusal leaves the repo as it was. if ($renormalize -and -not $AllowDirty) { $dirty = @(Get-SLDirtyFiles -RepoPath $repo) if ($dirty.Count -gt 0) { $names = (@($dirty | Select-Object -First 5 | ForEach-Object { $_.Path }) -join ', ') if ($dirty.Count -gt 5) { $names += ', ...' } Write-Error ("$repo has uncommitted changes to $($dirty.Count) tracked file(s): $names. " + 'Renormalizing now would mix line-ending changes into them. Commit or stash them first, so the ' + 'line-ending change is a commit of its own, or pass -AllowDirty. Nothing was changed.') return } } switch ($action) { 'Created' { Write-Host " Create .gitattributes from profile '$($check.Profile)' ($($added.Count) rules)" -ForegroundColor Yellow if ($WhatIfPreference) { foreach ($l in $newLines) { Write-Host " | $l" -ForegroundColor DarkGray } } } 'Unchanged' { Write-Host " .gitattributes $($check.Status.ToLowerInvariant()) the profile; $($check.FilesToRenormalize) file(s) need renormalizing." -ForegroundColor Green } default { foreach ($r in $replaced) { Write-Host " replace: $r" -ForegroundColor Yellow } foreach ($line in @(Format-SLRuleGroups -Rules $added)) { $label = 'add: ' if ($line.StartsWith(' ')) { $label = ' ' } # continuation Write-Host " $label$line" -ForegroundColor Yellow } } } $gitCommands = "git -C $quoted add .gitattributes; git -C $quoted add --renormalize ." if ($renormalize) { Write-Host " then: git -C $quoted add .gitattributes" -ForegroundColor DarkGray Write-Host " git -C $quoted add --renormalize ." -ForegroundColor DarkGray } Write-Host '' if ($action -ne 'Unchanged') { $verbs = @{ Created = 'Create'; Appended = 'Append missing rules to'; Replaced = 'Replace conflicting rules in (and complete)' } $what = "$($verbs[$action]) .gitattributes (profile '$($check.Profile)')" if (-not $PSCmdlet.ShouldProcess($file, $what)) { return $result } Write-SLTextLines -Path $file -Lines $newLines $result.Applied = $true Write-Host " OK $action .gitattributes" -ForegroundColor Green } if (-not $renormalize) { Write-Host '' return $result } if (-not $PSCmdlet.ShouldProcess($repo, $gitCommands)) { return $result } foreach ($gitArgs in @(@('add', '--', '.gitattributes'), @('add', '--renormalize', '.'))) { if ($gitArgs[-1] -eq '.gitattributes' -and -not (Test-Path -LiteralPath $file -PathType Leaf)) { continue } $r = Invoke-SLGit -RepoPath $repo -Arguments $gitArgs if ($r.ExitCode -ne 0) { Write-Error "git $($gitArgs -join ' ') failed in $($repo): $($r.Errors)" return $result } } $result.Applied = $true $status = Invoke-SLGit -RepoPath $repo -Arguments @('status', '--porcelain=v1', '-z', '--untracked-files=no') $staged = @(ConvertFrom-SLPorcelain $status.Output | Where-Object { $_.Index -ne ' ' -and $_.Index -ne '?' }) $result.FilesStaged = @($staged | ForEach-Object { $_.Path }) Write-Host " OK Staged $($staged.Count) file(s) (git status --short):" -ForegroundColor Green foreach ($s in @($staged | Select-Object -First 20)) { Write-Host " $($s.Index)$($s.WorkTree) $($s.Path)" -ForegroundColor DarkGray } if ($staged.Count -gt 20) { Write-Host " ... and $($staged.Count - 20) more" -ForegroundColor DarkGray } $message = 'Renormalize line endings' if ($action -eq 'Created') { $message = 'Add .gitattributes (line-ending standard) and renormalize' } elseif ($action -ne 'Unchanged') { $message = 'Update .gitattributes (line-ending standard) and renormalize' } Write-Host '' Write-Host ' Nothing was committed. Review, then commit on its own:' -ForegroundColor White Write-Host " git -C $quoted diff --cached --stat" -ForegroundColor Gray Write-Host " git -C $quoted commit -m `"$message`"" -ForegroundColor Gray Write-Host '' return $result } } |