Repository navigation
Expand file tree
/
Copy pathGet-ConflictContext.ps1
More file actions
490 lines (410 loc) · 18.2 KB
/
Copy pathGet-ConflictContext.ps1
File metadata and controls
490 lines (410 loc) · 18.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
<#
.SYNOPSIS
Retrieves three-way context for a conflicted file to aid complex conflict resolution.
.DESCRIPTION
MCP-compatible tool that fetches three versions of a file involved in a merge or
cherry-pick conflict — the upstream file before the commit, the upstream file after
the commit, and our fork's version (via HEAD) — and extracts focused, line-numbered
excerpts centered on each changed hunk.
For each hunk in the upstream diff, the tool locates the corresponding region in our
fork using two-tier content matching:
Tier 1: Sliding-window overlap score against unchanged context lines from the diff.
Tier 2: Fallback to searching for the function name from the @@ hunk header.
This handles line-number divergence between upstream and our fork by finding the
region by content rather than by position.
Use this tool ONLY when conflict complexity assessment returns HIGH_COMPLEXITY.
.PARAMETER FilePath
Path to the conflicted file, relative to the repository root.
.PARAMETER CommitHash
The upstream commit SHA that caused the conflict. During the scratch-branch merge
workflow, this is typically the batch endpoint commit passed to git merge.
.PARAMETER ContextLines
Number of lines of context above and below each hunk match to include in excerpts.
Default: 40
.PARAMETER MaxTotalLines
Maximum total lines returned across all three versions combined (across all hunks).
Budget per version per hunk = max(10, floor(MaxTotalLines / 3 / hunkCount)).
The minimum floor of 10 lines per version per hunk is always enforced.
If the floor overrides the calculated budget, a warning is included in Message.
Default: 150 (approximately 50 lines per version)
.OUTPUTS
Hashtable with:
Success [bool] Whether the operation completed without errors
Message [string] Human-readable summary (includes warnings about budget)
CommitMessage [string] The commit message of CommitHash
UpstreamDiff [string] Raw unified diff for the file from this commit
HunkCount [int] Number of hunks found in the upstream diff
IsBinary [bool] True if the file is binary (no excerpts returned)
Hunks [array] One entry per hunk:
HunkIndex [int] 1-based hunk number
HunkHeader [string] The @@ header line
FunctionName [string] Function name extracted from @@ header (may be empty)
UpstreamBefore [object] { Lines, StartLine, EndLine, Note }
UpstreamAfter [object] { Lines, StartLine, EndLine, Note }
OurFork [object] { Lines, StartLine, EndLine, Note }
Each excerpt object:
Lines [string[]] The extracted lines (null if version unavailable)
StartLine [int] 1-based line number of first line in the excerpt
EndLine [int] 1-based line number of last line in the excerpt
Note [string] Explanation if unavailable or how region was located
.EXAMPLE
# Get conflict context for a high-complexity conflict
# MCP Tool: mcp_openssh-server_Get_ConflictContext
# FilePath="auth.c", CommitHash="abc1234"
.EXAMPLE
# Get context with increased budget for a file with many hunks
# MCP Tool: mcp_openssh-server_Get_ConflictContext
# FilePath="channels.c", CommitHash="abc1234", MaxTotalLines=300
#>
param(
[Parameter(Mandatory)]
[string]$FilePath,
[Parameter(Mandatory)]
[string]$CommitHash,
[int]$ContextLines = 40,
[int]$MaxTotalLines = 150
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ── Git process helper (same pattern as Invoke-Git.ps1) ──────────────────────
function Invoke-GitCommand {
param([string[]]$Arguments)
$processInfo = [System.Diagnostics.ProcessStartInfo]::new()
$processInfo.FileName = 'git'
$processInfo.Arguments = ($Arguments | ForEach-Object {
if ($_ -match '\s') { "`"$_`"" } else { $_ }
}) -join ' '
$processInfo.UseShellExecute = $false
$processInfo.CreateNoWindow = $true
$processInfo.RedirectStandardInput = $true
$processInfo.RedirectStandardOutput = $true
$processInfo.RedirectStandardError = $true
$process = [System.Diagnostics.Process]::new()
$process.StartInfo = $processInfo
$process.Start() | Out-Null
$process.StandardInput.Close()
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
$stderrTask = $process.StandardError.ReadToEndAsync()
$completed = $process.WaitForExit(30000)
if (-not $completed) {
$process.Kill()
return @{
ExitCode = -1
Success = $false
Output = ''
Error = "git $($Arguments -join ' ') timed out after 30 seconds"
}
}
return @{
ExitCode = $process.ExitCode
Success = ($process.ExitCode -eq 0)
Output = $stdoutTask.GetAwaiter().GetResult()
Error = $stderrTask.GetAwaiter().GetResult().TrimEnd()
}
}
# ── Helper: fetch a file at a git ref ────────────────────────────────────────
function Get-FileAtRef {
param([string]$Ref, [string]$File)
$r = Invoke-GitCommand -Arguments @('show', "${Ref}:${File}")
if (-not $r.Success) {
return @{ Lines = $null; Note = "File not available at ref '${Ref}': $($r.Error.Trim())" }
}
$lines = $r.Output -split "`n"
# Remove trailing empty element produced by split on a newline-terminated string
if ($lines.Count -gt 0 -and $lines[-1] -eq '') {
$lines = $lines[0..($lines.Count - 2)]
}
return @{ Lines = $lines; Note = $null }
}
# ── Helper: slice a line-numbered excerpt centred on a 1-based line ──────────
function Get-Excerpt {
param(
[string[]]$Lines,
[int]$CenterLine, # 1-based
[int]$Budget # max lines to return
)
if (-not $Lines) { return $null }
$half = [Math]::Floor($Budget / 2)
$start = [Math]::Max(0, $CenterLine - 1 - $half)
$end = [Math]::Min($Lines.Count - 1, $CenterLine - 1 + $half)
# Expand toward the opposite edge if we hit a boundary before using the full budget
if (($end - $start + 1) -lt $Budget) {
if ($start -eq 0) {
$end = [Math]::Min($Lines.Count - 1, $Budget - 1)
} else {
$start = [Math]::Max(0, $end - $Budget + 1)
}
}
return @{
Lines = $Lines[$start..$end]
StartLine = $start + 1
EndLine = $end + 1
Note = $null
}
}
# ── Helper: sliding-window content-anchor match ───────────────────────────────
# Returns the 1-based centre line in $FileLines that best overlaps $AnchorLines.
function Find-AnchorMatch {
param(
[string[]]$FileLines,
[string[]]$AnchorLines,
[int]$ExpectedCenter # 1-based fallback if no match found
)
$anchorSet = @{}
foreach ($a in $AnchorLines) {
$t = $a.Trim()
if ($t) { $anchorSet[$t] = $true }
}
$windowSize = [Math]::Max($AnchorLines.Count, 5)
$bestScore = -1
$bestCenter = $ExpectedCenter
for ($i = 0; $i -le ($FileLines.Count - $windowSize); $i++) {
$score = 0
for ($j = $i; $j -lt ($i + $windowSize) -and $j -lt $FileLines.Count; $j++) {
$trimmed = $FileLines[$j].Trim()
if ($trimmed -and $anchorSet.ContainsKey($trimmed)) { $score++ }
}
if ($score -gt $bestScore) {
$bestScore = $score
$bestCenter = $i + [Math]::Floor($windowSize / 2) + 1 # convert to 1-based
}
}
return @{
Center = $bestCenter
Score = $bestScore
MaxPossible = $anchorSet.Count
}
}
# ── Helper: find the first line in $FileLines containing $FunctionName ───────
function Find-FunctionMatch {
param(
[string[]]$FileLines,
[string]$FunctionName
)
$pattern = "\b$([regex]::Escape($FunctionName))\b"
for ($i = 0; $i -lt $FileLines.Count; $i++) {
if ($FileLines[$i] -match $pattern) {
return $i + 1 # 1-based
}
}
return -1
}
# ── Helper: parse all @@ hunks from unified diff text ────────────────────────
function Parse-DiffHunks {
param([string]$DiffText)
$hunks = @()
$lines = $DiffText -split "`n"
$currentHunk = $null
$contextAccum = @()
$inHunk = $false
foreach ($line in $lines) {
if ($line -match '^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$') {
# Flush the previous hunk before starting a new one
if ($null -ne $currentHunk) {
$currentHunk['ContextLines'] = $contextAccum
$hunks += $currentHunk
}
$upstreamStart = [int]$Matches[1]
$upstreamCount = if ($Matches[2]) { [int]$Matches[2] } else { 1 }
$afterStart = [int]$Matches[3]
$afterCount = if ($Matches[4]) { [int]$Matches[4] } else { 1 }
$funcTrailer = $Matches[5].Trim()
# Extract function name from the optional trailer after @@
$funcName = ''
if ($funcTrailer -match '(\w[\w_]*)\s*\(') {
$funcName = $Matches[1]
} elseif ($funcTrailer -match '(\w[\w_]+)') {
$funcName = $Matches[1]
}
$currentHunk = @{
Header = $line.Trim()
FunctionName = $funcName
UpstreamStart = $upstreamStart
UpstreamCount = $upstreamCount
AfterStart = $afterStart
AfterCount = $afterCount
}
$contextAccum = @()
$inHunk = $true
} elseif ($inHunk) {
# Collect unchanged context lines (lines starting with a space)
if ($line -match '^ (.*)$') {
$contextAccum += $Matches[1]
}
}
}
# Flush the last hunk
if ($null -ne $currentHunk) {
$currentHunk['ContextLines'] = $contextAccum
$hunks += $currentHunk
}
return $hunks
}
# ── Main ──────────────────────────────────────────────────────────────────────
$warnings = @()
# 1. Get the upstream diff for this file at this commit
$diffResult = Invoke-GitCommand -Arguments @('diff', "${CommitHash}^..${CommitHash}", '--', $FilePath)
if (-not $diffResult.Success -and $diffResult.ExitCode -ne 1) {
return @{
Success = $false
Message = "Failed to get diff for '${FilePath}' at commit ${CommitHash}: $($diffResult.Error)"
CommitMessage = ''
UpstreamDiff = ''
HunkCount = 0
IsBinary = $false
Hunks = @()
}
}
$upstreamDiff = $diffResult.Output
# 2. Binary file — return early with a note, no excerpts
if ($upstreamDiff -match 'Binary files .* differ') {
return @{
Success = $true
Message = "Binary file — context not available for '${FilePath}'."
CommitMessage = ''
UpstreamDiff = $upstreamDiff
HunkCount = 0
IsBinary = $true
Hunks = @()
}
}
# 3. File not touched by this commit
if ([string]::IsNullOrWhiteSpace($upstreamDiff)) {
return @{
Success = $true
Message = "File '${FilePath}' was not modified by commit ${CommitHash}."
CommitMessage = ''
UpstreamDiff = ''
HunkCount = 0
IsBinary = $false
Hunks = @()
}
}
# 4. Get the commit message
$commitMsgResult = Invoke-GitCommand -Arguments @('log', '-1', '--pretty=format:%s%n%n%b', $CommitHash)
$commitMessage = if ($commitMsgResult.Success) { $commitMsgResult.Output.Trim() } else { '' }
# 5. Fetch the three file versions
# HEAD is our fork's version of the file during both merge and cherry-pick conflicts.
# CommitHash^ is the upstream state before the commit; CommitHash is after.
$upstreamBefore = Get-FileAtRef -Ref "${CommitHash}^" -File $FilePath
$upstreamAfter = Get-FileAtRef -Ref "${CommitHash}" -File $FilePath
$ourFork = Get-FileAtRef -Ref 'HEAD' -File $FilePath
# 6. Parse hunks from the diff
$hunks = Parse-DiffHunks -DiffText $upstreamDiff
$hunkCount = $hunks.Count
if ($hunkCount -eq 0) {
return @{
Success = $true
Message = "No hunks found in diff for '${FilePath}' at commit ${CommitHash}."
CommitMessage = $commitMessage
UpstreamDiff = $upstreamDiff
HunkCount = 0
IsBinary = $false
Hunks = @()
}
}
# 7. Compute per-hunk line budget
# MaxTotalLines is split evenly across 3 versions and all hunks.
# Minimum floor of 10 lines per version per hunk is always enforced.
$MIN_LINES_PER_HUNK = 10
$budgetPerHunk = [Math]::Floor($MaxTotalLines / 3 / $hunkCount)
if ($budgetPerHunk -lt $MIN_LINES_PER_HUNK) {
$warnings += "MaxTotalLines=${MaxTotalLines} is too small for ${hunkCount} hunk(s) across 3 versions; " +
"minimum floor of ${MIN_LINES_PER_HUNK} lines applied — consider increasing MaxTotalLines."
$budgetPerHunk = $MIN_LINES_PER_HUNK
}
# Never exceed the caller's ContextLines preference
$budgetPerHunk = [Math]::Min($budgetPerHunk, $ContextLines)
# 8. Build per-hunk results
$ANCHOR_SCORE_THRESHOLD = 2
$resultHunks = @()
for ($h = 0; $h -lt $hunkCount; $h++) {
$hunk = $hunks[$h]
$anchorLines = $hunk['ContextLines']
# ── upstream-before: line numbers are known from the diff header ──────────
$uBefore = @{ Lines = $null; StartLine = $null; EndLine = $null; Note = $upstreamBefore.Note }
if ($upstreamBefore.Lines) {
$center = $hunk['UpstreamStart'] + [Math]::Floor($hunk['UpstreamCount'] / 2)
$excerpt = Get-Excerpt -Lines $upstreamBefore.Lines -CenterLine $center -Budget $budgetPerHunk
if ($excerpt) { $uBefore = $excerpt }
}
# ── upstream-after: line numbers are known from the diff header ───────────
$uAfter = @{ Lines = $null; StartLine = $null; EndLine = $null; Note = $upstreamAfter.Note }
if ($upstreamAfter.Lines) {
$center = $hunk['AfterStart'] + [Math]::Floor($hunk['AfterCount'] / 2)
$excerpt = Get-Excerpt -Lines $upstreamAfter.Lines -CenterLine $center -Budget $budgetPerHunk
if ($excerpt) { $uAfter = $excerpt }
}
# ── our fork: line numbers diverge — locate by content matching ──────────
$forkExcerpt = @{ Lines = $null; StartLine = $null; EndLine = $null; Note = $ourFork.Note }
if ($ourFork.Lines) {
$forkCenter = $hunk['UpstreamStart'] # fallback: use upstream line number as estimate
$matchNote = $null
if ($anchorLines.Count -ge 2) {
# Tier 1: sliding-window content match against unchanged context lines
$match = Find-AnchorMatch -FileLines $ourFork.Lines `
-AnchorLines $anchorLines `
-ExpectedCenter $forkCenter
if ($match.Score -ge $ANCHOR_SCORE_THRESHOLD) {
$forkCenter = $match.Center
$matchNote = "Located via content-anchor match (score $($match.Score)/$($match.MaxPossible))."
} elseif ($hunk['FunctionName']) {
# Tier 2: anchor score too low — fall back to function name search
$fnLine = Find-FunctionMatch -FileLines $ourFork.Lines -FunctionName $hunk['FunctionName']
if ($fnLine -gt 0) {
$forkCenter = $fnLine
$matchNote = "Located via function-name fallback ('$($hunk['FunctionName'])')."
} else {
$matchNote = "Could not locate region — anchor score too low and function " +
"'$($hunk['FunctionName'])' not found. Using upstream line number as estimate."
}
} else {
$matchNote = "Could not locate region — anchor score too low and no function name " +
"available. Using upstream line number as estimate."
}
} elseif ($hunk['FunctionName']) {
# Too few anchor lines for sliding window — go straight to function-name search
$fnLine = Find-FunctionMatch -FileLines $ourFork.Lines -FunctionName $hunk['FunctionName']
if ($fnLine -gt 0) {
$forkCenter = $fnLine
$matchNote = "Located via function-name fallback ('$($hunk['FunctionName'])') — " +
"insufficient anchor lines for content match."
} else {
$matchNote = "Insufficient anchor lines and function '$($hunk['FunctionName'])' not found. " +
"Using upstream line number as estimate."
}
} else {
$matchNote = "Insufficient anchor lines and no function name available. " +
"Using upstream line number as estimate."
}
$excerpt = Get-Excerpt -Lines $ourFork.Lines -CenterLine $forkCenter -Budget $budgetPerHunk
if ($excerpt) {
$excerpt['Note'] = $matchNote
$forkExcerpt = $excerpt
}
}
$resultHunks += @{
HunkIndex = $h + 1
HunkHeader = $hunk['Header']
FunctionName = $hunk['FunctionName']
UpstreamBefore = $uBefore
UpstreamAfter = $uAfter
OurFork = $forkExcerpt
}
}
# 9. Return final result
$message = if ($warnings.Count -gt 0) {
"Context retrieved for ${hunkCount} hunk(s) in '${FilePath}' (commit ${CommitHash}). " +
"Warnings: $($warnings -join ' | ')"
} else {
"Context retrieved for ${hunkCount} hunk(s) in '${FilePath}' (commit ${CommitHash})."
}
return @{
Success = $true
Message = $message
CommitMessage = $commitMessage
UpstreamDiff = $upstreamDiff
HunkCount = $hunkCount
IsBinary = $false
Hunks = $resultHunks
}