private/Invoke-WtwSourceGit.ps1

function Get-WtwSourceGitPreferencePath {
    <#
    .SYNOPSIS
        Return SourceGit's preference.json path when SourceGit is installed.
    .DESCRIPTION
        SourceGit (cross-platform) stores its managed repository list in
        <DataDir>/preference.json under the RepositoryNodes array. DataDir per
        platform (mirrors src/Native/{MacOS,Linux,Windows}.cs GetDataDir):
          - macOS: ~/Library/Application Support/SourceGit
          - Linux: ~/.sourcegit
          - Windows: %APPDATA%\SourceGit
        Portable installs (Windows exe-dir\data, AppImage-dir\data) aren't
        auto-detected — set WTW_SOURCEGIT_PREF to override. Returns $null when
        the file is missing (treated as "SourceGit not configured here").
    #>

    $override = [Environment]::GetEnvironmentVariable('WTW_SOURCEGIT_PREF')
    if ($override) {
        return $(if (Test-Path $override) { $override } else { $null })
    }

    $path = $null
    if ($IsMacOS) {
        $path = Join-Path $HOME 'Library/Application Support/SourceGit/preference.json'
    } elseif ($IsLinux) {
        $path = Join-Path $HOME '.sourcegit/preference.json'
    } elseif ($IsWindows -or $env:OS -eq 'Windows_NT') {
        $appData = [Environment]::GetFolderPath('ApplicationData')
        $scoopUser = [Environment]::GetEnvironmentVariable('SCOOP')
        if (-not $scoopUser) { $scoopUser = Join-Path $HOME 'scoop' }
        
        $candidates = @()
        if ($appData) { $candidates += Join-Path $appData 'SourceGit/preference.json' }
        $candidates += Join-Path $scoopUser 'persist/sourcegit/data/preference.json'

        foreach ($c in $candidates) {
            if (Test-Path $c) {
                $path = $c
                break
            }
        }
    }

    if (-not $path -or -not (Test-Path $path)) { return $null }
    return $path
}

function Test-WtwSourceGitRunning {
    $proc = Get-Process -Name 'SourceGit' -ErrorAction SilentlyContinue
    return [bool]$proc
}

function Stop-WtwSourceGitProcess {
    <#
    .SYNOPSIS
        Stop SourceGit and wait for it to exit. Returns $true on success.
    #>

    param([int] $TimeoutSeconds = 10)

    $procs = @(Get-Process -Name 'SourceGit' -ErrorAction SilentlyContinue)
    if ($procs.Count -eq 0) { return $true }

    foreach ($p in $procs) {
        try { $p.CloseMainWindow() | Out-Null } catch { }
    }
    $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
    while ((Get-Date) -lt $deadline) {
        if (-not (Test-WtwSourceGitRunning)) { return $true }
        Start-Sleep -Milliseconds 250
    }

    # Still running — escalate to Kill
    foreach ($p in @(Get-Process -Name 'SourceGit' -ErrorAction SilentlyContinue)) {
        try { $p.Kill($true) } catch { try { $p.Kill() } catch { } }
    }
    $deadline = (Get-Date).AddSeconds(5)
    while ((Get-Date) -lt $deadline) {
        if (-not (Test-WtwSourceGitRunning)) { return $true }
        Start-Sleep -Milliseconds 250
    }
    return -not (Test-WtwSourceGitRunning)
}

function Start-WtwSourceGitApp {
    <#
    .SYNOPSIS
        Relaunch SourceGit on the current platform. Best-effort.
    #>

    param([string] $OpenPath)

    try {
        if ($IsMacOS) {
            $bin = '/Applications/SourceGit.app/Contents/MacOS/SourceGit'
            if (Test-Path $bin) {
                # Invoke binary directly so argv reaches SourceGit's IPC handler; `open -a` drops the path
                # (Apple file-open event) and `open -n --args` is suppressed by single-instance lock.
                if ($OpenPath) { Start-Process -FilePath $bin -ArgumentList $OpenPath }
                else           { Start-Process -FilePath $bin }
                return $true
            }
        } elseif ($IsLinux) {
            $cmd = Get-Command sourcegit -ErrorAction SilentlyContinue
            if ($cmd) {
                if ($OpenPath) { Start-Process -FilePath $cmd.Source -ArgumentList $OpenPath } else { Start-Process -FilePath $cmd.Source }
                return $true
            }
        } elseif ($IsWindows -or $env:OS -eq 'Windows_NT') {
            $candidates = @(
                (Join-Path ${env:LOCALAPPDATA} 'SourceGit/SourceGit.exe'),
                (Join-Path ${env:ProgramFiles} 'SourceGit/SourceGit.exe')
            ) | Where-Object { $_ -and (Test-Path $_) }
            $exe = $candidates | Select-Object -First 1
            if (-not $exe) {
                $cmd = Get-Command SourceGit -ErrorAction SilentlyContinue
                if ($cmd) { $exe = $cmd.Source }
            }
            if ($exe) {
                if ($OpenPath) { Start-Process -FilePath $exe -ArgumentList $OpenPath } else { Start-Process -FilePath $exe }
                return $true
            }
        }
    } catch { }
    return $false
}

