scripts/internal/worktree-awareness.ps1

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

$sharedGovernancePath = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) 'extensions\specrew-speckit\scripts\shared-governance.ps1'
if (-not (Test-Path -LiteralPath $sharedGovernancePath -PathType Leaf)) {
    throw "Missing shared governance helper '$sharedGovernancePath'."
}
. $sharedGovernancePath

function ConvertFrom-SpecrewFrontmatterBlock {
    param([AllowNull()][string]$Content)

    $frontmatter = [ordered]@{}
    if ([string]::IsNullOrWhiteSpace($Content) -or $Content -notmatch '(?ms)^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?(.*)$') {
        return $frontmatter
    }

    foreach ($line in ($Matches[1] -split '\r?\n')) {
        if ($line -match '^\s*([^:]+):\s*(.*?)\s*$') {
            $key = $Matches[1].Trim()
            $value = $Matches[2].Trim()
            if (($value.StartsWith('"') -and $value.EndsWith('"')) -or ($value.StartsWith("'") -and $value.EndsWith("'"))) {
                $value = $value.Substring(1, $value.Length - 2)
            }
            $frontmatter[$key] = $value
        }
    }

    return $frontmatter
}

function Get-WorktreeSessionState {
    param([Parameter(Mandatory = $true)][string]$WorktreePath)

    $promptPath = Join-Path $WorktreePath '.specrew\last-start-prompt.md'
    if (-not (Test-Path -LiteralPath $promptPath -PathType Leaf)) {
        return $null
    }

    $frontmatter = ConvertFrom-SpecrewFrontmatterBlock -Content (Get-Content -LiteralPath $promptPath -Raw -Encoding UTF8)
    if ($frontmatter.Count -eq 0) {
        return $null
    }

    return [pscustomobject]@{
        feature_ref   = if ($frontmatter.Contains('session_state_feature')) { [string]$frontmatter['session_state_feature'] } else { $null }
        boundary_type = if ($frontmatter.Contains('session_state_boundary')) { [string]$frontmatter['session_state_boundary'] } else { $null }
        recorded_at   = if ($frontmatter.Contains('session_state_recorded_at')) { [string]$frontmatter['session_state_recorded_at'] } else { $null }
        feature_path  = if ($frontmatter.Contains('session_state_feature_path')) { [string]$frontmatter['session_state_feature_path'] } else { $null }
        iteration     = if ($frontmatter.Contains('session_state_iteration')) { [string]$frontmatter['session_state_iteration'] } else { $null }
    }
}

function Get-WorktreeFeatureRef {
    param([Parameter(Mandatory = $true)][string]$WorktreePath)

    $featureJsonPath = Join-Path $WorktreePath '.specify\feature.json'
    if (Test-Path -LiteralPath $featureJsonPath -PathType Leaf) {
        try {
            # F-023: Use -AsHashtable for StrictMode compatibility; hashtable indexer tolerates missing fields
            $featureJson = Get-Content -LiteralPath $featureJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json -AsHashtable -Depth 12

            # F-023: Legacy schema handling - missing 'schema' field implies v0
            $schema = Get-SpecrewStateSchemaVersion -State $featureJson -Path $featureJsonPath
            # v0 behavior: feature_directory field is optional
            # v1+ behavior: same as v0 for this field (no behavioral divergence yet)

            if (-not [string]::IsNullOrWhiteSpace([string]$featureJson['feature_directory'])) {
                return Split-Path -Leaf ([string]$featureJson['feature_directory'])
            }
        }
        catch {
            if (Test-IsUnsupportedSpecrewSchemaError -ErrorRecord $_) {
                throw
            }
        }
    }

    $sessionState = Get-WorktreeSessionState -WorktreePath $WorktreePath
    if ($null -ne $sessionState -and -not [string]::IsNullOrWhiteSpace([string]$sessionState.feature_ref) -and [string]$sessionState.feature_ref -ne '(none)') {
        return [string]$sessionState.feature_ref
    }

    return $null
}

