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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- All
- Active
- Failed
- Pending
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- All
- Archived only
- Not archived
-
-
-
-
-
-
-
-
-
- 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}" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+ Glyph=""
+ Foreground="{x:Bind TextFilterIndicatorBrush(ViewModel.NameFilter), Mode=OneWay}" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ All
+ Active
+ Failed
+ Pending
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ All
+ Archived only
+ Not archived
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
-
+
-
-
+
+
- /// 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