function Resolve-WtwSourceGitConflict {
    <#
    .SYNOPSIS
        When SourceGit is running, ask the user how to proceed before modifying
        preference.json. Caller must relaunch after the write if 'relaunch' is true.
    .OUTPUTS
        Hashtable @{ proceed=$bool; relaunch=$bool } — proceed=false means skip the write.
    #>

    param([string] $OperationLabel = 'update preference.json')

    if (-not (Test-WtwSourceGitRunning)) { return @{ proceed = $true; relaunch = $false } }

    Write-Host ''
    Write-Host ' SourceGit is running — it overwrites preference.json on exit.' -ForegroundColor Yellow
    Write-Host " How should I $OperationLabel"'?' -ForegroundColor Yellow
    Write-Host ' [c] Close SourceGit, then write (I will wait, then relaunch)'
    Write-Host ' [k] Force-kill SourceGit, write, relaunch'
    Write-Host ' [i] Ignore — write anyway (you restart SourceGit later)'
    Write-Host ' [s] Skip — do not modify preference.json'

    $answer = (Read-Host ' Choice [c/k/i/s]').Trim().ToLowerInvariant()
    if (-not $answer) { $answer = 'c' }

    switch ($answer) {
        'c' {
            Write-Host ' Waiting for SourceGit to close (Ctrl+C to abort)...' -ForegroundColor Cyan
            while (Test-WtwSourceGitRunning) { Start-Sleep -Milliseconds 500 }
            Write-Host ' SourceGit closed.' -ForegroundColor Green
            return @{ proceed = $true; relaunch = $true }
        }
        'k' {
            Write-Host ' Force-closing SourceGit...' -ForegroundColor Cyan
            if (-not (Stop-WtwSourceGitProcess)) {
                Write-Host ' Could not stop SourceGit — skipping write.' -ForegroundColor Red
                return @{ proceed = $false; relaunch = $false }
            }
            Write-Host ' SourceGit stopped.' -ForegroundColor Green
            return @{ proceed = $true; relaunch = $true }
        }
        's' {
            Write-Host ' Skipped SourceGit update.' -ForegroundColor DarkGray
            return @{ proceed = $false; relaunch = $false }
        }
        default {
            # 'i' or anything else → write while running; user restarts later
            Write-Host ' Writing anyway — restart SourceGit to pick up the change.' -ForegroundColor Yellow
            return @{ proceed = $true; relaunch = $false }
        }
    }
}

