microsoft/hve-core

Public

mirrored from https://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
7956ca698dfb9e6612525bbda2dd0cd22c7c8fe9

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/plugins/Modules/PluginHelpers.psm1

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