From 2d64ac475a5cfe70236675e2bef793b76347e5ef Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 9 Oct 2026 07:53:23 -0500 Subject: [PATCH 1/4] Packaging: Scoop bucket, Chocolatey package, SHA256SUMS.txt (#9) - bucket/gclo.json: this repo is its own Scoop bucket; checkver github, autoupdate keyed on SHA256SUMS.txt. Verified locally with scoop install. - packaging/chocolatey: nuspec + Install-ChocolateyZipPackage script for the same gclo-cli-win-x64.zip asset. - .github/scripts/Update-PackageManifests.ps1: single source of truth that re-points both manifests at a stable release (refuses prereleases). - release.yml: write + attest + attach SHA256SUMS.txt on every release; on stable tags update the manifests, choco pack, choco push when CHOCO_API_KEY is set, and open a PR against dev with the updated manifests. - gclo.Cli.Tests/PackagingManifestTests.cs: both manifests valid, stable, and describing the same asset. - docs/RELEASING.md + README: install and secret docs. Closes #9 Co-Authored-By: Claude Fable 5.1 --- .github/scripts/Update-PackageManifests.ps1 | 80 +++++++++++++++++ .github/workflows/release.yml | 73 ++++++++++++++- README.md | 13 ++- bucket/gclo.json | 28 ++++++ docs/RELEASING.md | 21 +++++ gclo.Cli.Tests/PackagingManifestTests.cs | 90 +++++++++++++++++++ packaging/chocolatey/gclo.nuspec | 43 +++++++++ .../chocolatey/tools/chocolateyinstall.ps1 | 16 ++++ 8 files changed, 361 insertions(+), 3 deletions(-) create mode 100644 .github/scripts/Update-PackageManifests.ps1 create mode 100644 bucket/gclo.json create mode 100644 gclo.Cli.Tests/PackagingManifestTests.cs create mode 100644 packaging/chocolatey/gclo.nuspec create mode 100644 packaging/chocolatey/tools/chocolateyinstall.ps1 diff --git a/.github/scripts/Update-PackageManifests.ps1 b/.github/scripts/Update-PackageManifests.ps1 new file mode 100644 index 0000000..8aacc7d --- /dev/null +++ b/.github/scripts/Update-PackageManifests.ps1 @@ -0,0 +1,80 @@ +<# +.SYNOPSIS + Points the Scoop manifest (bucket/gclo.json) and the Chocolatey package + (packaging/chocolatey) at a stable release's CLI zip. + +.DESCRIPTION + Both package managers install the same asset, gclo-cli-win-x64.zip, by URL and + SHA-256. This script rewrites the version, URL, checksum, and release-notes + link in all three files from one source of truth: the version and the zip's + hash (computed from -CliZipPath, or taken from -Sha256). The release workflow + runs it on every stable tag before `choco pack` and opens a PR with the result + so the committed manifests always describe the latest stable release. + +.PARAMETER Version + The stable semantic version, e.g. 1.2.3 (no leading 'v'). + +.PARAMETER CliZipPath + Path to the built gclo-cli-win-x64.zip to hash. Either this or -Sha256. + +.PARAMETER Sha256 + The zip's SHA-256 (lowercase hex) when it is already known. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string]$Version, + [string]$CliZipPath, + [string]$Sha256 +) + +$ErrorActionPreference = 'Stop' + +if ($Version -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$') { + throw "Version '$Version' is not a stable semantic version (prerelease builds are not published to package managers)." +} +if (-not $Sha256) { + if (-not $CliZipPath) { throw 'Give -CliZipPath or -Sha256.' } + $Sha256 = (Get-FileHash -LiteralPath $CliZipPath -Algorithm SHA256).Hash.ToLowerInvariant() +} +if ($Sha256 -notmatch '^[0-9a-f]{64}$') { + throw "'$Sha256' is not a lowercase SHA-256." +} + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..') +$url = "https://github.com/KofTwentyTwo/gclo/releases/download/v$Version/gclo-cli-win-x64.zip" + +function Set-Content-Preserving([string]$path, [string]$text) { + # Keep the file's encoding (no BOM) and line endings as they are. + $bytes = [System.IO.File]::ReadAllBytes($path) + $hasBom = $bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF + [System.IO.File]::WriteAllText($path, $text, (New-Object System.Text.UTF8Encoding($hasBom))) +} + +# ---- Scoop: a real JSON edit, so the file stays valid whatever its layout. +$scoopPath = Join-Path $repoRoot 'bucket\gclo.json' +$scoop = Get-Content -LiteralPath $scoopPath -Raw | ConvertFrom-Json +$scoop.version = $Version +$scoop.architecture.'64bit'.url = $url +$scoop.architecture.'64bit'.hash = $Sha256 +$scoopJson = ($scoop | ConvertTo-Json -Depth 10) -replace '\\u0026', '&' +# ConvertTo-Json indents with four spaces and escapes '$'-free text; Scoop reads either way. +Set-Content-Preserving $scoopPath ($scoopJson + "`n") +Write-Host "Scoop manifest -> $Version" + +# ---- Chocolatey: nuspec version + release notes link, install script URL + checksum. +$nuspecPath = Join-Path $repoRoot 'packaging\chocolatey\gclo.nuspec' +$nuspec = Get-Content -LiteralPath $nuspecPath -Raw +$nuspec = $nuspec -replace '[^<]+', "$Version" +$nuspec = $nuspec -replace '[^<]+', "https://github.com/KofTwentyTwo/gclo/releases/tag/v$Version" +Set-Content-Preserving $nuspecPath $nuspec + +$installPath = Join-Path $repoRoot 'packaging\chocolatey\tools\chocolateyinstall.ps1' +$install = Get-Content -LiteralPath $installPath -Raw +$install = $install -replace "url64bit\s*=\s*'[^']+'", "url64bit = '$url'" +$install = $install -replace "checksum64\s*=\s*'[^']+'", "checksum64 = '$Sha256'" +Set-Content-Preserving $installPath $install +Write-Host "Chocolatey package -> $Version" + +if ($env:GITHUB_OUTPUT) { + Add-Content -Path $env:GITHUB_OUTPUT -Value "sha256=$Sha256" +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c7dcc30..37be17e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -160,6 +160,16 @@ jobs: - name: Velopack pack run: vpk pack --packId gclo --packVersion ${{ needs.verify.outputs.version }} --packDir publish/app --mainExe gclo.exe --channel ${{ needs.verify.outputs.channel }} --icon gclo/Assets/Brand/gclo.ico + # SHA256SUMS.txt: one line per asset (' *'), so users and the + # Scoop autoupdate can verify downloads without computing hashes by hand. + - name: Write SHA256SUMS.txt + shell: pwsh + run: | + $files = @(Get-Item artifacts/gclo-cli-win-x64.zip) + @(Get-ChildItem Releases -File | Where-Object { $_.Extension -in '.exe', '.zip', '.nupkg' }) + $lines = $files | ForEach-Object { "{0} *{1}" -f (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant(), $_.Name } + [System.IO.File]::WriteAllText("artifacts/SHA256SUMS.txt", ($lines -join "`n") + "`n", (New-Object System.Text.UTF8Encoding($false))) + Get-Content artifacts/SHA256SUMS.txt + # Provenance: a signed statement that THIS workflow run built these exact # bytes, verifiable with `gh attestation verify -R KofTwentyTwo/gclo`. # Not a code signature (see #57); it makes a release attributable to the @@ -169,6 +179,7 @@ jobs: with: subject-path: | artifacts/gclo-cli-win-x64.zip + artifacts/SHA256SUMS.txt Releases/*.exe Releases/*.zip Releases/*.nupkg @@ -185,10 +196,10 @@ jobs: if ('${{ needs.verify.outputs.prerelease }}' -eq 'true') { $preFlag = @('--pre') } vpk upload github --repoUrl $env:REPO_URL --token $env:GITHUB_TOKEN --channel ${{ needs.verify.outputs.channel }} --releaseName "gclo ${{ needs.verify.outputs.version }}" --tag $env:GITHUB_REF_NAME @preFlag - - name: Attach CLI zip to the draft release + - name: Attach CLI zip and checksums to the draft release env: GH_TOKEN: ${{ github.token }} - run: gh release upload $env:GITHUB_REF_NAME artifacts/gclo-cli-win-x64.zip --clobber + run: gh release upload $env:GITHUB_REF_NAME artifacts/gclo-cli-win-x64.zip artifacts/SHA256SUMS.txt --clobber # Auto-generated changelog (categorized by labels via .github/release.yml). - name: Generate release changelog @@ -222,9 +233,11 @@ jobs: env: NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} + CHOCO_API_KEY: ${{ secrets.CHOCO_API_KEY }} run: | Add-Content -Path $env:GITHUB_OUTPUT -Value ("nuget=" + (-not [string]::IsNullOrEmpty($env:NUGET_API_KEY)).ToString().ToLower()) Add-Content -Path $env:GITHUB_OUTPUT -Value ("winget=" + (-not [string]::IsNullOrEmpty($env:WINGET_TOKEN)).ToString().ToLower()) + Add-Content -Path $env:GITHUB_OUTPUT -Value ("choco=" + (-not [string]::IsNullOrEmpty($env:CHOCO_API_KEY)).ToString().ToLower()) - name: Push gclo.Engine to nuget.org if: steps.secrets.outputs.nuget == 'true' @@ -259,3 +272,59 @@ jobs: # Velopack names the installer '--Setup.exe'. $setupUrl = "$env:REPO_URL/releases/download/$env:GITHUB_REF_NAME/gclo-stable-Setup.exe" .\wingetcreate.exe update KofTwentyTwo.gclo --urls $setupUrl --version ${{ needs.verify.outputs.version }} --submit --token $env:WINGET_TOKEN + + # ---------------------------------------------------------------------- + # (e) Scoop + Chocolatey (#9), STABLE releases only. The repository is its + # own Scoop bucket (bucket/gclo.json) and ships the Chocolatey package + # source (packaging/chocolatey). Both are pointed at this release's CLI + # zip; the Chocolatey package is pushed when CHOCO_API_KEY is configured, + # and the updated manifests go back to dev as a pull request so the + # committed files always describe the latest stable release. + # ---------------------------------------------------------------------- + - name: Point the Scoop and Chocolatey manifests at this release + if: needs.verify.outputs.prerelease == 'false' + id: manifests + shell: pwsh + run: ./.github/scripts/Update-PackageManifests.ps1 -Version ${{ needs.verify.outputs.version }} -CliZipPath artifacts/gclo-cli-win-x64.zip + + - name: Pack the Chocolatey package + if: needs.verify.outputs.prerelease == 'false' + shell: pwsh + run: | + New-Item -ItemType Directory -Force artifacts/chocolatey | Out-Null + choco pack packaging/chocolatey/gclo.nuspec --outputdirectory artifacts/chocolatey + Get-ChildItem artifacts/chocolatey + + - name: Push the Chocolatey package + if: needs.verify.outputs.prerelease == 'false' && steps.secrets.outputs.choco == 'true' + shell: pwsh + env: + CHOCO_API_KEY: ${{ secrets.CHOCO_API_KEY }} + run: choco push (Get-ChildItem artifacts/chocolatey/*.nupkg | Select-Object -First 1).FullName --source https://push.chocolatey.org/ --api-key $env:CHOCO_API_KEY + + - name: Open a PR with the updated package manifests + if: needs.verify.outputs.prerelease == 'false' + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ secrets.DEPS_PAT || github.token }} + base: dev + branch: packaging/v${{ needs.verify.outputs.version }} + delete-branch: true + commit-message: 'Packaging: point the Scoop and Chocolatey manifests at v${{ needs.verify.outputs.version }}' + title: 'Packaging: Scoop and Chocolatey manifests for v${{ needs.verify.outputs.version }}' + body: | + Generated by the Release workflow for tag `${{ github.ref_name }}`. + + - `bucket/gclo.json` (Scoop) and `packaging/chocolatey` now point at + `gclo-cli-win-x64.zip` of v${{ needs.verify.outputs.version }} + (SHA-256 `${{ steps.manifests.outputs.sha256 }}`). + - `scoop update gclo` picks this up once merged; the Chocolatey package + was ${{ steps.secrets.outputs.choco == 'true' && 'pushed to chocolatey.org' || 'packed but not pushed (CHOCO_API_KEY not configured)' }}. + + Relates to #9. + labels: | + release + add-paths: | + bucket/gclo.json + packaging/chocolatey/gclo.nuspec + packaging/chocolatey/tools/chocolateyinstall.ps1 diff --git a/README.md b/README.md index eb10966..1347290 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,18 @@ Plain version tags (`v1.2.3`) are **stable** releases; pre-release tags (`v1.2.3 > **Upgrading from a beta?** Channels don't cross: a dev/beta install never auto-updates to a stable release (its **Check for updates** only looks at the dev channel). To move to stable, install `gclo-stable-Setup.exe` once — it takes over the existing install and self-updates within the stable channel from then on. -`winget` support is planned (tracked in [#7](https://github.com/KofTwentyTwo/gclo/issues/7)); until then, install from the Releases page above. +**Package managers (CLI):** + +```powershell +# Scoop: this repository is its own bucket +scoop bucket add gclo https://github.com/KofTwentyTwo/gclo +scoop install gclo + +# Chocolatey (once the package has cleared moderation; see #7) +choco install gclo +``` + +Every release also attaches `SHA256SUMS.txt`, and every asset carries a build-provenance attestation you can check with `gh attestation verify -R KofTwentyTwo/gclo`. `winget` support for the desktop app is tracked in [#7](https://github.com/KofTwentyTwo/gclo/issues/7). ## Getting a token diff --git a/bucket/gclo.json b/bucket/gclo.json new file mode 100644 index 0000000..13f96b0 --- /dev/null +++ b/bucket/gclo.json @@ -0,0 +1,28 @@ +{ + "version": "1.0.0", + "description": "Git Clone Large Organizations: clone or update every repository of a GitHub organization or user account, in parallel (command-line edition)", + "homepage": "https://github.com/KofTwentyTwo/gclo", + "license": "MIT", + "notes": [ + "gclo needs a GitHub personal access token: set GITHUB_TOKEN, or pass --token-env, --token-file, or --token-stdin.", + "The desktop app is a separate download: https://github.com/KofTwentyTwo/gclo/releases" + ], + "architecture": { + "64bit": { + "url": "https://github.com/KofTwentyTwo/gclo/releases/download/v1.0.0/gclo-cli-win-x64.zip", + "hash": "42805ca95a4f4d306df04f47a2b9aba54eee3df11cda8e3f201fe5a58311feee" + } + }, + "bin": "gclo.exe", + "checkver": "github", + "autoupdate": { + "architecture": { + "64bit": { + "url": "https://github.com/KofTwentyTwo/gclo/releases/download/v$version/gclo-cli-win-x64.zip", + "hash": { + "url": "$baseurl/SHA256SUMS.txt" + } + } + } + } +} diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 12e250d..7088564 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -53,6 +53,9 @@ get a release without a delta package, which is normal. | GitHub Release (Setup, portable, full/delta packages, CLI zip) | always | none — the built-in `GITHUB_TOKEN` is enough | | nuget.org (`gclo.Engine` package) | `NUGET_API_KEY` secret is set | `NUGET_API_KEY` — an API key from nuget.org with push rights for `gclo.Engine` | | winget (`KofTwentyTwo.gclo`) | stable releases only, and `WINGET_TOKEN` secret is set | `WINGET_TOKEN` — a GitHub personal access token (classic, `public_repo` scope) used by `wingetcreate` to fork/PR [microsoft/winget-pkgs](https://github.com/microsoft/winget-pkgs) | +| `SHA256SUMS.txt` on the GitHub Release | always | none | +| Scoop (`bucket/gclo.json` in this repository) | stable releases only | none — the workflow opens a PR against `dev` with the updated manifest; merge it and `scoop update gclo` sees the release | +| Chocolatey (`gclo` on chocolatey.org) | stable releases only, and `CHOCO_API_KEY` secret is set | `CHOCO_API_KEY` — an API key from your chocolatey.org account; the first push creates the package (it then goes through Chocolatey moderation) | If a secret is not configured, the corresponding step is skipped cleanly — the release still succeeds. Configure secrets under @@ -78,6 +81,24 @@ Once that first PR is merged and the package is live, set the `WINGET_TOKEN` secret and every subsequent stable release updates the manifest automatically via `wingetcreate update KofTwentyTwo.gclo ... --submit`. +## Scoop and Chocolatey + +Both install the same asset, `gclo-cli-win-x64.zip`, by URL and SHA-256. + +- **Scoop** needs no account: this repository is its own bucket. Users run + `scoop bucket add gclo https://github.com/KofTwentyTwo/gclo` once, then + `scoop install gclo`. The manifest (`bucket/gclo.json`) carries `checkver` and + `autoupdate` entries keyed on `SHA256SUMS.txt`, so `scoop update` works as + soon as the manifest on `main` names the new version — which is what the + release workflow's "Packaging" pull request does after each stable release. +- **Chocolatey** needs a one-time account on chocolatey.org and its API key as + the `CHOCO_API_KEY` secret. The workflow packs `packaging/chocolatey` on every + stable release and pushes it when the key exists; the first version goes + through Chocolatey's moderation queue (expect a few days), later versions are + usually auto-approved. Users then run `choco install gclo`. +- To re-point the manifests by hand (for example after a missed release): + `./.github/scripts/Update-PackageManifests.ps1 -Version 1.2.3 -CliZipPath `. + ## Cutting a release, start to finish 1. **Make sure `main` is green.** The CI, CodeQL, and (for PRs) dependency diff --git a/gclo.Cli.Tests/PackagingManifestTests.cs b/gclo.Cli.Tests/PackagingManifestTests.cs new file mode 100644 index 0000000..cda946d --- /dev/null +++ b/gclo.Cli.Tests/PackagingManifestTests.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace gclo.Cli.Tests; + +/// +/// Pins the package-manager manifests the repository ships (#9): the Scoop bucket +/// manifest and the Chocolatey package must stay valid, point at the same CLI +/// asset of one stable release, and agree with each other — the release workflow +/// rewrites them, and a drift would break `scoop install` / `choco install`. +/// +public sealed class PackagingManifestTests +{ + private static readonly Regex Sha256 = new("^[0-9a-f]{64}$", RegexOptions.Compiled); + + private static string RepoRoot + { + get + { + DirectoryInfo? current = new(AppContext.BaseDirectory); + while (current is not null && !File.Exists(Path.Combine(current.FullName, "gclo.slnx"))) + { + current = current.Parent; + } + return current?.FullName ?? throw new FileNotFoundException("gclo.slnx not found above the test directory"); + } + } + + private static (string Version, string Url, string Hash) ReadScoop() + { + using JsonDocument doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(RepoRoot, "bucket", "gclo.json"))); + JsonElement root = doc.RootElement; + JsonElement x64 = root.GetProperty("architecture").GetProperty("64bit"); + Assert.Equal("gclo.exe", root.GetProperty("bin").GetString()); + Assert.Equal("MIT", root.GetProperty("license").GetString()); + Assert.Equal("github", root.GetProperty("checkver").GetString()); + string autoupdateUrl = root.GetProperty("autoupdate").GetProperty("architecture").GetProperty("64bit").GetProperty("url").GetString()!; + Assert.Contains("v$version/gclo-cli-win-x64.zip", autoupdateUrl); + Assert.Contains("SHA256SUMS.txt", root.GetProperty("autoupdate").GetProperty("architecture").GetProperty("64bit").GetProperty("hash").GetProperty("url").GetString()); + return (root.GetProperty("version").GetString()!, x64.GetProperty("url").GetString()!, x64.GetProperty("hash").GetString()!); + } + + private static (string Version, string Url, string Hash) ReadChocolatey() + { + XDocument nuspec = XDocument.Load(Path.Combine(RepoRoot, "packaging", "chocolatey", "gclo.nuspec")); + XNamespace ns = nuspec.Root!.Name.Namespace; + XElement metadata = nuspec.Root.Element(ns + "metadata")!; + Assert.Equal("gclo", metadata.Element(ns + "id")!.Value); + string version = metadata.Element(ns + "version")!.Value; + Assert.EndsWith($"/releases/tag/v{version}", metadata.Element(ns + "releaseNotes")!.Value); + + string install = File.ReadAllText(Path.Combine(RepoRoot, "packaging", "chocolatey", "tools", "chocolateyinstall.ps1")); + string url = Regex.Match(install, @"url64bit\s*=\s*'([^']+)'").Groups[1].Value; + string hash = Regex.Match(install, @"checksum64\s*=\s*'([^']+)'").Groups[1].Value; + Assert.Contains("Install-ChocolateyZipPackage", install); + return (version, url, hash); + } + + [Fact] + public void ScoopManifest_IsValid_AndPointsAtAStableCliRelease() + { + var (version, url, hash) = ReadScoop(); + + Assert.Matches(@"^\d+\.\d+\.\d+$", version); // package managers get stable releases only + Assert.Equal($"https://github.com/KofTwentyTwo/gclo/releases/download/v{version}/gclo-cli-win-x64.zip", url); + Assert.Matches(Sha256, hash); + } + + [Fact] + public void ChocolateyPackage_IsValid_AndPointsAtAStableCliRelease() + { + var (version, url, hash) = ReadChocolatey(); + + Assert.Matches(@"^\d+\.\d+\.\d+$", version); + Assert.Equal($"https://github.com/KofTwentyTwo/gclo/releases/download/v{version}/gclo-cli-win-x64.zip", url); + Assert.Matches(Sha256, hash); + } + + [Fact] + public void ScoopAndChocolatey_DescribeTheSameRelease() + { + var scoop = ReadScoop(); + var choco = ReadChocolatey(); + + Assert.Equal(scoop.Version, choco.Version); + Assert.Equal(scoop.Url, choco.Url); + Assert.Equal(scoop.Hash, choco.Hash); + } +} diff --git a/packaging/chocolatey/gclo.nuspec b/packaging/chocolatey/gclo.nuspec new file mode 100644 index 0000000..c801df0 --- /dev/null +++ b/packaging/chocolatey/gclo.nuspec @@ -0,0 +1,43 @@ + + + + + gclo + 1.0.0 + gclo (Git Clone Large Organizations) + James Maes + KofTwentyTwo + https://github.com/KofTwentyTwo/gclo + https://github.com/KofTwentyTwo/gclo + https://github.com/KofTwentyTwo/gclo/tree/main/packaging/chocolatey + https://github.com/KofTwentyTwo/gclo/blob/main/docs/CLI.md + https://github.com/KofTwentyTwo/gclo/issues + https://github.com/KofTwentyTwo/gclo/blob/main/LICENSE + false + https://raw.githubusercontent.com/KofTwentyTwo/gclo/main/gclo/Assets/Brand/gclo-icon.svg + git github clone mirror organization cli + Clone or update every repository of a GitHub organization in one pass (CLI). + --target ` — mirror an organization +- `gclo repos --org ` — list what a sync would see +- `gclo orgs` — list the organizations a token can see +- `gclo accounts` — saved profiles with tokens in Windows Credential Manager +- `--json` / `--json-lines` for scripts; exit codes distinguish auth, transient, and usage errors + +A GitHub personal access token is required: set `GITHUB_TOKEN`, or use `--token-env`, `--token-file`, or `--token-stdin` (there is deliberately no `--token ` option). Full reference: https://github.com/KofTwentyTwo/gclo/blob/main/docs/CLI.md + +The desktop app (WinUI 3) is a separate download from the GitHub Releases page. +]]> + https://github.com/KofTwentyTwo/gclo/releases/tag/v1.0.0 + + + + + diff --git a/packaging/chocolatey/tools/chocolateyinstall.ps1 b/packaging/chocolatey/tools/chocolateyinstall.ps1 new file mode 100644 index 0000000..748428d --- /dev/null +++ b/packaging/chocolatey/tools/chocolateyinstall.ps1 @@ -0,0 +1,16 @@ +$ErrorActionPreference = 'Stop' + +# Installs the self-contained gclo CLI zip into the package's tools folder; +# Chocolatey shims gclo.exe onto PATH automatically. The URL and checksum are +# updated per stable release by .github/scripts/Update-PackageManifests.ps1. +$toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition + +$packageArgs = @{ + packageName = 'gclo' + unzipLocation = $toolsDir + url64bit = 'https://github.com/KofTwentyTwo/gclo/releases/download/v1.0.0/gclo-cli-win-x64.zip' + checksum64 = '42805ca95a4f4d306df04f47a2b9aba54eee3df11cda8e3f201fe5a58311feee' + checksumType64 = 'sha256' +} + +Install-ChocolateyZipPackage @packageArgs -- 2.54.0 From 347527fffd76aff4526599941b7feb73b0350422 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 9 Oct 2026 07:56:47 -0500 Subject: [PATCH 2/4] Packaging: open the Scoop/Chocolatey manifest PR against main, not dev (#9) Scoop reads bucket/gclo.json from the default branch, so a PR against dev would leave scoop install on the previous version until the next dev->main merge. RELEASING.md now describes the real flow (merge, then main back into dev; DEPS_PAT needed for checks to trigger). Co-Authored-By: Claude Fable 5.1 --- .github/workflows/release.yml | 13 +++++++++---- docs/RELEASING.md | 10 +++++++--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37be17e..da1ca0e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -278,8 +278,11 @@ jobs: # own Scoop bucket (bucket/gclo.json) and ships the Chocolatey package # source (packaging/chocolatey). Both are pointed at this release's CLI # zip; the Chocolatey package is pushed when CHOCO_API_KEY is configured, - # and the updated manifests go back to dev as a pull request so the - # committed files always describe the latest stable release. + # and the updated manifests are proposed as a pull request against MAIN: + # Scoop reads bucket/gclo.json from the default branch, so a PR against + # dev would leave `scoop install` on the previous version until the next + # dev->main merge. Merge the PR, then merge main back into dev. (With only + # GITHUB_TOKEN the PR triggers no checks; DEPS_PAT fixes that, see #7.) # ---------------------------------------------------------------------- - name: Point the Scoop and Chocolatey manifests at this release if: needs.verify.outputs.prerelease == 'false' @@ -307,7 +310,7 @@ jobs: uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ secrets.DEPS_PAT || github.token }} - base: dev + base: main branch: packaging/v${{ needs.verify.outputs.version }} delete-branch: true commit-message: 'Packaging: point the Scoop and Chocolatey manifests at v${{ needs.verify.outputs.version }}' @@ -318,8 +321,10 @@ jobs: - `bucket/gclo.json` (Scoop) and `packaging/chocolatey` now point at `gclo-cli-win-x64.zip` of v${{ needs.verify.outputs.version }} (SHA-256 `${{ steps.manifests.outputs.sha256 }}`). - - `scoop update gclo` picks this up once merged; the Chocolatey package + - `scoop update gclo` picks this up as soon as this PR is merged (Scoop + reads the manifest from `main`); the Chocolatey package was ${{ steps.secrets.outputs.choco == 'true' && 'pushed to chocolatey.org' || 'packed but not pushed (CHOCO_API_KEY not configured)' }}. + - After merging, merge `main` back into `dev` so the branches agree. Relates to #9. labels: | diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 7088564..4ee18bd 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -54,7 +54,7 @@ get a release without a delta package, which is normal. | nuget.org (`gclo.Engine` package) | `NUGET_API_KEY` secret is set | `NUGET_API_KEY` — an API key from nuget.org with push rights for `gclo.Engine` | | winget (`KofTwentyTwo.gclo`) | stable releases only, and `WINGET_TOKEN` secret is set | `WINGET_TOKEN` — a GitHub personal access token (classic, `public_repo` scope) used by `wingetcreate` to fork/PR [microsoft/winget-pkgs](https://github.com/microsoft/winget-pkgs) | | `SHA256SUMS.txt` on the GitHub Release | always | none | -| Scoop (`bucket/gclo.json` in this repository) | stable releases only | none — the workflow opens a PR against `dev` with the updated manifest; merge it and `scoop update gclo` sees the release | +| Scoop (`bucket/gclo.json` in this repository) | stable releases only | none — the workflow opens a PR against `main` with the updated manifest; merge it (then `main` back into `dev`) and `scoop update gclo` sees the release | | Chocolatey (`gclo` on chocolatey.org) | stable releases only, and `CHOCO_API_KEY` secret is set | `CHOCO_API_KEY` — an API key from your chocolatey.org account; the first push creates the package (it then goes through Chocolatey moderation) | If a secret is not configured, the corresponding step is skipped cleanly — the @@ -89,8 +89,12 @@ Both install the same asset, `gclo-cli-win-x64.zip`, by URL and SHA-256. `scoop bucket add gclo https://github.com/KofTwentyTwo/gclo` once, then `scoop install gclo`. The manifest (`bucket/gclo.json`) carries `checkver` and `autoupdate` entries keyed on `SHA256SUMS.txt`, so `scoop update` works as - soon as the manifest on `main` names the new version — which is what the - release workflow's "Packaging" pull request does after each stable release. + soon as the manifest on `main` names the new version. The release workflow + therefore opens its "Packaging" pull request **against `main`** (the one + exception to the dev-first branch model: three generated files, nothing else). + Merge it, then merge `main` back into `dev`. Note that a PR opened with the + default `GITHUB_TOKEN` triggers no status checks, so the required checks on + `main` must be run by hand or `DEPS_PAT` must be configured (#7). - **Chocolatey** needs a one-time account on chocolatey.org and its API key as the `CHOCO_API_KEY` secret. The workflow packs `packaging/chocolatey` on every stable release and pushes it when the key exists; the first version goes -- 2.54.0 From 524be527958e13b9b5c6ab0a82354f39d82f0177 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 9 Oct 2026 08:05:57 -0500 Subject: [PATCH 3/4] Clone in WSL instead: real recovery verb for Windows-invalid paths (#8) Engine: IWslCloner/WslCloner over an IProcessRunner seam. Probe runs `wsl.exe -e git --version`; the clone feeds a POSIX script to `sh -s` on stdin with org/repo/url as positional parameters (nothing re-parsed by wsl.exe), and the token reaches git only as GCLO_GIT_TOKEN via WSLENV through an inline credential helper - never argv, never a file. Destination ~/gclo// in the default distribution; an existing clone is fast-forwarded. The real ProcessRunner decodes wsl.exe's UTF-16 messages and is coverage-excluded; WslCloner is fully covered through FakeProcessRunner. ViewModels: PathRecoveryDecision (Apply | CloneInWsl); WorkspaceViewModel probes WSL once, lazily, before the recovery interaction (IsWslCloneAvailable, WslAvailabilityDetail), runs the clone under the same IsResolvingPaths lock, marks the row Done with a Note naming the \wsl.localhost path, and keeps the payload on failure so the rename/skip route stays open. RepoItemViewModel.Note. App: PathRecoveryDialog offers "Clone in WSL instead" as its secondary button when available; row template shows the Note. README documents the option. Verified the real script end to end in WSL (clone, then pull) on this machine. Closes #8 Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- gclo.Engine.Tests/ActionLoggingTests.cs | 37 ++- gclo.Engine.Tests/Fakes.cs | 66 +++++ gclo.Engine.Tests/WorkspaceViewModelTests.cs | 161 ++++++++++- gclo.Engine.Tests/WslClonerTests.cs | 236 ++++++++++++++++ gclo.Engine/WslCloner.cs | 278 +++++++++++++++++++ gclo.ViewModels/PathRecoveryDecision.cs | 21 ++ gclo.ViewModels/RepoItemViewModel.cs | 11 + gclo.ViewModels/WorkspaceViewModel.cs | 100 ++++++- gclo/PathRecoveryDialog.xaml | 3 +- gclo/PathRecoveryDialog.xaml.cs | 26 +- gclo/WorkspacePage.xaml | 15 +- gclo/WorkspacePage.xaml.cs | 6 +- 13 files changed, 927 insertions(+), 35 deletions(-) create mode 100644 gclo.Engine.Tests/WslClonerTests.cs create mode 100644 gclo.Engine/WslCloner.cs create mode 100644 gclo.ViewModels/PathRecoveryDecision.cs diff --git a/README.md b/README.md index 1347290..33a3148 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ On launch, gclo shows a brief branded splash, then the **Quick Sync** connect ca 8. **Cancel** at any time: in-flight repos stop cleanly, unstarted repos are marked Canceled, and the summary still appears. After a run, **Retry failed** re-runs exactly the failed rows, and **Open folder** opens the target folder in Explorer. -**When something goes wrong:** failures are isolated per repository — one broken repo never stops the rest, and a failed row shows its error message inline. Failed clones are cleaned up, and a clone whose checkout never finished (a crash, a kill, a cleanup that could not delete the folder) is flagged inside its `.git`, so a partial working tree is never mistaken for an up-to-date repo on the next run — gclo completes the checkout instead. Repositories with Windows-impossible paths are the deliberate exception: gclo checks every path in the incoming tree against Windows file-system rules *before* checkout — invalid characters, reserved device names like `CON`, trailing spaces or dots, names that differ only by case — and when it finds any, the repo is marked Failed with a per-path list of reasons, but the fully fetched `.git` is **kept** (nothing was ever checked out). Recovery happens in place, with no re-download: click the row's **Resolve…** link to rename each offending path (safe names are pre-suggested) or skip it, and gclo materializes the working tree right there; the CLI does the same automatically with [`--sanitize-paths`](docs/CLI.md#windows-invalid-paths-and---sanitize-paths). Long paths beyond the classic 260-character limit are handled automatically (`core.longpaths`). +**When something goes wrong:** failures are isolated per repository — one broken repo never stops the rest, and a failed row shows its error message inline. Failed clones are cleaned up, and a clone whose checkout never finished (a crash, a kill, a cleanup that could not delete the folder) is flagged inside its `.git`, so a partial working tree is never mistaken for an up-to-date repo on the next run — gclo completes the checkout instead. Repositories with Windows-impossible paths are the deliberate exception: gclo checks every path in the incoming tree against Windows file-system rules *before* checkout — invalid characters, reserved device names like `CON`, trailing spaces or dots, names that differ only by case — and when it finds any, the repo is marked Failed with a per-path list of reasons, but the fully fetched `.git` is **kept** (nothing was ever checked out). Recovery happens in place, with no re-download: click the row's **Resolve…** link to rename each offending path (safe names are pre-suggested) or skip it, and gclo materializes the working tree right there; the CLI does the same automatically with [`--sanitize-paths`](docs/CLI.md#windows-invalid-paths-and---sanitize-paths). When WSL is installed with git in its default distribution, the same dialog offers **Clone in WSL instead**: the repository is cloned unchanged into `~/gclo//` inside WSL (reachable from Explorer as `\wsl.localhost\\home\...`), the token travels only through the environment, and the Windows copy is left as it is. Long paths beyond the classic 260-character limit are handled automatically (`core.longpaths`). **Settings** (File > Settings…) let you set a default GitHub token (stored in the Windows Credential Manager), the default target folder (with a folder picker), the default parallelism, and the theme (System, Light, or Dark); an Advanced section toggles the startup splash and its duration. diff --git a/gclo.Engine.Tests/ActionLoggingTests.cs b/gclo.Engine.Tests/ActionLoggingTests.cs index 5d624f8..fdbbce3 100644 --- a/gclo.Engine.Tests/ActionLoggingTests.cs +++ b/gclo.Engine.Tests/ActionLoggingTests.cs @@ -28,8 +28,10 @@ public sealed class ActionLoggingTests : IDisposable public void Report(RepoProgress value) => handler(value); } + private readonly FakeWslCloner _wsl = new(); + private WorkspaceViewModel CreateViewModel() - => new(_lister, _git, _orgs, handler => new SyncProgress(handler), TimeSpan.FromMilliseconds(1), _log); + => new(_lister, _git, _orgs, handler => new SyncProgress(handler), TimeSpan.FromMilliseconds(1), _log, wsl: _wsl); private void AssertNoTokenLogged() => Assert.All(_log.Messages, m => Assert.DoesNotContain(Token, m)); @@ -86,9 +88,11 @@ public sealed class ActionLoggingTests : IDisposable Assert.Contains("Retrying 1 failed repositories.", _log.Messages); // The user opens recovery and cancels it. - vm.RecoveryInteraction = _ => Task.FromResult(null); + _wsl.Availability = new WslAvailability(false, "wsl.exe is not installed"); + vm.RecoveryInteraction = _ => Task.FromResult(null); await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]); Assert.Contains("alpha: path recovery requested (1 invalid paths).", _log.Messages); + Assert.Contains("WSL clone not available: wsl.exe is not installed", _log.Messages); Assert.Contains("alpha: path recovery canceled; the repository stays failed.", _log.Messages); vm.NoteFolderOpened(); @@ -96,6 +100,35 @@ public sealed class ActionLoggingTests : IDisposable AssertNoTokenLogged(); } + [Fact] + public async Task Workspace_CloneInWsl_IsLogged_WithoutTheToken() + { + _lister.Repositories = Repos("alpha"); + var vm = CreateViewModel(); + vm.Organization = "acme"; + vm.Token = Token; + await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); + vm.TargetFolder = _root; + await vm.LoadReposCommand.ExecuteAsync(null); + var invalid = new List { new("bad:name.txt", "invalid on Windows", "bad_name.txt") }; + _git.CloneHandler = (_, _, _, _, _) => Task.FromException(new InvalidRepositoryPathsException(invalid)); + await vm.SyncCommand.ExecuteAsync(null); + _wsl.Availability = new WslAvailability(true, "git version 2.53.0"); + vm.RecoveryInteraction = _ => Task.FromResult(new PathRecoveryDecision.CloneInWsl()); + + await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]); + Assert.Contains("WSL clone available (git version 2.53.0).", _log.Messages); + Assert.Contains("alpha: cloning in WSL instead.", _log.Messages); + Assert.Contains("alpha: cloned in WSL (Ubuntu) at /home/me/gclo/acme/alpha.", _log.Messages); + + _wsl.CloneHandler = (_, _) => Task.FromException(new WslCloneException("Clone in WSL failed: boom")); + vm.Repos[0].InvalidPaths = invalid; + vm.Repos[0].Status = SyncStatus.Failed; + await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]); + Assert.Contains("alpha: clone in WSL failed: Clone in WSL failed: boom", _log.Messages); + AssertNoTokenLogged(); + } + // ---------------------------------------------------------------- accounts [Fact] diff --git a/gclo.Engine.Tests/Fakes.cs b/gclo.Engine.Tests/Fakes.cs index 62e4fce..07c4305 100644 --- a/gclo.Engine.Tests/Fakes.cs +++ b/gclo.Engine.Tests/Fakes.cs @@ -155,3 +155,69 @@ public sealed class RecordingProgress : IProgress public RepoProgress? LastFor(string repoName) => _reports.LastOrDefault(r => r.RepoName == repoName); } + +/// One scripted invocation, as recorded by . +public sealed record ProcessCall( + string FileName, + IReadOnlyList Arguments, + IReadOnlyDictionary? Environment, + string? StandardInput); + +/// An that records calls and answers from a delegate. +public sealed class FakeProcessRunner : IProcessRunner +{ + private readonly ConcurrentQueue _calls = new(); + + /// Answers each call; default: exit 0 with empty output. + public Func> Handler { get; set; } + = (_, _) => Task.FromResult(new ProcessResult(0, "", "")); + + public IReadOnlyList Calls => _calls.ToArray(); + + public Task RunAsync( + string fileName, + IReadOnlyList arguments, + IReadOnlyDictionary? environment, + string? standardInput, + CancellationToken cancellationToken) + { + var call = new ProcessCall(fileName, arguments, environment, standardInput); + _calls.Enqueue(call); + return Handler(call, cancellationToken); + } +} + +public sealed record WslCloneCall(string Url, string Organization, string RepositoryName, string Token); + +/// An with scripted availability and clone outcome. +public sealed class FakeWslCloner : IWslCloner +{ + private readonly ConcurrentQueue _cloneCalls = new(); + private int _probes; + + /// What reports; default: not available. + public WslAvailability Availability { get; set; } = new(false, "wsl.exe is not installed (test default)"); + + /// Body of ; default: a successful clone into /home/me/gclo/org/repo. + public Func> CloneHandler { get; set; } + = (call, _) => Task.FromResult(new WslCloneResult( + "Ubuntu", + $"/home/me/gclo/{call.Organization}/{call.RepositoryName}", + $@"\\wsl.localhost\Ubuntu\home\me\gclo\{call.Organization}\{call.RepositoryName}")); + + public int Probes => _probes; + public IReadOnlyList CloneCalls => _cloneCalls.ToArray(); + + public Task ProbeAsync(CancellationToken cancellationToken) + { + Interlocked.Increment(ref _probes); + return Task.FromResult(Availability); + } + + public Task CloneAsync(string url, string organization, string repositoryName, string token, CancellationToken cancellationToken) + { + var call = new WslCloneCall(url, organization, repositoryName, token); + _cloneCalls.Enqueue(call); + return CloneHandler(call, cancellationToken); + } +} diff --git a/gclo.Engine.Tests/WorkspaceViewModelTests.cs b/gclo.Engine.Tests/WorkspaceViewModelTests.cs index a01c8f1..d183729 100644 --- a/gclo.Engine.Tests/WorkspaceViewModelTests.cs +++ b/gclo.Engine.Tests/WorkspaceViewModelTests.cs @@ -14,6 +14,7 @@ public sealed class WorkspaceViewModelTests : IDisposable private readonly FakeRepositoryLister _lister = new(); private readonly FakeGitClient _git = new(); private readonly FakeOrganizationLister _orgs = new(); + private readonly FakeWslCloner _wsl = new(); /// Every view model the test created, disposed and cleaned up after it — pass or fail. private readonly List _created = new(); @@ -56,7 +57,7 @@ public sealed class WorkspaceViewModelTests : IDisposable handler => new SyncProgress(handler), debounce ?? TimeSpan.FromMilliseconds(1), new NullActivityLog(), - account, vault, store)); + account, vault, store, _wsl)); private WorkspaceViewModel Track(WorkspaceViewModel vm) { @@ -621,7 +622,7 @@ public sealed class WorkspaceViewModelTests : IDisposable vm.RecoveryInteraction = item => { asked.Add(item); - return Task.FromResult(recovery); + return Task.FromResult(new PathRecoveryDecision.Apply(recovery)); }; await vm.ResolvePathsCommand.ExecuteAsync(row); @@ -651,8 +652,8 @@ public sealed class WorkspaceViewModelTests : IDisposable var vm = await CreateLoadedViewModelAsync(Repo("alpha")); await vm.SyncCommand.ExecuteAsync(null); RepoItemViewModel row = vm.Repos[0]; - vm.RecoveryInteraction = _ => Task.FromResult( - new PathRecovery(new Dictionary(), new HashSet())); + vm.RecoveryInteraction = _ => Task.FromResult( + new PathRecoveryDecision.Apply(new PathRecovery(new Dictionary(), new HashSet()))); Task resolve = vm.ResolvePathsCommand.ExecuteAsync(row); await WaitUntilAsync(() => row.Status == SyncStatus.Pulling, "the in-flight recovery to show"); @@ -674,7 +675,7 @@ public sealed class WorkspaceViewModelTests : IDisposable var vm = await CreateLoadedViewModelAsync(Repo("alpha")); await vm.SyncCommand.ExecuteAsync(null); RepoItemViewModel row = vm.Repos[0]; - vm.RecoveryInteraction = _ => Task.FromResult(null); + vm.RecoveryInteraction = _ => Task.FromResult(null); await vm.ResolvePathsCommand.ExecuteAsync(row); @@ -698,8 +699,8 @@ public sealed class WorkspaceViewModelTests : IDisposable var vm = await CreateLoadedViewModelAsync(Repo("alpha")); await vm.SyncCommand.ExecuteAsync(null); RepoItemViewModel row = vm.Repos[0]; - vm.RecoveryInteraction = _ => Task.FromResult( - new PathRecovery(new Dictionary(), new HashSet())); + vm.RecoveryInteraction = _ => Task.FromResult( + new PathRecoveryDecision.Apply(new PathRecovery(new Dictionary(), new HashSet()))); await vm.ResolvePathsCommand.ExecuteAsync(row); @@ -720,8 +721,8 @@ public sealed class WorkspaceViewModelTests : IDisposable var vm = await CreateLoadedViewModelAsync(Repo("alpha")); await vm.SyncCommand.ExecuteAsync(null); RepoItemViewModel row = vm.Repos[0]; - vm.RecoveryInteraction = _ => Task.FromResult( - new PathRecovery(new Dictionary(), new HashSet())); + vm.RecoveryInteraction = _ => Task.FromResult( + new PathRecoveryDecision.Apply(new PathRecovery(new Dictionary(), new HashSet()))); await vm.ResolvePathsCommand.ExecuteAsync(row); @@ -731,6 +732,144 @@ public sealed class WorkspaceViewModelTests : IDisposable } + // ---------------------------------------------------------------- clone in WSL (#8) + + [Fact] + public async Task ResolvePaths_ProbesWslOnce_BeforeTheInteraction_AndExposesAvailability() + { + MakeAlphaFailWithInvalidPaths(); + _wsl.Availability = new WslAvailability(true, "git version 2.53.0"); + var vm = await CreateLoadedViewModelAsync(Repo("alpha")); + await vm.SyncCommand.ExecuteAsync(null); + Assert.False(vm.IsWslCloneAvailable); // not probed until recovery is requested + Assert.Equal("", vm.WslAvailabilityDetail); + + bool? seenDuringInteraction = null; + vm.RecoveryInteraction = _ => + { + seenDuringInteraction = vm.IsWslCloneAvailable; + return Task.FromResult(null); + }; + + await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]); + await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]); + + Assert.True(seenDuringInteraction); + Assert.True(vm.IsWslCloneAvailable); + Assert.Equal("git version 2.53.0", vm.WslAvailabilityDetail); + Assert.Equal(1, _wsl.Probes); // cached after the first request + } + + [Fact] + public async Task ResolvePaths_WslUnavailable_StaysUnavailable_WithTheReason() + { + MakeAlphaFailWithInvalidPaths(); + _wsl.Availability = new WslAvailability(false, "wsl.exe is not installed"); + var vm = await CreateLoadedViewModelAsync(Repo("alpha")); + await vm.SyncCommand.ExecuteAsync(null); + vm.RecoveryInteraction = _ => Task.FromResult(null); + + await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]); + + Assert.False(vm.IsWslCloneAvailable); + Assert.Equal("wsl.exe is not installed", vm.WslAvailabilityDetail); + } + + [Fact] + public async Task ResolvePaths_CloneInWsl_ClonesWithTheRowsUrlAndTheWorkspaceToken_AndMarksRowDone() + { + MakeAlphaFailWithInvalidPaths(); + _wsl.Availability = new WslAvailability(true, "git version 2.53.0"); + var vm = await CreateLoadedViewModelAsync(Repo("alpha")); + await vm.SyncCommand.ExecuteAsync(null); + RepoItemViewModel row = vm.Repos[0]; + vm.RecoveryInteraction = _ => Task.FromResult(new PathRecoveryDecision.CloneInWsl()); + + await vm.ResolvePathsCommand.ExecuteAsync(row); + + WslCloneCall call = Assert.Single(_wsl.CloneCalls); + Assert.Equal(row.Descriptor.CloneUrl, call.Url); + Assert.Equal(vm.Organization, call.Organization); + Assert.Equal("alpha", call.RepositoryName); + Assert.Equal(vm.Token, call.Token); + Assert.Empty(_git.ApplyRecoveryCalls); // the Windows copy is left alone + + Assert.Equal(SyncStatus.Done, row.Status); + Assert.Null(row.Error); + Assert.Null(row.InvalidPaths); + Assert.False(row.HasPathIssue); + Assert.True(row.HasNote); + Assert.Equal(@"Cloned in WSL (Ubuntu): \\wsl.localhost\Ubuntu\home\me\gclo\" + vm.Organization + @"\alpha", row.Note); + Assert.False(vm.ResolvePathsCommand.CanExecute(row)); + Assert.False(vm.RetryFailedCommand.CanExecute(null)); + Assert.False(vm.IsResolvingPaths); + } + + [Fact] + public async Task ResolvePaths_CloneInWsl_ShowsAnIndeterminateBar_AndLocksTheWorkspace_WhileInFlight() + { + MakeAlphaFailWithInvalidPaths(); + _wsl.Availability = new WslAvailability(true, "git"); + var gate = new TaskCompletionSource(); + _wsl.CloneHandler = (_, _) => gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None); + var vm = await CreateLoadedViewModelAsync(Repo("alpha")); + await vm.SyncCommand.ExecuteAsync(null); + RepoItemViewModel row = vm.Repos[0]; + vm.RecoveryInteraction = _ => Task.FromResult(new PathRecoveryDecision.CloneInWsl()); + + Task resolve = vm.ResolvePathsCommand.ExecuteAsync(row); + await WaitUntilAsync(() => row.Status == SyncStatus.Pulling, "the in-flight WSL clone to show"); + + Assert.True(row.IsIndeterminate); + Assert.True(vm.IsResolvingPaths); + Assert.False(vm.SyncCommand.CanExecute(null)); + + gate.SetResult(new WslCloneResult("Ubuntu", "/home/me/gclo/o/alpha", @"\\wsl.localhost\Ubuntu\home\me\gclo\o\alpha")); + await resolve; + + Assert.Equal(SyncStatus.Done, row.Status); + Assert.False(vm.IsResolvingPaths); + Assert.True(vm.SyncCommand.CanExecute(null)); + } + + [Fact] + public async Task ResolvePaths_CloneInWslFails_KeepsRowFailed_WithPayload_ForAnotherAttempt() + { + MakeAlphaFailWithInvalidPaths(); + _wsl.Availability = new WslAvailability(true, "git"); + _wsl.CloneHandler = (_, _) => Task.FromException( + new WslCloneException("Clone in WSL failed: fatal: Authentication failed")); + var vm = await CreateLoadedViewModelAsync(Repo("alpha")); + await vm.SyncCommand.ExecuteAsync(null); + RepoItemViewModel row = vm.Repos[0]; + vm.RecoveryInteraction = _ => Task.FromResult(new PathRecoveryDecision.CloneInWsl()); + + await vm.ResolvePathsCommand.ExecuteAsync(row); + + Assert.Equal(SyncStatus.Failed, row.Status); + Assert.Equal("Clone in WSL failed: fatal: Authentication failed", row.Error); + Assert.True(row.HasPathIssue); // the rename/skip route is still open + Assert.False(row.HasNote); + Assert.True(vm.ResolvePathsCommand.CanExecute(row)); + Assert.True(vm.RetryFailedCommand.CanExecute(null)); + Assert.False(vm.IsResolvingPaths); + } + + [Fact] + public void RepoItem_Note_RaisesHasNote() + { + var item = new RepoItemViewModel(Repo("alpha")); + var changed = new List(); + item.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + Assert.False(item.HasNote); + + item.Note = "Cloned in WSL"; + + Assert.True(item.HasNote); + Assert.Contains(nameof(RepoItemViewModel.Note), changed); + Assert.Contains(nameof(RepoItemViewModel.HasNote), changed); + } + [Fact] public async Task ResolvePaths_DisabledWhileARunIsInFlight() { @@ -1771,8 +1910,8 @@ public sealed class WorkspaceViewModelTests : IDisposable await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None); var vm = await CreateLoadedViewModelAsync(Repo("alpha")); await vm.SyncCommand.ExecuteAsync(null); - vm.RecoveryInteraction = _ => Task.FromResult( - new PathRecovery(new Dictionary(), new HashSet())); + vm.RecoveryInteraction = _ => Task.FromResult( + new PathRecoveryDecision.Apply(new PathRecovery(new Dictionary(), new HashSet()))); Task resolve = vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]); await WaitUntilAsync(() => vm.IsResolvingPaths, "the recovery to start"); diff --git a/gclo.Engine.Tests/WslClonerTests.cs b/gclo.Engine.Tests/WslClonerTests.cs new file mode 100644 index 0000000..fef0a0d --- /dev/null +++ b/gclo.Engine.Tests/WslClonerTests.cs @@ -0,0 +1,236 @@ +using gclo.Engine; + +namespace gclo.Engine.Tests; + +/// +/// (#8) through a fake process runner: no WSL needed. The +/// contract under test is what wsl.exe is asked to run, that the token never reaches +/// an argument vector or the script, and how output and failures are interpreted. +/// +public sealed class WslClonerTests +{ + private const string WslExe = @"C:\Windows\System32\wsl.exe"; + private const string Token = "ghp_secret_token_value_1234567890"; + + private readonly FakeProcessRunner _runner = new(); + + private WslCloner Create(bool wslExists = true) + => new(_runner, _ => wslExists, WslExe); + + // ---------------------------------------------------------------- probe + + [Fact] + public async Task Probe_NoWslExe_IsUnavailable_WithoutRunningAnything() + { + WslAvailability result = await Create(wslExists: false).ProbeAsync(CancellationToken.None); + + Assert.False(result.Available); + Assert.Contains("not installed", result.Detail); + Assert.Empty(_runner.Calls); + } + + [Fact] + public async Task Probe_GitRunsInDistro_IsAvailable_WithTheGitVersion() + { + _runner.Handler = (_, _) => Task.FromResult(new ProcessResult(0, "git version 2.53.0\n", "")); + + WslAvailability result = await Create().ProbeAsync(CancellationToken.None); + + Assert.True(result.Available); + Assert.Equal("git version 2.53.0", result.Detail); + ProcessCall call = Assert.Single(_runner.Calls); + Assert.Equal(WslExe, call.FileName); + Assert.Equal(["-e", "git", "--version"], call.Arguments); + Assert.Null(call.Environment); + Assert.Null(call.StandardInput); + } + + [Fact] + public async Task Probe_NoDistribution_IsUnavailable_WithWslsOwnMessage() + { + _runner.Handler = (_, _) => Task.FromResult(new ProcessResult( + -1, "", "Windows Subsystem for Linux has no installed distributions.\nUse 'wsl.exe --list --online'.\n")); + + WslAvailability result = await Create().ProbeAsync(CancellationToken.None); + + Assert.False(result.Available); + Assert.Equal("the default WSL distribution did not run git: Windows Subsystem for Linux has no installed distributions.", result.Detail); + } + + [Fact] + public async Task Probe_ExitZeroButNotGit_IsUnavailable() + { + _runner.Handler = (_, _) => Task.FromResult(new ProcessResult(0, "sh: git: not found\n", "")); + + WslAvailability result = await Create().ProbeAsync(CancellationToken.None); + + Assert.False(result.Available); + Assert.Contains("git is not installed in the default WSL distribution (sh: git: not found)", result.Detail); + } + + [Fact] + public async Task Probe_NonZeroWithNoOutput_ReportsTheExitCode() + { + _runner.Handler = (_, _) => Task.FromResult(new ProcessResult(127, "", "")); + + WslAvailability result = await Create().ProbeAsync(CancellationToken.None); + + Assert.False(result.Available); + Assert.EndsWith("exit code 127", result.Detail); + } + + [Fact] + public async Task Probe_RunnerThrows_IsUnavailable_NotAnException() + { + _runner.Handler = (_, _) => throw new System.ComponentModel.Win32Exception("access denied"); + + WslAvailability result = await Create().ProbeAsync(CancellationToken.None); + + Assert.False(result.Available); + Assert.Contains("could not be started: access denied", result.Detail); + } + + [Fact] + public async Task Probe_Canceled_Propagates() + { + _runner.Handler = (_, ct) => Task.FromCanceled(new CancellationToken(canceled: true)); + + await Assert.ThrowsAnyAsync(() => Create().ProbeAsync(CancellationToken.None)); + } + + // ---------------------------------------------------------------- clone + + [Fact] + public async Task Clone_RunsTheScriptThroughStdin_WithTheTokenOnlyInTheEnvironment() + { + _runner.Handler = (_, _) => Task.FromResult(new ProcessResult(0, "Ubuntu\n/home/me/gclo/acme/repo\n", "")); + + WslCloneResult result = await Create().CloneAsync( + "https://github.com/acme/repo.git", "acme", "repo", Token, CancellationToken.None); + + ProcessCall call = Assert.Single(_runner.Calls); + Assert.Equal(WslExe, call.FileName); + Assert.Equal(["-e", "sh", "-s", "acme", "repo", "https://github.com/acme/repo.git"], call.Arguments); + Assert.Equal(WslCloner.CloneScript, call.StandardInput); + Assert.DoesNotContain(Token, string.Join(" ", call.Arguments)); + Assert.DoesNotContain(Token, call.StandardInput); + Assert.NotNull(call.Environment); + Assert.Equal(Token, call.Environment![WslCloner.TokenVariable]); + Assert.Equal("0", call.Environment["GIT_TERMINAL_PROMPT"]); + Assert.Equal(WslCloner.TokenVariable + ":GIT_TERMINAL_PROMPT", call.Environment["WSLENV"]); + + Assert.Equal("Ubuntu", result.Distribution); + Assert.Equal("/home/me/gclo/acme/repo", result.LinuxPath); + Assert.Equal(@"\\wsl.localhost\Ubuntu\home\me\gclo\acme\repo", result.WindowsPath); + } + + [Fact] + public void CloneScript_ReadsTheTokenThroughACredentialHelper_AndFastForwardsAnExistingClone() + { + string script = WslCloner.CloneScript; + + Assert.Contains("password=$" + WslCloner.TokenVariable, script); + Assert.Contains("credential.helper=", script); + Assert.Contains("git clone", script.Replace("-c credential.helper= -c \"credential.helper=$helper\" ", "")); + Assert.Contains("pull --ff-only", script); + Assert.Contains("$HOME/gclo/$org/$repo", script); + Assert.Contains("$WSL_DISTRO_NAME", script); + Assert.StartsWith("set -e", script); + } + + [Fact] + public async Task Clone_IgnoresChatterBeforeTheLastTwoLines() + { + _runner.Handler = (_, _) => Task.FromResult(new ProcessResult( + 0, "Cloning into '/home/me/gclo/acme/repo'...\nwarning: something\nDebian\n/home/me/gclo/acme/repo\n", "noise on stderr\n")); + + WslCloneResult result = await Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None); + + Assert.Equal("Debian", result.Distribution); + Assert.Equal("/home/me/gclo/acme/repo", result.LinuxPath); + } + + [Fact] + public async Task Clone_GitFails_ThrowsWithTheLastStderrLine() + { + _runner.Handler = (_, _) => Task.FromResult(new ProcessResult( + 128, "", "Cloning into '/home/me/gclo/acme/repo'...\nfatal: Authentication failed for 'https://github.com/acme/repo.git/'\n")); + + var ex = await Assert.ThrowsAsync( + () => Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None)); + + Assert.Equal("Clone in WSL failed: fatal: Authentication failed for 'https://github.com/acme/repo.git/'", ex.Message); + } + + [Fact] + public async Task Clone_FailsWithStdoutOnly_UsesStdout_ThenExitCode() + { + _runner.Handler = (_, _) => Task.FromResult(new ProcessResult(1, "disk full\n", "")); + var ex = await Assert.ThrowsAsync( + () => Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None)); + Assert.Equal("Clone in WSL failed: disk full", ex.Message); + + _runner.Handler = (_, _) => Task.FromResult(new ProcessResult(9, "", "")); + ex = await Assert.ThrowsAsync( + () => Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None)); + Assert.Equal("Clone in WSL failed: exit code 9", ex.Message); + } + + [Fact] + public async Task Clone_SucceedsWithoutReportingAPath_Throws() + { + _runner.Handler = (_, _) => Task.FromResult(new ProcessResult(0, "only-one-line\n", "")); + + var ex = await Assert.ThrowsAsync( + () => Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None)); + + Assert.Contains("did not report where", ex.Message); + } + + [Fact] + public async Task Clone_RunnerThrows_IsWrapped() + { + _runner.Handler = (_, _) => throw new System.ComponentModel.Win32Exception("access denied"); + + var ex = await Assert.ThrowsAsync( + () => Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None)); + + Assert.Equal("wsl.exe could not be started: access denied", ex.Message); + } + + [Fact] + public async Task Clone_Canceled_Propagates() + { + _runner.Handler = (_, _) => Task.FromCanceled(new CancellationToken(canceled: true)); + + await Assert.ThrowsAnyAsync( + () => Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None)); + } + + [Theory] + [InlineData("", "acme", "repo")] + [InlineData("https://x/y.git", " ", "repo")] + [InlineData("https://x/y.git", "acme", "")] + public async Task Clone_RejectsBlankInputs(string url, string org, string repo) + { + await Assert.ThrowsAsync( + () => Create().CloneAsync(url, org, repo, Token, CancellationToken.None)); + Assert.Empty(_runner.Calls); + } + + [Fact] + public async Task Clone_RejectsNullToken() + { + await Assert.ThrowsAsync( + () => Create().CloneAsync("https://x/y.git", "acme", "repo", null!, CancellationToken.None)); + } + + [Fact] + public void Defaults_PointAtSystem32Wsl() + { + // The default constructor must be usable by the app without arguments; its + // probe answers "not installed" cleanly on a machine without wsl.exe. + var cloner = new WslCloner(); + Assert.NotNull(cloner); + } +} diff --git a/gclo.Engine/WslCloner.cs b/gclo.Engine/WslCloner.cs new file mode 100644 index 0000000..58005a8 --- /dev/null +++ b/gclo.Engine/WslCloner.cs @@ -0,0 +1,278 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace gclo.Engine; + +/// Outcome of one external process: exit code plus captured output. +public sealed record ProcessResult(int ExitCode, string StandardOutput, string StandardError); + +/// +/// Runs an external process to completion. The seam that keeps +/// testable without a WSL installation: the offline suite scripts results through a fake. +/// +public interface IProcessRunner +{ + /// Executable path. + /// Argument vector (quoted by the runner, never by the caller). + /// Extra environment variables for the child; the parent's are inherited. + /// Text written to the child's stdin, then closed; null leaves stdin closed. + /// Kills the child and throws . + Task RunAsync( + string fileName, + IReadOnlyList arguments, + IReadOnlyDictionary? environment, + string? standardInput, + CancellationToken cancellationToken); +} + +/// Whether a clone into WSL can be attempted on this machine, and why not when it cannot. +/// True when wsl.exe exists, a default distribution starts, and git runs inside it. +/// The git version when available; the reason otherwise (shown to the user). +public sealed record WslAvailability(bool Available, string Detail); + +/// Where a repository ended up after a clone into WSL. +/// The WSL distribution that holds the clone (its default distribution). +/// Path inside the distribution, e.g. /home/me/gclo/acme/repo. +/// The same folder as Explorer reaches it: \\wsl.localhost\<distro>\home\me\gclo\acme\repo. +public sealed record WslCloneResult(string Distribution, string LinuxPath, string WindowsPath); + +/// A clone into WSL did not complete; the message carries git's or wsl.exe's own explanation. +public sealed class WslCloneException : Exception +{ + /// What failed, in git's or wsl.exe's words. + public WslCloneException(string message) : base(message) { } +} + +/// +/// The "Clone in WSL instead" recovery for repositories whose trees contain +/// Windows-invalid paths (#8): a Linux file system has no such rule, so the +/// repository is cloned by the distribution's own git into +/// ~/gclo/<organization>/<repository> and left there. +/// +public interface IWslCloner +{ + /// Checks, without side effects, whether a clone into WSL can be attempted. + Task ProbeAsync(CancellationToken cancellationToken); + + /// + /// Clones inside the default WSL distribution, or fast-forwards + /// it when that folder is already a repository. Throws + /// when git or wsl.exe fail. + /// + /// HTTPS clone URL. + /// Organization (or user) name; the parent folder under ~/gclo. + /// Repository name; the clone's folder name. + /// GitHub token; handed to git through the environment only, never on a command line. + /// Kills wsl.exe (and the clone) when canceled. + Task CloneAsync(string url, string organization, string repositoryName, string token, CancellationToken cancellationToken); +} + +/// +/// Drives wsl.exe for . Everything that runs inside the +/// distribution is a POSIX sh script fed through stdin (sh -s) with the +/// organization, repository, and URL as positional parameters, so no value is ever +/// re-parsed by wsl.exe's own command-line splitting and nothing needs quoting. The token +/// travels as GCLO_GIT_TOKEN, forwarded by WSLENV, and is read by an inline +/// credential helper: it appears in no argument vector and no file. +/// +public sealed class WslCloner : IWslCloner +{ + /// Environment variable the inline credential helper reads the token from. + public const string TokenVariable = "GCLO_GIT_TOKEN"; + + /// + /// The clone script. $1 organization, $2 repository, $3 clone URL. Prints the + /// distribution name and the destination as its last two lines on success. A folder + /// that already is a repository is fast-forwarded instead of failing, so the verb is + /// safe to use twice. + /// + internal const string CloneScript = + "set -e\n" + + "org=\"$1\"; repo=\"$2\"; url=\"$3\"\n" + + "dest=\"$HOME/gclo/$org/$repo\"\n" + + "helper='!f() { echo username=x-access-token; echo \"password=$" + TokenVariable + "\"; }; f'\n" + + "if [ -d \"$dest/.git\" ]; then\n" + + " git -C \"$dest\" -c credential.helper= -c \"credential.helper=$helper\" pull --ff-only --quiet\n" + + "else\n" + + " mkdir -p \"$HOME/gclo/$org\"\n" + + " git -c credential.helper= -c \"credential.helper=$helper\" clone --quiet \"$url\" \"$dest\"\n" + + "fi\n" + + "printf '%s\\n%s\\n' \"$WSL_DISTRO_NAME\" \"$dest\"\n"; + + private readonly IProcessRunner _runner; + private readonly Func _fileExists; + private readonly string _wslExe; + + /// Process seam; the real by default. + /// File-existence probe for wsl.exe; by default. + /// Path of wsl.exe; %SystemRoot%\System32\wsl.exe by default. + public WslCloner(IProcessRunner? runner = null, Func? fileExists = null, string? wslExePath = null) + { + _runner = runner ?? new ProcessRunner(); + _fileExists = fileExists ?? File.Exists; + _wslExe = wslExePath ?? Path.Combine(Environment.SystemDirectory, "wsl.exe"); + } + + /// + public async Task ProbeAsync(CancellationToken cancellationToken) + { + if (!_fileExists(_wslExe)) + { + return new WslAvailability(false, "wsl.exe is not installed (Windows Subsystem for Linux is not enabled)."); + } + + ProcessResult result; + try + { + result = await _runner.RunAsync(_wslExe, ["-e", "git", "--version"], null, null, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return new WslAvailability(false, $"wsl.exe could not be started: {ex.Message}"); + } + + string stdout = result.StandardOutput.Trim(); + if (result.ExitCode == 0 && stdout.StartsWith("git version", StringComparison.Ordinal)) + { + return new WslAvailability(true, stdout); + } + + string reason = FirstLine(result.StandardError) ?? FirstLine(stdout) ?? $"exit code {result.ExitCode}"; + return new WslAvailability(false, result.ExitCode == 0 + ? $"git is not installed in the default WSL distribution ({reason})." + : $"the default WSL distribution did not run git: {reason}"); + } + + /// + public async Task CloneAsync(string url, string organization, string repositoryName, string token, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(url); + ArgumentException.ThrowIfNullOrWhiteSpace(organization); + ArgumentException.ThrowIfNullOrWhiteSpace(repositoryName); + ArgumentNullException.ThrowIfNull(token); + + var environment = new Dictionary(StringComparer.Ordinal) + { + [TokenVariable] = token, + ["GIT_TERMINAL_PROMPT"] = "0", + // Forward both into the distribution (WSLENV is a colon-separated list). + ["WSLENV"] = TokenVariable + ":GIT_TERMINAL_PROMPT", + }; + + ProcessResult result; + try + { + result = await _runner.RunAsync( + _wslExe, + ["-e", "sh", "-s", organization, repositoryName, url], + environment, + CloneScript, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + throw new WslCloneException($"wsl.exe could not be started: {ex.Message}"); + } + + if (result.ExitCode != 0) + { + string reason = LastLine(result.StandardError) ?? LastLine(result.StandardOutput) ?? $"exit code {result.ExitCode}"; + throw new WslCloneException($"Clone in WSL failed: {reason}"); + } + + string[] lines = result.StandardOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (lines.Length < 2 || lines[^1].Length == 0 || lines[^2].Length == 0) + { + throw new WslCloneException("Clone in WSL finished but did not report where the repository was placed."); + } + + string distribution = lines[^2]; + string linuxPath = lines[^1]; + string windowsPath = @"\\wsl.localhost\" + distribution + linuxPath.Replace('/', '\\'); + return new WslCloneResult(distribution, linuxPath, windowsPath); + } + + private static string? FirstLine(string text) + => text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault(); + + private static string? LastLine(string text) + => text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).LastOrDefault(); +} + +/// +/// The real . Output is read as bytes and decoded as UTF-16 +/// when it carries NULs (wsl.exe's own messages, e.g. "no distribution installed", are +/// UTF-16) and as UTF-8 otherwise (everything a Linux process prints). +/// +[ExcludeFromCodeCoverage(Justification = "Spawns real processes; WslCloner is covered through a fake runner.")] +public sealed class ProcessRunner : IProcessRunner +{ + /// + public async Task RunAsync( + string fileName, + IReadOnlyList arguments, + IReadOnlyDictionary? environment, + string? standardInput, + CancellationToken cancellationToken) + { + var start = new ProcessStartInfo(fileName) + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + foreach (string argument in arguments) + { + start.ArgumentList.Add(argument); + } + if (environment is not null) + { + foreach (KeyValuePair pair in environment) + { + start.Environment[pair.Key] = pair.Value; + } + } + + using var process = new Process { StartInfo = start }; + process.Start(); + + using var stdout = new MemoryStream(); + using var stderr = new MemoryStream(); + Task copyOut = process.StandardOutput.BaseStream.CopyToAsync(stdout, cancellationToken); + Task copyErr = process.StandardError.BaseStream.CopyToAsync(stderr, cancellationToken); + if (standardInput is not null) + { + await process.StandardInput.WriteAsync(standardInput.AsMemory(), cancellationToken).ConfigureAwait(false); + } + process.StandardInput.Close(); + + try + { + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + await Task.WhenAll(copyOut, copyErr).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + try { process.Kill(entireProcessTree: true); } catch (InvalidOperationException) { /* already exited */ } + throw; + } + + return new ProcessResult(process.ExitCode, Decode(stdout.ToArray()), Decode(stderr.ToArray())); + } + + private static string Decode(byte[] bytes) + => bytes.Length >= 2 && Array.IndexOf(bytes, (byte)0) >= 0 + ? Encoding.Unicode.GetString(bytes).Replace("\0", "") + : Encoding.UTF8.GetString(bytes); +} diff --git a/gclo.ViewModels/PathRecoveryDecision.cs b/gclo.ViewModels/PathRecoveryDecision.cs new file mode 100644 index 0000000..8718e22 --- /dev/null +++ b/gclo.ViewModels/PathRecoveryDecision.cs @@ -0,0 +1,21 @@ +using gclo.Engine; + +namespace gclo.ViewModels; + +/// +/// What the user chose in the path-recovery UI for a repository whose tree has +/// Windows-invalid paths. Null (no decision) means the dialog was dismissed. +/// +public abstract record PathRecoveryDecision +{ + private PathRecoveryDecision() { } + + /// Rename/skip the offending paths and check the repository out on Windows. + public sealed record Apply(PathRecovery Recovery) : PathRecoveryDecision; + + /// + /// Leave the Windows copy alone and clone the repository inside WSL instead, where + /// the paths are legal (#8). Only offered when . + /// + public sealed record CloneInWsl : PathRecoveryDecision; +} diff --git a/gclo.ViewModels/RepoItemViewModel.cs b/gclo.ViewModels/RepoItemViewModel.cs index 15daed1..97962bd 100644 --- a/gclo.ViewModels/RepoItemViewModel.cs +++ b/gclo.ViewModels/RepoItemViewModel.cs @@ -74,6 +74,17 @@ public sealed partial class RepoItemViewModel : ObservableObject /// True when there is an error message to show inline. public bool HasError => !string.IsNullOrEmpty(Error); + /// + /// A non-error remark shown under the row, e.g. where a repository was cloned when + /// it lives outside the target folder (in WSL, #8). Cleared by the next run. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasNote))] + public partial string? Note { get; set; } + + /// True when there is a remark to show inline. + public bool HasNote => !string.IsNullOrEmpty(Note); + partial void OnStatusChanged(SyncStatus value) { OnPropertyChanged(nameof(StatusText)); diff --git a/gclo.ViewModels/WorkspaceViewModel.cs b/gclo.ViewModels/WorkspaceViewModel.cs index b93c14d..bc019f8 100644 --- a/gclo.ViewModels/WorkspaceViewModel.cs +++ b/gclo.ViewModels/WorkspaceViewModel.cs @@ -18,6 +18,10 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable { private readonly IRepositoryLister _lister; private readonly IGitClient _git; + private readonly IWslCloner _wsl; + + /// Result of the one-time WSL probe; null until the first path recovery asks for it. + private WslAvailability? _wslAvailability; private readonly IOrganizationLister _orgLister; private readonly Func, IProgress> _progressFactory; private readonly TimeSpan _orgLookupDebounce; @@ -86,10 +90,12 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable IActivityLog? log = null, Account? account = null, ITokenVault? tokenVault = null, - AccountsStore? accountsStore = null) + AccountsStore? accountsStore = null, + IWslCloner? wsl = null) { _lister = lister ?? new GitHubRepositoryLister(); _git = git ?? new LibGit2GitClient(); + _wsl = wsl ?? new WslCloner(); _orgLister = orgLister ?? new GitHubOrganizationLister(); _progressFactory = progressFactory ?? (handler => new Progress(handler)); _orgLookupDebounce = orgLookupDebounce ?? TimeSpan.FromMilliseconds(600); @@ -832,8 +838,35 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable /// Raised when the user asks to resolve a row's Windows-invalid paths. The view /// subscribes and shows recovery UI (the VM stays UI-free), returning the user's /// decision, or null when they cancel. With no subscriber the command does nothing. + /// is settled before the interaction runs, so the + /// view can decide whether to offer "Clone in WSL instead". /// - public Func>? RecoveryInteraction { get; set; } + public Func>? RecoveryInteraction { get; set; } + + /// + /// True when a clone into WSL can be offered as a recovery (#8): wsl.exe exists, the + /// default distribution starts, and git runs inside it. Probed once, lazily, the + /// first time path recovery is requested; false until then. + /// + [ObservableProperty] + public partial bool IsWslCloneAvailable { get; private set; } + + /// Why is false (or the git version when true), for the log and the dialog. + public string WslAvailabilityDetail => _wslAvailability?.Detail ?? ""; + + private async Task EnsureWslProbedAsync(CancellationToken cancellationToken) + { + if (_wslAvailability is not null) + { + return; + } + WslAvailability availability = await _wsl.ProbeAsync(cancellationToken); + _wslAvailability = availability; + IsWslCloneAvailable = availability.Available; + _log.Info(availability.Available + ? $"WSL clone available ({availability.Detail})." + : $"WSL clone not available: {availability.Detail}"); + } private bool CanResolvePaths(RepoItemViewModel item) => item is not null && item.HasPathIssue && !IsRunning && !IsResolvingPaths; @@ -851,16 +884,39 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable } _log.Info($"{item.Name}: path recovery requested ({item.InvalidPaths?.Count ?? 0} invalid paths)."); - PathRecovery? recovery = await interaction(item); - if (recovery is null) + await EnsureWslProbedAsync(_lifetimeCts.Token); + PathRecoveryDecision? decision = await interaction(item); + if (decision is null) { _log.Info($"{item.Name}: path recovery canceled; the repository stays failed."); return; // user canceled; the row keeps its Failed state and payload } - string path = Path.Combine(EffectiveTargetRoot, item.Name); IsResolvingPaths = true; try + { + if (decision is PathRecoveryDecision.CloneInWsl) + { + await CloneInWslAsync(item); + } + else + { + await ApplyRecoveryAsync(item, ((PathRecoveryDecision.Apply)decision).Recovery); + } + } + finally + { + IsResolvingPaths = false; + } + RecomputeCompletedCount(); + UpdateFilteredMembership(item); + RetryFailedCommand.NotifyCanExecuteChanged(); + } + + private async Task ApplyRecoveryAsync(RepoItemViewModel item, PathRecovery recovery) + { + string path = Path.Combine(EffectiveTargetRoot, item.Name); + try { _log.Info($"{item.Name}: applying path recovery " + $"({recovery.SegmentRenames.Count} renamed, {recovery.SkippedPaths.Count} skipped)."); @@ -883,13 +939,37 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable item.InvalidPaths = (ex as InvalidRepositoryPathsException)?.Paths; _log.Error($"{item.Name}: path recovery failed: {ex.Message}", ex); } - finally + } + + /// + /// The "Clone in WSL instead" recovery (#8): the repository is cloned by the default + /// WSL distribution's git into ~/gclo/<org>/<repo>, where its paths are + /// legal. The fetched-but-unchecked-out Windows copy is left as it is (a later + /// Windows sync reports the same invalid paths again, by design: nothing on the + /// Windows side changed). On failure the row keeps its payload so the user can pick + /// the other recovery. + /// + private async Task CloneInWslAsync(RepoItemViewModel item) + { + try { - IsResolvingPaths = false; + _log.Info($"{item.Name}: cloning in WSL instead."); + item.Status = SyncStatus.Pulling; // indeterminate bar; git in WSL reports no percentage here + WslCloneResult result = await _wsl.CloneAsync( + item.Descriptor.CloneUrl, Organization.Trim(), item.Name, Token.Trim(), _lifetimeCts.Token); + + item.Status = SyncStatus.Done; + item.Error = null; + item.InvalidPaths = null; + item.Note = $"Cloned in WSL ({result.Distribution}): {result.WindowsPath}"; + _log.Info($"{item.Name}: cloned in WSL ({result.Distribution}) at {result.LinuxPath}."); + } + catch (Exception ex) + { + item.Status = SyncStatus.Failed; + item.Error = ex.Message; + _log.Error($"{item.Name}: clone in WSL failed: {ex.Message}", ex); } - RecomputeCompletedCount(); - UpdateFilteredMembership(item); - RetryFailedCommand.NotifyCanExecuteChanged(); } // ---------------------------------------------------------------- sorting diff --git a/gclo/PathRecoveryDialog.xaml b/gclo/PathRecoveryDialog.xaml index 524e3c2..0dd65ef 100644 --- a/gclo/PathRecoveryDialog.xaml +++ b/gclo/PathRecoveryDialog.xaml @@ -11,7 +11,8 @@ PrimaryButtonText="Apply and check out" CloseButtonText="Skip this repo" DefaultButton="Primary" - PrimaryButtonClick="OnPrimaryButtonClick"> + PrimaryButtonClick="OnPrimaryButtonClick" + SecondaryButtonClick="OnSecondaryButtonClick"> diff --git a/gclo/PathRecoveryDialog.xaml.cs b/gclo/PathRecoveryDialog.xaml.cs index dfda500..2b2d0ce 100644 --- a/gclo/PathRecoveryDialog.xaml.cs +++ b/gclo/PathRecoveryDialog.xaml.cs @@ -18,7 +18,9 @@ namespace gclo /// pre-flights the choices through and keeps the /// dialog open with the problems listed when they would fail. /// carries the chosen after a sound apply and stays null - /// when the dialog is dismissed ("Skip this repo"). + /// when the dialog is dismissed ("Skip this repo"). When WSL can run git on this + /// machine the dialog also offers "Clone in WSL instead" (#8), which skips the + /// Windows checkout entirely: then reports that choice. /// /// As with every ContentDialog, the caller must set XamlRoot before /// ShowAsync. @@ -28,9 +30,13 @@ namespace gclo private readonly List _rows; /// The user's recovery choice, or null when the dialog was dismissed. - public PathRecovery? Result { get; private set; } + public PathRecoveryDecision? Decision { get; private set; } - public PathRecoveryDialog(string repoName, IReadOnlyList paths) + /// The chosen renames/skips when is an apply; null otherwise. + public PathRecovery? Result => (Decision as PathRecoveryDecision.Apply)?.Recovery; + + /// Offers the "Clone in WSL instead" button (see ). + public PathRecoveryDialog(string repoName, IReadOnlyList paths, bool wslAvailable = false) { ArgumentNullException.ThrowIfNull(repoName); ArgumentNullException.ThrowIfNull(paths); @@ -38,7 +44,14 @@ namespace gclo IntroText.Text = $"'{repoName}' contains paths that are legal in git but cannot be created on Windows. " - + "Edit the replacement name for each entry, or mark it Skip to leave it out of the checkout."; + + "Edit the replacement name for each entry, or mark it Skip to leave it out of the checkout." + + (wslAvailable + ? " Or clone the repository unchanged inside WSL (~/gclo// in your default distribution), where these paths are legal." + : ""); + if (wslAvailable) + { + SecondaryButtonText = "Clone in WSL instead"; + } _rows = new List(paths.Count); foreach (InvalidPathInfo path in paths) @@ -64,8 +77,11 @@ namespace gclo } ProblemsBar.IsOpen = false; - Result = plan.Recovery; + Decision = new PathRecoveryDecision.Apply(plan.Recovery); } + + private void OnSecondaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) + => Decision = new PathRecoveryDecision.CloneInWsl(); } /// diff --git a/gclo/WorkspacePage.xaml b/gclo/WorkspacePage.xaml index e044d00..155138b 100644 --- a/gclo/WorkspacePage.xaml +++ b/gclo/WorkspacePage.xaml @@ -1,4 +1,4 @@ - + + @@ -800,10 +801,20 @@ Foreground="{ThemeResource SystemFillColorCriticalBrush}" Text="{x:Bind Error, Mode=OneWay}" Visibility="{x:Bind local:StatusFormat.VisibleIf(HasError), Mode=OneWay}" /> - + + - private async Task ShowPathRecoveryDialogAsync(RepoItemViewModel item) + private async Task ShowPathRecoveryDialogAsync(RepoItemViewModel item) { if (item.InvalidPaths is not { Count: > 0 } paths) { return null; } - var dialog = new PathRecoveryDialog(item.Name, paths) { XamlRoot = XamlRoot }; + var dialog = new PathRecoveryDialog(item.Name, paths, ViewModel.IsWslCloneAvailable) { XamlRoot = XamlRoot }; await DialogGuard.ShowAsync(dialog); - return dialog.Result; // blocked by another open dialog reads as dismissed + return dialog.Decision; // blocked by another open dialog reads as dismissed } private async void OpenFolderButton_Click(object sender, RoutedEventArgs e) -- 2.54.0 From a5875d0968d23161abf98495cf2dbebfc339c286 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 9 Oct 2026 08:41:11 -0500 Subject: [PATCH 4/4] Repo table: migrate to WinUI.TableView 1.5.0 with an accessible header (#33) The hand-rolled header Grid + ListView become one TableView (pinned 1.5.0, the first release with UI Automation peers). The view model stays the owner of sort and filter state: FilteredRepos is the ItemsSource, Sorting/ClearSorting are intercepted into SortCommand (the control's third "clear" step starts the ascending/descending cycle over), and the sort indicator plus the header's composed automation name ("Branch, sorted descending") are written back from SortColumn/SortDescending. The control's Excel-style filters stay off; each header hosts the same funnel flyout as before (NameFilterButton etc.), bound to the same view-model filters. Selection remains model state (IsSelected column), the error line, WSL note, and Resolve link move to an always-on row-details template (collapsed when empty). Accessibility finding: TableView 1.5.0 inherits ListViewAutomationPeer's children (rows only), so its header row, headers, and anything inside them are unreachable by tree walking and the Table pattern's ColumnHeaders property fails. AccessibleTableView's peer prepends the column header peers, restoring what the old header exposed; ColumnHeaderContent gives cells "Name, Row 3, alpha" names instead of the header Grid's type name. Tests: gclo/UiTestFixture.cs is an offline seam (GCLO_UITEST_FIXTURE=1 plus the magic token "gclo-uitest-fixture-token" only) that answers GitHub and git for one fixed organization; gclo.UiTests/RepoTableTests.cs loads it in its own collection (collections now run one at a time) and asserts the table renders, headers sort through the view model via Invoke, and the Name funnel filters and clears. Keyboard reachability of the funnels stays a manual check. Closes #33 Co-Authored-By: Claude Fable 5.1 --- gclo.Engine.Tests/WorkspaceViewModelTests.cs | 9 + gclo.UiTests/AppSession.cs | 2 + gclo.UiTests/RepoTableTests.cs | 150 +++++ gclo.UiTests/UiSessionTests.cs | 4 + gclo.ViewModels/RepoItemViewModel.cs | 10 +- gclo/AccessibleTableView.cs | 118 ++++ gclo/MainWindow.xaml.cs | 9 +- gclo/UiTestFixture.cs | 78 +++ gclo/WorkspacePage.xaml | 637 +++++++++---------- gclo/WorkspacePage.xaml.cs | 76 ++- gclo/gclo.csproj | 1 + gclo/packages.lock.json | 9 + 12 files changed, 762 insertions(+), 341 deletions(-) create mode 100644 gclo.UiTests/RepoTableTests.cs create mode 100644 gclo/AccessibleTableView.cs create mode 100644 gclo/UiTestFixture.cs diff --git a/gclo.Engine.Tests/WorkspaceViewModelTests.cs b/gclo.Engine.Tests/WorkspaceViewModelTests.cs index d183729..95e4bfc 100644 --- a/gclo.Engine.Tests/WorkspaceViewModelTests.cs +++ b/gclo.Engine.Tests/WorkspaceViewModelTests.cs @@ -866,8 +866,17 @@ public sealed class WorkspaceViewModelTests : IDisposable item.Note = "Cloned in WSL"; Assert.True(item.HasNote); + Assert.True(item.HasDetails); // the row-details strip shows for a note alone Assert.Contains(nameof(RepoItemViewModel.Note), changed); Assert.Contains(nameof(RepoItemViewModel.HasNote), changed); + Assert.Contains(nameof(RepoItemViewModel.HasDetails), changed); + + changed.Clear(); + item.Note = null; + Assert.False(item.HasDetails); + item.Error = "boom"; + Assert.True(item.HasDetails); // and for an error alone + Assert.Contains(nameof(RepoItemViewModel.HasDetails), changed); } [Fact] diff --git a/gclo.UiTests/AppSession.cs b/gclo.UiTests/AppSession.cs index 2c5b5e6..f4664e8 100644 --- a/gclo.UiTests/AppSession.cs +++ b/gclo.UiTests/AppSession.cs @@ -40,6 +40,8 @@ public sealed class AppSession : IDisposable WorkingDirectory = Path.GetDirectoryName(ExePath) ?? "", }; startInfo.Environment["GCLO_DATA_DIR"] = DataDirectory; + // Enables the offline fixture for the one magic token only (see UiTestFixture). + startInfo.Environment["GCLO_UITEST_FIXTURE"] = "1"; Automation = new UIA3Automation(); try diff --git a/gclo.UiTests/RepoTableTests.cs b/gclo.UiTests/RepoTableTests.cs new file mode 100644 index 0000000..524f7be --- /dev/null +++ b/gclo.UiTests/RepoTableTests.cs @@ -0,0 +1,150 @@ +using FlaUI.Core.AutomationElements; +using FlaUI.Core.Input; +using FlaUI.Core.WindowsAPI; + +namespace gclo.UiTests; + +/// +/// Drives the repository table (WinUI.TableView, #33) against the app's offline +/// fixture: the magic token routes Quick Sync to a fixed organization with three +/// repositories (see UiTestFixture in the app), so the table renders without +/// GitHub. Its own collection, because loading repositories replaces the connect +/// card the smoke tests expect to find. +/// +[Collection(nameof(UiTableTests))] +public sealed class RepoTableTests : UiTestBase +{ + private const string FixtureToken = "gclo-uitest-fixture-token"; + + public RepoTableTests(AppSession session) + : base(session) + { + } + + // (1) The fixture organization loads into the table: the TableView is reachable + // by its automation id, the three repositories are rendered as cells, and + // the header row (select-all, funnels) is in the UI Automation tree. + [Fact] + public void LoadRepositories_WithFixtureToken_RendersTheTable() + { + EnsureLoaded(); + + Assert.NotNull(Session.FindInApp(cf => cf.ByName("alpha"))); + Assert.NotNull(Session.FindInApp(cf => cf.ByName("bravo"))); + Assert.NotNull(Session.FindInApp(cf => cf.ByName("charlie"))); + Assert.NotNull(Session.WaitFor(() => Funnel("StatusFilterButton"), "the Status funnel")); + Assert.NotNull(Session.WaitFor(() => ColumnHeader("Select"), "the select-all header")); + } + + // (2) Column headers carry composed sort names and the Invoke pattern; invoking + // one sorts through the view model (ascending, then descending, then back to + // ascending on the control's "clear" step), and other columns stay unsorted. + [Fact] + public void Headers_InvokeSortsThroughTheViewModel() + { + EnsureLoaded(); + + AutomationElement header = Session.WaitFor(() => ColumnHeader("Branch, not sorted"), "the Branch column header"); + Assert.True(header.Patterns.Invoke.IsSupported, "a sortable header exposes the Invoke pattern"); + + header.Patterns.Invoke.Pattern.Invoke(); + Session.WaitFor(() => ColumnHeader("Branch, sorted ascending"), "the header after one sort"); + Assert.NotNull(ColumnHeader("Name, not sorted")); + + header.Patterns.Invoke.Pattern.Invoke(); + Session.WaitFor(() => ColumnHeader("Branch, sorted descending"), "the header after a second sort"); + + header.Patterns.Invoke.Pattern.Invoke(); // the control's third step is "clear"; the view model starts over + Session.WaitFor(() => ColumnHeader("Branch, sorted ascending"), "the header after a third sort"); + } + + // (3) The per-column funnel opens its flyout; its text box drives the view + // model's filter (rows vanish, the empty-filter text appears, the funnel + // names the active filter) and Clear restores every row. + [Fact] + public void NameFunnel_FiltersRows_AndClearRestoresThem() + { + EnsureLoaded(); + + try + { + Session.WaitFor(() => Funnel("NameFilterButton"), "the Name funnel").AsButton().Invoke(); + AutomationElement box = Session.WaitForElement("NameFilterBox"); + Session.EnterText(box, "zzz"); + + Session.WaitFor( + () => Session.FindInApp(cf => cf.ByName("Filter by name, filtering on 'zzz'")), + "the funnel to report the active filter"); + Session.WaitForText("No repositories match this filter."); + AppSession.WaitUntilGone(() => Session.FindInApp(cf => cf.ByName("alpha")), "the filtered-out row"); + + Session.ClickButton("Clear"); + Session.WaitFor(() => Session.FindInApp(cf => cf.ByName("Filter by name")), "the funnel after Clear"); + Session.WaitFor(() => Session.FindInApp(cf => cf.ByName("alpha")), "the row to come back"); + Session.WaitForTextGone("No repositories match this filter."); + } + finally + { + Keyboard.Press(VirtualKeyShort.ESCAPE); // close the flyout if it is still open + } + } + + // (4) Keyboard reachability of the funnels (Tab from the select-all checkbox) is + // not asserted here: synthesized Tab keystrokes did not move focus inside the + // TableView header under FlaUI on the development machine, so that remains a + // manual Narrator/keyboard check (see the #33 acceptance notes). + + /// A column header by its automation name; the header row is in the UIA tree thanks to AccessibleTableView. + private AutomationElement? ColumnHeader(string name) + => Session.FindInApp(cf => cf.ByAutomationId("RepoListView"))?.FindFirstDescendant(cf => cf.ByName(name)); + + /// The funnel button carrying , inside its column header. + private AutomationElement? Funnel(string automationId) + => Session.FindInApp(cf => cf.ByAutomationId("RepoListView"))?.FindFirstDescendant(cf => cf.ByAutomationId(automationId)); + + /// + /// Loads the fixture organization once per session: paste the magic token (the + /// org lookup is answered by the fixture), pick the org, then Load repositories. + /// A session that already shows the table skips straight through. + /// + private void EnsureLoaded() + { + if (Session.FindInApp(cf => cf.ByAutomationId("RepoListView")) is not null) + { + return; + } + + AutomationElement tokenBox = Session.WaitForElement("ConnectTokenBox"); + Session.EnterText(tokenBox, FixtureToken); + // The debounced org lookup is answered by the fixture (one organization); the + // log line is the oracle, then the org is picked from the combo box's list so + // the selection commits to the view model's Organization, which enables Load. + Session.WaitForLogLine("Organization lookup finished: 1 organizations", TimeSpan.FromSeconds(20)); + ComboBox orgBox = Session.WaitForElement("ConnectOrgBox").AsComboBox(); + Session.WaitFor( + () => + { + orgBox.Expand(); + return orgBox.Items.FirstOrDefault(i => i.Name == "fixture-org"); + }, + "the fixture org in the organization list"); + orgBox.Select("fixture-org"); + orgBox.Collapse(); + + Session.WaitFor( + () => Session.FindInApp(cf => cf.ByName("Load repositories")) is { IsEnabled: true } button ? button : null, + "the Load repositories button to enable"); + Session.ClickButton("Load repositories"); + Session.WaitForElement("RepoListView", TimeSpan.FromSeconds(20)); + Session.WaitFor(() => Session.FindInApp(cf => cf.ByName("charlie")), "the last fixture row"); + } +} + +/// +/// A second collection (and therefore a second launched app) for the table tests; +/// see for why they cannot share the smoke session. +/// +[CollectionDefinition(nameof(UiTableTests), DisableParallelization = true)] +public sealed class UiTableTests : ICollectionFixture +{ +} diff --git a/gclo.UiTests/UiSessionTests.cs b/gclo.UiTests/UiSessionTests.cs index 5e4681b..19c5d90 100644 --- a/gclo.UiTests/UiSessionTests.cs +++ b/gclo.UiTests/UiSessionTests.cs @@ -1,3 +1,7 @@ +// The table tests launch a second app instance in their own collection; two FlaUI +// sessions must never drive input at the same time, so collections run one at a time. +[assembly: CollectionBehavior(DisableTestParallelization = true)] + namespace gclo.UiTests; /// diff --git a/gclo.ViewModels/RepoItemViewModel.cs b/gclo.ViewModels/RepoItemViewModel.cs index 97962bd..b26c2dc 100644 --- a/gclo.ViewModels/RepoItemViewModel.cs +++ b/gclo.ViewModels/RepoItemViewModel.cs @@ -80,11 +80,15 @@ public sealed partial class RepoItemViewModel : ObservableObject /// [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasNote))] + [NotifyPropertyChangedFor(nameof(HasDetails))] public partial string? Note { get; set; } /// True when there is a remark to show inline. public bool HasNote => !string.IsNullOrEmpty(Note); + /// True when the row has anything to show under its cells (error, note, or both). + public bool HasDetails => HasError || HasNote; + partial void OnStatusChanged(SyncStatus value) { OnPropertyChanged(nameof(StatusText)); @@ -105,7 +109,11 @@ public sealed partial class RepoItemViewModel : ObservableObject OnPropertyChanged(nameof(ProgressValue)); } - partial void OnErrorChanged(string? value) => OnPropertyChanged(nameof(HasError)); + partial void OnErrorChanged(string? value) + { + OnPropertyChanged(nameof(HasError)); + OnPropertyChanged(nameof(HasDetails)); + } partial void OnInvalidPathsChanged(IReadOnlyList? value) => OnPropertyChanged(nameof(HasPathIssue)); diff --git a/gclo/AccessibleTableView.cs b/gclo/AccessibleTableView.cs new file mode 100644 index 0000000..c24793c --- /dev/null +++ b/gclo/AccessibleTableView.cs @@ -0,0 +1,118 @@ +using System.Collections.Generic; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation.Peers; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using WinUI.TableView; + +namespace gclo +{ + /// + /// whose UI Automation tree includes the column header row. + /// WinUI.TableView 1.5.0 gives headers their own peers (Invoke sorts, the name + /// composes the sort state) but inherits ListViewAutomationPeer's children, + /// which are the rows only: the header row, its headers, and anything hosted in + /// them (the filter funnels here) are unreachable by tree walking, and the Table + /// pattern's ColumnHeaders property fails for the same reason. This peer + /// prepends the header row, so screen readers and the FlaUI suite see the table + /// the way the hand-rolled header was seen before (#33). + /// + public sealed partial class AccessibleTableView : TableView + { + protected override AutomationPeer OnCreateAutomationPeer() => new AccessibleTableViewPeer(this); + + /// The template's header row, once the template has been applied; null before. + internal TableViewHeaderRow? FindHeaderRow() => FindDescendant(this); + + private static T? FindDescendant(DependencyObject root) where T : DependencyObject + { + int count = VisualTreeHelper.GetChildrenCount(root); + for (int i = 0; i < count; i++) + { + DependencyObject child = VisualTreeHelper.GetChild(root, i); + if (child is T match) + { + return match; + } + if (child is not ItemsPresenter && FindDescendant(child) is { } nested) + { + return nested; // rows live under the ItemsPresenter; the header row does not + } + } + return null; + } + } + + /// See . + public sealed partial class AccessibleTableViewPeer : WinUI.TableView.AutomationPeers.TableViewAutomationPeer + { + private readonly AccessibleTableView _owner; + + public AccessibleTableViewPeer(AccessibleTableView owner) + : base(owner) + { + _owner = owner; + } + + protected override IList GetChildrenCore() + { + IList children = base.GetChildrenCore() ?? new List(); + if (_owner.FindHeaderRow() is not { } headerRow) + { + return children; + } + + // TableViewHeaderRow is a plain Control with no peer of its own, so its + // column headers (which do have peers) become direct children of the table. + var withHeaders = new List(children.Count + 8); + foreach (TableViewColumnHeader header in FindAll(headerRow)) + { + if (FrameworkElementAutomationPeer.CreatePeerForElement(header) is { } headerPeer) + { + withHeaders.Add(headerPeer); + } + } + withHeaders.AddRange(children); + return withHeaders; + } + + private static IEnumerable FindAll(DependencyObject root) where T : DependencyObject + { + int count = VisualTreeHelper.GetChildrenCount(root); + for (int i = 0; i < count; i++) + { + DependencyObject child = VisualTreeHelper.GetChild(root, i); + if (child is T match) + { + yield return match; + continue; + } + foreach (T nested in FindAll(child)) + { + yield return nested; + } + } + } + } + + /// + /// Header content for a table column: the title text plus whatever the column adds + /// beside it (the filter funnel). TableView names each cell from its column's + /// header object, so the override makes that "Name, Row 3, alpha" rather than the + /// Grid's type name. + /// + public sealed partial class ColumnHeaderContent : Grid + { + public static readonly DependencyProperty TitleProperty = DependencyProperty.Register( + nameof(Title), typeof(string), typeof(ColumnHeaderContent), new PropertyMetadata("")); + + /// The column title, as read by assistive technology. + public string Title + { + get => (string)GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + public override string ToString() => Title; + } +} diff --git a/gclo/MainWindow.xaml.cs b/gclo/MainWindow.xaml.cs index 7306551..1660efb 100644 --- a/gclo/MainWindow.xaml.cs +++ b/gclo/MainWindow.xaml.cs @@ -427,7 +427,11 @@ namespace gclo { // Quick Sync: no account behind it. A saved default token (Settings) // pre-fills the connect card; setting Token also starts the org lookup. - var quickSync = new WorkspaceViewModel(log: _log); + var quickSync = new WorkspaceViewModel( + UiTestFixture.WrapRepositoryLister(new GitHubRepositoryLister()), + UiTestFixture.WrapGitClient(new LibGit2GitClient()), + UiTestFixture.WrapOrganizationLister(new GitHubOrganizationLister()), + log: _log); if (_tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId) is { Length: > 0 } defaultToken) { quickSync.Token = defaultToken; @@ -442,6 +446,9 @@ namespace gclo return null; } return new WorkspaceViewModel( + UiTestFixture.WrapRepositoryLister(new GitHubRepositoryLister()), + UiTestFixture.WrapGitClient(new LibGit2GitClient()), + UiTestFixture.WrapOrganizationLister(new GitHubOrganizationLister()), account: account, tokenVault: _tokenVault, accountsStore: _accountsStore, diff --git a/gclo/UiTestFixture.cs b/gclo/UiTestFixture.cs new file mode 100644 index 0000000..60394b0 --- /dev/null +++ b/gclo/UiTestFixture.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using gclo.Engine; + +namespace gclo +{ + /// + /// Offline seam for the FlaUI suite (#54, #33): when the process runs with + /// GCLO_UITEST_FIXTURE=1 and a workspace's token is exactly + /// , GitHub and git are answered by this fixture instead of the + /// network - a fixed organization with three repositories, and a git client that + /// succeeds without touching disk. Any other token (even with the variable set) goes + /// to the real services, so the existing offline-input tests keep their meaning + /// (a dummy token still gets GitHub's 401). Production never sets the variable. + /// + internal static class UiTestFixture + { + /// The magic token that routes a workspace to the fixture. + public const string Token = "gclo-uitest-fixture-token"; + + /// The organization the fixture lists. + public const string Organization = "fixture-org"; + + /// True when the process was started for the UI suite with the fixture enabled. + public static bool IsEnabled { get; } = + Environment.GetEnvironmentVariable("GCLO_UITEST_FIXTURE") == "1"; + + private static readonly IReadOnlyList Repositories = + [ + new("alpha", "https://github.com/fixture-org/alpha.git", "main", IsArchived: false), + new("bravo", "https://github.com/fixture-org/bravo.git", "develop", IsArchived: true), + new("charlie", "https://github.com/fixture-org/charlie.git", "main", IsArchived: false), + ]; + + public static IRepositoryLister WrapRepositoryLister(IRepositoryLister real) + => IsEnabled ? new RepositoryLister(real) : real; + + public static IOrganizationLister WrapOrganizationLister(IOrganizationLister real) + => IsEnabled ? new OrganizationLister(real) : real; + + public static IGitClient WrapGitClient(IGitClient real) + => IsEnabled ? new GitClient(real) : real; + + private sealed class RepositoryLister(IRepositoryLister real) : IRepositoryLister + { + public Task> ListOrganizationRepositoriesAsync( + string organization, string token, CancellationToken cancellationToken = default) + => token == Token + ? Task.FromResult(Repositories) + : real.ListOrganizationRepositoriesAsync(organization, token, cancellationToken); + } + + private sealed class OrganizationLister(IOrganizationLister real) : IOrganizationLister + { + public Task> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default) + => token == Token + ? Task.FromResult>([Organization]) + : real.ListOrganizationsAsync(token, cancellationToken); + } + + /// Succeeds without writing anything; the real client handles every other token. + private sealed class GitClient(IGitClient real) : IGitClient + { + public bool IsValidRepository(string path) => false; + + public Task CloneAsync(string url, string path, string token, Action? onProgress, CancellationToken cancellationToken) + => token == Token ? Task.CompletedTask : real.CloneAsync(url, path, token, onProgress, cancellationToken); + + public Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken) + => token == Token ? Task.CompletedTask : real.FetchAndPullAsync(path, token, cancellationToken); + + public Task ApplyRecoveryAsync(string path, PathRecovery recovery, CancellationToken cancellationToken) + => real.ApplyRecoveryAsync(path, recovery, cancellationToken); + } + } +} diff --git a/gclo/WorkspacePage.xaml b/gclo/WorkspacePage.xaml index 155138b..a4fbf8b 100644 --- a/gclo/WorkspacePage.xaml +++ b/gclo/WorkspacePage.xaml @@ -5,36 +5,44 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:gclo" xmlns:vm="using:gclo.ViewModels" + xmlns:tv="using:WinUI.TableView" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d"> - - - + - + 40 2* 160 120 - 80 + 96 + + + + + + @@ -487,345 +495,326 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - SortCommand; the indicator and the header's + automation name follow SortColumn from code) and its Excel-style + column filters are off in favor of the funnel flyouts in each + header, which bind the same view-model filters as before. Selection + is model state (the IsSelected column), never the control's. The + TableView carries the view model as DataContext so the row-details + template's Resolve link can reach ResolvePathsCommand by ElementName. --> + - - - - - - - - - - - - - - - - - - - + AutoGenerateColumns="False" + IsReadOnly="True" + SelectionMode="None" + CornerButtonMode="None" + HeadersVisibility="Columns" + CanFilterColumns="False" + CanSortColumns="True" + CanReorderColumns="False" + CanResizeColumns="True" + ShowExportOptions="False" + GridLinesVisibility="Horizontal" + HeaderGridLinesVisibility="Horizontal" + RowDetailsVisibilityMode="Visible" + Sorting="RepoTable_Sorting" + ClearSorting="RepoTable_ClearSorting" + Loaded="RepoTable_Loaded"> + + + + + - - - + ToolTipService.ToolTip="Select or clear every visible row (hidden rows keep their selection)" + AutomationProperties.Name="Select all visible repositories" + IsChecked="{x:Bind ViewModel.AllSelected, Mode=TwoWay}" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + - - + + - /// Sort-direction arrow (visual only; the header button's automation name - /// carries the state) for the active sort column: chevron up = ascending. - /// - public string SortGlyph(string column, string? sortColumn, bool sortDescending) - => column == sortColumn ? (sortDescending ? "" : "") : ""; - - /// Shows the sort arrow only on the column the table is sorted by. - public Visibility SortGlyphVisibility(string column, string? sortColumn) - => column == sortColumn ? Visibility.Visible : Visibility.Collapsed; - /// /// Composed automation name for a sortable column header, e.g. /// "Name, sorted ascending" or "Branch, not sorted". /// - public string SortAutomationName(string column, string? sortColumn, bool sortDescending) + public static string SortAutomationName(string column, string? sortColumn, bool sortDescending) => column != sortColumn ? $"{column}, not sorted" : sortDescending ? $"{column}, sorted descending" : $"{column}, sorted ascending"; + // ---- TableView bridge (#33): the control raises sort requests, the view model + // decides. Sorting (first and second click) and ClearSorting (the control's + // third-click "unsorted" step) both hand the column to SortCommand, which + // cycles ascending <-> descending; the indicator and the header's automation + // name are then written back from SortColumn/SortDescending. + + private void RepoTable_Sorting(object sender, TableViewSortingEventArgs e) + { + e.Handled = true; // never let the control re-sort its own collection view + if (e.Column?.Tag is string column) + { + ViewModel.SortCommand.Execute(column); + } + } + + private void RepoTable_ClearSorting(object sender, TableViewClearSortingEventArgs e) + { + e.Handled = true; + if (e.Column?.Tag is string column) + { + ViewModel.SortCommand.Execute(column); + } + } + + private void RepoTable_Loaded(object sender, RoutedEventArgs e) => SyncSortIndicators(); + + /// + /// Writes the view model's sort state onto the columns: the header's sort + /// indicator and its composed automation name ("Name, sorted ascending"). + /// Columns whose header control is not realized yet keep the static name from + /// their HeaderStyle until the next call. + /// + private void SyncSortIndicators() + { + foreach (TableViewColumn column in RepoTable.Columns) + { + if (column.Tag is not string key || !column.CanSort) + { + continue; + } + bool active = key == ViewModel.SortColumn; + column.SortDirection = active + ? (ViewModel.SortDescending ? SortDirection.Descending : SortDirection.Ascending) + : null; + if (column.HeaderControl is { } header) + { + AutomationProperties.SetName(header, SortAutomationName(key, ViewModel.SortColumn, ViewModel.SortDescending)); + } + } + } + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) { - if (e.PropertyName == nameof(WorkspaceViewModel.StatusText)) + if (e.PropertyName is nameof(WorkspaceViewModel.SortColumn) or nameof(WorkspaceViewModel.SortDescending)) + { + SyncSortIndicators(); + } + else if (e.PropertyName == nameof(WorkspaceViewModel.StatusText)) { // LiveSetting alone does not announce: XAML never raises // LiveRegionChanged automatically, so each StatusText update must raise @@ -344,8 +390,8 @@ namespace gclo return; } - var peer = Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.FromElement(RepoListView) - ?? Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.CreatePeerForElement(RepoListView); + var peer = Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.FromElement(RepoTable) + ?? Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.CreatePeerForElement(RepoTable); peer?.RaiseNotificationEvent( Microsoft.UI.Xaml.Automation.Peers.AutomationNotificationKind.ActionCompleted, Microsoft.UI.Xaml.Automation.Peers.AutomationNotificationProcessing.ImportantMostRecent, diff --git a/gclo/gclo.csproj b/gclo/gclo.csproj index 5cefa7e..a936eb6 100644 --- a/gclo/gclo.csproj +++ b/gclo/gclo.csproj @@ -47,6 +47,7 @@ + diff --git a/gclo/packages.lock.json b/gclo/packages.lock.json index 3fb3770..c87700e 100644 --- a/gclo/packages.lock.json +++ b/gclo/packages.lock.json @@ -38,6 +38,15 @@ "resolved": "1.2.161", "contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg==" }, + "WinUI.TableView": { + "type": "Direct", + "requested": "[1.5.0, )", + "resolved": "1.5.0", + "contentHash": "YtZtk/JHhL668jfAhjlCm9u8kchajg5FJQVbeYDNKjjl3JqmQbcpCY5McHwaTrlADVwmoDn/x794Px16ZtyRRQ==", + "dependencies": { + "Microsoft.WindowsAppSDK.WinUI": "1.8.260528001" + } + }, "LibGit2Sharp": { "type": "Transitive", "resolved": "0.32.0", -- 2.54.0