function ConvertTo-WtwSourceGitId {
    param([string] $Path)
    # Mirror SourceGit's RepositoryNode.Id setter: backslash → forward slash, trim trailing /
    $normalized = $Path.Replace('\', '/')
    $normalized = $normalized.TrimEnd('/')
    return $normalized
}

function Read-WtwSourceGitPreferences {
    param([string] $Path)
    try {
        $raw = Get-Content -Path $Path -Raw -ErrorAction Stop
        return ($raw | ConvertFrom-Json -Depth 100 -ErrorAction Stop)
    } catch {
        Write-Host " SourceGit: could not parse preference.json — skipping ($($_.Exception.Message))" -ForegroundColor Yellow
        return $null
    }
}

function Save-WtwSourceGitPreferences {
    param(
        [string] $Path,
        [psobject] $Preferences
    )
    Backup-WtwExternalConfig -System 'sourcegit' -Path $Path | Out-Null
    # Use Depth 100 so nested SubNodes survive round-trip. Write without BOM to match SourceGit's writer.
    $json = $Preferences | ConvertTo-Json -Depth 100
    $utf8NoBom = [System.Text.UTF8Encoding]::new($false)
    [System.IO.File]::WriteAllText($Path, $json, $utf8NoBom)
}

function Get-WtwSourceGitWriteDecision {
    param(
        [string] $OperationLabel,
        [switch] $Force
    )
    if ($Force -or $script:WtwSourceGitForce -eq $true -or $env:WTW_SOURCEGIT_FORCE -eq '1') {
        return @{ proceed = $true; relaunch = $false }
    }
    return Resolve-WtwSourceGitConflict -OperationLabel $OperationLabel
}

function Find-WtwSourceGitNode {
    <#
    .SYNOPSIS
        Find a RepositoryNode by Id, including nested SubNodes.
    #>

    param(
        $Nodes,
        [Parameter(Mandatory)]
        [string] $Id
    )

    foreach ($node in @($Nodes)) {
        if ($null -eq $node) { continue }
        if ((Get-WtwPropertyValue -Object $node -Name 'Id') -eq $Id) { return $node }
        $sub = Get-WtwPropertyValue -Object $node -Name 'SubNodes'
        if ($sub) {
            $found = Find-WtwSourceGitNode -Nodes @($sub) -Id $Id
            if ($found) { return $found }
        }
    }
    return $null
}

function Remove-WtwSourceGitNodeById {
    <#
    .SYNOPSIS
        Drop a RepositoryNode by Id from a tree, including nested SubNodes.
    #>

    param(
        [AllowNull()]
        [object[]] $Nodes,

        [Parameter(Mandatory)]
        [string] $Id
    )

    $kept = [System.Collections.Generic.List[object]]::new()
    $removed = $false
    foreach ($node in @($Nodes)) {
        if ($null -eq $node) { continue }
        if ((Get-WtwPropertyValue -Object $node -Name 'Id') -eq $Id) {
            $removed = $true
            continue
        }
        $sub = @(Get-WtwPropertyValue -Object $node -Name 'SubNodes')
        if ($sub.Count -gt 0 -and $null -ne $sub[0]) {
            $result = Remove-WtwSourceGitNodeById -Nodes $sub -Id $Id
            $node | Add-Member -NotePropertyName 'SubNodes' -NotePropertyValue @($result.Nodes) -Force
            if ($result.Removed) { $removed = $true }
        }
        $kept.Add($node)
    }
    return [PSCustomObject]@{
        Nodes   = @($kept.ToArray())
        Removed = $removed
    }
}

function Update-WtwSourceGitNodeNames {
    <#
    .SYNOPSIS
        Rename matching SourceGit nodes (by Id and/or current Name).
    #>

    param(
        $Nodes,
        [string[]] $MatchIds,
        [string[]] $MatchNames,
        [Parameter(Mandatory)]
        [string] $NewName
    )

    $idSet = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
    foreach ($id in @($MatchIds)) {
        if ($id) { [void]$idSet.Add($id) }
    }
    $nameSet = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
    foreach ($name in @($MatchNames)) {
        if ($name) { [void]$nameSet.Add($name) }
    }

    $changed = $false
    foreach ($node in @($Nodes)) {
        if ($null -eq $node) { continue }
        $id = Get-WtwPropertyValue -Object $node -Name 'Id'
        $name = Get-WtwPropertyValue -Object $node -Name 'Name'
        $matches = ($id -and $idSet.Contains([string]$id)) -or ($name -and $nameSet.Contains([string]$name))
        if ($matches -and $name -ne $NewName) {
            $node.Name = $NewName
            $changed = $true
        }
        $sub = Get-WtwPropertyValue -Object $node -Name 'SubNodes'
        if ($sub) {
            if (Update-WtwSourceGitNodeNames -Nodes @($sub) -MatchIds $MatchIds -MatchNames $MatchNames -NewName $NewName) {
                $changed = $true
            }
        }
    }
    return $changed
}

function Add-WtwSourceGitRepository {
    <#
    .SYNOPSIS
        Register a worktree in SourceGit's managed repository list (macOS only).
    .DESCRIPTION
        Appends or updates a RepositoryNode entry pointing at the worktree path.
        Silently no-ops when not on macOS, when SourceGit isn't installed, or
        when the preference file is missing. Warns when SourceGit is running
        because it overwrites preference.json on exit.
    #>

    param(
        [Parameter(Mandatory)][string] $Path,
        [Parameter(Mandatory)][string] $Name,
        [string] $Hex,
        [string] $RepoName,
        [switch] $Force
    )

    $bookmark = Get-WtwSourceGitBookmark -Hex $Hex

    $prefPath = Get-WtwSourceGitPreferencePath
    if (-not $prefPath) { return }

    $decision = Get-WtwSourceGitWriteDecision -OperationLabel "register '$Name'" -Force:$Force
    if (-not $decision.proceed) { return }

    $prefs = Read-WtwSourceGitPreferences -Path $prefPath
    if (-not $prefs) {
        if ($decision.relaunch) { Start-WtwSourceGitApp -OpenPath $Path | Out-Null }
        return
    }

    if (-not ((Get-WtwPropertyNames -Object $prefs) -contains 'RepositoryNodes') -or -not $prefs.RepositoryNodes) {
        $prefs | Add-Member -NotePropertyName 'RepositoryNodes' -NotePropertyValue @() -Force
    }

    $id = ConvertTo-WtwSourceGitId -Path $Path

    $nodes = @($prefs.RepositoryNodes)
    $existing = Find-WtwSourceGitNode -Nodes $nodes -Id $id
    if ($existing) {
        $existing.Name = $Name
        $existing.IsRepository = $true
        if ($bookmark -gt 0) { $existing.Bookmark = $bookmark }
        $prefs.RepositoryNodes = $nodes
        Save-WtwSourceGitPreferences -Path $prefPath -Preferences $prefs
        Write-Host " SourceGit: updated entry '$Name' (bookmark $($existing.Bookmark))." -ForegroundColor Green
    } else {
        $node = [PSCustomObject]@{
            Id           = $id
            Name         = $Name
            Bookmark     = $bookmark
            IsRepository = $true
            IsExpanded   = $false
            Status       = $null
            SubNodes     = @()
        }
        $owner = Resolve-WtwSourceGitRepoOwner -Path $Path -RepoName $RepoName
        $placed = $false
        if ($owner) {
            $skipNest = $false
            if ((Get-WtwPropertyNames -Object $owner.Entry) -contains 'sourceGitFolder') {
                $skipNest = ($owner.Entry.sourceGitFolder -eq $false)
            }
            $folder = Find-WtwSourceGitRepoFolder -Nodes $nodes -RepoName $owner.Name -RepoEntry $owner.Entry
            if ($folder) {
                $subs = @(Get-WtwPropertyValue -Object $folder -Name 'SubNodes')
                $folder | Add-Member -NotePropertyName 'SubNodes' -NotePropertyValue @($subs + $node) -Force
                $prefs.RepositoryNodes = $nodes
                $placed = $true
                Write-Host " SourceGit: registered '$Name' under folder '$($folder.Name)' (bookmark $bookmark)." -ForegroundColor Green
            } elseif (-not $skipNest -and ((Get-WtwPropertyNames -Object $owner.Entry) -contains 'sourceGitFolder') -and $owner.Entry.sourceGitFolder -eq $true) {
                $folder = New-WtwSourceGitFolderNode -RepoName $owner.Name -RepoEntry $owner.Entry -Bookmark $bookmark
                Move-WtwSourceGitMainIntoFolder -Preferences $prefs -Folder $folder -MainPath $owner.Entry.mainPath
                $subs = @(Get-WtwPropertyValue -Object $folder -Name 'SubNodes')
                $folder | Add-Member -NotePropertyName 'SubNodes' -NotePropertyValue @($subs + $node) -Force
                if (-not (Find-WtwSourceGitNode -Nodes @($prefs.RepositoryNodes) -Id $folder.Id)) {
                    $prefs.RepositoryNodes = @($prefs.RepositoryNodes + $folder)
                }
                $placed = $true
                Write-Host " SourceGit: created folder '$($folder.Name)' and registered '$Name' (bookmark $bookmark)." -ForegroundColor Green
            }
        }
        if (-not $placed) {
            $prefs.RepositoryNodes = @($nodes + $node)
            Write-Host " SourceGit: registered '$Name' (bookmark $bookmark)." -ForegroundColor Green
        }
        Save-WtwSourceGitPreferences -Path $prefPath -Preferences $prefs
    }

    if ($decision.relaunch) {
        Write-Host ' SourceGit: relaunching...' -ForegroundColor Cyan
        Start-WtwSourceGitApp -OpenPath $Path | Out-Null
    }
}

function Remove-WtwSourceGitRepository {
    <#
    .SYNOPSIS
        Drop a worktree from SourceGit's managed repository list (macOS only).
    .DESCRIPTION
        Removes any RepositoryNode whose Id matches the given path. Silent no-op
        when not on macOS, when SourceGit is missing, or when no match is found.
    #>

    param(
        [Parameter(Mandatory)][string] $Path,
        [switch] $Force
    )

    $prefPath = Get-WtwSourceGitPreferencePath
    if (-not $prefPath) { return }

    $prefs = Read-WtwSourceGitPreferences -Path $prefPath
    if (-not $prefs) { return }

    if (-not ((Get-WtwPropertyNames -Object $prefs) -contains 'RepositoryNodes') -or -not $prefs.RepositoryNodes) { return }

    $id = ConvertTo-WtwSourceGitId -Path $Path
    $nodes = @($prefs.RepositoryNodes)
    $pruned = Remove-WtwSourceGitNodeById -Nodes $nodes -Id $id
    if (-not $pruned.Removed) { return }

    $decision = Get-WtwSourceGitWriteDecision -OperationLabel 'drop entry from preference.json' -Force:$Force
    if (-not $decision.proceed) { return }

    # Re-read after potentially closing SourceGit so we don't overwrite its on-exit writes
    if ($decision.relaunch) {
        $prefs = Read-WtwSourceGitPreferences -Path $prefPath
        if (-not $prefs) {
            Start-WtwSourceGitApp | Out-Null
            return
        }
        $nodes = @($prefs.RepositoryNodes)
        $pruned = Remove-WtwSourceGitNodeById -Nodes $nodes -Id $id
        if (-not $pruned.Removed) {
            Start-WtwSourceGitApp | Out-Null
            return
        }
    }

    $prefs.RepositoryNodes = @($pruned.Nodes)
    Save-WtwSourceGitPreferences -Path $prefPath -Preferences $prefs
    Write-Host ' SourceGit: removed entry.' -ForegroundColor Green

    if ($decision.relaunch) {
        Write-Host ' SourceGit: relaunching...' -ForegroundColor Cyan
        Start-WtwSourceGitApp | Out-Null
    }
}

function Sync-WtwSourceGitRepoDisplayName {
    <#
    .SYNOPSIS
        Apply a repo's emoji-prefixed display name to SourceGit nodes.
    .DESCRIPTION
        Updates the main-checkout node (by path Id) and any group/node whose
        Name is the registry key or a previous display name. Registers the
        main path if SourceGit has no matching node yet.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string] $RepoName,
        [Parameter(Mandatory)] $RepoEntry,
        [string] $PreviousDisplayName,
        [switch] $Force
    )

    $prefPath = Get-WtwSourceGitPreferencePath
    if (-not $prefPath) { return }

    $mainPath = Get-WtwPropertyValue -Object $RepoEntry -Name 'mainPath'
    if (-not $mainPath) { return }

    $display = Format-WtwRepoDisplayName -Name $RepoName -RepoEntry $RepoEntry
    $hex = Get-WtwPropertyValue -Object (Get-WtwColors).assignments -Name "$RepoName/main"
    $id = ConvertTo-WtwSourceGitId -Path $mainPath

    $decision = Get-WtwSourceGitWriteDecision -OperationLabel "rename repo to '$display'" -Force:$Force
    if (-not $decision.proceed) { return }

    if ($decision.relaunch) {
        $prefs = Read-WtwSourceGitPreferences -Path $prefPath
    } else {
        $prefs = Read-WtwSourceGitPreferences -Path $prefPath
    }
    if (-not $prefs) {
        if ($decision.relaunch) { Start-WtwSourceGitApp | Out-Null }
        return
    }

    if (-not ((Get-WtwPropertyNames -Object $prefs) -contains 'RepositoryNodes') -or -not $prefs.RepositoryNodes) {
        Add-WtwSourceGitRepository -Path $mainPath -Name $display -Hex $hex -Force
        if ($decision.relaunch) { Start-WtwSourceGitApp -OpenPath $mainPath | Out-Null }
        return
    }

    $matchNames = @($RepoName, $PreviousDisplayName) | Where-Object { $_ -and $_ -ne $display }
    $changed = Update-WtwSourceGitNodeNames `
        -Nodes @($prefs.RepositoryNodes) `
        -MatchIds @($id, "wtw-folder:$RepoName") `
        -MatchNames $matchNames `
        -NewName $display

    if (-not $changed) {
        if (-not (Find-WtwSourceGitNode -Nodes @($prefs.RepositoryNodes) -Id $id)) {
            Add-WtwSourceGitRepository -Path $mainPath -Name $display -Hex $hex -Force
        }
        if ($decision.relaunch) { Start-WtwSourceGitApp -OpenPath $mainPath | Out-Null }
        return
    }

    Save-WtwSourceGitPreferences -Path $prefPath -Preferences $prefs
    Write-Host " SourceGit: repo display name is now '$display'." -ForegroundColor Green

    if ($decision.relaunch) {
        Write-Host ' SourceGit: relaunching...' -ForegroundColor Cyan
        Start-WtwSourceGitApp -OpenPath $mainPath | Out-Null
    }
}

