microsoft/hve-core

Public

mirrored fromhttps://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/fix-broken-file-references

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

scripts/plugins/Modules/PluginHelpers.psm1

820lines · modecode

1# Copyright (c) Microsoft Corporation.
2# SPDX-License-Identifier: MIT
3
4# PluginHelpers.psm1
5#
6# Purpose: Shared functions for the Copilot CLI plugin generation pipeline.
7# Author: HVE Core Team
8
9#Requires -Version 7.0
10
11Import-Module (Join-Path $PSScriptRoot '../../collections/Modules/CollectionHelpers.psm1') -Force
12
13# ---------------------------------------------------------------------------
14# Pure Functions (no file system side effects)
15# ---------------------------------------------------------------------------
16
17function Get-PluginItemName {
18 <#
19 .SYNOPSIS
20 Strips artifact-type suffix from a filename.
21
22 .DESCRIPTION
23 Removes the kind-specific suffix from a filename and returns the
24 simplified name with a .md extension (or the directory name for skills).
25
26 .PARAMETER FileName
27 The original filename (e.g. task-researcher.agent.md).
28
29 .PARAMETER Kind
30 The artifact kind: agent, prompt, instruction, or skill.
31
32 .OUTPUTS
33 [string] The simplified item name.
34 #>
35 [CmdletBinding()]
36 [OutputType([string])]
37 param(
38 [Parameter(Mandatory = $true)]
39 [string]$FileName,
40
41 [Parameter(Mandatory = $true)]
42 [ValidateSet('agent', 'prompt', 'instruction', 'skill')]
43 [string]$Kind
44 )
45
46 switch ($Kind) {
47 'agent' {
48 return ($FileName -replace '\.agent\.md$', '') + '.md'
49 }
50 'prompt' {
51 return ($FileName -replace '\.prompt\.md$', '') + '.md'
52 }
53 'instruction' {
54 return ($FileName -replace '\.instructions\.md$', '') + '.md'
55 }
56 'skill' {
57 return $FileName
58 }
59 }
60}
61
62function Get-PluginSubdirectory {
63 <#
64 .SYNOPSIS
65 Returns the plugin subdirectory name for an artifact kind.
66
67 .DESCRIPTION
68 Maps a collection item kind to the corresponding subdirectory name
69 within the plugin directory structure.
70
71 .PARAMETER Kind
72 The artifact kind: agent, prompt, instruction, or skill.
73
74 .OUTPUTS
75 [string] The subdirectory name (agents, commands, instructions, or skills).
76 #>
77 [CmdletBinding()]
78 [OutputType([string])]
79 param(
80 [Parameter(Mandatory = $true)]
81 [ValidateSet('agent', 'prompt', 'instruction', 'skill')]
82 [string]$Kind
83 )
84
85 switch ($Kind) {
86 'agent' { return 'agents' }
87 'prompt' { return 'commands' }
88 'instruction' { return 'instructions' }
89 'skill' { return 'skills' }
90 }
91}
92
93function New-PluginManifestContent {
94 <#
95 .SYNOPSIS
96 Generates plugin.json content as a hashtable.
97
98 .DESCRIPTION
99 Creates a hashtable representing the plugin manifest with name,
100 description, and version sourced from the repository package.json.
101
102 .PARAMETER CollectionId
103 The collection identifier used as the plugin name.
104
105 .PARAMETER Description
106 A short description of the plugin.
107
108 .PARAMETER Version
109 Semantic version string from the repository package.json.
110
111 .OUTPUTS
112 [hashtable] Plugin manifest with name, description, and version keys.
113 #>
114 [CmdletBinding()]
115 [OutputType([hashtable])]
116 param(
117 [Parameter(Mandatory = $true)]
118 [string]$CollectionId,
119
120 [Parameter(Mandatory = $true)]
121 [string]$Description,
122
123 [Parameter(Mandatory = $true)]
124 [string]$Version
125 )
126
127 return [ordered]@{
128 name = $CollectionId
129 description = $Description
130 version = $Version
131 }
132}
133
134function New-PluginReadmeContent {
135 <#
136 .SYNOPSIS
137 Generates README.md markdown for a plugin.
138
139 .DESCRIPTION
140 Builds a complete README.md string with a markdownlint-disable header,
141 title, description, install command, and tables for each artifact kind
142 that has items. Only sections with items are included.
143
144 .PARAMETER Collection
145 Hashtable with id, name, and description keys from the collection manifest.
146
147 .PARAMETER Items
148 Array of processed item objects. Each object must have Name, Description,
149 and Kind properties.
150
151 .PARAMETER Maturity
152 Optional collection-level maturity string. When 'experimental', an
153 experimental notice is injected after the description.
154
155 .OUTPUTS
156 [string] Complete README markdown content.
157 #>
158 [CmdletBinding()]
159 [OutputType([string])]
160 param(
161 [Parameter(Mandatory = $true)]
162 [hashtable]$Collection,
163
164 [Parameter(Mandatory = $true)]
165 [AllowEmptyCollection()]
166 [array]$Items,
167
168 [Parameter(Mandatory = $false)]
169 [AllowNull()]
170 [AllowEmptyString()]
171 [string]$Maturity
172 )
173
174 $sb = [System.Text.StringBuilder]::new()
175 [void]$sb.AppendLine('<!-- markdownlint-disable-file -->')
176 [void]$sb.AppendLine("# $($Collection.name)")
177 [void]$sb.AppendLine()
178 [void]$sb.AppendLine($Collection.description)
179
180 # Inject experimental notice when collection is experimental
181 $effectiveMaturity = if ([string]::IsNullOrWhiteSpace($Maturity)) { 'stable' } else { $Maturity }
182 if ($effectiveMaturity -eq 'experimental') {
183 [void]$sb.AppendLine()
184 [void]$sb.AppendLine("> **`u{26A0}`u{FE0F} Experimental** `u{2014} This collection is experimental. Contents and behavior may change or be removed without notice.")
185 }
186
187 [void]$sb.AppendLine()
188 [void]$sb.AppendLine('## Install')
189 [void]$sb.AppendLine()
190 [void]$sb.AppendLine('```bash')
191 [void]$sb.AppendLine("copilot plugin install $($Collection.id)@hve-core")
192 [void]$sb.AppendLine('```')
193
194 $sectionMap = [ordered]@{
195 agent = @{ Title = 'Agents'; Header = 'Agent' }
196 prompt = @{ Title = 'Commands'; Header = 'Command' }
197 instruction = @{ Title = 'Instructions'; Header = 'Instruction' }
198 skill = @{ Title = 'Skills'; Header = 'Skill' }
199 }
200
201 foreach ($entry in $sectionMap.GetEnumerator()) {
202 $kind = $entry.Key
203 $meta = $entry.Value
204 $kindItems = @($Items | Where-Object { $_.Kind -eq $kind })
205 if ($kindItems.Count -eq 0) {
206 continue
207 }
208
209 [void]$sb.AppendLine()
210 [void]$sb.AppendLine("## $($meta.Title)")
211 [void]$sb.AppendLine()
212 [void]$sb.AppendLine("| $($meta.Header) | Description |")
213 [void]$sb.AppendLine('| ' + ('-' * $meta.Header.Length) + ' | ----------- |')
214 foreach ($item in $kindItems) {
215 [void]$sb.AppendLine("| $($item.Name) | $($item.Description) |")
216 }
217 }
218
219 [void]$sb.AppendLine()
220 [void]$sb.AppendLine('---')
221 [void]$sb.AppendLine()
222 [void]$sb.AppendLine('> Source: [microsoft/hve-core](https://github.com/microsoft/hve-core)')
223 [void]$sb.AppendLine()
224
225 return $sb.ToString()
226}
227
228function New-MarketplaceManifestContent {
229 <#
230 .SYNOPSIS
231 Generates marketplace.json content as a hashtable.
232
233 .DESCRIPTION
234 Creates a hashtable representing the marketplace manifest with repository
235 metadata, owner information, and plugin entries. Matches the schema used
236 by github/awesome-copilot.
237
238 .PARAMETER RepoName
239 Repository name used as the marketplace name.
240
241 .PARAMETER Description
242 Short description of the repository.
243
244 .PARAMETER Version
245 Semantic version string from package.json.
246
247 .PARAMETER OwnerName
248 Organization or individual owning the repository.
249
250 .PARAMETER Plugins
251 Array of ordered hashtables with name, description, and version keys
252 from New-PluginManifestContent.
253
254 .OUTPUTS
255 [hashtable] Marketplace manifest with name, metadata, owner, and plugins keys.
256 #>
257 [CmdletBinding()]
258 [OutputType([hashtable])]
259 param(
260 [Parameter(Mandatory = $true)]
261 [string]$RepoName,
262
263 [Parameter(Mandatory = $true)]
264 [string]$Description,
265
266 [Parameter(Mandatory = $true)]
267 [string]$Version,
268
269 [Parameter(Mandatory = $true)]
270 [string]$OwnerName,
271
272 [Parameter(Mandatory = $true)]
273 [AllowEmptyCollection()]
274 [array]$Plugins
275 )
276
277 $pluginEntries = @()
278 foreach ($plugin in $Plugins) {
279 $pluginEntries += [ordered]@{
280 name = $plugin.name
281 source = $plugin.name
282 description = $plugin.description
283 version = $plugin.version
284 }
285 }
286
287 return [ordered]@{
288 name = $RepoName
289 metadata = [ordered]@{
290 description = $Description
291 version = $Version
292 pluginRoot = './plugins'
293 }
294 owner = [ordered]@{
295 name = $OwnerName
296 }
297 plugins = $pluginEntries
298 }
299}
300
301function Write-MarketplaceManifest {
302 <#
303 .SYNOPSIS
304 Writes the marketplace.json file to .github/plugin/.
305
306 .DESCRIPTION
307 Assembles plugin metadata from generated collections and writes the
308 marketplace manifest to .github/plugin/marketplace.json. Creates the
309 directory when it does not exist.
310
311 .PARAMETER RepoRoot
312 Absolute path to the repository root directory.
313
314 .PARAMETER Collections
315 Array of collection manifest hashtables with id and description.
316
317 .PARAMETER DryRun
318 When specified, logs the action without writing to disk.
319 #>
320 [CmdletBinding()]
321 param(
322 [Parameter(Mandatory = $true)]
323 [ValidateNotNullOrEmpty()]
324 [string]$RepoRoot,
325
326 [Parameter(Mandatory = $true)]
327 [AllowEmptyCollection()]
328 [array]$Collections,
329
330 [Parameter(Mandatory = $false)]
331 [switch]$DryRun
332 )
333
334 $packageJsonPath = Join-Path -Path $RepoRoot -ChildPath 'package.json'
335 $packageJson = Get-Content -Path $packageJsonPath -Raw | ConvertFrom-Json
336
337 $plugins = @()
338 foreach ($collection in ($Collections | Sort-Object { $_.id })) {
339 $plugins += New-PluginManifestContent `
340 -CollectionId $collection.id `
341 -Description $collection.description `
342 -Version $packageJson.version
343 }
344
345 $manifest = New-MarketplaceManifestContent `
346 -RepoName $packageJson.name `
347 -Description $packageJson.description `
348 -Version $packageJson.version `
349 -OwnerName $packageJson.author `
350 -Plugins $plugins
351
352 $outputDir = Join-Path -Path $RepoRoot -ChildPath '.github' -AdditionalChildPath 'plugin'
353 $outputPath = Join-Path -Path $outputDir -ChildPath 'marketplace.json'
354
355 if ($DryRun) {
356 Write-Host " [DRY RUN] Would write marketplace.json at $outputPath" -ForegroundColor Yellow
357 return
358 }
359
360 if (-not (Test-Path -Path $outputDir)) {
361 New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
362 }
363
364 $manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $outputPath -Encoding utf8 -NoNewline
365 Write-Host " Marketplace manifest: $outputPath" -ForegroundColor Green
366}
367
368function New-GenerateResult {
369 <#
370 .SYNOPSIS
371 Creates a standardized result object.
372
373 .DESCRIPTION
374 Returns a hashtable representing the outcome of a plugin generation run
375 with success status, plugin count, and optional error message.
376
377 .PARAMETER Success
378 Whether the operation succeeded.
379
380 .PARAMETER PluginCount
381 Number of plugins generated.
382
383 .PARAMETER ErrorMessage
384 Optional error message when Success is $false.
385
386 .OUTPUTS
387 [hashtable] Result with Success, PluginCount, and ErrorMessage keys.
388 #>
389 [CmdletBinding()]
390 [OutputType([hashtable])]
391 param(
392 [Parameter(Mandatory = $true)]
393 [bool]$Success,
394
395 [Parameter(Mandatory = $true)]
396 [int]$PluginCount,
397
398 [Parameter(Mandatory = $false)]
399 [string]$ErrorMessage = ''
400 )
401
402 return @{
403 Success = $Success
404 PluginCount = $PluginCount
405 ErrorMessage = $ErrorMessage
406 }
407}
408
409# ---------------------------------------------------------------------------
410# I/O Functions (file system operations)
411# ---------------------------------------------------------------------------
412
413function Test-SymlinkCapability {
414 <#
415 .SYNOPSIS
416 Probes whether the current process can create symbolic links.
417
418 .DESCRIPTION
419 Creates a temporary file and attempts to symlink to it. Returns $true
420 when the OS and process privileges allow symlink creation, $false
421 otherwise. The probe directory is cleaned up unconditionally.
422 #>
423 [CmdletBinding()]
424 [OutputType([bool])]
425 param()
426
427 $tempDir = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "hve-symlink-probe-$PID"
428 $targetFile = Join-Path -Path $tempDir -ChildPath 'target.txt'
429 $linkFile = Join-Path -Path $tempDir -ChildPath 'link.txt'
430 try {
431 New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
432 Set-Content -Path $targetFile -Value 'probe' -NoNewline
433 New-Item -ItemType SymbolicLink -Path $linkFile -Target $targetFile -ErrorAction Stop | Out-Null
434 return $true
435 }
436 catch {
437 return $false
438 }
439 finally {
440 if (Test-Path -Path $tempDir) {
441 Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
442 }
443 }
444}
445
446function New-PluginLink {
447 <#
448 .SYNOPSIS
449 Links a source path into a plugin destination via symlink or text stub.
450
451 .DESCRIPTION
452 When SymlinkCapable is set, creates a relative symbolic link from
453 DestinationPath to SourcePath. Otherwise writes a text stub file
454 containing the relative path, matching the format git produces when
455 core.symlinks is false. Text stubs keep git status clean on Windows
456 without Developer Mode or elevated privileges.
457
458 .PARAMETER SourcePath
459 Absolute path to the real file or directory.
460
461 .PARAMETER DestinationPath
462 Absolute path where the link or text stub will be created.
463
464 .PARAMETER SymlinkCapable
465 When set, create a symbolic link; otherwise write a text stub.
466 #>
467 [CmdletBinding()]
468 param(
469 [Parameter(Mandatory = $true)]
470 [string]$SourcePath,
471
472 [Parameter(Mandatory = $true)]
473 [string]$DestinationPath,
474
475 [Parameter(Mandatory = $false)]
476 [switch]$SymlinkCapable
477 )
478
479 $destinationDir = Split-Path -Parent $DestinationPath
480 if (-not (Test-Path -Path $destinationDir)) {
481 New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null
482 }
483
484 $relativePath = [System.IO.Path]::GetRelativePath($destinationDir, $SourcePath) -replace '\\', '/'
485
486 if ($SymlinkCapable) {
487 New-Item -ItemType SymbolicLink -Path $DestinationPath -Value $relativePath -Force | Out-Null
488 }
489 else {
490 [System.IO.File]::WriteAllText($DestinationPath, $relativePath)
491 }
492}
493
494function Write-PluginDirectory {
495 <#
496 .SYNOPSIS
497 Creates a complete plugin directory structure from a collection.
498
499 .DESCRIPTION
500 Builds the full plugin layout under the specified plugins directory,
501 including subdirectories for agents, commands, instructions, and skills.
502 Each item is linked or copied from the plugin directory back to its
503 source in the repository. Generates plugin.json and README.md.
504
505 .PARAMETER Collection
506 Parsed collection manifest hashtable with id, name, description, and items.
507
508 .PARAMETER PluginsDir
509 Absolute path to the root plugins output directory.
510
511 .PARAMETER RepoRoot
512 Absolute path to the repository root.
513
514 .PARAMETER Version
515 Semantic version string from the repository package.json.
516
517 .PARAMETER Maturity
518 Optional collection-level maturity string. Forwarded to
519 New-PluginReadmeContent for experimental notice injection.
520
521 .PARAMETER DryRun
522 When specified, logs actions without creating files or directories.
523
524 .PARAMETER SymlinkCapable
525 When specified, creates symbolic links; otherwise copies files.
526
527 .OUTPUTS
528 [hashtable] Result with Success, AgentCount, CommandCount, InstructionCount,
529 and SkillCount keys.
530 #>
531 [CmdletBinding()]
532 [OutputType([hashtable])]
533 param(
534 [Parameter(Mandatory = $true)]
535 [hashtable]$Collection,
536
537 [Parameter(Mandatory = $true)]
538 [string]$PluginsDir,
539
540 [Parameter(Mandatory = $true)]
541 [string]$RepoRoot,
542
543 [Parameter(Mandatory = $true)]
544 [string]$Version,
545
546 [Parameter(Mandatory = $false)]
547 [AllowNull()]
548 [AllowEmptyString()]
549 [string]$Maturity,
550
551 [Parameter(Mandatory = $false)]
552 [switch]$DryRun,
553
554 [Parameter(Mandatory = $false)]
555 [switch]$SymlinkCapable
556 )
557
558 $collectionId = $Collection.id
559 $pluginRoot = Join-Path -Path $PluginsDir -ChildPath $collectionId
560
561 $counts = @{
562 AgentCount = 0
563 CommandCount = 0
564 InstructionCount = 0
565 SkillCount = 0
566 }
567
568 $readmeItems = @()
569
570 foreach ($item in $Collection.items) {
571 $kind = $item.kind
572 $sourcePath = Join-Path -Path $RepoRoot -ChildPath $item.path
573 $subdir = Get-PluginSubdirectory -Kind $kind
574
575 if ($kind -eq 'skill') {
576 # Skills are directory symlinks; use the directory name as FileName
577 $fileName = Split-Path -Leaf $item.path
578 $itemName = Get-PluginItemName -FileName $fileName -Kind $kind
579 $destPath = Join-Path -Path $pluginRoot -ChildPath $subdir -AdditionalChildPath $itemName
580 $description = $fileName
581 }
582 else {
583 $fileName = Split-Path -Leaf $item.path
584 $itemName = Get-PluginItemName -FileName $fileName -Kind $kind
585 $destPath = Join-Path -Path $pluginRoot -ChildPath $subdir -AdditionalChildPath $itemName
586
587 # Read frontmatter from the source file for description
588 $fallback = $itemName -replace '\.md$', ''
589 if (Test-Path -Path $sourcePath) {
590 $frontmatter = Get-ArtifactFrontmatter -FilePath $sourcePath -FallbackDescription $fallback
591 $description = $frontmatter.description
592 }
593 else {
594 $description = $fallback
595 Write-Warning "Source file not found: $sourcePath"
596 }
597 }
598
599 $readmeItems += @{
600 Name = $itemName -replace '\.md$', ''
601 Description = $description
602 Kind = $kind
603 }
604
605 # Update counts
606 switch ($kind) {
607 'agent' { $counts.AgentCount++ }
608 'prompt' { $counts.CommandCount++ }
609 'instruction' { $counts.InstructionCount++ }
610 'skill' { $counts.SkillCount++ }
611 }
612
613 if ($DryRun) {
614 Write-Verbose "DryRun: Would create link $destPath -> $sourcePath"
615 continue
616 }
617
618 New-PluginLink -SourcePath $sourcePath -DestinationPath $destPath -SymlinkCapable:$SymlinkCapable
619 }
620
621 # Link shared resource directories (unconditional, all plugins)
622 $sharedDirs = @(
623 @{ Source = 'docs/templates'; Destination = 'docs/templates' }
624 @{ Source = 'scripts/lib'; Destination = 'scripts/lib' }
625 )
626
627 foreach ($dir in $sharedDirs) {
628 $sourcePath = Join-Path -Path $RepoRoot -ChildPath $dir.Source
629 $destPath = Join-Path -Path $pluginRoot -ChildPath $dir.Destination
630
631 if (-not (Test-Path -Path $sourcePath)) {
632 Write-Warning "Shared directory not found: $sourcePath"
633 continue
634 }
635
636 if ($DryRun) {
637 Write-Verbose "DryRun: Would create shared directory link $destPath -> $sourcePath"
638 continue
639 }
640
641 New-PluginLink -SourcePath $sourcePath -DestinationPath $destPath -SymlinkCapable:$SymlinkCapable
642 }
643
644 # Generate plugin.json
645 $manifestDir = Join-Path -Path $pluginRoot -ChildPath '.github' -AdditionalChildPath 'plugin'
646 $manifestPath = Join-Path -Path $manifestDir -ChildPath 'plugin.json'
647 $manifest = New-PluginManifestContent -CollectionId $collectionId -Description $Collection.description -Version $Version
648
649 if ($DryRun) {
650 Write-Verbose "DryRun: Would write plugin.json at $manifestPath"
651 }
652 else {
653 if (-not (Test-Path -Path $manifestDir)) {
654 New-Item -ItemType Directory -Path $manifestDir -Force | Out-Null
655 }
656 $manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $manifestPath -Encoding utf8 -NoNewline
657 }
658
659 # Generate README.md
660 $readmePath = Join-Path -Path $pluginRoot -ChildPath 'README.md'
661 $readmeContent = New-PluginReadmeContent -Collection $Collection -Items $readmeItems -Maturity $Maturity
662
663 if ($DryRun) {
664 Write-Verbose "DryRun: Would write README.md at $readmePath"
665 }
666 else {
667 Set-Content -Path $readmePath -Value $readmeContent -Encoding utf8 -NoNewline
668 }
669
670 return @{
671 Success = $true
672 AgentCount = $counts.AgentCount
673 CommandCount = $counts.CommandCount
674 InstructionCount = $counts.InstructionCount
675 SkillCount = $counts.SkillCount
676 }
677}
678
679function Repair-PluginSymlinkIndex {
680 <#
681 .SYNOPSIS
682 Fixes git index modes for text stub files so they register as symlinks.
683
684 .DESCRIPTION
685 On systems where symlinks are unavailable (Windows without Developer Mode),
686 New-PluginLink writes text stubs containing relative paths. Git stages
687 these as mode 100644 (regular file). This function re-indexes each text
688 stub as mode 120000 (symlink) so that Linux/macOS checkouts materialize
689 real symbolic links.
690
691 .PARAMETER PluginsDir
692 Absolute path to the plugins output directory.
693
694 .PARAMETER RepoRoot
695 Absolute path to the repository root (git working tree).
696
697 .PARAMETER DryRun
698 When specified, logs what would be fixed without modifying the index.
699
700 .OUTPUTS
701 [int] Number of index entries corrected.
702 #>
703 [CmdletBinding()]
704 [OutputType([int])]
705 param(
706 [Parameter(Mandatory = $true)]
707 [ValidateNotNullOrEmpty()]
708 [string]$PluginsDir,
709
710 [Parameter(Mandatory = $true)]
711 [ValidateNotNullOrEmpty()]
712 [string]$RepoRoot,
713
714 [Parameter(Mandatory = $false)]
715 [switch]$DryRun
716 )
717
718 if (-not (Test-Path -Path $PluginsDir)) {
719 return 0
720 }
721
722 # Build a set of paths already tracked in the git index under plugins/.
723 # --index-info silently ignores untracked paths (PowerShell pipe encoding
724 # issue), so new files must be added individually via --cacheinfo.
725 $trackedPaths = [System.Collections.Generic.HashSet[string]]::new(
726 [System.StringComparer]::OrdinalIgnoreCase
727 )
728 $pluginsRel = [System.IO.Path]::GetRelativePath($RepoRoot, $PluginsDir) -replace '\\', '/'
729 $lsOutput = git ls-files -- $pluginsRel 2>$null
730 if ($lsOutput) {
731 foreach ($p in @($lsOutput)) { [void]$trackedPaths.Add($p) }
732 }
733
734 $fixedCount = 0
735 $newEntries = [System.Collections.Generic.List[PSCustomObject]]::new()
736 $batchEntries = [System.Collections.Generic.List[string]]::new()
737 $files = Get-ChildItem -Path $PluginsDir -File -Recurse
738
739 foreach ($file in $files) {
740 # Text stubs are small files whose content is a relative path with
741 # forward slashes, no line breaks, starting with ../
742 if ($file.Length -gt 500) {
743 continue
744 }
745
746 $content = [System.IO.File]::ReadAllText($file.FullName)
747
748 if ($content -notmatch '^\.\./') {
749 continue
750 }
751 if ($content.Contains("`n") -or $content.Contains("`r")) {
752 continue
753 }
754
755 $repoRelPath = [System.IO.Path]::GetRelativePath($RepoRoot, $file.FullName) -replace '\\', '/'
756
757 if ($DryRun) {
758 Write-Verbose "DryRun: Would fix index mode for $repoRelPath"
759 $fixedCount++
760 continue
761 }
762
763 $hashOutput = git hash-object -w -- $file.FullName 2>&1
764 if ($LASTEXITCODE -ne 0) {
765 Write-Warning "Failed to hash-object for $repoRelPath"
766 continue
767 }
768
769 # Extract clean SHA string, filtering out any ErrorRecord objects
770 $sha = @($hashOutput | Where-Object { $_ -is [string] -and $_ -match '^[0-9a-f]{40}' })[0]
771 if (-not $sha) {
772 Write-Warning "No valid SHA returned for $repoRelPath"
773 continue
774 }
775
776 if ($trackedPaths.Contains($repoRelPath)) {
777 $batchEntries.Add("120000 $sha`t$repoRelPath")
778 } else {
779 $newEntries.Add([PSCustomObject]@{ Sha = $sha; Path = $repoRelPath })
780 }
781 $fixedCount++
782 Write-Verbose "Queued index fix: $repoRelPath -> 120000"
783 }
784
785 # Add new/untracked files individually (typically few per run)
786 foreach ($entry in $newEntries) {
787 $cacheResult = git update-index --add --cacheinfo "120000,$($entry.Sha),$($entry.Path)" 2>&1
788 if ($LASTEXITCODE -ne 0) {
789 $errorMsg = @($cacheResult | ForEach-Object { $_.ToString() }) -join '; '
790 Write-Warning "Failed to add index entry for $($entry.Path): $errorMsg"
791 $fixedCount--
792 }
793 }
794
795 # Batch update existing entries in a single call to avoid index.lock contention
796 if ($batchEntries.Count -gt 0) {
797 $indexResult = $batchEntries | git update-index --index-info 2>&1
798 if ($LASTEXITCODE -ne 0) {
799 $errorMsg = @($indexResult | ForEach-Object { $_.ToString() }) -join '; '
800 Write-Warning "Failed to update git index: $errorMsg"
801 return 0
802 }
803 }
804
805 return $fixedCount
806}
807
808Export-ModuleMember -Function @(
809 'Get-PluginItemName',
810 'Get-PluginSubdirectory',
811 'New-GenerateResult',
812 'New-MarketplaceManifestContent',
813 'New-PluginLink',
814 'New-PluginManifestContent',
815 'New-PluginReadmeContent',
816 'Repair-PluginSymlinkIndex',
817 'Test-SymlinkCapability',
818 'Write-MarketplaceManifest',
819 'Write-PluginDirectory'
820)