Skip to content

Commit e49a1b5

Browse files
WilliamBerryiiiBill Berry
andauthored
fix(scripts): eliminate phantom git changes from plugin generation (#1035)
## Description Eliminated phantom git changes that `npm run plugin:generate` left in the working tree after every run. The root cause was a delete-and-recreate strategy that touched file timestamps and re-wrote identical content, combined with a `--index-info` stdin call that broke on Windows due to CRLF encoding. ### Plugin Generation — Conditional Writes Introduced `Set-ContentIfChanged` in *CollectionHelpers.psm1* as the single write choke point for all generated files. The function performs an ordinal comparison of proposed content against the existing file and skips the write when identical. Converted all five production write sites in *PluginHelpers.psm1* — `Write-MarketplaceManifest`, `New-PluginLink` fallback, *plugin.json*, *README.md*, and collection YAML — to route through `Set-ContentIfChanged`. Added a **GeneratedFiles** `HashSet` to `Write-PluginDirectory` that tracks every output path and returns it in the result object, enabling downstream orphan detection. Rewrote `Repair-PluginSymlinkIndex` to parse `git ls-files --stage` output, detect mode-120000 entries, skip already-correct symlinks, and use per-entry `--cacheinfo` calls instead of piped `--index-info`. The piped approach broke on Windows because PowerShell injected CRLF into stdin, corrupting the index update. ### Plugin Generation — Orphan Cleanup Replaced the delete-and-recreate Refresh strategy in *Generate-Plugins.ps1* with orphan cleanup. After overwrite-in-place generation completes, files not present in the `GeneratedFiles` set are removed, followed by a bottom-up empty-directory sweep. All `Remove-Item` calls gained `-ErrorAction Stop` for fail-fast behavior. ### Post-Processing Scope Extracted post-processing into a `plugin:postprocess` script in *package.json*, scoping *markdownlint-cli2* and *markdown-table-formatter* to `plugins/**/*.md` and `collections/*.md` only. This prevented post-processing from modifying unrelated files in the working tree. ### Windows Test Compatibility Separately fixed 14 pre-existing test failures in *Invoke-PptxPipeline.Tests.ps1*. The tests created shell-script `.exe` python stubs that are not valid PE executables on Windows. Replaced with `.cmd` batch files that execute natively and correctly set `$LASTEXITCODE`. ### Test Coverage - Added a **Set-ContentIfChanged** test suite in *CollectionHelpers.Tests.ps1* covering creation, skip-on-identical, overwrite-on-diff, case-sensitivity, empty string, and UTF-8-without-BOM behavior. - Added 4 **Orphan Cleanup** tests in *Generate-Plugins.Tests.ps1* validating orphan removal, generated file preservation, empty directory pruning, and DryRun logging. - Added 5 **Repair-PluginSymlinkIndex** tests in *PluginHelpers.Tests.ps1* using a real git repo fixture — covers DryRun counting, DryRun index immutability, mode-120000 re-indexing, and skip-if-already-fixed logic. ## Related Issue(s) Closes #1029 ## Type of Change Select all that apply: **Code & Documentation:** * [x] Bug fix (non-breaking change fixing an issue) * [ ] New feature (non-breaking change adding functionality) * [ ] Breaking change (fix or feature causing existing functionality to change) * [ ] Documentation update **Infrastructure & Configuration:** * [ ] GitHub Actions workflow * [ ] Linting configuration (markdown, PowerShell, etc.) * [ ] Security configuration * [ ] DevContainer configuration * [ ] Dependency update **AI Artifacts:** * [ ] Reviewed contribution with `prompt-builder` agent and addressed all feedback * [ ] Copilot instructions (`.github/instructions/*.instructions.md`) * [ ] Copilot prompt (`.github/prompts/*.prompt.md`) * [ ] Copilot agent (`.github/agents/*.agent.md`) * [ ] Copilot skill (`.github/skills/*/SKILL.md`) **Other:** * [x] Script/automation (`.ps1`, `.sh`, `.py`) * [ ] Other (please describe): ## Testing Full test suite validated after both commits: - **1707 passed**, 0 failed, 1 skipped - PSScriptAnalyzer clean on all modified PowerShell files - New tests cover `Set-ContentIfChanged`, orphan cleanup, and `Repair-PluginSymlinkIndex` comprehensively ## Checklist ### Required Checks * [ ] Documentation is updated (if applicable) * [x] Files follow existing naming conventions * [x] Changes are backwards compatible (if applicable) * [x] Tests added for new functionality (if applicable) ### AI Artifact Contributions <!-- Not applicable — no AI artifacts modified --> ### Required Automated Checks The following validation commands must pass before merging: * [x] Markdown linting: `npm run lint:md` * [x] Spell checking: `npm run spell-check` * [x] Frontmatter validation: `npm run lint:frontmatter` * [x] Skill structure validation: `npm run validate:skills` * [x] Link validation: `npm run lint:md-links` (pre-existing failures only — no new broken links introduced) * [x] PowerShell analysis: `npm run lint:ps` * [x] Plugin freshness: `npm run plugin:generate` ## Security Considerations * [x] This PR does not contain any sensitive or NDA information * [x] Any new dependencies have been reviewed for security issues * [x] Security-related scripts follow the principle of least privilege ## Additional Notes - Two test files (*CollectionHelpers.Tests.ps1*, *PluginHelpers.Tests.ps1*) gained UTF-8 BOM byte prefix — incidental encoding change from editor saves on Windows, no functional impact. - No new dependencies introduced. All changes are internal to existing PowerShell modules and npm scripts. --------- Co-authored-by: Bill Berry <wbery@microsoft.com>
1 parent 52b0885 commit e49a1b5

File tree

7 files changed

+365
-52
lines changed

7 files changed

+365
-52
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
"validate:skills": "pwsh -NoProfile -Command \"& './scripts/linting/Validate-SkillStructure.ps1' -WarningsAsErrors\"",
3131
"lint:py": "pwsh -NoProfile -File ./scripts/linting/Invoke-PythonLint.ps1",
3232
"test:py": "pwsh -NoProfile -File ./scripts/linting/Invoke-PythonTests.ps1",
33-
"plugin:generate": "pwsh -File scripts/plugins/Generate-Plugins.ps1 && npm run lint:md:fix && npm run format:tables",
33+
"plugin:postprocess": "markdownlint-cli2 \"plugins/**/*.md\" \"collections/*.md\" --fix && markdown-table-formatter \"plugins/**/*.md\" && markdown-table-formatter \"collections/*.md\"",
34+
"plugin:generate": "pwsh -File scripts/plugins/Generate-Plugins.ps1 && npm run plugin:postprocess",
3435
"plugin:validate": "npm run lint:collections-metadata",
3536
"docs:build": "npm --prefix docs/docusaurus run build",
3637
"docs:serve": "npm --prefix docs/docusaurus run serve"

scripts/collections/Modules/CollectionHelpers.psm1

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,50 @@
99
#Requires -Version 7.0
1010
#Requires -Modules PowerShell-Yaml
1111

12+
# ---------------------------------------------------------------------------
13+
# Internal Utilities
14+
# ---------------------------------------------------------------------------
15+
16+
function Set-ContentIfChanged {
17+
<#
18+
.SYNOPSIS
19+
Writes content to a file only when the content has changed.
20+
.DESCRIPTION
21+
Compares the provided value against the existing file content using
22+
case-sensitive ordinal comparison. Writes only when the file does not
23+
exist or content differs, preserving the git stat cache for unchanged files.
24+
.PARAMETER Path
25+
The file path to write.
26+
.PARAMETER Value
27+
The content to write.
28+
.OUTPUTS
29+
[bool] True if the file was written, false if skipped.
30+
#>
31+
[CmdletBinding()]
32+
[OutputType([bool])]
33+
param(
34+
[Parameter(Mandatory)]
35+
[string]$Path,
36+
37+
[Parameter(Mandatory)]
38+
[AllowEmptyString()]
39+
[string]$Value
40+
)
41+
42+
if (Test-Path -LiteralPath $Path) {
43+
$existing = Get-Content -LiteralPath $Path -Raw -Encoding utf8
44+
if ([string]::Equals($existing, $Value, [System.StringComparison]::Ordinal)) {
45+
return $false
46+
}
47+
}
48+
$parentDir = Split-Path -Path $Path -Parent
49+
if ($parentDir -and -not (Test-Path -LiteralPath $parentDir)) {
50+
New-Item -ItemType Directory -Path $parentDir -Force | Out-Null
51+
}
52+
Set-Content -LiteralPath $Path -Value $Value -Encoding utf8 -NoNewline
53+
return $true
54+
}
55+
1256
# ---------------------------------------------------------------------------
1357
# Pure Functions (no file system side effects)
1458
# ---------------------------------------------------------------------------
@@ -538,7 +582,7 @@ function Update-HveCoreAllCollection {
538582
}
539583

540584
$yaml = ConvertTo-Yaml -Data $manifest
541-
Set-Content -Path $collectionPath -Value $yaml -Encoding utf8 -NoNewline
585+
Set-ContentIfChanged -Path $collectionPath -Value $yaml | Out-Null
542586
Write-Verbose "Updated $collectionPath"
543587
}
544588