function Resolve-WtwSourceGitRepoOwner {
    param(
        [string] $Path,
        [string] $RepoName
    )

    $registry = Get-WtwRegistry
    if (-not $registry -or -not $registry.repos) { return $null }
    if ($RepoName -and ((Get-WtwPropertyNames -Object $registry.repos) -contains $RepoName)) {
        return [PSCustomObject]@{ Name = $RepoName; Entry = $registry.repos.$RepoName }
    }
    if (-not $Path) { return $null }
    $id = ConvertTo-WtwSourceGitId -Path $Path
    foreach ($name in (Get-WtwPropertyNames -Object $registry.repos)) {
        $entry = $registry.repos.$name
        $mainPath = Get-WtwPropertyValue -Object $entry -Name 'mainPath'
        if ($mainPath -and (ConvertTo-WtwSourceGitId -Path $mainPath) -eq $id) {
            return [PSCustomObject]@{ Name = $name; Entry = $entry }
        }
        if (-not $entry.worktrees) { continue }
        foreach ($task in (Get-WtwPropertyNames -Object $entry.worktrees)) {
            $wtPath = Get-WtwPropertyValue -Object $entry.worktrees.$task -Name 'path'
            if ($wtPath -and (ConvertTo-WtwSourceGitId -Path $wtPath) -eq $id) {
                return [PSCustomObject]@{ Name = $name; Entry = $entry }
            }
        }
    }
    return $null
}

