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')
        if ($appData) {
            $path = Join-Path $appData 'SourceGit/preference.json'
        }
    }

    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
    )
    # 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 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
    )

    $bookmark = Get-WtwSourceGitBookmark -Hex $Hex

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

    $decision = Resolve-WtwSourceGitConflict -OperationLabel "register '$Name'"
    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 ($prefs.PSObject.Properties.Name -contains 'RepositoryNodes') -or -not $prefs.RepositoryNodes) {
        $prefs | Add-Member -NotePropertyName 'RepositoryNodes' -NotePropertyValue @() -Force
    }

    $id = ConvertTo-WtwSourceGitId -Path $Path

    $nodes = @($prefs.RepositoryNodes)
    $existing = $nodes | Where-Object { $_.Id -eq $id } | Select-Object -First 1
    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     = @()
        }
        $prefs.RepositoryNodes = @($nodes + $node)
        Save-WtwSourceGitPreferences -Path $prefPath -Preferences $prefs
        Write-Host " SourceGit: registered '$Name' (bookmark $bookmark)." -ForegroundColor Green
    }

    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)

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

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

    if (-not ($prefs.PSObject.Properties.Name -contains 'RepositoryNodes') -or -not $prefs.RepositoryNodes) { return }

    $id = ConvertTo-WtwSourceGitId -Path $Path
    $nodes = @($prefs.RepositoryNodes)
    $remaining = @($nodes | Where-Object { $_.Id -ne $id })
    if ($remaining.Count -eq $nodes.Count) { return }

    $decision = Resolve-WtwSourceGitConflict -OperationLabel 'drop entry from preference.json'
    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)
        $remaining = @($nodes | Where-Object { $_.Id -ne $id })
        if ($remaining.Count -eq $nodes.Count) {
            Start-WtwSourceGitApp | Out-Null
            return
        }
    }

    $prefs.RepositoryNodes = $remaining
    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
    }
}