@@ -557,6 +601,7 @@ Export-ModuleMember -Function @(
557601
'Get-CollectionArtifactKey',
558602
'Get-CollectionManifest',
559603
'Resolve-CollectionItemMaturity',
604+
'Set-ContentIfChanged',
560605
'Test-ArtifactDeprecated',
561606
'Test-DeprecatedPath',
562607
'Test-HveCoreRepoRelativePath',

scripts/plugins/Generate-Plugins.ps1

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -255,18 +255,7 @@ function Invoke-PluginGeneration {
255255
continue
256256
}
257257

258-
# Refresh: remove existing plugin directory
259-
if ($Refresh -and (Test-Path -Path $pluginDir)) {
260-
if ($DryRun) {
261-
Write-Host " [DRY RUN] Would remove $pluginDir" -ForegroundColor Yellow
262-
}
263-
else {
264-
Remove-Item -Path $pluginDir -Recurse -Force
265-
Write-Verbose "Removed existing plugin directory: $pluginDir"
266-
}
267-
}
268-
269-
# Generate plugin directory structure
258+
# Generate plugin directory structure (overwrites in place)
270259
$filteredCollection = Select-CollectionItemsByChannel -Collection $collection -Channel $Channel
271260

272261
$result = Write-PluginDirectory -Collection $filteredCollection `
@@ -277,6 +266,47 @@ function Invoke-PluginGeneration {
277266
-DryRun:$DryRun `
278267
-SymlinkCapable:$symlinkCapable
279268

269+
# Orphan cleanup in Refresh mode
270+
if ($Refresh -and (Test-Path -LiteralPath $pluginDir)) {
271+
$generatedFiles = $result.GeneratedFiles
272+
$existingFiles = [System.Collections.Generic.List[string]]::new()
273+
$scanQueue = [System.Collections.Generic.Queue[string]]::new()
274+
$scanQueue.Enqueue($pluginDir)
275+
while ($scanQueue.Count -gt 0) {
276+
$currentDir = $scanQueue.Dequeue()
277+
foreach ($entry in Get-ChildItem -LiteralPath $currentDir -Force) {
278+
if ($entry.PSIsContainer -and -not $entry.LinkType) {
279+
$scanQueue.Enqueue($entry.FullName)
280+
}
281+
else {
282+
$existingFiles.Add($entry.FullName)
283+
}
284+
}
285+
}
286+
foreach ($existingFile in $existingFiles) {
287+
if (-not $generatedFiles.Contains($existingFile)) {
288+
if ($DryRun) {
289+
Write-Host " [DRY RUN] Would remove orphan: $existingFile" -ForegroundColor Yellow
290+
}
291+
else {
292+
Remove-Item -LiteralPath $existingFile -Force -ErrorAction Stop
293+
Write-Verbose "Removed orphan file: $existingFile"
294+
}
295+
}
296+
}
297+
# Remove empty directories bottom-up
298+
if (-not $DryRun) {
299+
Get-ChildItem -LiteralPath $pluginDir -Recurse -Directory |
300+
Where-Object { -not $_.LinkType } |
301+
Sort-Object { $_.FullName.Length } -Descending |
302+
Where-Object { @(Get-ChildItem -LiteralPath $_.FullName).Count -eq 0 } |
303+
ForEach-Object {
304+
Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop
305+
Write-Verbose "Removed empty directory: $($_.FullName)"
306+
}
307+
}
308+
}
309+
280310
$itemCount = $filteredCollection.items.Count
281311
$totalAgents += $result.AgentCount
282312
$totalCommands += $result.CommandCount

scripts/plugins/Modules/PluginHelpers.psm1

Lines changed: 41 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,8 @@ function Write-MarketplaceManifest {
399399
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
400400
}
401401

402-
$manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $outputPath -Encoding utf8 -NoNewline
402+
$manifestJson = $manifest | ConvertTo-Json -Depth 10
403+
Set-ContentIfChanged -Path $outputPath -Value $manifestJson | Out-Null
403404
Write-Host " Marketplace manifest: $outputPath" -ForegroundColor Green
404405
}
405406