function Find-WtwSourceGitRepoFolder {
    param(
        $Nodes,
        [Parameter(Mandatory)] [string] $RepoName,
        $RepoEntry
    )

    $folderId = "wtw-folder:$RepoName"
    $display = if ($RepoEntry) { Format-WtwRepoDisplayName -Name $RepoName -RepoEntry $RepoEntry } else { $RepoName }
    $mainId = $null
    if ($RepoEntry) {
        $mainPath = Get-WtwPropertyValue -Object $RepoEntry -Name 'mainPath'
        if ($mainPath) { $mainId = ConvertTo-WtwSourceGitId -Path $mainPath }
    }
    $displayKey = ConvertTo-WtwLookupKey $display
    $repoKey = ConvertTo-WtwLookupKey $RepoName

    foreach ($node in @($Nodes)) {
        if ($null -eq $node) { continue }
        $id = Get-WtwPropertyValue -Object $node -Name 'Id'
        $isRepo = Get-WtwPropertyValue -Object $node -Name 'IsRepository'
        $name = Get-WtwPropertyValue -Object $node -Name 'Name'
        $isFolder = ($isRepo -eq $false) -or ($id -eq $folderId)
        if ($isFolder) {
            if ($id -eq $folderId) { return $node }
            $nameKey = ConvertTo-WtwLookupKey $name
            if ($nameKey -and ($nameKey -eq $displayKey -or $nameKey -eq $repoKey)) { return $node }
            if ($mainId) {
                $sub = @(Get-WtwPropertyValue -Object $node -Name 'SubNodes')
                if (Find-WtwSourceGitNode -Nodes $sub -Id $mainId) { return $node }
            }
        }
        $nested = Get-WtwPropertyValue -Object $node -Name 'SubNodes'
        if ($nested) {
            $found = Find-WtwSourceGitRepoFolder -Nodes @($nested) -RepoName $RepoName -RepoEntry $RepoEntry
            if ($found) { return $found }
        }
    }
    return $null
}

