functions/MergeDriver/Merge-LabelFile.ps1

<#
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at https://mozilla.org/MPL/2.0/.
#>

#Requires -Version 7.0
<#
.SYNOPSIS
    Git merge driver and standalone sorter for AxLabel translation files.
 
.DESCRIPTION
    Merge-driver mode (invoked automatically by git during a merge):
        Performs a 3-way merge of label files, producing an alphabetically sorted
        result. Per-label comments (a line starting with " ;" immediately before
        the label definition) are kept with their label. Unresolvable conflicts are
        written as standard git conflict markers and the script exits 1 so git marks
        the file as conflicted.
 
    Standalone / pipeline mode (no -Base/-Ours/-Theirs supplied):
        Scans the repository for every file matching
            **/AxLabelFile/LabelResources/*/*.label.txt
        and sorts each file's entries alphabetically in-place.
 
    Label file format
    -----------------
        LabelId=value
         ;optional comment ← belongs to the label line before it
 
    Register the merge driver in git config once per clone (or in a pipeline step):
        git config merge.d365fo-label.name "AxLabel file merger"
        git config merge.d365fo-label.driver "pwsh -File D365GitOps/functions/MergeDriver/Merge-LabelFile.ps1 -Base %O -Ours %A -Theirs %B -MarkerSize %L -FilePath %P"
 
    Alternatively, install the D365GitOps module and run:
        Register-D365MergeDriver
 
.PARAMETER Base
    [Merge-driver] Ancestor (base) version of the file. Supplied as %O by git.
 
.PARAMETER Ours
    [Merge-driver] Current-branch version. Supplied as %A by git.
    The merged result is written back to this path.
 
.PARAMETER Theirs
    [Merge-driver] Other-branch version. Supplied as %B by git.
 
.PARAMETER MarkerSize
    [Merge-driver] Width of conflict-marker lines. Supplied as %L by git. Default: 7.
 
.PARAMETER FilePath
    [Merge-driver] Repository-relative path of the file. Supplied as %P by git.
    Used only for diagnostic messages.
 
.PARAMETER RepoRoot
    [Standalone] Root directory to search for label files.
    Defaults to the current working directory.
 
.EXAMPLE
    # Run from a pipeline to sort every label file in the repository:
    pwsh -File D365GitOps/functions/MergeDriver/Merge-LabelFile.ps1 -RepoRoot $(Build.SourcesDirectory)
#>


[CmdletBinding(DefaultParameterSetName = 'Standalone')]
param(
    # ── Merge-driver mode ────────────────────────────────────────────────────
    [Parameter(Mandatory, ParameterSetName = 'MergeDriver')]
    [string]$Base,          # %O ancestor version

    [Parameter(Mandatory, ParameterSetName = 'MergeDriver')]
    [string]$Ours,          # %A current-branch version; result written here

    [Parameter(Mandatory, ParameterSetName = 'MergeDriver')]
    [string]$Theirs,        # %B other-branch version

    [Parameter(ParameterSetName = 'MergeDriver')]
    [int]$MarkerSize = 7,   # %L conflict-marker width

    [Parameter(ParameterSetName = 'MergeDriver')]
    [string]$FilePath = '', # %P repo-relative path (informational only)

    # ── Standalone / pipeline mode ────────────────────────────────────────────
    [Parameter(ParameterSetName = 'Standalone')]
    [string]$RepoRoot = (Get-Location).Path
)


if ($MyInvocation.InvocationName -eq '.') {
    return
}

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# ─── Parsing ─────────────────────────────────────────────────────────────────

function Read-LabelEntries {
    [OutputType([System.Collections.Generic.List[pscustomobject]])]
    param([string]$Path)

    $entries   = [System.Collections.Generic.List[pscustomobject]]::new()
    $lastEntry = $null

    foreach ($line in (Get-Content -LiteralPath $Path -Encoding UTF8)) {
        if ($line -match '^ ;') {
            # Comment line – belongs to the previous label definition
            if ($null -ne $lastEntry) {
                $lastEntry.Comment = $line
            }
        }
        elseif ($line -match '^([^=]+)=(.*)$') {
            $entry = [pscustomobject]@{
                Comment = $null
                LabelId = $Matches[1]
                Value   = $Matches[2]
            }
            $entries.Add($entry)
            $lastEntry = $entry
        }
        else {
            # Blank or unrecognised line – reset context
            $lastEntry = $null
        }
    }

    return $entries
}

function Get-EntryMap {
    [OutputType([hashtable])]
    param([System.Collections.Generic.List[pscustomobject]]$Entries)

    $map = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($e in $Entries) { $map[$e.LabelId] = $e }
    return $map
}

# ─── Serialising ─────────────────────────────────────────────────────────────

function Format-LabelFile {
    [OutputType([string])]
    param($Entries)

    $lines = [System.Collections.Generic.List[string]]::new()

    foreach ($e in $Entries) {
        if ($e.PSObject.Properties['IsConflict'] -and $e.IsConflict) {
            foreach ($cl in $e.ConflictLines) { $lines.Add($cl) }
            if (![string]::IsNullOrEmpty($e.Comment)) { $lines.Add($e.Comment) }
        }
        else {
            $lines.Add("$($e.LabelId)=$($e.Value)")
            if (![string]::IsNullOrEmpty($e.Comment)) { $lines.Add($e.Comment) }
        }
    }

    # Join with LF; end with a single trailing newline
    return ($lines -join "`n") + "`n"
}

function Save-LabelFile {
    param(
        [string]$Path,
        $Entries
    )

    $text = Format-LabelFile -Entries $Entries
    [System.IO.File]::WriteAllText($Path, $text, [System.Text.UTF8Encoding]::new($false))
}

# ─── Sorting ─────────────────────────────────────────────────────────────────

function Sort-LabelEntries {
    [OutputType([System.Collections.Generic.List[pscustomobject]])]
    param([System.Collections.Generic.List[pscustomobject]]$Entries)

    $sorted = [System.Collections.Generic.List[pscustomobject]]::new()
    $sorted.AddRange([pscustomobject[]]($Entries | Sort-Object -Property LabelId))
    return $sorted
}

# ─── Three-way merge ─────────────────────────────────────────────────────────

# Builds a conflict-marker entry (no actual label value; ConflictLines contains
# the full block to be written verbatim). Either $OursEntry or $TheirsEntry
# may be $null to represent a deletion on that side.
function New-ConflictEntry {
    param(
        [string]$Id,
        [string]$BaseComment,          # may be $null
        [pscustomobject]$OursEntry,    # $null → ours deleted the label
        [pscustomobject]$TheirsEntry,  # $null → theirs deleted the label
        [string]$Lt,
        [string]$Sep,
        [string]$Gt
    )

    $cl = [System.Collections.Generic.List[string]]::new()
    $cl.Add("$Lt ours")
    if ($null -ne $OursEntry) {
        $cl.Add("$Id=$($OursEntry.Value)")
        if (![string]::IsNullOrEmpty($OursEntry.Comment)) { $cl.Add($OursEntry.Comment) }
    }
    $cl.Add($Sep)
    if ($null -ne $TheirsEntry) {
        $cl.Add("$Id=$($TheirsEntry.Value)")
        if (![string]::IsNullOrEmpty($TheirsEntry.Comment)) { $cl.Add($TheirsEntry.Comment) }
    }
    $cl.Add("$Gt theirs")

    return [pscustomobject]@{
        Comment       = $BaseComment
        LabelId       = $Id
        Value         = ''
        IsConflict    = $true
        ConflictLines = $cl
    }
}

function Invoke-LabelMerge {
    param(
        [System.Collections.Generic.List[pscustomobject]]$BaseList,
        [System.Collections.Generic.List[pscustomobject]]$OursList,
        [System.Collections.Generic.List[pscustomobject]]$TheirsList,
        [int]$MarkerSize
    )

    $bMap = Get-EntryMap $BaseList
    $oMap = Get-EntryMap $OursList
    $tMap = Get-EntryMap $TheirsList

    # Union of all label IDs (order here doesn't matter; we sort at the end)
    $allIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($e in $BaseList)   { [void]$allIds.Add($e.LabelId) }
    foreach ($e in $OursList)   { [void]$allIds.Add($e.LabelId) }
    foreach ($e in $TheirsList) { [void]$allIds.Add($e.LabelId) }

    $merged      = [System.Collections.Generic.List[pscustomobject]]::new()
    $conflictIds = [System.Collections.Generic.List[string]]::new()

    $lt  = '<' * $MarkerSize
    $sep = '=' * $MarkerSize
    $gt  = '>' * $MarkerSize

    foreach ($id in $allIds) {
        $inB = $bMap.ContainsKey($id)
        $inO = $oMap.ContainsKey($id)
        $inT = $tMap.ContainsKey($id)

        # ── Not in base: newly added ──────────────────────────────────────────
        if (-not $inB) {
            if ($inO -and -not $inT) { $merged.Add($oMap[$id]); continue }
            if (-not $inO -and $inT) { $merged.Add($tMap[$id]); continue }
            # Added by both
            if ($oMap[$id].Value -eq $tMap[$id].Value) {
                $merged.Add($oMap[$id])
            }
            else {
                $conflictIds.Add($id)
                $merged.Add((New-ConflictEntry $id $null $oMap[$id] $tMap[$id] $lt $sep $gt))
            }
            continue
        }

        # ── In base: check for deletions ─────────────────────────────────────
        if (-not $inO -and -not $inT) {
            # Deleted by both → drop
            continue
        }

        if (-not $inO) {
            # Deleted by ours; theirs is present
            if ($tMap[$id].Value -eq $bMap[$id].Value) {
                # theirs unchanged → accept our deletion
            }
            else {
                # theirs modified it → conflict
                $conflictIds.Add($id)
                $merged.Add((New-ConflictEntry $id $bMap[$id].Comment $null $tMap[$id] $lt $sep $gt))
            }
            continue
        }

        if (-not $inT) {
            # Deleted by theirs; ours is present
            if ($oMap[$id].Value -eq $bMap[$id].Value) {
                # ours unchanged → accept their deletion
            }
            else {
                # ours modified it → conflict
                $conflictIds.Add($id)
                $merged.Add((New-ConflictEntry $id $bMap[$id].Comment $oMap[$id] $null $lt $sep $gt))
            }
            continue
        }

        # ── Present in all three: standard 3-way merge ───────────────────────
        $oChanged = $oMap[$id].Value -ne $bMap[$id].Value
        $tChanged = $tMap[$id].Value -ne $bMap[$id].Value

        if (-not $oChanged -and -not $tChanged) {
            $merged.Add($bMap[$id])
        }
        elseif ($oChanged -and -not $tChanged) {
            $merged.Add($oMap[$id])
        }
        elseif (-not $oChanged -and $tChanged) {
            $merged.Add($tMap[$id])
        }
        elseif ($oMap[$id].Value -eq $tMap[$id].Value) {
            # Both changed to the same value
            $merged.Add($oMap[$id])
        }
        else {
            # True conflict: both changed to different values
            $conflictIds.Add($id)
            $merged.Add((New-ConflictEntry $id $bMap[$id].Comment $oMap[$id] $tMap[$id] $lt $sep $gt))
        }
    }

    return @{
        Entries     = Sort-LabelEntries -Entries $merged
        ConflictIds = $conflictIds
    }
}

# ─── Entry point ─────────────────────────────────────────────────────────────

if ($PSCmdlet.ParameterSetName -eq 'MergeDriver') {

    $displayPath = if ($FilePath) { $FilePath } else { $Ours }
    Write-Host "Merging label file: $displayPath"

    $result = Invoke-LabelMerge `
        -BaseList   (Read-LabelEntries -Path $Base) `
        -OursList   (Read-LabelEntries -Path $Ours) `
        -TheirsList (Read-LabelEntries -Path $Theirs) `
        -MarkerSize $MarkerSize

    Save-LabelFile -Path $Ours -Entries $result.Entries

    if ($result.ConflictIds.Count -gt 0) {
        Write-Warning "Merge conflicts in '$displayPath' for label(s): $($result.ConflictIds -join ', ')"
        exit 1
    }

    exit 0
}

# ── Standalone / pipeline mode ────────────────────────────────────────────────

$labelFiles = @(Get-ChildItem -Path $RepoRoot -Filter '*.label.txt' -Recurse -File |
    Where-Object { $_.FullName -replace '\\', '/' -match '/AxLabelFile/LabelResources/[^/]+/' })

if ($labelFiles.Count -eq 0) {
    Write-Host 'No label files found matching **/AxLabelFile/LabelResources/*/*.label.txt'
    exit 0
}

$changedCount = 0

foreach ($file in $labelFiles) {
    $entries = Read-LabelEntries -Path $file.FullName
    $sorted  = Sort-LabelEntries -Entries $entries
    $before  = Format-LabelFile  -Entries $entries
    $after   = Format-LabelFile  -Entries $sorted

    if ($before -ne $after) {
        Save-LabelFile -Path $file.FullName -Entries $sorted
        Write-Host "Sorted: $($file.FullName)"
        $changedCount++
    }
    else {
        Write-Host "OK: $($file.FullName)"
    }
}

Write-Host ''
Write-Host "Done. $changedCount file(s) updated."