@@ -525,7 +526,7 @@ function New-PluginLink {
525526
New-Item -ItemType SymbolicLink -Path $DestinationPath -Value $relativePath -Force | Out-Null
526527
}
527528
else {
528-
[System.IO.File]::WriteAllText($DestinationPath, $relativePath)
529+
Set-ContentIfChanged -Path $DestinationPath -Value $relativePath | Out-Null
529530
}
530531
}
531532

@@ -604,6 +605,9 @@ function Write-PluginDirectory {
604605
}
605606

606607
$readmeItems = @()
608+
$generatedFiles = [System.Collections.Generic.HashSet[string]]::new(
609+
[System.StringComparer]::OrdinalIgnoreCase
610+
)
607611

608612
foreach ($item in $Collection.items) {
609613
$kind = $item.kind
@@ -657,6 +661,8 @@ function Write-PluginDirectory {
657661
'skill' { $counts.SkillCount++ }
658662
}
659663

664+
[void]$generatedFiles.Add($destPath)
665+
660666
if ($DryRun) {
661667
Write-Verbose "DryRun: Would create link $destPath -> $sourcePath"
662668
continue
@@ -680,6 +686,8 @@ function Write-PluginDirectory {
680686
continue
681687
}
682688

689+
[void]$generatedFiles.Add($destPath)
690+
683691
if ($DryRun) {
684692
Write-Verbose "DryRun: Would create shared directory link $destPath -> $sourcePath"
685693
continue
@@ -692,6 +700,7 @@ function Write-PluginDirectory {
692700
$manifestDir = Join-Path -Path $pluginRoot -ChildPath '.github' -AdditionalChildPath 'plugin'
693701
$manifestPath = Join-Path -Path $manifestDir -ChildPath 'plugin.json'
694702
$manifest = New-PluginManifestContent -CollectionId $collectionId -Description $Collection.description -Version $Version
703+
[void]$generatedFiles.Add($manifestPath)
695704

696705
if ($DryRun) {
697706
Write-Verbose "DryRun: Would write plugin.json at $manifestPath"
@@ -700,7 +709,8 @@ function Write-PluginDirectory {
700709
if (-not (Test-Path -Path $manifestDir)) {
701710
New-Item -ItemType Directory -Path $manifestDir -Force | Out-Null
702711
}
703-
$manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $manifestPath -Encoding utf8 -NoNewline
712+
$jsonContent = $manifest | ConvertTo-Json -Depth 10
713+
Set-ContentIfChanged -Path $manifestPath -Value $jsonContent | Out-Null
704714
}
705715

706716
# Generate README.md
@@ -710,12 +720,13 @@ function Write-PluginDirectory {
710720
Get-Content -Path $collectionMdPath -Raw
711721
} else { $null }
712722
$readmeContent = New-PluginReadmeContent -Collection $Collection -Items $readmeItems -Maturity $Maturity -CollectionContent $collectionContent
723+
[void]$generatedFiles.Add($readmePath)
713724

714725
if ($DryRun) {
715726
Write-Verbose "DryRun: Would write README.md at $readmePath"
716727
}
717728
else {
718-
Set-Content -Path $readmePath -Value $readmeContent -Encoding utf8 -NoNewline
729+
Set-ContentIfChanged -Path $readmePath -Value $readmeContent | Out-Null
719730
}
720731

721732
return @{
@@ -724,6 +735,7 @@ function Write-PluginDirectory {
724735
CommandCount = $counts.CommandCount
725736
InstructionCount = $counts.InstructionCount
726737
SkillCount = $counts.SkillCount
738+
GeneratedFiles = $generatedFiles
727739
}
728740
}
729741

@@ -776,15 +788,23 @@ function Repair-PluginSymlinkIndex {
776788
$trackedPaths = [System.Collections.Generic.HashSet[string]]::new(
777789
[System.StringComparer]::OrdinalIgnoreCase
778790
)
791+
$alreadySymlink = [System.Collections.Generic.HashSet[string]]::new(
792+
[System.StringComparer]::OrdinalIgnoreCase
793+
)
779794
$pluginsRel = [System.IO.Path]::GetRelativePath($RepoRoot, $PluginsDir) -replace '\\', '/'
780-
$lsOutput = git ls-files -- $pluginsRel 2>$null
795+
$lsOutput = git ls-files --stage -- $pluginsRel 2>$null
781796
if ($lsOutput) {
782-
foreach ($p in @($lsOutput)) { [void]$trackedPaths.Add($p) }
797+
foreach ($line in @($lsOutput)) {
798+
if ($line -match '^(\d+)\s+[0-9a-f]+\s+\d+\t(.+)$') {
799+
[void]$trackedPaths.Add($Matches[2])
800+
if ($Matches[1] -eq '120000') {
801+
[void]$alreadySymlink.Add($Matches[2])
802+
}
803+
}
804+
}
783805
}
784806

785807
$fixedCount = 0
786-
$newEntries = [System.Collections.Generic.List[PSCustomObject]]::new()
787-
$batchEntries = [System.Collections.Generic.List[string]]::new()
788808
$files = Get-ChildItem -Path $PluginsDir -File -Recurse
789809

790810
foreach ($file in $files) {
@@ -805,6 +825,10 @@ function Repair-PluginSymlinkIndex {
805825

806826
$repoRelPath = [System.IO.Path]::GetRelativePath($RepoRoot, $file.FullName) -replace '\\', '/'
807827

828+
if ($alreadySymlink.Contains($repoRelPath)) {
829+
continue
830+
}
831+
808832
if ($DryRun) {
809833
Write-Verbose "DryRun: Would fix index mode for $repoRelPath"
810834
$fixedCount++
@@ -824,33 +848,18 @@ function Repair-PluginSymlinkIndex {
824848
continue
825849
}
826850

827-
if ($trackedPaths.Contains($repoRelPath)) {
828-
$batchEntries.Add("120000 $sha`t$repoRelPath")
829-
} else {
830-
$newEntries.Add([PSCustomObject]@{ Sha = $sha; Path = $repoRelPath })
831-
}
832-
$fixedCount++
833-
Write-Verbose "Queued index fix: $repoRelPath -> 120000"
834-
}
835-
836-
# Add new/untracked files individually (typically few per run)
837-
foreach ($entry in $newEntries) {
838-
$cacheResult = git update-index --add --cacheinfo "120000,$($entry.Sha),$($entry.Path)" 2>&1
851+
# Use --add for untracked files; harmless for already-tracked entries.
852+
# Avoids --index-info piping which breaks on Windows due to CRLF stdin.
853+
$addFlag = if (-not $trackedPaths.Contains($repoRelPath)) { '--add' } else { $null }
854+
$cacheArgs = @('update-index') + @($addFlag | Where-Object { $_ }) + @('--cacheinfo', "120000,$sha,$repoRelPath")
855+
$cacheResult = & git @cacheArgs 2>&1
839856
if ($LASTEXITCODE -ne 0) {
840857
$errorMsg = @($cacheResult | ForEach-Object { $_.ToString() }) -join '; '
841-
Write-Warning "Failed to add index entry for $($entry.Path): $errorMsg"
842-
$fixedCount--
843-
}
844-
}
845-
846-
# Batch update existing entries in a single call to avoid index.lock contention
847-
if ($batchEntries.Count -gt 0) {
848-
$indexResult = $batchEntries | git update-index --index-info 2>&1
849-
if ($LASTEXITCODE -ne 0) {
850-
$errorMsg = @($indexResult | ForEach-Object { $_.ToString() }) -join '; '
851-
Write-Warning "Failed to update git index: $errorMsg"
852-
return 0
858+
Write-Warning "Failed to update index entry for ${repoRelPath}: $errorMsg"
859+
continue
853860
}
861+
$fixedCount++
862+
Write-Verbose "Fixed index mode: $repoRelPath -> 120000"
854863
}
855864

856865
return $fixedCount

scripts/tests/collections/CollectionHelpers.Tests.ps1

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#Requires -Modules Pester
1+
#Requires -Modules Pester
22
# Copyright (c) Microsoft Corporation.
33
# SPDX-License-Identifier: MIT
44

@@ -562,3 +562,63 @@ display:
562562
$output | Should -Match 'items: \[\]' -Because 'DryRun should not modify the file'
563563
}
564564
}
565+
566+
Describe 'Set-ContentIfChanged' {
567+
BeforeAll {
568+
$script:testDir = Join-Path $TestDrive ([System.Guid]::NewGuid().ToString())
569+
New-Item -ItemType Directory -Path $script:testDir -Force | Out-Null
570+
}
571+
572+
It 'Creates file when it does not exist' {
573+
$path = Join-Path $script:testDir 'new-file.txt'
574+
$result = Set-ContentIfChanged -Path $path -Value 'hello'
575+
$result | Should -BeTrue
576+
Get-Content -Path $path -Raw -Encoding utf8 | Should -Be 'hello'
577+
}
578+
579+
It 'Skips write when content is identical' {
580+
$path = Join-Path $script:testDir 'same-content.txt'
581+
Set-Content -Path $path -Value 'unchanged' -Encoding utf8 -NoNewline
582+
$before = (Get-Item -LiteralPath $path).LastWriteTimeUtc
583+
Start-Sleep -Milliseconds 50
584+
$result = Set-ContentIfChanged -Path $path -Value 'unchanged'
585+
$result | Should -BeFalse
586+
(Get-Item -LiteralPath $path).LastWriteTimeUtc | Should -Be $before
587+
}
588+
589+
It 'Overwrites when content differs' {
590+
$path = Join-Path $script:testDir 'diff-content.txt'
591+
Set-Content -Path $path -Value 'old' -Encoding utf8 -NoNewline
592+
$result = Set-ContentIfChanged -Path $path -Value 'new'
593+
$result | Should -BeTrue
594+
Get-Content -Path $path -Raw -Encoding utf8 | Should -Be 'new'
595+
}
596+
597+
It 'Case-sensitive comparison triggers write' {
598+
$path = Join-Path $script:testDir 'case-sensitive.txt'
599+
Set-Content -Path $path -Value 'Hello' -Encoding utf8 -NoNewline
600+
$result = Set-ContentIfChanged -Path $path -Value 'hello'
601+
$result | Should -BeTrue
602+
Get-Content -Path $path -Raw -Encoding utf8 | Should -Be 'hello'
603+
}
604+
605+
It 'Handles empty string content' {
606+
$path = Join-Path $script:testDir 'empty-content.txt'
607+
$result = Set-ContentIfChanged -Path $path -Value ''
608+
$result | Should -BeTrue
609+
[System.IO.File]::ReadAllText($path) | Should -Be ''
610+
}
611+
612+
It 'Writes UTF-8 without BOM and no trailing newline' {
613+
$path = Join-Path $script:testDir 'encoding-check.txt'
614+
Set-ContentIfChanged -Path $path -Value 'test content' | Out-Null
615+
$bytes = [System.IO.File]::ReadAllBytes($path)
616+
# UTF-8 BOM is 0xEF 0xBB 0xBF — first bytes must not match
617+
if ($bytes.Length -ge 3) {
618+
($bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) | Should -BeFalse
619+
}
620+
# No trailing newline
621+
$text = [System.IO.File]::ReadAllText($path)
622+
$text | Should -Be 'test content'
623+
}
624+
}

0 commit comments

Comments
 (0)