function New-WtwSourceGitFolderNode {
    param(
        [Parameter(Mandatory)] [string] $RepoName,
        [Parameter(Mandatory)] $RepoEntry,
        [int] $Bookmark = 5
    )

    $display = Format-WtwRepoDisplayName -Name $RepoName -RepoEntry $RepoEntry
    return [PSCustomObject]@{
        Id           = "wtw-folder:$RepoName"
        Name         = $display
        Bookmark     = $Bookmark
        IsRepository = $false
        IsExpanded   = $true
        Status       = $null
        SubNodes     = @()
    }
}

function Move-WtwSourceGitMainIntoFolder {
    param(
        [Parameter(Mandatory)] $Preferences,
        [Parameter(Mandatory)] $Folder,
        [string] $MainPath
    )

    if (-not $MainPath) { return }
    $mainId = ConvertTo-WtwSourceGitId -Path $MainPath
    $already = Find-WtwSourceGitNode -Nodes @(Get-WtwPropertyValue -Object $Folder -Name 'SubNodes') -Id $mainId
    if ($already) { return }
    $main = Find-WtwSourceGitNode -Nodes @($Preferences.RepositoryNodes) -Id $mainId
    if (-not $main) { return }
    $pruned = Remove-WtwSourceGitNodeById -Nodes @($Preferences.RepositoryNodes) -Id $mainId
    $Preferences.RepositoryNodes = @($pruned.Nodes)
    $subs = @(Get-WtwPropertyValue -Object $Folder -Name 'SubNodes')
    $Folder | Add-Member -NotePropertyName 'SubNodes' -NotePropertyValue (@($main) + $subs) -Force
}

