@danmoisan/drm-copilot-mcp 1.1.9 → 1.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/out/mcp-server.js +3526 -1037
- package/package.json +1 -1
- package/resources/claude-customizations/.claude/agents/parallel-orchestrator.md +15 -2
- package/resources/claude-customizations/.claude/agents/parallel-planner.md +3 -0
- package/resources/claude-customizations/.claude/hooks/enforce-epic-merge-gate.ps1 +49 -14
- package/resources/claude-customizations/.claude/hooks/enforce-epic-worktree-removal-gate.ps1 +52 -3
- package/resources/claude-customizations/.claude/hooks/enforce-orchestration-preimplementation-gate.ps1 +7 -1
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-abandon-gate.ps1 +103 -6
- package/resources/claude-customizations/.claude/hooks/enforce-parallel-worktree-removal-gate.ps1 +58 -3
- package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill-helpers.ps1 +37 -5
- package/resources/claude-customizations/.claude/hooks/enforce-pr-author-skill.epic-base-branch.ps1 +13 -2
- package/resources/claude-customizations/.claude/hooks/enforce-promotion-mcp-only.ps1 +35 -7
- package/resources/claude-customizations/.claude/hooks/hook-command-invocation.ps1 +483 -0
- package/resources/claude-customizations/.claude/hooks/hook-command-scanner.ps1 +483 -0
- package/resources/claude-customizations/.claude/hooks/validate-bash.ps1 +254 -5
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadius.psm1 +18 -75
- package/resources/claude-customizations/.claude/lib/blast-radius/BlastRadiusConflict.psm1 +290 -0
- package/resources/claude-customizations/.claude/lib/cleanup-manifest/CleanupWorktreeManifest.psm1 +415 -0
- package/resources/claude-customizations/.claude/lib/project-file-merge/ProjectFileMerge.psm1 +355 -0
- package/resources/claude-customizations/.claude/lib/project-file-merge/ProjectFileMergeGrammar.psm1 +318 -0
- package/resources/claude-customizations/.claude/lib/project-file-merge/Resolve-MergeableConflict.ps1 +229 -0
- package/resources/claude-customizations/.claude/rules/parallel-orchestration.md +66 -2
- package/resources/claude-customizations/.claude/skills/cleanup-merged-worktrees/SKILL.md +311 -16
- package/resources/claude-customizations/.claude/skills/orchestrate/SKILL.md +42 -0
- package/resources/claude-customizations/.claude/skills/parallel-add/SKILL.md +3 -1
- package/resources/claude-customizations/.claude/skills/parallel-orchestrate/SKILL.md +36 -2
- package/resources/claude-customizations/.claude/skills/parallel-plan/SKILL.md +6 -0
- package/resources/claude-customizations/.claude/skills/powershell-orchestration-state-machine/SKILL.md +29 -1
- package/resources/claude-customizations/config/blast-radius.json +7 -0
- package/resources/claude-customizations/pack-manifests/core.json +7 -0
- package/resources/codex-and-agents-customizations/.agents/skills/orchestrate/SKILL.md +41 -0
- package/resources/codex-and-agents-customizations/.agents/skills/orchestrator-state/SKILL.md +41 -0
- package/resources/codex-and-agents-customizations/.agents/skills/repo-automation-adapter/SKILL.md +26 -0
- package/resources/codex-and-agents-customizations/.codex/config.toml +1 -1
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-merge-gate.ps1 +49 -3
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-planning-only.ps1 +57 -12
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-epic-worktree-removal-gate.ps1 +34 -8
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-orchestration-preimplementation-gate.ps1 +6 -1
- package/resources/codex-and-agents-customizations/.codex/hooks/enforce-promotion-mcp-only.ps1 +34 -7
- package/resources/codex-and-agents-customizations/.codex/hooks/hook-command-invocation.ps1 +483 -0
- package/resources/codex-and-agents-customizations/.codex/hooks/hook-command-scanner.ps1 +483 -0
- package/resources/codex-and-agents-customizations/.codex/hooks/validate-bash.ps1 +130 -2
- package/resources/codex-and-agents-customizations/pack-manifests/core.json +6 -1
- package/resources/config/orchestration-handoff-registry.json +138 -0
- package/resources/config/orchestration-handoff.schema.json +472 -0
- package/resources/powershell/PoshQC/settings/pester.runsettings.psd1 +38 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Keyed-union resolution of conflicted project-file text (issue #643).
|
|
4
|
+
|
|
5
|
+
.DESCRIPTION
|
|
6
|
+
Splits a conflicted file into hunks, parses each side with the grammar
|
|
7
|
+
module, and rebuilds the file as the keyed union of the two sides: ours
|
|
8
|
+
entries in ours order, then theirs entries whose key ours does not carry.
|
|
9
|
+
|
|
10
|
+
Nothing here is an XML operation. A line the merge keeps is the byte-for-byte
|
|
11
|
+
line one side wrote, so indentation, attribute order, and line terminators
|
|
12
|
+
survive; the one exception is the app.config oldVersion upper bound, which is
|
|
13
|
+
rewritten in place so the retained redirect still covers the chosen version.
|
|
14
|
+
|
|
15
|
+
The merge refuses far more often than it resolves. A side with any line
|
|
16
|
+
outside the grammar, a version pair that does not rank, and a key carried at
|
|
17
|
+
one version by both sides with different attributes all escalate, because a
|
|
18
|
+
mechanical union of a conflict nobody understands is worse than a human one.
|
|
19
|
+
CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop.
|
|
20
|
+
#>
|
|
21
|
+
|
|
22
|
+
Set-StrictMode -Version Latest
|
|
23
|
+
$ErrorActionPreference = 'Stop'
|
|
24
|
+
|
|
25
|
+
Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'ProjectFileMergeGrammar.psm1') -Force -ErrorAction Stop
|
|
26
|
+
|
|
27
|
+
# Conflict markers. git writes seven characters; the base section appears only in
|
|
28
|
+
# the diff3 and zdiff3 styles, and the separator line carries nothing else.
|
|
29
|
+
$script:HunkStartPattern = '^<{7}'
|
|
30
|
+
$script:HunkBasePattern = '^\|{7}'
|
|
31
|
+
$script:HunkSplitPattern = '^={7}\s*$'
|
|
32
|
+
$script:HunkEndPattern = '^>{7}'
|
|
33
|
+
|
|
34
|
+
# Widest window Get-UnitKeySet offers the grammar when scanning a whole file. A
|
|
35
|
+
# paired item and a dependentAssembly block are both far shorter than this.
|
|
36
|
+
$script:MaximumUnitSpan = 16
|
|
37
|
+
|
|
38
|
+
# The app.config redirect range, whose upper bound follows the chosen newVersion.
|
|
39
|
+
$script:OldVersionRangePattern = '(oldVersion=")([^"-]*)(-)([^"]*)(")'
|
|
40
|
+
|
|
41
|
+
function Get-ConflictHunk {
|
|
42
|
+
<#
|
|
43
|
+
.SYNOPSIS
|
|
44
|
+
Split conflicted text into its conflict hunks.
|
|
45
|
+
.DESCRIPTION
|
|
46
|
+
Accepts the merge style and the diff3/zdiff3 style, whose extra |||||||
|
|
47
|
+
section carries the base text. Indices are zero-based positions in Line.
|
|
48
|
+
.PARAMETER Line
|
|
49
|
+
The whole conflicted file, each line retaining its own terminator.
|
|
50
|
+
.OUTPUTS
|
|
51
|
+
System.Object[] of hunks carrying Start, End, Ours, Theirs, and Base, or
|
|
52
|
+
$null when a hunk opens and no closing marker follows it.
|
|
53
|
+
#>
|
|
54
|
+
[CmdletBinding()]
|
|
55
|
+
[OutputType([System.Object[]])]
|
|
56
|
+
param([Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Line)
|
|
57
|
+
|
|
58
|
+
$hunk = [System.Collections.Generic.List[object]]::new()
|
|
59
|
+
$index = 0
|
|
60
|
+
while ($index -lt $Line.Count) {
|
|
61
|
+
# Everything outside a hunk is context and is not collected here.
|
|
62
|
+
if (-not [regex]::IsMatch($Line[$index], $script:HunkStartPattern)) { $index++; continue }
|
|
63
|
+
$start = $index
|
|
64
|
+
$index++
|
|
65
|
+
$ours = [System.Collections.Generic.List[string]]::new()
|
|
66
|
+
$base = [System.Collections.Generic.List[string]]::new()
|
|
67
|
+
$theirs = [System.Collections.Generic.List[string]]::new()
|
|
68
|
+
$section = 'ours'
|
|
69
|
+
$end = -1
|
|
70
|
+
while ($index -lt $Line.Count) {
|
|
71
|
+
$text = $Line[$index]
|
|
72
|
+
$index++
|
|
73
|
+
# The two mid-hunk markers switch sections and carry no content.
|
|
74
|
+
if ([regex]::IsMatch($text, $script:HunkBasePattern)) { $section = 'base'; continue }
|
|
75
|
+
if ([regex]::IsMatch($text, $script:HunkSplitPattern)) { $section = 'theirs'; continue }
|
|
76
|
+
if ([regex]::IsMatch($text, $script:HunkEndPattern)) { $end = $index - 1; break }
|
|
77
|
+
if ($section -eq 'ours') { $ours.Add($text) } elseif ($section -eq 'base') { $base.Add($text) } else { $theirs.Add($text) }
|
|
78
|
+
}
|
|
79
|
+
# A hunk that never closes means the file is not a git conflict result.
|
|
80
|
+
if ($end -lt 0) { return $null }
|
|
81
|
+
$hunk.Add([pscustomobject]@{
|
|
82
|
+
Start = $start
|
|
83
|
+
End = $end
|
|
84
|
+
Ours = $ours.ToArray()
|
|
85
|
+
Theirs = $theirs.ToArray()
|
|
86
|
+
Base = $base.ToArray()
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
return , $hunk.ToArray()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function Get-UnitKeySet {
|
|
93
|
+
<#
|
|
94
|
+
.SYNOPSIS
|
|
95
|
+
Collect the unit keys a complete file text carries.
|
|
96
|
+
.DESCRIPTION
|
|
97
|
+
Offers the grammar a widening window at each position and skips a line no
|
|
98
|
+
window parses, so surrounding project structure contributes no key.
|
|
99
|
+
.PARAMETER Line
|
|
100
|
+
A complete file text, each line retaining its own terminator.
|
|
101
|
+
.PARAMETER Kind
|
|
102
|
+
'msbuild', 'packages', or 'appconfig'.
|
|
103
|
+
.OUTPUTS
|
|
104
|
+
System.Object[] of keys, deduplicated and ordinally sorted.
|
|
105
|
+
#>
|
|
106
|
+
[CmdletBinding()]
|
|
107
|
+
[OutputType([System.Object[]])]
|
|
108
|
+
param(
|
|
109
|
+
[Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Line,
|
|
110
|
+
[Parameter(Mandatory = $true)][AllowEmptyString()][string] $Kind
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
$key = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
|
|
114
|
+
$index = 0
|
|
115
|
+
while ($index -lt $Line.Count) {
|
|
116
|
+
$limit = [System.Math]::Min($Line.Count - $index, $script:MaximumUnitSpan)
|
|
117
|
+
$span = 1
|
|
118
|
+
$consumed = 0
|
|
119
|
+
while ($span -le $limit) {
|
|
120
|
+
$unit = Get-MergeableUnit -Line @($Line[$index..($index + $span - 1)]) -Kind $Kind
|
|
121
|
+
# A parse of zero units is a run of blank lines, not a unit boundary.
|
|
122
|
+
if ($null -ne $unit -and $unit.Count -ge 1) {
|
|
123
|
+
foreach ($entry in $unit) { [void] $key.Add($entry.Key) }
|
|
124
|
+
$consumed = $span
|
|
125
|
+
break
|
|
126
|
+
}
|
|
127
|
+
$span++
|
|
128
|
+
}
|
|
129
|
+
# No window parsed here, so this line is structure and is stepped over.
|
|
130
|
+
$index += [System.Math]::Max($consumed, 1)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
$sorted = [System.Collections.Generic.List[string]]::new($key)
|
|
134
|
+
$sorted.Sort([StringComparer]::Ordinal)
|
|
135
|
+
return , $sorted.ToArray()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function ConvertTo-MergeOutcome {
|
|
139
|
+
<#
|
|
140
|
+
.SYNOPSIS
|
|
141
|
+
Build the result record Merge-ConflictedText returns.
|
|
142
|
+
.DESCRIPTION
|
|
143
|
+
A non-null Reason is what makes the record an escalation, so the caller
|
|
144
|
+
never has to keep the flag and the reason in step by hand.
|
|
145
|
+
#>
|
|
146
|
+
[CmdletBinding()]
|
|
147
|
+
[OutputType([pscustomobject])]
|
|
148
|
+
param(
|
|
149
|
+
[AllowEmptyCollection()][AllowEmptyString()][string[]] $Line = @(),
|
|
150
|
+
[AllowEmptyCollection()][AllowEmptyString()][string[]] $FromOurs = @(),
|
|
151
|
+
[AllowEmptyCollection()][AllowEmptyString()][string[]] $FromTheirs = @(),
|
|
152
|
+
[AllowEmptyCollection()][object[]] $Resolution = @(),
|
|
153
|
+
[AllowNull()][AllowEmptyString()][string] $Reason = $null
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return [pscustomobject]@{
|
|
157
|
+
Lines = $Line
|
|
158
|
+
EntriesAddedFromOurs = $FromOurs
|
|
159
|
+
EntriesAddedFromTheirs = $FromTheirs
|
|
160
|
+
VersionResolutions = $Resolution
|
|
161
|
+
Escalate = -not [string]::IsNullOrEmpty($Reason)
|
|
162
|
+
EscalateReason = if ([string]::IsNullOrEmpty($Reason)) { $null } else { $Reason }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function Test-UnitEquivalent {
|
|
167
|
+
<#
|
|
168
|
+
.SYNOPSIS
|
|
169
|
+
Report whether two units with one key are byte-equivalent.
|
|
170
|
+
.DESCRIPTION
|
|
171
|
+
Compares the keying element's attributes and then the unit's whole line
|
|
172
|
+
run, so a differing metadata child is a difference like any other.
|
|
173
|
+
#>
|
|
174
|
+
[CmdletBinding()]
|
|
175
|
+
[OutputType([bool])]
|
|
176
|
+
param(
|
|
177
|
+
[Parameter(Mandatory = $true)][object] $Left,
|
|
178
|
+
[Parameter(Mandatory = $true)][object] $Right
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if ($Left.Attributes.Count -ne $Right.Attributes.Count) { return $false }
|
|
182
|
+
foreach ($name in $Left.Attributes.Keys) {
|
|
183
|
+
# A missing or differing attribute makes the two sides genuinely opposed.
|
|
184
|
+
if (-not $Right.Attributes.ContainsKey($name)) { return $false }
|
|
185
|
+
if (-not [string]::Equals($Left.Attributes[$name], $Right.Attributes[$name], [System.StringComparison]::Ordinal)) { return $false }
|
|
186
|
+
}
|
|
187
|
+
return [string]::Equals(($Left.Lines -join ''), ($Right.Lines -join ''), [System.StringComparison]::Ordinal)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function ConvertTo-RetargetedRedirectLine {
|
|
191
|
+
<#
|
|
192
|
+
.SYNOPSIS
|
|
193
|
+
Rewrite an app.config redirect's oldVersion upper bound.
|
|
194
|
+
.DESCRIPTION
|
|
195
|
+
The retained block was written for its own newVersion, so keeping the
|
|
196
|
+
higher newVersion without widening the range would leave the redirect
|
|
197
|
+
covering less than it claims.
|
|
198
|
+
#>
|
|
199
|
+
[CmdletBinding()]
|
|
200
|
+
[OutputType([System.Object[]])]
|
|
201
|
+
param(
|
|
202
|
+
[Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Line,
|
|
203
|
+
[Parameter(Mandatory = $true)][AllowEmptyString()][string] $NewVersion
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
$rewritten = [System.Collections.Generic.List[string]]::new()
|
|
207
|
+
foreach ($text in $Line) {
|
|
208
|
+
# Only the range form carries an upper bound to move. The braced group
|
|
209
|
+
# references keep the version digits from being read as a group number.
|
|
210
|
+
$rewritten.Add([regex]::Replace($text, $script:OldVersionRangePattern, ('${1}${2}${3}' + $NewVersion + '${5}')))
|
|
211
|
+
}
|
|
212
|
+
return , $rewritten.ToArray()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function Merge-ConflictedText {
|
|
216
|
+
<#
|
|
217
|
+
.SYNOPSIS
|
|
218
|
+
Resolve every conflict hunk of a project file as a keyed union.
|
|
219
|
+
.DESCRIPTION
|
|
220
|
+
Ours entries keep their order and lead; theirs entries whose key ours
|
|
221
|
+
does not carry follow in theirs order. A key both sides carry at
|
|
222
|
+
different versions keeps the ranked side and records the choice; the same
|
|
223
|
+
key at one version with differing attributes or children escalates, as
|
|
224
|
+
does any side outside the grammar.
|
|
225
|
+
.PARAMETER Line
|
|
226
|
+
The whole conflicted file, each line retaining its own terminator.
|
|
227
|
+
.PARAMETER Kind
|
|
228
|
+
'msbuild', 'packages', or 'appconfig'.
|
|
229
|
+
.OUTPUTS
|
|
230
|
+
A record carrying Lines, EntriesAddedFromOurs, EntriesAddedFromTheirs,
|
|
231
|
+
VersionResolutions, Escalate, and EscalateReason.
|
|
232
|
+
#>
|
|
233
|
+
[CmdletBinding()]
|
|
234
|
+
[OutputType([pscustomobject])]
|
|
235
|
+
param(
|
|
236
|
+
[Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Line,
|
|
237
|
+
[Parameter(Mandatory = $true)][AllowEmptyString()][string] $Kind
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
$hunk = Get-ConflictHunk -Line $Line
|
|
241
|
+
if ($null -eq $hunk) { return (ConvertTo-MergeOutcome -Reason 'a conflict hunk is not terminated') }
|
|
242
|
+
|
|
243
|
+
$merged = [System.Collections.Generic.List[string]]::new()
|
|
244
|
+
$fromOurs = [System.Collections.Generic.List[string]]::new()
|
|
245
|
+
$fromTheirs = [System.Collections.Generic.List[string]]::new()
|
|
246
|
+
$resolution = [System.Collections.Generic.List[object]]::new()
|
|
247
|
+
$cursor = 0
|
|
248
|
+
foreach ($section in $hunk) {
|
|
249
|
+
# Context ahead of the hunk is copied through untouched.
|
|
250
|
+
while ($cursor -lt $section.Start) { $merged.Add($Line[$cursor]); $cursor++ }
|
|
251
|
+
$oursUnit = Get-MergeableUnit -Line $section.Ours -Kind $Kind
|
|
252
|
+
$theirsUnit = Get-MergeableUnit -Line $section.Theirs -Kind $Kind
|
|
253
|
+
if ($null -eq $oursUnit -or $null -eq $theirsUnit) {
|
|
254
|
+
return (ConvertTo-MergeOutcome -Reason ('the hunk opening at line {0} is outside the merge grammar' -f ($section.Start + 1)))
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
$theirsByKey = @{}
|
|
258
|
+
foreach ($unit in $theirsUnit) { $theirsByKey[$unit.Key] = $unit }
|
|
259
|
+
$oursKey = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
|
|
260
|
+
foreach ($unit in $oursUnit) { [void] $oursKey.Add($unit.Key) }
|
|
261
|
+
|
|
262
|
+
foreach ($unit in $oursUnit) {
|
|
263
|
+
$kept = $unit
|
|
264
|
+
if ($theirsByKey.ContainsKey($unit.Key)) {
|
|
265
|
+
$other = $theirsByKey[$unit.Key]
|
|
266
|
+
$oursVersion = [string] $unit.Version
|
|
267
|
+
$theirsVersion = [string] $other.Version
|
|
268
|
+
if (-not [string]::Equals($oursVersion, $theirsVersion, [System.StringComparison]::Ordinal)) {
|
|
269
|
+
$choice = Compare-UnitVersion -Ours $oursVersion -Theirs $theirsVersion
|
|
270
|
+
# An unrankable pair is exactly the case a human must settle.
|
|
271
|
+
if ($choice -eq 'escalate') {
|
|
272
|
+
return (ConvertTo-MergeOutcome -Reason ('{0} carries versions that do not rank: {1} and {2}' -f $unit.Key, $oursVersion, $theirsVersion))
|
|
273
|
+
}
|
|
274
|
+
if ($choice -eq 'theirs') { $kept = $other }
|
|
275
|
+
$chosen = if ($choice -eq 'theirs') { $theirsVersion } else { $oursVersion }
|
|
276
|
+
$resolution.Add([pscustomobject]@{ key = $unit.Key; ours = $oursVersion; theirs = $theirsVersion; chosen = $chosen })
|
|
277
|
+
# The retained redirect must still cover the version it pins.
|
|
278
|
+
if ($Kind -eq 'appconfig') {
|
|
279
|
+
$kept = [pscustomobject]@{
|
|
280
|
+
Key = $kept.Key
|
|
281
|
+
Version = $chosen
|
|
282
|
+
Lines = (ConvertTo-RetargetedRedirectLine -Line $kept.Lines -NewVersion $chosen)
|
|
283
|
+
Attributes = $kept.Attributes
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
} elseif (-not (Test-UnitEquivalent -Left $unit -Right $other)) {
|
|
287
|
+
return (ConvertTo-MergeOutcome -Reason ('{0} is carried at one version with differing content on each side' -f $unit.Key))
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
foreach ($text in $kept.Lines) { $merged.Add($text) }
|
|
291
|
+
$fromOurs.Add($unit.Key)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
foreach ($unit in $theirsUnit) {
|
|
295
|
+
# A key ours already placed was settled above and is not repeated.
|
|
296
|
+
if ($oursKey.Contains($unit.Key)) { continue }
|
|
297
|
+
foreach ($text in $unit.Lines) { $merged.Add($text) }
|
|
298
|
+
$fromTheirs.Add($unit.Key)
|
|
299
|
+
}
|
|
300
|
+
$cursor = $section.End + 1
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
# Trailing context after the last hunk is copied through untouched.
|
|
304
|
+
while ($cursor -lt $Line.Count) { $merged.Add($Line[$cursor]); $cursor++ }
|
|
305
|
+
return (ConvertTo-MergeOutcome -Line $merged.ToArray() -FromOurs $fromOurs.ToArray() -FromTheirs $fromTheirs.ToArray() -Resolution $resolution.ToArray())
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function Test-NeverDropPostCondition {
|
|
309
|
+
<#
|
|
310
|
+
.SYNOPSIS
|
|
311
|
+
Report whether a merged text dropped nothing either side carried.
|
|
312
|
+
.DESCRIPTION
|
|
313
|
+
The merged key set must equal the union of the two stage key sets, and
|
|
314
|
+
every key the base and both sides carried must survive. The second check
|
|
315
|
+
is redundant with the first by construction and is asserted anyway,
|
|
316
|
+
because it is the property the merge exists to guarantee.
|
|
317
|
+
.PARAMETER MergedLine
|
|
318
|
+
The merged file text.
|
|
319
|
+
.PARAMETER OursLine
|
|
320
|
+
The stage-2 text.
|
|
321
|
+
.PARAMETER TheirsLine
|
|
322
|
+
The stage-3 text.
|
|
323
|
+
.PARAMETER BaseLine
|
|
324
|
+
The stage-1 text.
|
|
325
|
+
.PARAMETER Kind
|
|
326
|
+
'msbuild', 'packages', or 'appconfig'.
|
|
327
|
+
.OUTPUTS
|
|
328
|
+
System.Boolean.
|
|
329
|
+
#>
|
|
330
|
+
[CmdletBinding()]
|
|
331
|
+
[OutputType([bool])]
|
|
332
|
+
param(
|
|
333
|
+
[Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]] $MergedLine,
|
|
334
|
+
[Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]] $OursLine,
|
|
335
|
+
[Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]] $TheirsLine,
|
|
336
|
+
[Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]] $BaseLine,
|
|
337
|
+
[Parameter(Mandatory = $true)][AllowEmptyString()][string] $Kind
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
$mergedKey = [System.Collections.Generic.HashSet[string]]::new([string[]] (Get-UnitKeySet -Line $MergedLine -Kind $Kind), [StringComparer]::Ordinal)
|
|
341
|
+
$oursKey = [System.Collections.Generic.HashSet[string]]::new([string[]] (Get-UnitKeySet -Line $OursLine -Kind $Kind), [StringComparer]::Ordinal)
|
|
342
|
+
$theirsKey = [System.Collections.Generic.HashSet[string]]::new([string[]] (Get-UnitKeySet -Line $TheirsLine -Kind $Kind), [StringComparer]::Ordinal)
|
|
343
|
+
$union = [System.Collections.Generic.HashSet[string]]::new($oursKey, [StringComparer]::Ordinal)
|
|
344
|
+
$union.UnionWith($theirsKey)
|
|
345
|
+
# A merged set larger than the union means an invented key; smaller, a drop.
|
|
346
|
+
if (-not $mergedKey.SetEquals($union)) { return $false }
|
|
347
|
+
|
|
348
|
+
foreach ($key in (Get-UnitKeySet -Line $BaseLine -Kind $Kind)) {
|
|
349
|
+
# Only a key both sides still carry is one the merge had to keep.
|
|
350
|
+
if ($oursKey.Contains($key) -and $theirsKey.Contains($key) -and -not $mergedKey.Contains($key)) { return $false }
|
|
351
|
+
}
|
|
352
|
+
return $true
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
Export-ModuleMember -Function Get-ConflictHunk, Get-UnitKeySet, Merge-ConflictedText, Test-NeverDropPostCondition
|
package/resources/claude-customizations/.claude/lib/project-file-merge/ProjectFileMergeGrammar.psm1
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Line grammar for the mechanically-mergeable project-file shapes.
|
|
4
|
+
|
|
5
|
+
.DESCRIPTION
|
|
6
|
+
Classifies a project file into one of the three kinds the merge step admits
|
|
7
|
+
and parses one side of a conflict hunk into ordered, keyed units (issue #643).
|
|
8
|
+
|
|
9
|
+
The grammar is deliberately narrow: a side is parsed only when every one of
|
|
10
|
+
its lines is an admitted shape, and anything else yields $null, which the
|
|
11
|
+
caller turns into an escalation. Lines are matched as opaque text and no XML
|
|
12
|
+
API is used, so attribute order, indentation, self-closing spacing, and line
|
|
13
|
+
terminators all survive a merge: a kept line is copied, not re-serialised.
|
|
14
|
+
CONVENTION: this module fails fast at module scope and imports its siblings with -ErrorAction Stop.
|
|
15
|
+
#>
|
|
16
|
+
|
|
17
|
+
Set-StrictMode -Version Latest
|
|
18
|
+
$ErrorActionPreference = 'Stop'
|
|
19
|
+
|
|
20
|
+
# File extensions sharing the MSBuild item grammar, compared lower-case.
|
|
21
|
+
$script:MsBuildExtension = @('.csproj', '.vbproj', '.props')
|
|
22
|
+
|
|
23
|
+
# Attribute shape shared by every admitted element: name="value".
|
|
24
|
+
$script:AttributePattern = '([A-Za-z_][-A-Za-z0-9_.:]*)\s*=\s*"([^"]*)"'
|
|
25
|
+
|
|
26
|
+
# The five MSBuild item elements, single-line and paired-open. The single-line
|
|
27
|
+
# form is tested first, because the paired pattern also matches a self-closing
|
|
28
|
+
# line whose attribute run happens to end with a slash.
|
|
29
|
+
$script:MsBuildSinglePattern = '^\s*<(Compile|Analyzer|None|Content|EmbeddedResource)\s+([^<>]*?)\s*/>\s*$'
|
|
30
|
+
$script:MsBuildOpenPattern = '^\s*<(Compile|Analyzer|None|Content|EmbeddedResource)\s+([^<>]*?)\s*>\s*$'
|
|
31
|
+
|
|
32
|
+
# The metadata children a paired item may carry; any other child is ungrammatical.
|
|
33
|
+
$script:MsBuildChildPattern = '^\s*<(DependentUpon|SubType|AutoGen|DesignTime|Link|CopyToOutputDirectory|Generator|LastGenOutput)>[^<>]*</\1>\s*$'
|
|
34
|
+
|
|
35
|
+
# packages.config carries exactly one admitted element.
|
|
36
|
+
$script:PackagePattern = '^\s*<package\s+([^<>]*?)\s*/>\s*$'
|
|
37
|
+
|
|
38
|
+
# app.config binding redirects are keyed on the enclosing dependentAssembly block.
|
|
39
|
+
$script:AppConfigOpenPattern = '^\s*<dependentAssembly>\s*$'
|
|
40
|
+
$script:AppConfigClosePattern = '^\s*</dependentAssembly>\s*$'
|
|
41
|
+
$script:AppConfigChildPattern = '^\s*<(assemblyIdentity|bindingRedirect|publisherPolicy|codeBase)\s+([^<>]*?)\s*/>\s*$'
|
|
42
|
+
|
|
43
|
+
function Get-ProjectFileKind {
|
|
44
|
+
<#
|
|
45
|
+
.SYNOPSIS
|
|
46
|
+
Classify a repository path into a merge grammar kind.
|
|
47
|
+
.DESCRIPTION
|
|
48
|
+
Returns 'packages', 'appconfig', 'msbuild', or $null. The two .config
|
|
49
|
+
shapes are keyed on the whole leaf name because the .config extension is
|
|
50
|
+
shared with files this grammar does not admit.
|
|
51
|
+
.PARAMETER Path
|
|
52
|
+
A repository-relative or absolute path; only its leaf name is read.
|
|
53
|
+
.OUTPUTS
|
|
54
|
+
System.String, or $null when the path is not a mergeable project file.
|
|
55
|
+
#>
|
|
56
|
+
[CmdletBinding()]
|
|
57
|
+
[OutputType([string])]
|
|
58
|
+
param([Parameter(Mandatory = $true)][AllowEmptyString()][string] $Path)
|
|
59
|
+
|
|
60
|
+
$leaf = [System.IO.Path]::GetFileName($Path)
|
|
61
|
+
# Whole-name shapes settle first: .config alone would admit every other one.
|
|
62
|
+
if ([string]::Equals($leaf, 'packages.config', [System.StringComparison]::OrdinalIgnoreCase)) { return 'packages' }
|
|
63
|
+
if ([string]::Equals($leaf, 'app.config', [System.StringComparison]::OrdinalIgnoreCase)) { return 'appconfig' }
|
|
64
|
+
|
|
65
|
+
# The three project shapes share one item grammar, so they share one kind.
|
|
66
|
+
$extension = [System.IO.Path]::GetExtension($leaf).ToLowerInvariant()
|
|
67
|
+
if ($script:MsBuildExtension -contains $extension) { return 'msbuild' }
|
|
68
|
+
return $null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function Get-AttributeMap {
|
|
72
|
+
<#
|
|
73
|
+
.SYNOPSIS
|
|
74
|
+
Read the name="value" attributes of one element's attribute run.
|
|
75
|
+
.DESCRIPTION
|
|
76
|
+
A repeated name keeps its first value, which is the value an XML reader
|
|
77
|
+
would have seen before rejecting the duplicate.
|
|
78
|
+
#>
|
|
79
|
+
[CmdletBinding()]
|
|
80
|
+
[OutputType([hashtable])]
|
|
81
|
+
param([Parameter(Mandatory = $true)][AllowEmptyString()][string] $Text)
|
|
82
|
+
|
|
83
|
+
$map = @{}
|
|
84
|
+
foreach ($match in [regex]::Matches($Text, $script:AttributePattern)) {
|
|
85
|
+
# First value wins, so a malformed duplicate cannot change the key.
|
|
86
|
+
if (-not $map.ContainsKey($match.Groups[1].Value)) { $map[$match.Groups[1].Value] = $match.Groups[2].Value }
|
|
87
|
+
}
|
|
88
|
+
return $map
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function Get-MsBuildUnit {
|
|
92
|
+
<#
|
|
93
|
+
.SYNOPSIS
|
|
94
|
+
Parse MSBuild item lines into keyed units.
|
|
95
|
+
.DESCRIPTION
|
|
96
|
+
Accepts the single-line self-closing form and the paired open/close form
|
|
97
|
+
whose only children are metadata elements; any other line yields $null.
|
|
98
|
+
#>
|
|
99
|
+
[CmdletBinding()]
|
|
100
|
+
[OutputType([System.Object[]])]
|
|
101
|
+
param([Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Line)
|
|
102
|
+
|
|
103
|
+
$unit = [System.Collections.Generic.List[object]]::new()
|
|
104
|
+
$index = 0
|
|
105
|
+
while ($index -lt $Line.Count) {
|
|
106
|
+
$text = $Line[$index]
|
|
107
|
+
# Blank separators inside a hunk side carry no unit and are dropped.
|
|
108
|
+
if ([string]::IsNullOrWhiteSpace($text)) { $index++; continue }
|
|
109
|
+
$single = [regex]::Match($text, $script:MsBuildSinglePattern)
|
|
110
|
+
if ($single.Success) {
|
|
111
|
+
$attribute = Get-AttributeMap -Text $single.Groups[2].Value
|
|
112
|
+
# An item with no Include has no key, so it cannot be reconciled.
|
|
113
|
+
if (-not $attribute.ContainsKey('Include')) { return $null }
|
|
114
|
+
$unit.Add([pscustomobject]@{
|
|
115
|
+
Key = ('{0}:{1}' -f $single.Groups[1].Value, $attribute['Include'])
|
|
116
|
+
Version = $null
|
|
117
|
+
Lines = @($text)
|
|
118
|
+
Attributes = $attribute
|
|
119
|
+
})
|
|
120
|
+
$index++
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
$open = [regex]::Match($text, $script:MsBuildOpenPattern)
|
|
124
|
+
# Neither admitted form matched, so this whole side is ungrammatical.
|
|
125
|
+
if (-not $open.Success) { return $null }
|
|
126
|
+
$itemType = $open.Groups[1].Value
|
|
127
|
+
$attribute = Get-AttributeMap -Text $open.Groups[2].Value
|
|
128
|
+
if (-not $attribute.ContainsKey('Include')) { return $null }
|
|
129
|
+
$body = [System.Collections.Generic.List[string]]::new()
|
|
130
|
+
$body.Add($text)
|
|
131
|
+
$index++
|
|
132
|
+
$closePattern = '^\s*</{0}>\s*$' -f [regex]::Escape($itemType)
|
|
133
|
+
$isClosed = $false
|
|
134
|
+
while ($index -lt $Line.Count) {
|
|
135
|
+
$child = $Line[$index]
|
|
136
|
+
$body.Add($child)
|
|
137
|
+
$index++
|
|
138
|
+
# The matching close tag ends the unit and is part of its lines.
|
|
139
|
+
if ([regex]::IsMatch($child, $closePattern)) { $isClosed = $true; break }
|
|
140
|
+
# A child outside the metadata set means the form is not understood.
|
|
141
|
+
if (-not [regex]::IsMatch($child, $script:MsBuildChildPattern)) { return $null }
|
|
142
|
+
}
|
|
143
|
+
# A side ending mid-element is a truncated hunk, not a mergeable unit.
|
|
144
|
+
if (-not $isClosed) { return $null }
|
|
145
|
+
$unit.Add([pscustomobject]@{
|
|
146
|
+
Key = ('{0}:{1}' -f $itemType, $attribute['Include'])
|
|
147
|
+
Version = $null
|
|
148
|
+
Lines = $body.ToArray()
|
|
149
|
+
Attributes = $attribute
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
return , $unit.ToArray()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function Get-PackageUnit {
|
|
156
|
+
<#
|
|
157
|
+
.SYNOPSIS
|
|
158
|
+
Parse packages.config package lines into keyed units.
|
|
159
|
+
.DESCRIPTION
|
|
160
|
+
The key is package:<id> and the version is the version attribute, so two
|
|
161
|
+
sides pinning one package at different versions are comparable.
|
|
162
|
+
#>
|
|
163
|
+
[CmdletBinding()]
|
|
164
|
+
[OutputType([System.Object[]])]
|
|
165
|
+
param([Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Line)
|
|
166
|
+
|
|
167
|
+
$unit = [System.Collections.Generic.List[object]]::new()
|
|
168
|
+
foreach ($text in $Line) {
|
|
169
|
+
# Blank separators carry no unit.
|
|
170
|
+
if ([string]::IsNullOrWhiteSpace($text)) { continue }
|
|
171
|
+
$match = [regex]::Match($text, $script:PackagePattern)
|
|
172
|
+
# Anything other than a package element is outside the grammar.
|
|
173
|
+
if (-not $match.Success) { return $null }
|
|
174
|
+
$attribute = Get-AttributeMap -Text $match.Groups[1].Value
|
|
175
|
+
# Without an id there is no key to reconcile the two sides on.
|
|
176
|
+
if (-not $attribute.ContainsKey('id')) { return $null }
|
|
177
|
+
$version = if ($attribute.ContainsKey('version')) { $attribute['version'] } else { $null }
|
|
178
|
+
$unit.Add([pscustomobject]@{
|
|
179
|
+
Key = ('package:{0}' -f $attribute['id'])
|
|
180
|
+
Version = $version
|
|
181
|
+
Lines = @($text)
|
|
182
|
+
Attributes = $attribute
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
return , $unit.ToArray()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function Get-BindingRedirectUnit {
|
|
189
|
+
<#
|
|
190
|
+
.SYNOPSIS
|
|
191
|
+
Parse app.config dependentAssembly blocks into keyed units.
|
|
192
|
+
.DESCRIPTION
|
|
193
|
+
The key is bindingRedirect:<assemblyIdentity name> and the version is the
|
|
194
|
+
bindingRedirect newVersion, so two redirects for one assembly compare.
|
|
195
|
+
#>
|
|
196
|
+
[CmdletBinding()]
|
|
197
|
+
[OutputType([System.Object[]])]
|
|
198
|
+
param([Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Line)
|
|
199
|
+
|
|
200
|
+
$unit = [System.Collections.Generic.List[object]]::new()
|
|
201
|
+
$index = 0
|
|
202
|
+
while ($index -lt $Line.Count) {
|
|
203
|
+
$text = $Line[$index]
|
|
204
|
+
# Blank separators between blocks carry no unit.
|
|
205
|
+
if ([string]::IsNullOrWhiteSpace($text)) { $index++; continue }
|
|
206
|
+
# Only a whole dependentAssembly block is admitted; a bare child is not.
|
|
207
|
+
if (-not [regex]::IsMatch($text, $script:AppConfigOpenPattern)) { return $null }
|
|
208
|
+
$body = [System.Collections.Generic.List[string]]::new()
|
|
209
|
+
$body.Add($text)
|
|
210
|
+
$index++
|
|
211
|
+
$name = $null
|
|
212
|
+
$identity = @{}
|
|
213
|
+
$newVersion = $null
|
|
214
|
+
$isClosed = $false
|
|
215
|
+
while ($index -lt $Line.Count) {
|
|
216
|
+
$child = $Line[$index]
|
|
217
|
+
$body.Add($child)
|
|
218
|
+
$index++
|
|
219
|
+
if ([regex]::IsMatch($child, $script:AppConfigClosePattern)) { $isClosed = $true; break }
|
|
220
|
+
$match = [regex]::Match($child, $script:AppConfigChildPattern)
|
|
221
|
+
# An unrecognised child means the block is not understood.
|
|
222
|
+
if (-not $match.Success) { return $null }
|
|
223
|
+
$attribute = Get-AttributeMap -Text $match.Groups[2].Value
|
|
224
|
+
# The identity supplies the key; the redirect supplies the version.
|
|
225
|
+
if ($match.Groups[1].Value -eq 'assemblyIdentity' -and $attribute.ContainsKey('name')) {
|
|
226
|
+
$name = $attribute['name']
|
|
227
|
+
$identity = $attribute
|
|
228
|
+
}
|
|
229
|
+
if ($match.Groups[1].Value -eq 'bindingRedirect' -and $attribute.ContainsKey('newVersion')) { $newVersion = $attribute['newVersion'] }
|
|
230
|
+
}
|
|
231
|
+
# A block with no close tag or no identity name cannot be keyed.
|
|
232
|
+
if (-not $isClosed -or $null -eq $name) { return $null }
|
|
233
|
+
$unit.Add([pscustomobject]@{
|
|
234
|
+
Key = ('bindingRedirect:{0}' -f $name)
|
|
235
|
+
Version = $newVersion
|
|
236
|
+
Lines = $body.ToArray()
|
|
237
|
+
Attributes = $identity
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
return , $unit.ToArray()
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function Get-MergeableUnit {
|
|
244
|
+
<#
|
|
245
|
+
.SYNOPSIS
|
|
246
|
+
Parse one side of a conflict hunk into ordered, keyed units.
|
|
247
|
+
.DESCRIPTION
|
|
248
|
+
Dispatches to the parser for the supplied kind. The whole side is parsed
|
|
249
|
+
or none of it is: one line outside the grammar yields $null, which the
|
|
250
|
+
merge turns into an escalation rather than a partial resolution.
|
|
251
|
+
.PARAMETER Line
|
|
252
|
+
The lines of one hunk side, each retaining its own terminator.
|
|
253
|
+
.PARAMETER Kind
|
|
254
|
+
'msbuild', 'packages', or 'appconfig' from Get-ProjectFileKind.
|
|
255
|
+
.OUTPUTS
|
|
256
|
+
System.Object[] of units carrying Key, Version, Lines, and Attributes in
|
|
257
|
+
side order, or $null when the side is ungrammatical.
|
|
258
|
+
#>
|
|
259
|
+
[CmdletBinding()]
|
|
260
|
+
[OutputType([System.Object[]])]
|
|
261
|
+
param(
|
|
262
|
+
[Parameter(Mandatory = $true)][AllowEmptyCollection()][AllowEmptyString()][string[]] $Line,
|
|
263
|
+
[Parameter(Mandatory = $true)][AllowEmptyString()][string] $Kind
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# One parser per kind. An unrecognised kind is ungrammatical rather than
|
|
267
|
+
# defaulting to a parser, so a typo cannot silently merge a file.
|
|
268
|
+
if ($Kind -eq 'msbuild') {
|
|
269
|
+
$unit = Get-MsBuildUnit -Line $Line
|
|
270
|
+
} elseif ($Kind -eq 'packages') {
|
|
271
|
+
$unit = Get-PackageUnit -Line $Line
|
|
272
|
+
} elseif ($Kind -eq 'appconfig') {
|
|
273
|
+
$unit = Get-BindingRedirectUnit -Line $Line
|
|
274
|
+
} else {
|
|
275
|
+
return $null
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
# The comma keeps an empty parse distinguishable from a failed parse: without
|
|
279
|
+
# it an empty array returns as $null and a clean side would read as escalate.
|
|
280
|
+
if ($null -eq $unit) { return $null }
|
|
281
|
+
return , $unit
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function Compare-UnitVersion {
|
|
285
|
+
<#
|
|
286
|
+
.SYNOPSIS
|
|
287
|
+
Rank two version strings recorded for the same unit key.
|
|
288
|
+
.DESCRIPTION
|
|
289
|
+
Returns 'ours', 'theirs', or 'equal' when both sides parse as
|
|
290
|
+
System.Version, and 'escalate' otherwise: a prerelease or non-numeric
|
|
291
|
+
version such as 1.0.0-beta1 cannot be ranked mechanically.
|
|
292
|
+
.PARAMETER Ours
|
|
293
|
+
The version recorded on the ours side. May be absent.
|
|
294
|
+
.PARAMETER Theirs
|
|
295
|
+
The version recorded on the theirs side. May be absent.
|
|
296
|
+
.OUTPUTS
|
|
297
|
+
System.String: 'ours', 'theirs', 'equal', or 'escalate'.
|
|
298
|
+
#>
|
|
299
|
+
[CmdletBinding()]
|
|
300
|
+
[OutputType([string])]
|
|
301
|
+
param(
|
|
302
|
+
[Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string] $Ours,
|
|
303
|
+
[Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string] $Theirs
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
[System.Version] $oursVersion = $null
|
|
307
|
+
[System.Version] $theirsVersion = $null
|
|
308
|
+
# An unparseable side on either hand is unrankable, so neither is chosen.
|
|
309
|
+
if (-not [System.Version]::TryParse($Ours, [ref] $oursVersion)) { return 'escalate' }
|
|
310
|
+
if (-not [System.Version]::TryParse($Theirs, [ref] $theirsVersion)) { return 'escalate' }
|
|
311
|
+
|
|
312
|
+
# Strictly higher wins; anything else is an equal pin needing no choice.
|
|
313
|
+
if ($oursVersion -gt $theirsVersion) { return 'ours' }
|
|
314
|
+
if ($theirsVersion -gt $oursVersion) { return 'theirs' }
|
|
315
|
+
return 'equal'
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
Export-ModuleMember -Function Get-ProjectFileKind, Get-MergeableUnit, Compare-UnitVersion
|