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..da1ca0e 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,64 @@ 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 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' + 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: 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 }}' + 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 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: | + 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..33a3148 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 @@ -70,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/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..4ee18bd 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 `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 release still succeeds. Configure secrets under @@ -78,6 +81,28 @@ 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. 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 + 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/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..95e4bfc 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,153 @@ 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.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] public async Task ResolvePaths_DisabledWhileARunIsInFlight() { @@ -1771,8 +1919,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.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/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..b26c2dc 100644 --- a/gclo.ViewModels/RepoItemViewModel.cs +++ b/gclo.ViewModels/RepoItemViewModel.cs @@ -74,6 +74,21 @@ 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))] + [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)); @@ -94,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.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/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/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/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 e044d00..a4fbf8b 100644 --- a/gclo/WorkspacePage.xaml +++ b/gclo/WorkspacePage.xaml @@ -1,40 +1,48 @@ - + - - - + - + 40 2* 160 120 - 80 + 96 + + + + + + @@ -487,334 +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, @@ -358,16 +404,16 @@ namespace gclo /// blocked by Windows-invalid paths. Returns the user's recovery choice, or null /// when the dialog was dismissed (or the row carries no path details). /// - 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) 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", 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