function Ensure-WtwSourceGitRepoFolder {
    <#
    .SYNOPSIS
        Create a SourceGit group for a repo and move its main checkout into it.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $RepoName,
        [Parameter(Mandatory)] $RepoEntry,
        [switch] $Force
    )

    $prefPath = Get-WtwSourceGitPreferencePath
    if (-not $prefPath) { return }

    $decision = Get-WtwSourceGitWriteDecision -OperationLabel "group '$RepoName' in a folder" -Force:$Force
    if (-not $decision.proceed) { return }

    $prefs = Read-WtwSourceGitPreferences -Path $prefPath
    if (-not $prefs) {
        if ($decision.relaunch) { Start-WtwSourceGitApp | Out-Null }
        return
    }
    if (-not ((Get-WtwPropertyNames -Object $prefs) -contains 'RepositoryNodes') -or -not $prefs.RepositoryNodes) {
        $prefs | Add-Member -NotePropertyName 'RepositoryNodes' -NotePropertyValue @() -Force
    }

    $folder = Find-WtwSourceGitRepoFolder -Nodes @($prefs.RepositoryNodes) -RepoName $RepoName -RepoEntry $RepoEntry
    if (-not $folder) {
        $hex = Get-WtwPropertyValue -Object (Get-WtwColors).assignments -Name "$RepoName/main"
        $bookmark = Get-WtwSourceGitBookmark -Hex $hex
        $folder = New-WtwSourceGitFolderNode -RepoName $RepoName -RepoEntry $RepoEntry -Bookmark $bookmark
        $prefs.RepositoryNodes = @($prefs.RepositoryNodes + $folder)
    }
    Move-WtwSourceGitMainIntoFolder -Preferences $prefs -Folder $folder -MainPath (Get-WtwPropertyValue -Object $RepoEntry -Name 'mainPath')
    Save-WtwSourceGitPreferences -Path $prefPath -Preferences $prefs
    Write-Host " SourceGit: using folder '$($folder.Name)' for $RepoName." -ForegroundColor Green
    if ($decision.relaunch) {
        Write-Host ' SourceGit: relaunching...' -ForegroundColor Cyan
        Start-WtwSourceGitApp | Out-Null
    }
}