function Get-WorktreeBoundarySummary {
    param([Parameter(Mandatory = $true)][string]$WorktreePath)

    $sessionState = Get-WorktreeSessionState -WorktreePath $WorktreePath
    if ($null -ne $sessionState -and -not [string]::IsNullOrWhiteSpace([string]$sessionState.boundary_type) -and [string]$sessionState.boundary_type -ne '(none)') {
        return $sessionState
    }

    return [pscustomobject]@{
        boundary_type = $null
        recorded_at   = $null
        feature_path  = $null
        iteration     = $null
    }
}

function Get-WorktreeFeatureNumber {
    param([AllowNull()][string]$FeatureRef)

    if ([string]::IsNullOrWhiteSpace($FeatureRef)) {
        return $null
    }

    if ($FeatureRef -match '^(?<number>\d{3})[-_]') {
        return $Matches['number']
    }

    return $FeatureRef
}

function Get-WorktreeRecords {
    param([Parameter(Mandatory = $true)][string]$ProjectRoot)

    $resolvedProjectRoot = Resolve-ProjectPath -Path $ProjectRoot
    $output = @(& git -C $resolvedProjectRoot worktree list --porcelain 2>&1)
    if ($LASTEXITCODE -ne 0) {
        throw ("Failed to enumerate git worktrees: {0}" -f (($output -join [Environment]::NewLine).Trim()))
    }

    $records = New-Object System.Collections.Generic.List[object]
    $current = $null
    foreach ($line in $output) {
        $text = [string]$line
        if ($text -match '^worktree\s+(.+)$') {
            if ($null -ne $current) {
                $records.Add([pscustomobject]$current) | Out-Null
            }

            $current = [ordered]@{
                path     = $Matches[1].Trim()
                branch   = $null
                head     = $null
                prunable = $false
            }
            continue
        }

        if ($null -eq $current -or [string]::IsNullOrWhiteSpace($text)) {
            continue
        }

        if ($text -match '^branch\s+(.+)$') {
            $current.branch = $Matches[1].Trim()
        }
        elseif ($text -match '^HEAD\s+(.+)$') {
            $current.head = $Matches[1].Trim()
        }
        elseif ($text -match '^prunable') {
            $current.prunable = $true
        }
    }

    if ($null -ne $current) {
        $records.Add([pscustomobject]$current) | Out-Null
    }

    return $records.ToArray()
}

function Get-WorktreeState {
    param([Parameter(Mandatory = $true)][string]$ProjectRoot)

    $resolvedProjectRoot = Resolve-ProjectPath -Path $ProjectRoot
    $records = @(Get-WorktreeRecords -ProjectRoot $resolvedProjectRoot)
    $states = New-Object System.Collections.Generic.List[object]

    foreach ($record in $records) {
        $worktreePath = [System.IO.Path]::GetFullPath([string]$record.path)
        $exists = Test-Path -LiteralPath $worktreePath -PathType Container
        $featureRef = if ($exists) { Get-WorktreeFeatureRef -WorktreePath $worktreePath } else { $null }
        $boundary = if ($exists) { Get-WorktreeBoundarySummary -WorktreePath $worktreePath } else { $null }
        $lastActivity = if ($null -ne $boundary -and -not [string]::IsNullOrWhiteSpace([string]$boundary.recorded_at) -and [string]$boundary.recorded_at -ne '(none)') {
            [string]$boundary.recorded_at
        }
        elseif ($exists) {
            $promptPath = Join-Path $worktreePath '.specrew\last-start-prompt.md'
            if (Test-Path -LiteralPath $promptPath -PathType Leaf) {
                (Get-Item -LiteralPath $promptPath).LastWriteTimeUtc.ToString('o')
            }
            else {
                $null
            }
        }
        else {
            $null
        }

        $states.Add([pscustomobject]@{
                path           = $worktreePath
                is_current     = ($worktreePath -eq $resolvedProjectRoot)
                exists         = $exists
                feature_ref    = $featureRef
                feature_number = Get-WorktreeFeatureNumber -FeatureRef $featureRef
                boundary_type  = if ($exists -and $null -ne $boundary) { [string]$boundary.boundary_type } else { $null }
                last_activity  = $lastActivity
                branch         = [string]$record.branch
                head           = [string]$record.head
                note           = if (-not $exists) { '(path not found; run git worktree prune)' } elseif ($record.prunable) { '(prunable)' } else { $null }
            }) | Out-Null
    }

    return $states.ToArray()
}