Known-issues sweep: verified review findings (#30, #31, #32), update progress (#39), full action logging (#40) #58

Merged
KofTwentyTwo merged 10 commits from dev into main 2026-10-09 12:46:51 +00:00
75 changed files with 6086 additions and 758 deletions

No files matched your search

+33 -11
View File
@@ -37,15 +37,20 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up .NET SDK
uses: actions/setup-dotnet@v6
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 10.0.x
cache: true
cache-dependency-path: '**/packages.lock.json'
# RestoreLockedMode: the committed packages.lock.json files are the restore
# graph; a stale lock file (a bump without 'dotnet restore --force-evaluate')
# fails here instead of silently restoring something else.
- name: Build solution (x64, warnings as errors)
run: dotnet build gclo.slnx -p:Platform=x64 -warnaserror
run: dotnet build gclo.slnx -p:Platform=x64 -warnaserror -p:RestoreLockedMode=true
# coverage.runsettings scopes collection to the three library assemblies and
# excludes source-generated code and the [ExcludeFromCodeCoverage] native/
@@ -96,7 +101,7 @@ jobs:
- name: Upload coverage artifact
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: coverage-cobertura
path: TestResults/**/coverage.cobertura.xml
@@ -115,20 +120,35 @@ jobs:
continue-on-error: ${{ inputs.ui-tests-advisory == true }}
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up .NET SDK
uses: actions/setup-dotnet@v6
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 10.0.x
cache: true
cache-dependency-path: '**/packages.lock.json'
# Self-contained Windows App SDK: hosted runners do not have the runtime
# installed. The output path is the same one AppSession resolves by default.
- name: Build app (unpackaged, self-contained Windows App SDK)
run: dotnet build gclo/gclo.csproj -p:Platform=x64 -p:WindowsPackageType=None -p:WindowsAppSDKSelfContained=true
run: dotnet build gclo/gclo.csproj -p:Platform=x64 -p:WindowsPackageType=None -p:WindowsAppSDKSelfContained=true -p:RestoreLockedMode=true
# Diagnostics for the day this job fails on a hosted runner: a TRX log of
# the run, a screenshot of the screen at the end of every test session, and
# the app's own activity log, all uploaded only when something failed.
- name: Run UI tests
run: dotnet test gclo.UiTests/gclo.UiTests.csproj
env:
GCLO_UITEST_ARTIFACTS: ${{ github.workspace }}\TestResults\ui
run: dotnet test gclo.UiTests/gclo.UiTests.csproj --logger "trx;LogFileName=uitests.trx" --results-directory TestResults
- name: Upload UI test diagnostics
if: failure()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: ui-test-diagnostics
path: TestResults
if-no-files-found: warn
format:
name: Format (style gate)
@@ -136,15 +156,17 @@ jobs:
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up .NET SDK
uses: actions/setup-dotnet@v6
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 10.0.x
cache: true
cache-dependency-path: '**/packages.lock.json'
- name: Restore
run: dotnet restore gclo.slnx
run: dotnet restore gclo.slnx --locked-mode
# Kept at --severity error so this gates real issues (analyzer errors and
# error-severity style rules), not whitespace preferences.
+7 -5
View File
@@ -35,23 +35,25 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up .NET SDK
uses: actions/setup-dotnet@v6
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 10.0.x
cache: true
cache-dependency-path: '**/packages.lock.json'
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4
with:
languages: csharp
build-mode: manual
- name: Build solution (x64)
run: dotnet build gclo.slnx -p:Platform=x64
run: dotnet build gclo.slnx -p:Platform=x64 -p:RestoreLockedMode=true
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4
with:
category: '/language:csharp'
+2 -2
View File
@@ -22,10 +22,10 @@ jobs:
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Dependency review
uses: actions/dependency-review-action@v5
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
# Fail on ANY known vulnerability, even low severity.
fail-on-severity: low
+11 -2
View File
@@ -50,14 +50,16 @@ jobs:
pull-requests: write
steps:
- name: Checkout dev
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: dev
- name: Set up .NET SDK
uses: actions/setup-dotnet@v6
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 10.0.x
cache: true
cache-dependency-path: '**/packages.lock.json'
- name: Bump package versions
id: bump
@@ -67,6 +69,12 @@ jobs:
if ('${{ inputs.include-major }}' -eq 'true') { $params.IncludeMajor = $true }
./.github/scripts/Update-NuGetPackages.ps1 @params
# The committed lock files must follow the bump, or CI's locked restore
# rejects the PR.
- name: Refresh package lock files
if: steps.bump.outputs.changed == 'true'
run: dotnet restore gclo.slnx --force-evaluate
# The gates CI applies, run against the bumped tree. They are allowed to fail
# so the PR still opens and shows what broke; the outcome lands in the body.
- name: Gate - build (x64, warnings as errors)
@@ -136,6 +144,7 @@ jobs:
.NET
add-paths: |
**/*.csproj
**/packages.lock.json
- name: Nothing to update
if: steps.bump.outputs.changed != 'true'
+63 -12
View File
@@ -33,7 +33,7 @@ jobs:
channel: ${{ steps.version.outputs.channel }}
steps:
- name: Checkout (full history, so main's ancestry can be checked)
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
@@ -96,20 +96,30 @@ jobs:
timeout-minutes: 90
permissions:
contents: write
# Build provenance attestations for the published assets.
id-token: write
attestations: write
env:
# Optional secrets. Empty when not configured; the steps that need them
# check for that in their `if:` and are skipped cleanly.
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
REPO_URL: https://github.com/KofTwentyTwo/gclo
# Pinned release tooling. vpk builds the installer and packages users
# receive and wingetcreate is a binary fetched at run time; neither may
# float. Bump deliberately, together with the Velopack package reference.
VPK_VERSION: 1.2.161
WINGETCREATE_VERSION: v1.12.13.0
WINGETCREATE_SHA256: 24042bd37915805615e6cf969ac57c6439124c3fe85823327f5f3fb24bd9ffea
# The publishing secrets (NUGET_API_KEY, WINGET_TOKEN) are NOT job-level env:
# only the two steps that publish receive them, so nothing executed by the
# build, the tests, vpk, or wingetcreate's download can read them.
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up .NET SDK
uses: actions/setup-dotnet@v6
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: 10.0.x
cache: true
cache-dependency-path: '**/packages.lock.json'
# ----------------------------------------------------------------------
# (a) CLI: self-contained single-file publish, zipped as a release asset.
@@ -132,7 +142,7 @@ jobs:
run: dotnet publish gclo/gclo.csproj -c Release -p:Platform=x64 -r win-x64 --self-contained -p:Version=${{ needs.verify.outputs.version }} -p:WindowsPackageType=None -p:WindowsAppSDKSelfContained=true -p:PublishTrimmed=false -o publish/app
- name: Install Velopack CLI
run: dotnet tool install -g vpk
run: dotnet tool install -g vpk --version $env:VPK_VERSION
- name: Download previous Velopack release (delta baseline)
# The very first release on a channel has nothing to download; that is
@@ -150,6 +160,19 @@ 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
# Provenance: a signed statement that THIS workflow run built these exact
# bytes, verifiable with `gh attestation verify <file> -R KofTwentyTwo/gclo`.
# Not a code signature (see #57); it makes a release attributable to the
# workflow rather than only to whoever holds the account.
- name: Attest build provenance
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4
with:
subject-path: |
artifacts/gclo-cli-win-x64.zip
Releases/*.exe
Releases/*.zip
Releases/*.nupkg
# This repo has immutable releases: assets freeze the moment a release is
# PUBLISHED. So vpk creates a DRAFT (no --publish), every asset and the
# changelog land while it is still mutable, and publishing happens last.
@@ -184,11 +207,29 @@ jobs:
# (c) NuGet: pack gclo.Engine and push to nuget.org - only when the
# NUGET_API_KEY secret is configured on the repository.
# ----------------------------------------------------------------------
# ContinuousIntegrationBuild normalizes the embedded source paths so the
# package is deterministic and SourceLink step-through works; the .snupkg
# beside it carries the symbols.
- name: Pack gclo.Engine
run: dotnet pack gclo.Engine -c Release -p:Version=${{ needs.verify.outputs.version }} -o artifacts/nuget
run: dotnet pack gclo.Engine -c Release -p:Version=${{ needs.verify.outputs.version }} -p:ContinuousIntegrationBuild=true -o artifacts/nuget
# The secrets context is not available in a step's `if`, so one step turns
# "is it configured?" into outputs; it is the only step besides the two
# publishers that sees the values, and it never prints them.
- name: Check which optional publishing secrets are configured
id: secrets
shell: pwsh
env:
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
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())
- name: Push gclo.Engine to nuget.org
if: env.NUGET_API_KEY != ''
if: steps.secrets.outputs.nuget == 'true'
env:
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
run: dotnet nuget push "artifacts/nuget/*.nupkg" --api-key $env:NUGET_API_KEY --source https://api.nuget.org/v3/index.json --skip-duplicate
# ----------------------------------------------------------------------
@@ -201,10 +242,20 @@ jobs:
# docs/RELEASING.md), then this step keeps it current.
# ----------------------------------------------------------------------
- name: Submit winget manifest update
if: needs.verify.outputs.prerelease == 'false' && env.WINGET_TOKEN != ''
if: needs.verify.outputs.prerelease == 'false' && steps.secrets.outputs.winget == 'true'
shell: pwsh
env:
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
run: |
Invoke-WebRequest https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe
# A pinned release asset, verified against its committed SHA-256, instead
# of whatever https://aka.ms/wingetcreate/latest redirects to today.
$url = "https://github.com/microsoft/winget-create/releases/download/$env:WINGETCREATE_VERSION/wingetcreate.exe"
Invoke-WebRequest $url -OutFile wingetcreate.exe
$actual = (Get-FileHash wingetcreate.exe -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -ne $env:WINGETCREATE_SHA256) {
Write-Error "wingetcreate.exe hash mismatch: expected $env:WINGETCREATE_SHA256, got $actual"
exit 1
}
# Velopack names the installer '<packId>-<channel>-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
+6 -2
View File
@@ -12,8 +12,8 @@ Before starting on a feature, check the [development roadmap (#11)](https://gith
## Branch model
- **`dev`** is the integration branch. **Pull requests target `dev`**, not `main`.
- **`main`** is the stable branch. It only moves via reviewed PRs from `dev` and is protected by required status checks. Releases are tagged from `main`.
- **`dev`** is the integration branch. **Pull requests target `dev`**, not `main`. CI, CodeQL, and dependency review run on every push and PR to `dev` and their results are visible on the PR, but they are **not enforced** as required checks there — the maintainer merges on green.
- **`main`** is the stable branch. It only moves via PRs from `dev`, and those are enforced: the branch ruleset requires the build/test, format, dependency-review, and CodeQL checks to pass before a merge. Releases are tagged from `main`, and the release workflow refuses a tag that is not on it.
Fork the repository, branch from `dev`, and open your PR against `dev`.
@@ -26,6 +26,10 @@ All commands run from the repository root:
# CI runs this with -warnaserror — the build must produce ZERO warnings.
dotnet build gclo.slnx -p:Platform=x64
# Changed a PackageReference? Refresh the committed lock files, or CI's
# locked restore rejects the change.
dotnet restore gclo.slnx --force-evaluate
# Unpackaged build — runs without MSIX deployment or package identity.
# Verify this still works before opening a PR.
dotnet build gclo.slnx -p:Platform=x64 -p:WindowsPackageType=None
+5
View File
@@ -8,6 +8,11 @@
<AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<Deterministic>true</Deterministic>
<!-- packages.lock.json per project: pins the restore graph (NuGetAudit sees
exactly what ships) and keys the CI restore cache. CI restores in locked
mode; after changing a PackageReference run
dotnet restore gclo.slnx with the force-evaluate switch and commit the lock files. -->
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<!-- Single version source. Local builds are <prefix>-dev; release builds pass an
+8
View File
@@ -100,6 +100,14 @@ gclo orgs
# Mirror an organization, 16 git operations at a time
gclo sync --org contoso --target C:\src\contoso --parallel 16
# Preview, then sync a subset (globs on the repository name; archived ones skipped)
gclo repos --org contoso --include "platform-*" --skip-archived
gclo sync --org contoso --target C:\src\contoso --include "platform-*" --skip-archived --dry-run
# Save the connection as an account (token in Windows Credential Manager) and reuse it
gh auth token | gclo accounts add --name work --org contoso --target C:\src\contoso --token-stdin
gclo sync --account work
# Pipe the token from a secret store — it never touches a command line or disk
gh auth token | gclo sync --org contoso --target C:\src\contoso --token-stdin
+11 -3
View File
@@ -2,7 +2,7 @@
## Supported versions
gclo is pre-1.0 software. Only the **latest release** receives security fixes — there are no maintenance branches for older versions.
Only the **latest release** receives security fixes — there are no maintenance branches for older versions.
| Version | Supported |
| --- | --- |
@@ -36,8 +36,16 @@ gclo is maintained by a single person in their spare time, so response times are
gclo works with GitHub Personal Access Tokens, so token safety is part of the design:
- **Quick Sync tokens are kept in memory only.** A token pasted into Quick Sync is used for the GitHub API and as the git HTTPS credential for the duration of the session, and is never written to disk, settings, or logs.
- **Saved-account tokens and the default token live in the Windows Credential Manager.** When you save an account, or set a default token in Settings, the token is stored in the per-user Windows Credential Manager (DPAPI-protected), under a `gclo:account:<id>` target, and removed when the account or token is deleted. It is **never** written to `settings.json`, `accounts.json`, or any log — those files hold only non-secret metadata (organization, target folder, parallelism, theme, last-sync summary). This is why accounts are Windows-only.
- **Saved-account tokens and the default token are persisted — in the Windows Credential Manager, nowhere else.** When you save an account (in the app or with `gclo accounts add`), or set a default token in Settings, the token is stored as a generic credential in the per-user Windows Credential Manager (DPAPI-protected, non-roaming) under a `gclo:account:<id>` target, and removed when the account or token is deleted. It is **never** written to `settings.json`, `accounts.json`, or any log — those files hold only non-secret metadata (organization, target folder, parallelism, theme, last-sync summary). This is why accounts are Windows-only.
- **The trust boundary of stored tokens is the Windows user account.** Credential Manager protects a stored token from other users and from offline access, not from code running as the same user: any process you run can read it with `CredRead`, exactly as it can read your git credentials or browser cookies. Treat a machine where untrusted code runs as your user as a machine where the token is exposed.
- **The app never redisplays a stored token.** Editing an account or opening a connection flyout shows an empty token box with a placeholder; the stored value is read from Credential Manager only when it must be sent to GitHub (a sync, an organization lookup, a validation on save). Activity-log entries about tokens record only that one was entered or replaced, and its length.
- **The CLI refuses tokens on the command line.** There is deliberately no `--token <value>` option; tokens are accepted only via environment variable (`--token-env`, default `GITHUB_TOKEN`), a file (`--token-file`), or standard input (`--token-stdin`). See [docs/CLI.md](docs/CLI.md).
- **Secret-scanning push protection is enabled** on this repository, so credentials cannot be accidentally committed and pushed.
If you find any code path where a token can end up on disk, in a log, in process output, or on a command line, that is a vulnerability — please report it through the channel above.
If you find any code path where a token can end up in a file other than Credential Manager, in a log, in process output, on a command line, or redisplayed by the UI, that is a vulnerability — please report it through the channel above.
## How releases are trusted
- Releases are built only by the `Release` workflow from a tag that must be contained in `main`, after the full CI gates, and every published asset carries a [build provenance attestation](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations) you can verify with `gh attestation verify <file> -R KofTwentyTwo/gclo`.
- Self-update downloads only from this repository's GitHub Releases and verifies each package against the release's `releases.<channel>.json` feed.
- The binaries are **not yet Authenticode-signed** (tracked in [#57](https://github.com/KofTwentyTwo/gclo/issues/57)); until they are, expect a SmartScreen prompt on first install, and prefer the attestation check above over trusting a download by its name.
+213 -41
View File
@@ -25,13 +25,22 @@ and runs on Linux/macOS hosts with the .NET 10 SDK.
```
gclo sync --org <name> --target <folder> [--parallel N] [--sanitize-paths]
[--include GLOB]... [--exclude GLOB]... [--skip-archived] [--dry-run]
[--token-env VAR | --token-file PATH | --token-stdin]
[--json] [--quiet]
[--json | --json-lines] [--quiet]
gclo sync --account <name> [same options — each one overrides the account value]
gclo repos --org <name> [--include GLOB]... [--exclude GLOB]... [--skip-archived]
[--token-env VAR | --token-file PATH | --token-stdin] [--json]
gclo orgs [--token-env VAR | --token-file PATH | --token-stdin] [--json]
gclo accounts [--json]
gclo accounts [list] [--json]
gclo accounts add --name <name> --org <name> --target <folder> [--parallel N]
[--org-subfolder] [--description TEXT] [token option]
gclo accounts edit --name <name> [--rename NEW] [--org NAME] [--target FOLDER]
[--parallel N] [--org-subfolder | --no-org-subfolder] [--description TEXT]
[token option]
gclo accounts remove --name <name>
gclo --version
gclo --help (each command also accepts --help)
gclo --help (each command also accepts --help; 'gclo help sync' works too)
```
Options may be written as `--name value` or `--name=value`.
@@ -41,13 +50,37 @@ Options may be written as `--name value` or `--name=value`.
Clones every repository of `--org` into `<target>\<repo>`. Repositories that are
already valid git repositories locally are fetched and fast-forwarded instead.
Repositories fail independently — one failure never stops the rest. Up to
`--parallel` git operations run at once (default 8).
`--parallel` git operations run at once (default 8, maximum 64 — the same
ceiling the desktop app applies, because more would trip GitHub's secondary
rate limit).
`--org` accepts an organization login or a user account login: if the name is not
an organization, gclo falls back to the account's repositories (your own account
includes private repos the token can see).
`--org` accepts an organization login or a user account login. If the name is
an organization the token cannot see, the run fails with exit code 3 and a hint
to grant the token access (an older build would silently sync only the
organization's public repositories). If the name is a user account, gclo lists
that account's repositories (your own account includes private repos the token
can see).
Progress output is one line per repository status transition:
#### Selecting repositories
The desktop app lets you list, pick, and sync a subset; the CLI has the same
shape:
| Option | Effect |
| --- | --- |
| `--include GLOB` | Only repositories whose **whole name** matches the pattern (`*` and `?` wildcards, case-insensitive). Repeatable; a repository is selected when any include matches. |
| `--exclude GLOB` | Drop repositories whose name matches. Repeatable; applied after `--include`. |
| `--skip-archived` | Drop archived repositories. |
| `--dry-run` | Print what the run would do (`<name> would clone` / `would update`), then exit 0 without touching the network beyond the listing or the disk at all. With `--json` or `--json-lines` the dry run prints a JSON array `[{"repo":"...","action":"clone"|"update"}]`. |
`gclo repos` (below) previews the same selection without a target folder.
The summary counts the **selection**, not the listing: `--include platform-*`
over a 400-repository organization with 15 matches reports `of 15`.
#### Progress output
One line per repository status transition:
```
gclo Queued
@@ -86,6 +119,11 @@ legacy-repo aux ('aux' is a reserved Windows device name)
legacy-repo docs/spec?.md (contains a character that is invalid on Windows)
```
A repository whose **name** is itself a reserved Windows device name (an
organization really can own a repository called `aux`) fails its own row with
a plain reason before anything is cloned; rename it on GitHub or sync it on
another platform.
This validation — like the automatic `core.longpaths` handling — applies on
Windows only: on Linux and macOS these paths are perfectly legal, so gclo
performs a plain checkout and `--sanitize-paths` has nothing to do.
@@ -96,16 +134,21 @@ With `--sanitize-paths`, gclo checks such a repository out anyway:
(`aux` → `aux_`, `spec?.md` → `spec_.md`).
- Paths with no safe automatic rename (case-only collisions) are **skipped**:
they are not materialized on disk.
- A note listing the renames goes to stderr and to the activity log, and the
repository counts as **cloned** — the exit code stays `0` and `--json` counts
it under `cloned`. The human summary calls the count out:
- A note listing the renames goes to stderr (text modes) and to the activity
log, and the repository counts as **cloned** — the exit code stays `0`. The
human summary calls the count out:
`Finished: 3 cloned (1 with sanitized paths), 41 updated, 0 failed, 0 canceled of 45.`
The JSON document lists each sanitized repository with its skipped paths
(see below), so a pipeline can tell an incomplete working tree from a
complete one.
- The mapping is remembered inside the repository
(`.git\gclo-recovery.json`) and reapplied by later syncs. A later sync that
brings *new* invalid paths not covered by the stored mapping fails with those
paths listed — sync again with `--sanitize-paths` (or use the GUI's
**Resolve…** dialog) and the new renames and skips are merged into the stored
mapping.
(`.git\gclo-recovery.json`) and reapplied by later syncs — which also remove
files that have since left the tree and leave the tree untouched when the
upstream tip has not moved. A later sync that brings *new* invalid paths not
covered by the stored mapping fails with those paths listed — sync again with
`--sanitize-paths` (or use the GUI's **Resolve…** dialog) and the new renames
and skips are merged into the stored mapping. Use `--include <repo>` to
target just that repository.
Only the working tree is renamed — the repository's history and index still hold
the original paths, so `git status` reports the renamed and skipped files as
@@ -117,9 +160,62 @@ Suppresses all progress lines and prints a single line of JSON to stdout when th
run ends (also on cancellation):
```json
{"total":45,"cloned":3,"updated":41,"failed":1,"canceled":0,"wasCanceled":false,"failures":[{"repo":"broken-repo","error":"remote authentication failed"}]}
{"type":"summary","total":45,"cloned":3,"updated":41,"failed":1,"canceled":0,"wasCanceled":false,
"failures":[{"repo":"broken-repo","error":"remote authentication failed","invalidPaths":null},
{"repo":"legacy","error":"2 paths in this repository cannot be created on Windows: ...",
"invalidPaths":[{"path":"aux","reason":"'aux' is a reserved Windows device name","suggestedName":"aux_"}]}],
"sanitized":[{"repo":"fixed","renamed":3,"skipped":1,"skippedPaths":["docs/Readme.md"]}]}
```
- `type` is always `"summary"`.
- `failures[].invalidPaths` is the **full** list of offending paths when the
failure was Windows path validation (the message itself only quotes the
first three); `null` for any other failure.
- `sanitized[]` lists the repositories `--sanitize-paths` checked out with
renames and skips; they are counted under `cloned`, but `skippedPaths` are
not on disk.
- Nothing else is printed to stdout, and nothing to stderr: the document is the
whole result. A fatal exit (codes 2 and up) prints **no** document — check
the exit code before parsing.
#### `--json-lines`
Prints one JSON object per status transition to stdout as it happens (NDJSON),
then the same summary object as `--json` as the last line:
```json
{"type":"progress","repo":"gclo","status":"Queued","error":null}
{"type":"progress","repo":"gclo","status":"Cloning","error":null}
{"type":"progress","repo":"broken-repo","status":"Failed","error":"remote authentication failed"}
{"type":"progress","repo":"gclo","status":"Done","error":null}
{"type":"summary","total":2,"cloned":1,...}
```
Clone percentage updates are not emitted. `--json` and `--json-lines` are
mutually exclusive.
### `gclo repos`
Lists the repositories a sync would process — one per line: name, default
branch (`-` for an empty repository), and `archived` when the repository is
archived — sorted by name. The same `--include`, `--exclude`, and
`--skip-archived` filters as `gclo sync` apply, so it previews a selection:
```
$ gclo repos --org contoso --include "platform-*" --skip-archived
platform-api main
platform-web main
platform-old master archived
```
With `--json` it prints a single-line JSON array:
```json
[{"name":"platform-api","defaultBranch":"main","archived":false,"cloneUrl":"https://github.com/contoso/platform-api.git"}]
```
An empty selection prints a hint on stderr and still exits 0.
### `gclo orgs`
Prints the logins the token can sync, one per line: the token's own account login
@@ -128,22 +224,24 @@ single-line JSON array instead (e.g. `["octocat","contoso","fabrikam"]`).
A token that cannot list organizations (a fine-grained PAT, or a classic PAT
without the `read:org` scope) still prints its own account login — you can pass
any organization name to `gclo sync` manually.
any organization name to `gclo sync` manually. A rate limit or temporary
throttle is reported as such (exit code 4) rather than shrinking the list.
## Accounts
An *account* is a saved sync profile: a name, an organization, a target root
folder, a parallelism setting, an org-subfolder preference, and a token stored
in **Windows Credential Manager** (under `gclo:account:<id>`; the metadata file,
`%LOCALAPPDATA%\gclo\accounts.json`, never contains the token). Accounts are
created and edited in the gclo desktop app's account wizard for now — a CLI
flag for creating accounts is planned.
folder, a parallelism setting, an org-subfolder preference, an optional
description, and a token stored in **Windows Credential Manager** (under
`gclo:account:<id>`; the metadata file, `%LOCALAPPDATA%\gclo\accounts.json`,
never contains the token). Accounts are shared between the CLI and the desktop
app: create them with `gclo accounts add` on a headless mirror server, or in
the app's account wizard, and use them from either.
> **Windows-only.** Because the token lives in Windows Credential Manager,
> `gclo accounts` and `gclo sync --account` exit with code 2 on Linux/macOS.
> Plain `gclo sync` and `gclo orgs` work everywhere.
> Plain `gclo sync`, `gclo repos`, and `gclo orgs` work everywhere.
### `gclo accounts`
### `gclo accounts` / `gclo accounts list`
Lists the saved accounts, one per line, in aligned columns: name, organization,
target root, and last sync time (local time, `never` when the account has not
@@ -154,16 +252,52 @@ work contoso C:\src\contoso 2026-07-04 09:12
personal octocat D:\mirror never
```
With `--json` it prints a single-line JSON array instead; `lastSync` is the UTC
timestamp of the last completed sync, or `null`:
With `--json` it prints a single-line JSON array with everything a script needs
to reason about a run; `lastSync` is the UTC timestamp of the last completed
sync and `lastSyncSummary` its summary line, both `null` until the first sync:
```json
[{"name":"work","organization":"contoso","targetRoot":"C:\\src\\contoso","lastSync":"2026-07-04T14:12:03+00:00"},{"name":"personal","organization":"octocat","targetRoot":"D:\\mirror","lastSync":null}]
[{"id":"d0f0e0c09c404a5e8f3a5b1e6f7a2c11","name":"work","description":"","organization":"contoso","targetRoot":"C:\\src\\contoso","createOrgSubfolder":true,"maxConcurrency":8,"lastSync":"2026-07-04T14:12:03+00:00","lastSyncSummary":"Finished: 3 cloned, 41 updated, 0 failed, 0 canceled of 44."}]
```
`id` is the key of the account's Credential Manager entry (`gclo:account:<id>`).
When no accounts exist yet, stdout stays empty (`--json` prints `[]`) and a
hint goes to stderr; the exit code is still 0.
### `gclo accounts add`
```powershell
gh auth token | gclo accounts add --name work --org contoso --target C:\src\contoso --org-subfolder --parallel 16 --token-stdin
```
`--name`, `--org`, and `--target` are required; `--parallel` (1-64, default 8),
`--org-subfolder`, and `--description` are optional. The token comes from a
token option exactly as for `gclo sync` (default: the `GITHUB_TOKEN` environment
variable) and is written to Windows Credential Manager — never put it on the
command line. A name that another account already uses (case-insensitive)
exits with code 2.
### `gclo accounts edit`
```powershell
gclo accounts edit --name work --parallel 24 --no-org-subfolder
gh auth token | gclo accounts edit --name work --token-stdin # rotate the stored token
gclo accounts edit --name work --rename "work (contoso)"
```
`--name` picks the account; every other option replaces just that setting.
Without a token option the stored token is left untouched. Editing with
nothing to change is a usage error.
### `gclo accounts remove`
```powershell
gclo accounts remove --name work
```
Deletes the account's settings and its Credential Manager entry. Repositories
on disk are not touched.
### `gclo sync --account <name>`
Runs a sync with the account's settings and its stored token — no `--org`,
@@ -194,8 +328,9 @@ Notes:
names on stderr.
- An account whose Credential Manager entry is missing (deleted, or the
profile moved to another machine — the entry does not roam) exits with
code 2. Re-enter the token in the desktop app's account wizard, restore the
`gclo:account:<id>` credential manually, or pass a token option for this run.
code 2. Re-enter the token with `gclo accounts edit --name <name> --token-stdin`
or in the desktop app's account wizard, restore the `gclo:account:<id>`
credential manually, or pass a token option for this run.
## Providing the token
@@ -233,14 +368,21 @@ else on disk; see [SECURITY.md](../SECURITY.md).
## Exit codes
| Code | Meaning |
| --- | --- |
| 0 | Everything succeeded. |
| 1 | The run completed, but some repositories failed or the run was canceled (Ctrl+C). |
| 2 | Fatal: bad arguments, missing/empty/rejected token, organization not found, unknown account, missing account token, or an account command on a non-Windows OS. |
| Code | Meaning | What to do |
| --- | --- | --- |
| 0 | Everything succeeded (or `--dry-run` / a listing completed). | — |
| 1 | The run completed, but some repositories failed or the run was canceled (Ctrl+C). `--json`'s `wasCanceled` tells the two apart. | Inspect the failures; retry them. |
| 2 | Fatal: bad arguments, missing/empty token, organization not found, unknown account, missing account token, unusable target folder, or an account command on a non-Windows OS. | Fix the invocation. |
| 3 | GitHub rejected the token (401) or the token may not see the organization (403). | Fix the token or its access; do not retry as is. |
| 4 | GitHub's primary or secondary rate limit, or a temporary throttle. | Retry later (the message says when). |
| 70 | An unexpected error inside gclo. The exception type is printed and the full details are in the activity log. | Please report it. |
Codes 3 and 4 were part of code 2 before version 1.1; a script that tested
`-eq 2` for "usage error" should treat `3` and `4` as distinct outcomes.
A repository recovered by `--sanitize-paths` counts as a success: if every other
repository also succeeds, the exit code is `0`.
repository also succeeds, the exit code is `0`. With `--json` or `--json-lines`,
exits 2 and up print no document on stdout.
## Examples
@@ -262,22 +404,39 @@ Get-Secret -Name GitHubPat -AsPlainText | gclo orgs --token-stdin
op read "op://Private/GitHub PAT/token" | gclo sync --org contoso --target C:\src\contoso --token-stdin
gh auth token | gclo sync --org contoso --target C:\src\contoso --token-stdin
# Preview, then sync a subset
gclo repos --org contoso --include "platform-*" --skip-archived
gclo sync --org contoso --target C:\src\contoso --include "platform-*" --skip-archived --dry-run
gclo sync --org contoso --target C:\src\contoso --include "platform-*" --skip-archived
# Machine-readable result
$result = gclo sync --org contoso --target C:\src\contoso --json | ConvertFrom-Json
if ($result.failed -gt 0) { $result.failures | ForEach-Object { "$($_.repo): $($_.error)" } }
$result.sanitized | ForEach-Object { "$($_.repo): skipped $($_.skippedPaths -join ', ')" }
# Nightly mirror job: quiet, check the exit code
# Live progress for a dashboard
gclo sync --org contoso --target C:\src\contoso --json-lines | ForEach-Object { $_ | ConvertFrom-Json }
# Nightly mirror job: quiet, branch on the exit code
gclo sync --org contoso --target D:\mirror\contoso --parallel 16 --quiet
if ($LASTEXITCODE -ne 0) { Write-Error "sync ended with code $LASTEXITCODE" }
switch ($LASTEXITCODE) {
0 { }
1 { Write-Warning "some repositories failed" }
4 { Write-Warning "rate limited; will retry next run" }
default { Write-Error "sync ended with code $LASTEXITCODE" }
}
# Legacy repos with Windows-invalid paths: rename/skip them instead of failing
gclo sync --org contoso --target C:\src\contoso --sanitize-paths
# Saved accounts (created in the desktop app; Windows only)
# Saved accounts (Windows only), created without the desktop app
gh auth token | gclo accounts add --name work --org contoso --target C:\src\contoso --org-subfolder --token-stdin
gclo accounts # name, organization, target root, last sync
gclo sync --account work # settings and token come from the account
gclo sync --account work --parallel 16 # explicit options override account values
gclo sync --account work --org other-org # same target root, different organization
gclo accounts edit --name work --parallel 24
gclo accounts remove --name work
# Nightly job over every account
gclo accounts --json | ConvertFrom-Json | ForEach-Object {
@@ -301,13 +460,26 @@ pass show github/pat | gclo sync --org contoso --target ~/src/contoso --token-st
op read "op://Private/GitHub PAT/token" | gclo orgs --token-stdin
gh auth token | gclo sync --org contoso --target ~/src/contoso --token-stdin
# Preview, then sync a subset
gclo repos --org contoso --include 'platform-*' --skip-archived --json | jq -r '.[].name'
gclo sync --org contoso --target ~/src/contoso --exclude '*-archive' --dry-run
# Machine-readable result with jq
gclo sync --org contoso --target ~/src/contoso --json |
jq -r '.failures[] | "\(.repo): \(.error)"'
# Live progress as NDJSON
gclo sync --org contoso --target ~/src/contoso --json-lines |
jq -r 'select(.type == "progress") | "\(.repo) \(.status)"'
# Cron-friendly: quiet progress, failures on stderr, exit code drives alerting
gclo sync --org contoso --target /srv/mirror/contoso --parallel 16 --quiet ||
echo "sync ended with code $?"
gclo sync --org contoso --target /srv/mirror/contoso --parallel 16 --quiet
case $? in
0) ;;
1) echo "some repositories failed" ;;
4) echo "rate limited; retrying next run" ;;
*) echo "sync ended with code $?" >&2 ;;
esac
# List orgs as JSON and sync each one
for org in $(gclo orgs --json | jq -r '.[]'); do
+248
View File
@@ -92,6 +92,254 @@ public sealed class AccountsCommandTests : IDisposable
Assert.Contains("\"name\":\"kof\"", console.Out);
Assert.Contains("\"organization\":\"KofTwentyTwo\"", console.Out);
Assert.Contains("\"lastSync\":null", console.Out);
// The fields a script needs to reason about a run.
Assert.Contains("\"id\":\"" + Store.FindByName("kof")!.Id.ToString("N") + "\"", console.Out);
Assert.Contains("\"createOrgSubfolder\":false", console.Out);
Assert.Contains("\"maxConcurrency\":8", console.Out);
Assert.Contains("\"lastSyncSummary\":null", console.Out);
}
[Fact]
public void ExplicitListSubcommand_IsTheDefault()
{
Seed("kof", "KofTwentyTwo", @"R:\repos\kof");
using var console = new ConsoleCapture();
Assert.Equal(0, AccountsCommand.Run(["list"], Open, new NullLog()));
Assert.Contains("kof", console.Out);
}
[Fact]
public void UnknownSubcommand_Throws()
=> Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["frobnicate"], Open, new NullLog()));
// ---------------------------------------------------------------- add
private const string EnvVar = "GCLO_ACCOUNTS_TEST_TOKEN";
private static IDisposable TokenEnv(string value)
{
Environment.SetEnvironmentVariable(EnvVar, value);
return new EnvScope();
}
private sealed class EnvScope : IDisposable
{
public void Dispose() => Environment.SetEnvironmentVariable(EnvVar, null);
}
[Fact]
public void Add_CreatesTheAccount_AndStoresTheToken()
{
using var _ = TokenEnv("ghp_added");
using var console = new ConsoleCapture();
int code = AccountsCommand.Run(
["add", "--name", "work", "--org", "acme", "--target", @"C:\src", "--parallel", "12",
"--org-subfolder", "--description", "day job", "--token-env", EnvVar],
Open, new NullLog());
Assert.Equal(0, code);
Assert.Contains("Account 'work' added.", console.Out);
Account saved = Assert.Single(Store.GetAll());
Assert.Equal("acme", saved.Organization);
Assert.Equal(@"C:\src", saved.TargetRoot);
Assert.Equal(12, saved.MaxConcurrency);
Assert.True(saved.CreateOrgSubfolder);
Assert.Equal("day job", saved.Description);
Assert.Equal("ghp_added", _vault.TryRetrieve(saved.Id));
}
[Fact]
public void Add_ReadsTheTokenFromStdin()
{
using var console = new ConsoleCapture();
using var stdin = new StdinRedirect("ghp_piped\n");
int code = AccountsCommand.Run(
["add", "--name", "work", "--org", "acme", "--target", @"C:\src", "--token-stdin"], Open, new NullLog());
Assert.Equal(0, code);
Assert.Equal("ghp_piped", _vault.TryRetrieve(Store.FindByName("work")!.Id));
}
[Theory]
[InlineData("--org", "acme", "--target", @"C:\src")]
[InlineData("--name", "work", "--target", @"C:\src")]
[InlineData("--name", "work", "--org", "acme")]
public void Add_MissingRequiredOption_IsAUsageError(params string[] options)
{
using var _ = TokenEnv("ghp_x");
Assert.Throws<CliUsageException>(
() => AccountsCommand.Run(["add", .. options, "--token-env", EnvVar], Open, new NullLog()));
}
[Fact]
public void Add_DuplicateName_IsAFatalError()
{
Seed("work", "acme", @"C:\a");
using var _ = TokenEnv("ghp_x");
var ex = Assert.Throws<CliErrorException>(
() => AccountsCommand.Run(["add", "--name", "WORK", "--org", "other", "--target", @"C:\b", "--token-env", EnvVar], Open, new NullLog()));
Assert.Contains("already exists", ex.Message);
Assert.Single(Store.GetAll());
}
[Fact]
public void Add_UnknownOption_Throws()
=> Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["add", "--nope"], Open, new NullLog()));
[Fact]
public void Add_Help_PrintsUsage()
{
using var console = new ConsoleCapture();
Assert.Equal(0, AccountsCommand.Run(["add", "--help"], Open, new NullLog()));
Assert.Contains("gclo accounts add", console.Out);
}
// ---------------------------------------------------------------- edit
[Fact]
public void Edit_ReplacesOnlyTheGivenSettings_AndLeavesTheTokenAlone()
{
Seed("work", "acme", @"C:\a");
Account before = Store.FindByName("work")!;
using var console = new ConsoleCapture();
int code = AccountsCommand.Run(
["edit", "--name", "work", "--org", "acme-labs", "--parallel", "3", "--org-subfolder", "--description", "moved"],
Open, new NullLog());
Assert.Equal(0, code);
Assert.Contains("Account 'work' updated.", console.Out);
Account after = Store.FindByName("work")!;
Assert.Equal(before.Id, after.Id);
Assert.Equal("acme-labs", after.Organization);
Assert.Equal(@"C:\a", after.TargetRoot); // untouched
Assert.Equal(3, after.MaxConcurrency);
Assert.True(after.CreateOrgSubfolder);
Assert.Equal("moved", after.Description);
Assert.Equal("tok-work", _vault.TryRetrieve(after.Id)); // untouched
}
[Fact]
public void Edit_RenameTargetAndNoOrgSubfolder_AndTokenReplacement()
{
Seed("work", "acme", @"C:\a");
Store.Save(Store.FindByName("work")! with { CreateOrgSubfolder = true }, null);
using var _ = TokenEnv("ghp_new");
using var console = new ConsoleCapture();
int code = AccountsCommand.Run(
["edit", "--name", "work", "--rename", "work2", "--target", @"D:\b", "--no-org-subfolder", "--token-env", EnvVar],
Open, new NullLog());
Assert.Equal(0, code);
Assert.Contains("Account 'work2' updated (token replaced).", console.Out);
Assert.Null(Store.FindByName("work"));
Account after = Store.FindByName("work2")!;
Assert.Equal(@"D:\b", after.TargetRoot);
Assert.False(after.CreateOrgSubfolder);
Assert.Equal("ghp_new", _vault.TryRetrieve(after.Id));
}
[Fact]
public void Edit_WithNothingToChange_IsAUsageError()
{
Seed("work", "acme", @"C:\a");
var ex = Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["edit", "--name", "work"], Open, new NullLog()));
Assert.Contains("Nothing to change", ex.Message);
}
[Fact]
public void Edit_MissingName_IsAUsageError()
=> Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["edit", "--org", "x"], Open, new NullLog()));
[Fact]
public void Edit_UnknownAccount_ListsTheOthers()
{
Seed("work", "acme", @"C:\a");
var ex = Assert.Throws<CliErrorException>(
() => AccountsCommand.Run(["edit", "--name", "ghost", "--org", "x"], Open, new NullLog()));
Assert.Contains("No account named 'ghost'", ex.Message);
Assert.Contains("Available accounts: work", ex.Message);
}
[Fact]
public void Edit_RenameOntoAnotherAccount_IsAFatalError()
{
Seed("work", "acme", @"C:\a");
Seed("home", "me", @"C:\h");
var ex = Assert.Throws<CliErrorException>(
() => AccountsCommand.Run(["edit", "--name", "home", "--rename", "work"], Open, new NullLog()));
Assert.Contains("already exists", ex.Message);
}
[Fact]
public void Edit_UnknownOption_Throws()
=> Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["edit", "--name", "work", "--nope"], Open, new NullLog()));
[Fact]
public void Edit_Help_PrintsUsage()
{
using var console = new ConsoleCapture();
Assert.Equal(0, AccountsCommand.Run(["edit", "--help"], Open, new NullLog()));
Assert.Contains("gclo accounts edit", console.Out);
}
// ---------------------------------------------------------------- remove
[Fact]
public void Remove_DeletesTheAccountAndItsToken()
{
Seed("work", "acme", @"C:\a");
Guid id = Store.FindByName("work")!.Id;
using var console = new ConsoleCapture();
int code = AccountsCommand.Run(["remove", "--name", "work"], Open, new NullLog());
Assert.Equal(0, code);
Assert.Contains("Account 'work' removed.", console.Out);
Assert.Empty(Store.GetAll());
Assert.Null(_vault.TryRetrieve(id));
}
[Fact]
public void Remove_UnknownAccount_WhenNoneExist_SaysSo()
{
var ex = Assert.Throws<CliErrorException>(() => AccountsCommand.Run(["remove", "--name", "ghost"], Open, new NullLog()));
Assert.Contains("No accounts exist yet", ex.Message);
}
[Fact]
public void Remove_MissingName_IsAUsageError()
=> Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["remove"], Open, new NullLog()));
[Fact]
public void Remove_UnknownOption_Throws()
=> Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["remove", "--nope"], Open, new NullLog()));
[Fact]
public void Remove_Help_PrintsUsage()
{
using var console = new ConsoleCapture();
Assert.Equal(0, AccountsCommand.Run(["remove", "--help"], Open, new NullLog()));
Assert.Contains("gclo accounts remove", console.Out);
}
[Fact]
public void Mutations_OpenThrows_Propagates()
{
using var _ = TokenEnv("ghp_x");
Func<IActivityLog, (AccountsStore, ITokenVault)> failing =
_ => throw new CliErrorException("Accounts require Windows credential storage; ...");
Assert.Throws<CliErrorException>(() => AccountsCommand.Run(
["add", "--name", "w", "--org", "o", "--target", "t", "--token-env", EnvVar], failing, new NullLog()));
Assert.Throws<CliErrorException>(() => AccountsCommand.Run(["edit", "--name", "w", "--org", "o"], failing, new NullLog()));
Assert.Throws<CliErrorException>(() => AccountsCommand.Run(["remove", "--name", "w"], failing, new NullLog()));
}
[Fact]
+16
View File
@@ -1,3 +1,5 @@
using gclo.Engine;
namespace gclo.Cli.Tests;
/// <summary>Covers 'gclo orgs': parsing, plain and JSON output, and error translation.</summary>
@@ -88,6 +90,20 @@ public sealed class OrgsCommandTests
Assert.Contains("401", ex.Message);
}
[Fact]
public async Task ListerAccessRefusal_MapsToTheExitCodeTaxonomy()
{
using var _ = Token("ghp_x");
var rejected = new FakeOrgLister { Throw = new GitHubAccessException(GitHubAccessKind.Unauthorized, "401") };
var ex = await Assert.ThrowsAsync<CliErrorException>(() => Run(rejected, "--token-env", EnvVar));
Assert.Equal(ExitCodes.Auth, ex.ExitCode);
var throttled = new FakeOrgLister { Throw = new GitHubAccessException(GitHubAccessKind.RateLimited, "slow down") };
ex = await Assert.ThrowsAsync<CliErrorException>(() => Run(throttled, "--token-env", EnvVar));
Assert.Equal(ExitCodes.Transient, ex.ExitCode);
}
[Fact]
public async Task Canceled_Propagates()
{
+79 -37
View File
@@ -2,57 +2,99 @@ using gclo.Engine;
namespace gclo.Cli.Tests;
/// <summary>Covers the console progress printer: dedup, stdout/stderr routing, and gating.</summary>
/// <summary>
/// Covers <see cref="ProgressPrinter"/>: transition deduplication, which stream each
/// output mode writes to, and the failure/sanitized records the summary is built from.
/// </summary>
public sealed class ProgressPrinterTests
{
private static readonly InvalidPathInfo BadPath = new("bad:name.txt", "invalid on Windows", "bad_name.txt");
[Fact]
public void Report_PrintsTransitionsToStdout_AndSuppressesRepeats()
public void Text_PrintsTransitionsOnStdout_FailuresOnStderr_AndDedupesRepeats()
{
using var console = new ConsoleCapture();
var printer = new ProgressPrinter(printProgress: true, printFailures: true);
var printer = new ProgressPrinter(OutputMode.Text);
printer.Report(new RepoProgress("alpha", SyncStatus.Cloning));
printer.Report(new RepoProgress("alpha", SyncStatus.Cloning, Percent: 0.5)); // repeat status: suppressed
printer.Report(new RepoProgress("alpha", SyncStatus.Done));
printer.Report(new RepoProgress("a", SyncStatus.Queued));
printer.Report(new RepoProgress("a", SyncStatus.Cloning, Percent: 0.1));
printer.Report(new RepoProgress("a", SyncStatus.Cloning, Percent: 0.5)); // same status: suppressed
printer.Report(new RepoProgress("a", SyncStatus.Done));
printer.Report(new RepoProgress("b", SyncStatus.Failed, "boom"));
string[] lines = console.Out.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(["alpha Cloning", "alpha Done"], lines);
Assert.Equal(["a Queued", "a Cloning", "a Done"], Lines(console.Out));
Assert.Equal(["b Failed boom"], Lines(console.Error));
Assert.Equal("boom", Assert.Single(printer.Failures).Error);
Assert.Null(printer.Failures[0].InvalidPaths);
}
[Fact]
public void Report_Failure_GoesToStderr_AndIsCollected()
public void Quiet_PrintsOnlyFailures()
{
using var console = new ConsoleCapture();
var printer = new ProgressPrinter(printProgress: true, printFailures: true);
var printer = new ProgressPrinter(OutputMode.Quiet);
printer.Report(new RepoProgress("bravo", SyncStatus.Failed, "boom"));
Assert.Contains("bravo Failed boom", console.Error);
var failure = Assert.Single(printer.Failures);
Assert.Equal("bravo", failure.Repo);
Assert.Equal("boom", failure.Error);
}
[Fact]
public void Report_Failure_NullError_RecordsUnknown()
{
using var console = new ConsoleCapture();
var printer = new ProgressPrinter(printProgress: false, printFailures: false);
printer.Report(new RepoProgress("bravo", SyncStatus.Failed));
Assert.Equal("unknown error", Assert.Single(printer.Failures).Error);
Assert.Equal("", console.Error); // printFailures off: nothing on stderr
}
[Fact]
public void Report_ProgressOff_PrintsNoTransitions()
{
using var console = new ConsoleCapture();
var printer = new ProgressPrinter(printProgress: false, printFailures: true);
printer.Report(new RepoProgress("alpha", SyncStatus.Cloning));
printer.Report(new RepoProgress("a", SyncStatus.Cloning));
printer.Report(new RepoProgress("a", SyncStatus.Done));
printer.Report(new RepoProgress("b", SyncStatus.Failed, null));
Assert.Equal("", console.Out);
Assert.Equal(["b Failed unknown error"], Lines(console.Error));
}
[Fact]
public void Json_PrintsNothing_ButRecordsFailuresWithTheirInvalidPaths()
{
using var console = new ConsoleCapture();
var printer = new ProgressPrinter(OutputMode.Json);
printer.Report(new RepoProgress("a", SyncStatus.Cloning));
printer.Report(new RepoProgress("b", SyncStatus.Failed, "paths", InvalidPaths: [BadPath]));
Assert.Equal("", console.Out);
Assert.Equal("", console.Error);
SyncFailure failure = Assert.Single(printer.Failures);
SyncInvalidPath path = Assert.Single(failure.InvalidPaths!);
Assert.Equal(("bad:name.txt", "invalid on Windows", "bad_name.txt"), (path.Path, path.Reason, path.SuggestedName));
}
[Fact]
public void JsonLines_PrintsOneObjectPerTransition_IncludingFailures_OnStdout()
{
using var console = new ConsoleCapture();
var printer = new ProgressPrinter(OutputMode.JsonLines);
printer.Report(new RepoProgress("a", SyncStatus.Pulling));
printer.Report(new RepoProgress("a", SyncStatus.Pulling)); // suppressed
printer.Report(new RepoProgress("b", SyncStatus.Failed, "boom"));
Assert.Equal(
[
"{\"type\":\"progress\",\"repo\":\"a\",\"status\":\"Pulling\",\"error\":null}",
"{\"type\":\"progress\",\"repo\":\"b\",\"status\":\"Failed\",\"error\":\"boom\"}",
],
Lines(console.Out));
Assert.Equal("", console.Error);
}
[Fact]
public void RecordSanitized_IsExposedForTheSummary()
{
var printer = new ProgressPrinter(OutputMode.Json);
printer.RecordSanitized(new SyncSanitized("a", 2, 1, ["dup"]));
SyncSanitized record = Assert.Single(printer.Sanitized);
Assert.Equal((2, 1), (record.Renamed, record.Skipped));
}
[Fact]
public void MapInvalidPaths_EmptyOrNull_IsNull()
{
Assert.Null(ProgressPrinter.MapInvalidPaths(null));
Assert.Null(ProgressPrinter.MapInvalidPaths([]));
}
private static string[] Lines(string text)
=> text.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(l => l.TrimEnd('\r')).ToArray();
}
+76
View File
@@ -0,0 +1,76 @@
using gclo.Engine;
namespace gclo.Cli.Tests;
/// <summary>Pins the '--include' / '--exclude' / '--skip-archived' selection rules.</summary>
public sealed class RepoFilterSpecTests
{
private static RepoDescriptor Repo(string name, bool archived = false)
=> new(name, $"https://x/{name}.git", "main", archived);
[Fact]
public void Empty_SelectsEverything()
{
var spec = new RepoFilterSpec();
Assert.True(spec.IsEmpty);
Assert.True(spec.Matches(Repo("anything")));
Assert.True(spec.Matches(Repo("archived", archived: true)));
Assert.Equal("none", spec.ToString());
}
[Theory]
[InlineData("platform-*", "platform-api", true)]
[InlineData("platform-*", "PLATFORM-WEB", true)] // case-insensitive
[InlineData("platform-*", "docs", false)]
[InlineData("*-api", "platform-api", true)]
[InlineData("api", "platform-api", false)] // whole-name match, not substring
[InlineData("repo-?", "repo-1", true)]
[InlineData("repo-?", "repo-10", false)]
[InlineData("a.b", "axb", false)] // '.' is literal
public void Include_MatchesTheWholeName_WithGlobWildcards(string glob, string name, bool expected)
{
var spec = new RepoFilterSpec();
spec.Include(glob);
Assert.Equal(expected, spec.Matches(Repo(name)));
}
[Fact]
public void MultipleIncludes_AreOrEd_AndExcludesWinOverIncludes()
{
var spec = new RepoFilterSpec();
spec.Include("platform-*");
spec.Include("docs");
spec.Exclude("*-legacy");
Assert.True(spec.Matches(Repo("platform-api")));
Assert.True(spec.Matches(Repo("docs")));
Assert.False(spec.Matches(Repo("platform-legacy")));
Assert.False(spec.Matches(Repo("other")));
Assert.Equal("include=2, exclude=1", spec.ToString());
}
[Fact]
public void SkipArchived_DropsArchivedRepositories()
{
var spec = new RepoFilterSpec { SkipArchived = true };
Assert.False(spec.IsEmpty);
Assert.True(spec.Matches(Repo("live")));
Assert.False(spec.Matches(Repo("old", archived: true)));
Assert.Equal("skipArchived", spec.ToString());
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void BlankPattern_IsAUsageError(string glob)
{
var spec = new RepoFilterSpec();
var ex = Assert.Throws<CliUsageException>(() => spec.Include(glob));
Assert.Contains("--include expects", ex.Message);
Assert.Throws<CliUsageException>(() => spec.Exclude(glob));
}
}
+136
View File
@@ -0,0 +1,136 @@
using gclo.Engine;
namespace gclo.Cli.Tests;
/// <summary>Covers 'gclo repos': parsing, filters, plain and JSON output, and error translation.</summary>
public sealed class ReposCommandTests
{
private const string EnvVar = "GCLO_REPOS_TEST_TOKEN";
private static Task<int> Run(FakeRepoLister lister, params string[] args)
=> ReposCommand.RunAsync(args, lister, new NullLog(), CancellationToken.None);
private static IDisposable Token(string value)
{
Environment.SetEnvironmentVariable(EnvVar, value);
return new TokenScope();
}
private sealed class TokenScope : IDisposable
{
public void Dispose() => Environment.SetEnvironmentVariable(EnvVar, null);
}
private static RepoDescriptor Repo(string name, string? branch = "main", bool archived = false)
=> new(name, $"https://x/{name}.git", branch, archived);
[Fact]
public async Task Help_PrintsUsage_AndReturnsZero()
{
using var console = new ConsoleCapture();
int code = await Run(new FakeRepoLister(), "--help");
Assert.Equal(0, code);
Assert.Contains("Usage: gclo repos", console.Out);
}
[Fact]
public async Task MissingOrg_IsAUsageError()
=> await Assert.ThrowsAsync<CliUsageException>(() => Run(new FakeRepoLister()));
[Fact]
public async Task UnknownOption_Throws()
=> await Assert.ThrowsAsync<CliUsageException>(() => Run(new FakeRepoLister(), "--org", "acme", "--nope"));
[Fact]
public async Task Plain_PrintsAlignedColumns_WithBranchAndArchivedMarker()
{
using var console = new ConsoleCapture();
using var _ = Token("ghp_x");
var lister = new FakeRepoLister { Result = [Repo("api"), Repo("empty-one", null), Repo("old", "master", archived: true)] };
int code = await Run(lister, "--org", "acme", "--token-env", EnvVar);
Assert.Equal(0, code);
string[] lines = console.Out.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(l => l.TrimEnd()).ToArray();
// Name and branch columns are padded to the widest value; 'archived' trails only archived rows.
Assert.Equal(["api main", "empty-one -", "old master archived"], lines);
}
[Fact]
public async Task Filters_NarrowTheListing_AndAnEmptyResultSaysWhy()
{
using var console = new ConsoleCapture();
using var _ = Token("ghp_x");
var lister = new FakeRepoLister { Result = [Repo("platform-api"), Repo("platform-old", archived: true), Repo("docs")] };
int code = await Run(lister, "--org", "acme", "--token-env", EnvVar, "--include", "platform-*", "--skip-archived");
Assert.Equal(0, code);
Assert.Contains("platform-api", console.Out);
Assert.DoesNotContain("platform-old", console.Out);
Assert.DoesNotContain("docs", console.Out);
code = await Run(lister, "--org", "acme", "--token-env", EnvVar, "--include", "nothing-*", "--exclude", "x");
Assert.Equal(0, code);
Assert.Contains("No repositories of 'acme' match the filters (3 listed).", console.Error);
code = await Run(new FakeRepoLister(), "--org", "acme", "--token-env", EnvVar);
Assert.Equal(0, code);
Assert.Contains("No repositories visible in 'acme'.", console.Error);
}
[Fact]
public async Task Json_PrintsAnArrayOfSummaries()
{
using var console = new ConsoleCapture();
using var _ = Token("ghp_x");
var lister = new FakeRepoLister { Result = [Repo("api"), Repo("old", null, archived: true)] };
int code = await Run(lister, "--org", "acme", "--token-env", EnvVar, "--json");
Assert.Equal(0, code);
Assert.Equal(
"[{\"name\":\"api\",\"defaultBranch\":\"main\",\"archived\":false,\"cloneUrl\":\"https://x/api.git\"},"
+ "{\"name\":\"old\",\"defaultBranch\":null,\"archived\":true,\"cloneUrl\":\"https://x/old.git\"}]",
console.Out.Trim());
}
[Fact]
public async Task ListerAccessRefusal_MapsToTheExitCodeTaxonomy()
{
using var _ = Token("ghp_x");
var lister = new FakeRepoLister { Throw = new GitHubAccessException(GitHubAccessKind.Forbidden, "no access") };
var ex = await Assert.ThrowsAsync<CliErrorException>(() => Run(lister, "--org", "acme", "--token-env", EnvVar));
Assert.Equal(ExitCodes.Auth, ex.ExitCode);
Assert.Contains("no access", ex.Message);
}
[Fact]
public async Task MissingToken_Throws()
{
string? original = Environment.GetEnvironmentVariable(TokenOptions.DefaultVariable);
Environment.SetEnvironmentVariable(TokenOptions.DefaultVariable, null);
try
{
await Assert.ThrowsAsync<CliErrorException>(() => Run(new FakeRepoLister(), "--org", "acme"));
}
finally
{
Environment.SetEnvironmentVariable(TokenOptions.DefaultVariable, original);
}
}
[Fact]
public async Task Canceled_Propagates()
{
using var _ = Token("ghp_x");
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => ReposCommand.RunAsync(["--org", "acme", "--token-env", EnvVar], new FakeRepoLister(), new NullLog(), cts.Token));
}
}
+183
View File
@@ -83,6 +83,25 @@ public sealed class SyncCommandTests : IDisposable
=> await Assert.ThrowsAsync<CliUsageException>(
() => Run(new FakeRepoLister(), new FakeGit(), "--org", "acme"));
[Theory]
[InlineData("0")]
[InlineData("65")]
[InlineData("abc")]
public async Task ParallelOutsideTheSupportedRange_IsRejected(string value)
{
var ex = await Assert.ThrowsAsync<CliUsageException>(
() => Run(new FakeRepoLister(), new FakeGit(), "--org", "acme", "--target", Target, "--parallel", value));
Assert.Contains("between 1 and 64", ex.Message);
}
[Fact]
public async Task JsonAndJsonLines_AreMutuallyExclusive()
{
var ex = await Assert.ThrowsAsync<CliUsageException>(
() => Run(new FakeRepoLister(), new FakeGit(), "--org", "acme", "--target", Target, "--json", "--json-lines"));
Assert.Contains("not both", ex.Message);
}
[Theory]
[InlineData("zero")]
[InlineData("0")]
@@ -221,6 +240,170 @@ public sealed class SyncCommandTests : IDisposable
Assert.Empty(git.RecoveredRepoNames); // recovery was never attempted
}
[Fact]
public async Task ListerAccessRefusal_MapsToTheExitCodeTaxonomy()
{
var unauthorized = new FakeRepoLister
{
Throw = new GitHubAccessException(GitHubAccessKind.Unauthorized, "GitHub rejected the token (401)."),
};
var ex = await Assert.ThrowsAsync<CliErrorException>(
() => Run(unauthorized, new FakeGit(), "--org", "acme", "--target", Target, "--token-env", EnvVar));
Assert.Equal(ExitCodes.Auth, ex.ExitCode);
var forbidden = new FakeRepoLister
{
Throw = new GitHubAccessException(GitHubAccessKind.Forbidden, "'acme' is an organization, but this token cannot see it."),
};
ex = await Assert.ThrowsAsync<CliErrorException>(
() => Run(forbidden, new FakeGit(), "--org", "acme", "--target", Target, "--token-env", EnvVar));
Assert.Equal(ExitCodes.Auth, ex.ExitCode);
var throttled = new FakeRepoLister
{
Throw = new GitHubAccessException(GitHubAccessKind.RateLimited, "rate limit exceeded"),
};
ex = await Assert.ThrowsAsync<CliErrorException>(
() => Run(throttled, new FakeGit(), "--org", "acme", "--target", Target, "--token-env", EnvVar));
Assert.Equal(ExitCodes.Transient, ex.ExitCode);
var missing = new FakeRepoLister
{
Throw = new GitHubAccessException(GitHubAccessKind.NotFound, "'ghost' was found neither as an organization nor as a user account (404)."),
};
ex = await Assert.ThrowsAsync<CliErrorException>(
() => Run(missing, new FakeGit(), "--org", "ghost", "--target", Target, "--token-env", EnvVar));
Assert.Equal(ExitCodes.Fatal, ex.ExitCode);
}
// ---------------------------------------------------------------- selection
[Fact]
public async Task IncludeExcludeAndSkipArchived_NarrowTheSelection()
{
using var console = new ConsoleCapture();
var lister = new FakeRepoLister
{
Result =
[
Repo("platform-api"), Repo("platform-web"), Repo("platform-legacy"), Repo("docs"),
new RepoDescriptor("platform-old", "https://x/platform-old.git", "main", IsArchived: true),
],
};
var git = new FakeGit();
int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar,
"--include", "platform-*", "--exclude", "*-legacy", "--skip-archived");
Assert.Equal(0, code);
Assert.Equal(["platform-api", "platform-web"], git.ClonedRepoNames.OrderBy(n => n));
Assert.Contains("2 cloned", console.Out);
Assert.Contains("of 2.", console.Out); // the summary counts the selection, not the listing
}
[Fact]
public async Task EmptyIncludePattern_IsAUsageError()
{
var ex = await Assert.ThrowsAsync<CliUsageException>(
() => Run(new FakeRepoLister(), new FakeGit(), "--org", "acme", "--target", Target, "--include", " "));
Assert.Contains("--include expects", ex.Message);
}
[Fact]
public async Task DryRun_ListsActionsAndTouchesNothing()
{
using var console = new ConsoleCapture();
var lister = new FakeRepoLister { Result = [Repo("new-one"), Repo("existing")] };
var git = new FakeGit { IsValid = path => Path.GetFileName(path) == "existing" };
int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar, "--dry-run");
Assert.Equal(0, code);
Assert.Empty(git.ClonedRepoNames);
Assert.Contains("new-one would clone", console.Out);
Assert.Contains("existing would update", console.Out);
Assert.Contains("Dry run: 1 would clone, 1 would update of 2.", console.Out);
}
[Fact]
public async Task DryRun_Json_PrintsAnArrayOfActions()
{
using var console = new ConsoleCapture();
var lister = new FakeRepoLister { Result = [Repo("new-one")] };
int code = await Run(lister, new FakeGit(), "--org", "acme", "--target", Target, "--token-env", EnvVar, "--dry-run", "--json");
Assert.Equal(0, code);
Assert.Equal("[{\"repo\":\"new-one\",\"action\":\"clone\"}]", console.Out.Trim());
}
// ---------------------------------------------------------------- machine-readable output
[Fact]
public async Task JsonLines_PrintsOneObjectPerTransition_ThenTheSummary()
{
using var console = new ConsoleCapture();
var lister = new FakeRepoLister { Result = [Repo("a"), Repo("b")] };
var git = new FakeGit
{
OnClone = (_, path, _, _, _) => Path.GetFileName(path) == "b"
? Task.FromException(new InvalidOperationException("boom"))
: Task.CompletedTask,
};
int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar, "--json-lines", "--parallel", "1");
Assert.Equal(1, code);
string[] lines = console.Out.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(l => l.TrimEnd('\r')).ToArray();
Assert.All(lines, l => Assert.StartsWith("{", l));
Assert.Contains("{\"type\":\"progress\",\"repo\":\"a\",\"status\":\"Queued\",\"error\":null}", lines);
Assert.Contains("{\"type\":\"progress\",\"repo\":\"a\",\"status\":\"Done\",\"error\":null}", lines);
Assert.Contains("{\"type\":\"progress\",\"repo\":\"b\",\"status\":\"Failed\",\"error\":\"boom\"}", lines);
Assert.StartsWith("{\"type\":\"summary\",\"total\":2,\"cloned\":1,\"updated\":0,\"failed\":1", lines[^1]);
Assert.Equal("", console.Error); // failures live in the stream, not on stderr
}
[Fact]
public async Task Json_CarriesSanitizedRepos_AndFullInvalidPathLists_WithNoStderrNoise()
{
using var console = new ConsoleCapture();
var lister = new FakeRepoLister { Result = [Repo("fixable"), Repo("stuck")] };
var git = new FakeGit
{
OnClone = (_, path, _, _, _) => Task.FromException(InvalidPaths()),
OnApplyRecovery = (path, _, _) => Path.GetFileName(path) == "stuck"
? Task.FromException(new IOException("disk full"))
: Task.CompletedTask,
};
int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar, "--json", "--sanitize-paths", "--parallel", "1");
Assert.Equal(1, code);
Assert.Equal("", console.Error); // the 'Sanitized' note and path details stay out of stderr in JSON mode
string json = console.Out.Trim();
Assert.StartsWith("{\"type\":\"summary\"", json);
Assert.Contains("\"sanitized\":[{\"repo\":\"fixable\",\"renamed\":1,\"skipped\":1,\"skippedPaths\":[\"dup\"]}]", json);
Assert.Contains("\"repo\":\"stuck\"", json);
Assert.Contains("path sanitization failed: disk full", json);
Assert.Contains("\"invalidPaths\":null", json); // a non-path failure has no list
}
[Fact]
public async Task Json_FailureFromInvalidPaths_CarriesEveryOffendingPath()
{
using var console = new ConsoleCapture();
var many = Enumerable.Range(0, 12)
.Select(i => new InvalidPathInfo($"bad{i}:x.txt", "invalid on Windows", $"bad{i}_x.txt"))
.ToList();
var lister = new FakeRepoLister { Result = [Repo("a")] };
var git = new FakeGit { OnClone = (_, _, _, _, _) => Task.FromException(new InvalidRepositoryPathsException(many)) };
int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar, "--json");
Assert.Equal(1, code);
Assert.Contains("\"path\":\"bad11:x.txt\",\"reason\":\"invalid on Windows\",\"suggestedName\":\"bad11_x.txt\"", console.Out);
}
[Fact]
public async Task ListerInvalidOperation_BecomesCliError()
{
+142
View File
@@ -0,0 +1,142 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"coverlet.collector": {
"type": "Direct",
"requested": "[10.1.0, )",
"resolved": "10.1.0",
"contentHash": "SlYCIHnBYxINSUpR75SiM5JEmnx11pqT+Ac/C/fUqTTA0dLPl9eYzmFXKKuxmQhL3ZdTQl4PBD3YdAxAauaKLQ=="
},
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
"requested": "[18.10.1, )",
"resolved": "18.10.1",
"contentHash": "SxCFvJE/2ltUQgCgv6+Ixagohw4sZc9hM7Wid2foaNM7paBscktoHLoRDz91fW8wbHG87Rg9Ku59/8PXumqUrA==",
"dependencies": {
"Microsoft.CodeCoverage": "18.10.1",
"Microsoft.TestPlatform.TestHost": "18.10.1"
}
},
"xunit": {
"type": "Direct",
"requested": "[2.9.3, )",
"resolved": "2.9.3",
"contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==",
"dependencies": {
"xunit.analyzers": "1.18.0",
"xunit.assert": "2.9.3",
"xunit.core": "[2.9.3]"
}
},
"xunit.runner.visualstudio": {
"type": "Direct",
"requested": "[4.0.0, )",
"resolved": "4.0.0",
"contentHash": "kzLFyBYUnoidpGOXvpNOfIo8C92gVgVR6X8ncHwhcrDYugiOut5rrhDkr0L3cWHd7J2pKXf6LgaeXoBBDlx1ag=="
},
"CommunityToolkit.Mvvm": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
},
"LibGit2Sharp": {
"type": "Transitive",
"resolved": "0.32.0",
"contentHash": "BL3eMVRnkWWS9zbz6n0H+iDVJ9ZK7hDeiuASnHlsFvwZYTJtDuBFRRVIaH//s4VlF0baOGvCeenXbobE1JLLiQ==",
"dependencies": {
"LibGit2Sharp.NativeBinaries": "[2.0.324]"
}
},
"LibGit2Sharp.NativeBinaries": {
"type": "Transitive",
"resolved": "2.0.324",
"contentHash": "5YZVGNuO9nYFQfzRQGPGY9fdpSsABwlF2jSaZ7ydxCfjvt5Krbj/eHbXkvvgAQzp4lkjvgDrlAuuI4KTmqhDlA=="
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
"resolved": "18.10.1",
"contentHash": "tV7tCyp/t+DuwQugLkCeUN7NBFFB4fEGRPr0gkMQ2nQgAvQQjmGlZZi1dsLzbyoe/A0mbnKybmkIh8qhT9p4Zw=="
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "18.10.1",
"contentHash": "o+PuLDRVp1iwiVAPBADZItXgm9Ck23XtPhE4kZtZVi0uYnMiiz1Dx9Cjh75kJ+ijfqnORL3VOTOaQVKi0f/ixg=="
},
"Microsoft.TestPlatform.TestHost": {
"type": "Transitive",
"resolved": "18.10.1",
"contentHash": "KYz2omqkoz9E2mWa4fdgT+Ycie1wi5968EmyJlusjlp87RxxtA2FvDm8tcBEYIDzLnkWGnsKjE4d13Z7/rxkQA==",
"dependencies": {
"Microsoft.TestPlatform.ObjectModel": "18.10.1"
}
},
"Octokit": {
"type": "Transitive",
"resolved": "14.0.0",
"contentHash": "jGOuTH1l+TCpJH+fwYOp7USzHDuGfN1jKbLz3J2COwyn+wL08eynvpnM6rY2qkzIEXum3PN2p2QkP3BW/p9Qcw=="
},
"xunit.abstractions": {
"type": "Transitive",
"resolved": "2.0.3",
"contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
},
"xunit.analyzers": {
"type": "Transitive",
"resolved": "1.18.0",
"contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ=="
},
"xunit.assert": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
},
"xunit.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]",
"xunit.extensibility.execution": "[2.9.3]"
}
},
"xunit.extensibility.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==",
"dependencies": {
"xunit.abstractions": "2.0.3"
}
},
"xunit.extensibility.execution": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]"
}
},
"gclo": {
"type": "Project",
"dependencies": {
"gclo.Engine": "[1.0.1-dev, )",
"gclo.ViewModels": "[1.0.1-dev, )"
}
},
"gclo.engine": {
"type": "Project",
"dependencies": {
"LibGit2Sharp": "[0.32.0, )",
"Octokit": "[14.0.0, )"
}
},
"gclo.viewmodels": {
"type": "Project",
"dependencies": {
"CommunityToolkit.Mvvm": "[8.4.2, )",
"gclo.Engine": "[1.0.1-dev, )"
}
}
}
}
}
+325 -23
View File
@@ -5,32 +5,63 @@ using gclo.ViewModels;
namespace gclo.Cli;
/// <summary>'gclo accounts': list the saved accounts and their last sync outcome.</summary>
/// <summary>
/// 'gclo accounts': list, add, edit, and remove the saved accounts — the same
/// profiles the desktop app's account wizard manages, so a headless mirror server
/// can set up 'gclo sync --account' without the GUI.
/// </summary>
internal static class AccountsCommand
{
private const string HelpText = """
Usage: gclo accounts [options]
Lists the saved accounts, one per line, in aligned columns:
name, organization, target root, and last sync time (local time,
'never' when the account has not completed a sync yet).
Usage: gclo accounts [list] [--json]
gclo accounts add --name <name> --org <name> --target <folder> [options] <token option>
gclo accounts edit --name <name> [options] [token option]
gclo accounts remove --name <name>
An account pairs sync settings (organization, target root, parallelism,
org-subfolder preference) with a token stored in Windows Credential
Manager. Use one with 'gclo sync --account <name>'. Accounts are created
and edited in the gclo desktop app for now; a CLI flag for creating them
is planned. Because the token lives in Windows Credential Manager,
accounts only work on Windows.
org-subfolder preference, description) with a token stored in Windows
Credential Manager. Use one with 'gclo sync --account <name>'. Because the
token lives in Windows Credential Manager, accounts only work on Windows.
Options:
list (default)
Prints the accounts, one per line, in aligned columns: name,
organization, target root, and last sync time (local time, 'never'
when the account has not completed a sync yet).
--json Print a single-line JSON array instead:
[{"name":"...","organization":"...","targetRoot":"...",
"lastSync":"2026-07-04T15:30:00+00:00" or null}]
--help Show this help.
[{"id":"...","name":"...","description":"...","organization":"...",
"targetRoot":"...","createOrgSubfolder":bool,"maxConcurrency":N,
"lastSync":"2026-07-04T15:30:00+00:00"|null,"lastSyncSummary":"..."|null}]
add
--name <name> Display name; must be unique (case-insensitive).
--org <name> GitHub organization or user account to sync.
--target <folder> Target root folder.
--parallel <N> Parallel clone/pull count, 1-64 (default 8).
--org-subfolder Clone under <target>\<org> instead of <target>.
--description <text> Optional note.
--token-env <VAR> | --token-file <path> | --token-stdin
The token to store (default: the GITHUB_TOKEN
environment variable). Never pass it as an argument.
edit
--name <name> The account to change (required).
--rename <new name> New display name.
--org, --target, --parallel, --description
Replace that setting.
--org-subfolder | --no-org-subfolder
Turn the organization subfolder on or off.
--token-env | --token-file | --token-stdin
Replace the stored token; without a token option
the stored token is left untouched.
remove
--name <name> Deletes the account's settings and its stored
token. Repositories on disk are not touched.
Exit codes:
0 listed successfully (also when no accounts exist yet)
2 fatal: bad arguments, or not running on Windows
0 done
2 fatal: bad arguments, unknown account, duplicate name, missing token,
or not running on Windows
""";
/// <summary>Composition root: wires the real Credential Manager store and file log.</summary>
@@ -39,6 +70,27 @@ internal static class AccountsCommand
public static int Run(string[] args) => Run(args, Open, new FileActivityLog());
internal static int Run(string[] args, Func<IActivityLog, (AccountsStore Store, ITokenVault Vault)> open, IActivityLog log)
{
// The first argument names the subcommand unless it is an option, in which
// case it is 'list' (the original, option-only form of the command).
string subcommand = args.Length > 0 && !args[0].StartsWith("--", StringComparison.Ordinal) ? args[0] : "list";
string[] rest = subcommand == "list" && (args.Length == 0 || args[0].StartsWith("--", StringComparison.Ordinal))
? args
: args[1..];
return subcommand switch
{
"list" => List(rest, open, log),
"add" => Add(rest, open, log),
"edit" => Edit(rest, open, log),
"remove" => Remove(rest, open, log),
_ => throw new CliUsageException($"Unknown subcommand '{subcommand}' for 'gclo accounts' (use list, add, edit, or remove)."),
};
}
// ---------------------------------------------------------------- list
private static int List(string[] args, Func<IActivityLog, (AccountsStore Store, ITokenVault Vault)> open, IActivityLog log)
{
bool json = false;
@@ -49,7 +101,7 @@ internal static class AccountsCommand
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
return 0;
return ExitCodes.Success;
case "--json":
reader.RejectValue();
json = true;
@@ -70,18 +122,20 @@ internal static class AccountsCommand
if (json)
{
IReadOnlyList<AccountSummary> summaries = accounts
.Select(a => new AccountSummary(a.Name, a.Organization, a.TargetRoot, a.LastSyncUtc))
.Select(a => new AccountSummary(
a.Id.ToString("N"), a.Name, a.Description, a.Organization, a.TargetRoot,
a.CreateOrgSubfolder, a.MaxConcurrency, a.LastSyncUtc, a.LastSyncSummary))
.ToList();
Console.Out.WriteLine(JsonSerializer.Serialize(
summaries, CliJsonContext.Default.IReadOnlyListAccountSummary));
return 0;
return ExitCodes.Success;
}
if (accounts.Count == 0)
{
// Diagnostics go to stderr so redirected stdout stays clean (and empty).
Console.Error.WriteLine("No accounts yet. Create one in the gclo desktop app.");
return 0;
Console.Error.WriteLine("No accounts yet. Create one with 'gclo accounts add' or in the gclo desktop app.");
return ExitCodes.Success;
}
int nameWidth = accounts.Max(a => a.Name.Length);
@@ -93,7 +147,7 @@ internal static class AccountsCommand
$"{account.Name.PadRight(nameWidth)} {account.Organization.PadRight(orgWidth)} "
+ $"{account.TargetRoot.PadRight(targetWidth)} {FormatLastSync(account.LastSyncUtc)}");
}
return 0;
return ExitCodes.Success;
}
catch (Exception ex)
{
@@ -104,6 +158,254 @@ internal static class AccountsCommand
}
}
// ---------------------------------------------------------------- add
private static int Add(string[] args, Func<IActivityLog, (AccountsStore Store, ITokenVault Vault)> open, IActivityLog log)
{
string? name = null;
string? org = null;
string? target = null;
string description = "";
int parallel = AppSettings.DefaultConcurrency;
bool orgSubfolder = false;
var tokenOptions = new TokenOptions();
var reader = new OptionReader(args);
while (reader.MoveNext())
{
if (tokenOptions.TryConsume(reader))
{
continue;
}
switch (reader.Current)
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
return ExitCodes.Success;
case "--name":
name = reader.RequireValue();
break;
case "--org":
org = reader.RequireValue();
break;
case "--target":
target = reader.RequireValue();
break;
case "--description":
description = reader.RequireValue();
break;
case "--parallel":
parallel = OptionReader.ParseParallel(reader.RequireValue());
break;
case "--org-subfolder":
reader.RejectValue();
orgSubfolder = true;
break;
default:
throw new CliUsageException($"Unknown option '{reader.Current}' for 'gclo accounts add'.");
}
}
if (string.IsNullOrWhiteSpace(name))
{
throw new CliUsageException("--name is required.");
}
if (string.IsNullOrWhiteSpace(org))
{
throw new CliUsageException("--org is required.");
}
if (string.IsNullOrWhiteSpace(target))
{
throw new CliUsageException("--target is required.");
}
log.Info($"accounts add started: name='{name.Trim()}', org='{org.Trim()}'");
try
{
string token = tokenOptions.Resolve();
(AccountsStore store, _) = open(log);
var account = new Account
{
Id = Guid.NewGuid(),
Name = name.Trim(),
Description = description.Trim(),
Organization = org.Trim(),
TargetRoot = target.Trim(),
CreateOrgSubfolder = orgSubfolder,
MaxConcurrency = parallel,
};
SaveOrFail(store, account, token);
Console.Out.WriteLine($"Account '{account.Name}' added.");
return ExitCodes.Success;
}
catch (Exception ex)
{
log.Error($"accounts add failed: {ex.Message}", ex);
throw;
}
}
// ---------------------------------------------------------------- edit
private static int Edit(string[] args, Func<IActivityLog, (AccountsStore Store, ITokenVault Vault)> open, IActivityLog log)
{
string? name = null;
string? rename = null;
string? org = null;
string? target = null;
string? description = null;
int? parallel = null;
bool? orgSubfolder = null;
var tokenOptions = new TokenOptions();
var reader = new OptionReader(args);
while (reader.MoveNext())
{
if (tokenOptions.TryConsume(reader))
{
continue;
}
switch (reader.Current)
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
return ExitCodes.Success;
case "--name":
name = reader.RequireValue();
break;
case "--rename":
rename = reader.RequireValue();
break;
case "--org":
org = reader.RequireValue();
break;
case "--target":
target = reader.RequireValue();
break;
case "--description":
description = reader.RequireValue();
break;
case "--parallel":
parallel = OptionReader.ParseParallel(reader.RequireValue());
break;
case "--org-subfolder":
reader.RejectValue();
orgSubfolder = true;
break;
case "--no-org-subfolder":
reader.RejectValue();
orgSubfolder = false;
break;
default:
throw new CliUsageException($"Unknown option '{reader.Current}' for 'gclo accounts edit'.");
}
}
if (string.IsNullOrWhiteSpace(name))
{
throw new CliUsageException("--name is required.");
}
if (rename is null && org is null && target is null && description is null && parallel is null
&& orgSubfolder is null && !tokenOptions.HasExplicitSource)
{
throw new CliUsageException("Nothing to change: give at least one of --rename, --org, --target, --parallel, "
+ "--description, --org-subfolder/--no-org-subfolder, or a token option.");
}
log.Info($"accounts edit started: name='{name.Trim()}'");
try
{
(AccountsStore store, _) = open(log);
Account existing = store.FindByName(name.Trim()) ?? throw UnknownAccount(name.Trim(), store);
string? token = tokenOptions.HasExplicitSource ? tokenOptions.Resolve() : null;
Account updated = existing with
{
Name = rename?.Trim() ?? existing.Name,
Organization = org?.Trim() ?? existing.Organization,
TargetRoot = target?.Trim() ?? existing.TargetRoot,
Description = description?.Trim() ?? existing.Description,
MaxConcurrency = parallel ?? existing.MaxConcurrency,
CreateOrgSubfolder = orgSubfolder ?? existing.CreateOrgSubfolder,
};
SaveOrFail(store, updated, token);
Console.Out.WriteLine($"Account '{updated.Name}' updated" + (token is null ? "." : " (token replaced)."));
return ExitCodes.Success;
}
catch (Exception ex)
{
log.Error($"accounts edit failed: {ex.Message}", ex);
throw;
}
}
// ---------------------------------------------------------------- remove
private static int Remove(string[] args, Func<IActivityLog, (AccountsStore Store, ITokenVault Vault)> open, IActivityLog log)
{
string? name = null;
var reader = new OptionReader(args);
while (reader.MoveNext())
{
switch (reader.Current)
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
return ExitCodes.Success;
case "--name":
name = reader.RequireValue();
break;
default:
throw new CliUsageException($"Unknown option '{reader.Current}' for 'gclo accounts remove'.");
}
}
if (string.IsNullOrWhiteSpace(name))
{
throw new CliUsageException("--name is required.");
}
log.Info($"accounts remove started: name='{name.Trim()}'");
try
{
(AccountsStore store, _) = open(log);
Account existing = store.FindByName(name.Trim()) ?? throw UnknownAccount(name.Trim(), store);
store.Delete(existing.Id); // the store logs the deletion
Console.Out.WriteLine($"Account '{existing.Name}' removed.");
return ExitCodes.Success;
}
catch (Exception ex)
{
log.Error($"accounts remove failed: {ex.Message}", ex);
throw;
}
}
// ---------------------------------------------------------------- shared
/// <summary>A duplicate name is the one validation the store itself enforces; everything else is fatal as is.</summary>
private static void SaveOrFail(AccountsStore store, Account account, string? token)
{
try
{
store.Save(account, token);
}
catch (ArgumentException ex)
{
throw new CliErrorException(ex.Message, ex);
}
}
private static CliErrorException UnknownAccount(string name, AccountsStore store)
{
IReadOnlyList<Account> all = store.GetAll();
string available = all.Count == 0
? "No accounts exist yet."
: "Available accounts: " + string.Join(", ", all.Select(a => a.Name)) + ".";
return new CliErrorException($"No account named '{name}'. {available}");
}
/// <summary>
/// Opens the accounts store backed by Windows Credential Manager. The vault is
/// returned alongside the store because token retrieval for 'gclo sync --account'
+44 -6
View File
@@ -2,29 +2,64 @@ using System.Text.Json.Serialization;
namespace gclo.Cli;
/// <summary>One failed repository in the JSON summary.</summary>
internal sealed record SyncFailure(string Repo, string Error);
/// <summary>One Windows-invalid path behind a failure, as the validator reported it.</summary>
internal sealed record SyncInvalidPath(string Path, string Reason, string? SuggestedName);
/// <summary>The single-line JSON document printed by 'gclo sync --json'.</summary>
/// <summary>
/// One failed repository in the JSON summary. <see cref="InvalidPaths"/> is present
/// (non-null) only when the failure was Windows path validation — the full list, not
/// the three-path excerpt the message carries.
/// </summary>
internal sealed record SyncFailure(string Repo, string Error, IReadOnlyList<SyncInvalidPath>? InvalidPaths = null);
/// <summary>
/// One repository that '--sanitize-paths' checked out with renames and skips: it
/// counts as cloned, but its working tree is incomplete by <see cref="Skipped"/>
/// paths, which a mirroring pipeline needs to know.
/// </summary>
internal sealed record SyncSanitized(string Repo, int Renamed, int Skipped, IReadOnlyList<string> SkippedPaths);
/// <summary>
/// The single-line JSON document printed by 'gclo sync --json', and the last line of
/// 'gclo sync --json-lines'. <see cref="Type"/> is always "summary" so a line reader
/// can tell it from progress lines.
/// </summary>
internal sealed record SyncJsonResult(
string Type,
int Total,
int Cloned,
int Updated,
int Failed,
int Canceled,
bool WasCanceled,
IReadOnlyList<SyncFailure> Failures);
IReadOnlyList<SyncFailure> Failures,
IReadOnlyList<SyncSanitized> Sanitized);
/// <summary>One status transition in 'gclo sync --json-lines'; <see cref="Type"/> is always "progress".</summary>
internal sealed record SyncProgressLine(string Type, string Repo, string Status, string? Error);
/// <summary>One repository of 'gclo sync --dry-run --json': what the run would do with it.</summary>
internal sealed record DryRunEntry(string Repo, string Action);
/// <summary>One repository in the JSON listing of 'gclo repos --json'.</summary>
internal sealed record RepoSummary(string Name, string? DefaultBranch, bool Archived, string CloneUrl);
/// <summary>
/// One saved account in the JSON listing of 'gclo accounts --json'.
/// <see cref="LastSync"/> is the UTC time of the last completed sync, or null
/// when the account has never synced.
/// when the account has never synced; <see cref="Id"/> is the key of its
/// Credential Manager entry ('gclo:account:&lt;id&gt;').
/// </summary>
internal sealed record AccountSummary(
string Id,
string Name,
string Description,
string Organization,
string TargetRoot,
DateTimeOffset? LastSync);
bool CreateOrgSubfolder,
int MaxConcurrency,
DateTimeOffset? LastSync,
string? LastSyncSummary);
/// <summary>
/// Source-generated System.Text.Json serialization: no runtime reflection, so the
@@ -32,6 +67,9 @@ internal sealed record AccountSummary(
/// </summary>
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(SyncJsonResult))]
[JsonSerializable(typeof(SyncProgressLine))]
[JsonSerializable(typeof(IReadOnlyList<DryRunEntry>))]
[JsonSerializable(typeof(IReadOnlyList<string>))]
[JsonSerializable(typeof(IReadOnlyList<RepoSummary>))]
[JsonSerializable(typeof(IReadOnlyList<AccountSummary>))]
internal sealed partial class CliJsonContext : JsonSerializerContext;
+71 -2
View File
@@ -1,5 +1,42 @@
using gclo.Engine;
namespace gclo.Cli;
/// <summary>
/// The process exit codes. 0-2 are the original contract; 3 and 4 split what used
/// to be 2 so a scheduler can tell "fix the invocation or token" from "transient,
/// retry later" without parsing stderr; 70 (BSD EX_SOFTWARE) marks a bug in gclo
/// itself rather than a caller mistake.
/// </summary>
internal static class ExitCodes
{
/// <summary>Every repository synced (or the listing completed).</summary>
public const int Success = 0;
/// <summary>The run completed, but some repositories failed or it was canceled.</summary>
public const int Partial = 1;
/// <summary>Fatal: bad arguments, missing token, unknown organization or account, unusable target.</summary>
public const int Fatal = 2;
/// <summary>GitHub rejected the token or refused access (401/403): fix the token; never retry as is.</summary>
public const int Auth = 3;
/// <summary>A rate limit or temporary throttle: retry later.</summary>
public const int Transient = 4;
/// <summary>An unexpected exception inside gclo; details are in the activity log.</summary>
public const int Unexpected = 70;
/// <summary>The exit code for an engine access refusal of the given kind.</summary>
public static int ForAccess(GitHubAccessKind kind) => kind switch
{
GitHubAccessKind.Unauthorized or GitHubAccessKind.Forbidden => Auth,
GitHubAccessKind.RateLimited => Transient,
_ => Fatal,
};
}
/// <summary>
/// A command-line usage error. The message is printed to stderr followed by a
/// help hint, and the process exits with code 2.
@@ -14,13 +51,27 @@ internal sealed class CliUsageException : Exception
/// <summary>
/// A fatal runtime error (missing token, rejected token, organization not found,
/// unusable target folder). The message is printed to stderr and the process
/// exits with code 2.
/// exits with <see cref="ExitCode"/> — 2 unless the failure is an auth (3) or
/// transient (4) refusal from GitHub.
/// </summary>
internal sealed class CliErrorException : Exception
{
public CliErrorException(string message, Exception? inner = null) : base(message, inner)
public CliErrorException(string message, Exception? inner = null, int exitCode = ExitCodes.Fatal)
: base(message, inner)
{
ExitCode = exitCode;
}
/// <summary>The process exit code this error maps to.</summary>
public int ExitCode { get; }
/// <summary>
/// Wraps an engine failure: the listers translate GitHub refusals into
/// <see cref="GitHubAccessException"/>, whose kind picks the exit code; any other
/// <see cref="InvalidOperationException"/> stays fatal (2).
/// </summary>
public static CliErrorException FromEngine(InvalidOperationException ex)
=> new(ex.Message, ex, ex is GitHubAccessException access ? ExitCodes.ForAccess(access.Kind) : ExitCodes.Fatal);
}
/// <summary>
@@ -91,4 +142,22 @@ internal sealed class OptionReader
throw new CliUsageException($"Option '{Current}' does not take a value.");
}
}
/// <summary>
/// Parses a '--parallel' value: an integer within the engine's supported range,
/// the same 1..64 the desktop app enforces (an unbounded value would open
/// thousands of connections and trip GitHub's secondary rate limit).
/// </summary>
public static int ParseParallel(string value)
{
if (!int.TryParse(value, out int parsed)
|| parsed < gclo.ViewModels.AppSettings.MinConcurrency
|| parsed > gclo.ViewModels.AppSettings.MaxConcurrency)
{
throw new CliUsageException(
$"--parallel expects an integer between {gclo.ViewModels.AppSettings.MinConcurrency} and "
+ $"{gclo.ViewModels.AppSettings.MaxConcurrency}, got '{value}'.");
}
return parsed;
}
}
+8 -6
View File
@@ -25,7 +25,9 @@ internal static class OrgsCommand
Exit codes:
0 listed successfully (even when only the account login is visible)
1 canceled (Ctrl+C)
2 fatal: bad arguments, or missing or rejected token
2 fatal: bad arguments, or missing token
3 the token was rejected (401)
4 rate limited; retry later
Security:
There is deliberately no '--token <value>' option: process command lines
@@ -56,7 +58,7 @@ internal static class OrgsCommand
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
return 0;
return ExitCodes.Success;
case "--json":
reader.RejectValue();
json = true;
@@ -81,9 +83,9 @@ internal static class OrgsCommand
}
catch (InvalidOperationException ex)
{
// The lister translates auth failures and rate limiting into
// InvalidOperationException.
throw new CliErrorException(ex.Message, ex);
// The lister translates auth failures and rate limiting; the kind
// picks the exit code (2/3/4).
throw CliErrorException.FromEngine(ex);
}
log.Info($"orgs finished: {logins.Count} login(s) listed.");
@@ -99,7 +101,7 @@ internal static class OrgsCommand
Console.Out.WriteLine(login);
}
}
return 0;
return ExitCodes.Success;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
+61 -13
View File
@@ -19,21 +19,25 @@ internal static class Program
Usage:
gclo sync --org <name> --target <folder> [options]
gclo sync --account <name> [options]
gclo repos --org <name> [options]
gclo orgs [options]
gclo accounts [--json]
gclo accounts [list|add|edit|remove] [options]
gclo --version
gclo --help
Commands:
sync Clone every repository of an organization; fast-forward ones
that already exist locally. '--account <name>' runs it with a
saved account's settings and stored token.
saved account's settings and stored token. '--include',
'--exclude', and '--skip-archived' narrow the selection;
'--dry-run' shows it without syncing.
repos List the repositories a sync would see, with the same filters.
orgs List the account and organization logins the token can see.
accounts List the saved accounts (name, organization, target root, last
sync). Accounts are created in the gclo desktop app and are
accounts List, add, edit, or remove saved accounts (name, organization,
target root, parallelism, token in Windows Credential Manager).
Windows-only.
Run 'gclo <command> --help' (e.g. 'gclo sync --help') for command options.
Run 'gclo <command> --help' or 'gclo help <command>' for command options.
Token:
There is deliberately no '--token <value>' option: command-line arguments
@@ -46,6 +50,11 @@ internal static class Program
When no token option is given, the GITHUB_TOKEN environment variable is
used — except with 'gclo sync --account', which reads the account's token
from Windows Credential Manager instead. A token option always wins.
Exit codes:
0 success, 1 completed with failures or canceled, 2 usage or fatal error,
3 token rejected or access denied, 4 rate limited (retry later),
70 unexpected error (details in the activity log).
""";
public static async Task<int> Main(string[] args)
@@ -72,25 +81,52 @@ internal static class Program
{
Console.Error.WriteLine(ex.Message);
Console.Error.WriteLine("Run 'gclo --help' for usage.");
return 2;
return ExitCodes.Fatal;
}
catch (CliErrorException ex)
{
Console.Error.WriteLine(ex.Message);
return 2;
return ex.ExitCode;
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
Console.Error.WriteLine("Canceled.");
return 1;
return ExitCodes.Partial;
}
catch (Exception ex)
{
Console.Error.WriteLine(ex.Message);
return 2;
return ReportUnexpected(ex);
}
}
/// <summary>
/// A bug in gclo, not a caller mistake: say so, with the exception type, and point
/// at the activity log where the full exception (with its stack) is recorded, so
/// a bug report has something to go on. Exit 70 keeps it apart from exit 2.
/// </summary>
private static int ReportUnexpected(Exception ex)
{
string details = "";
try
{
var log = new FileActivityLog();
log.Error($"unexpected error ({ex.GetType().FullName}): {ex.Message}", ex);
details = log.CurrentLogFilePath;
}
catch
{
// The log must never make a bad situation worse.
}
Console.Error.WriteLine($"Unexpected error ({ex.GetType().Name}): {ex.Message}");
if (details.Length > 0)
{
Console.Error.WriteLine($"Details: {details}");
}
Console.Error.WriteLine("This looks like a bug in gclo; please report it at https://github.com/KofTwentyTwo/gclo/issues.");
return ExitCodes.Unexpected;
}
private static Task<int> RunAsync(string[] args, CancellationToken cancellationToken)
{
if (args.Length == 0)
@@ -104,16 +140,28 @@ internal static class Program
{
case "sync":
return SyncCommand.RunAsync(rest, cancellationToken);
case "repos":
return ReposCommand.RunAsync(rest, cancellationToken);
case "orgs":
return OrgsCommand.RunAsync(rest, cancellationToken);
case "accounts":
return Task.FromResult(AccountsCommand.Run(rest));
case "--help" or "-h" or "help":
case "--help" or "-h":
Console.Out.WriteLine(RootHelp);
return Task.FromResult(0);
return Task.FromResult(ExitCodes.Success);
case "help":
if (rest.Length == 0)
{
Console.Out.WriteLine(RootHelp);
return Task.FromResult(ExitCodes.Success);
}
// 'gclo help sync' means 'gclo sync --help'; an unknown name is a usage error.
return rest[0] is "sync" or "repos" or "orgs" or "accounts"
? RunAsync([rest[0], "--help"], cancellationToken)
: throw new CliUsageException($"Unknown command '{rest[0]}'.");
case "--version":
Console.Out.WriteLine(Version());
return Task.FromResult(0);
return Task.FromResult(ExitCodes.Success);
default:
throw new CliUsageException($"Unknown command '{command}'.");
}
+78 -19
View File
@@ -1,29 +1,41 @@
using System.Text.Json;
using gclo.Engine;
namespace gclo.Cli;
/// <summary>How 'gclo sync' reports progress and the result.</summary>
internal enum OutputMode
{
/// <summary>Transitions on stdout, failures on stderr, a summary line at the end.</summary>
Text,
/// <summary>Failures on stderr and the summary line only.</summary>
Quiet,
/// <summary>Nothing until the end, then one JSON document.</summary>
Json,
/// <summary>One JSON object per transition on stdout (NDJSON), then the summary object.</summary>
JsonLines,
}
/// <summary>
/// Prints one line per repository status transition and records failures for the
/// final summary. Clone percent updates arrive as repeated
/// <see cref="SyncStatus.Cloning"/> snapshots and are suppressed — only actual
/// status transitions print. Failure lines go to stderr so they survive
/// '--quiet' and stdout redirection; everything else goes to stdout.
/// Prints one line per repository status transition and records failures (and
/// sanitized repositories) for the final summary. Clone percent updates arrive as
/// repeated <see cref="SyncStatus.Cloning"/> snapshots and are suppressed — only
/// actual status transitions print. In the text modes failure lines go to stderr so
/// they survive '--quiet' and stdout redirection; the JSON modes carry failures in
/// the document instead.
/// </summary>
internal sealed class ProgressPrinter : IProgress<RepoProgress>
{
private readonly object _gate = new();
private readonly Dictionary<string, SyncStatus> _lastStatus = new(StringComparer.OrdinalIgnoreCase);
private readonly List<SyncFailure> _failures = new();
private readonly bool _printProgress;
private readonly bool _printFailures;
private readonly List<SyncSanitized> _sanitized = new();
private readonly OutputMode _mode;
/// <param name="printProgress">Write non-failure transitions to stdout (off for --quiet and --json).</param>
/// <param name="printFailures">Write failure lines to stderr (off for --json, which carries failures in the document).</param>
public ProgressPrinter(bool printProgress, bool printFailures)
{
_printProgress = printProgress;
_printFailures = printFailures;
}
public ProgressPrinter(OutputMode mode) => _mode = mode;
/// <summary>Failed repositories in the order they failed.</summary>
public IReadOnlyList<SyncFailure> Failures
@@ -37,6 +49,27 @@ internal sealed class ProgressPrinter : IProgress<RepoProgress>
}
}
/// <summary>Repositories checked out with sanitized paths, in order.</summary>
public IReadOnlyList<SyncSanitized> Sanitized
{
get
{
lock (_gate)
{
return _sanitized.ToArray();
}
}
}
/// <summary>Records a '--sanitize-paths' outcome for the summary document.</summary>
public void RecordSanitized(SyncSanitized sanitized)
{
lock (_gate)
{
_sanitized.Add(sanitized);
}
}
// The engine reports from worker threads; the lock keeps lines whole and in
// a single consistent order.
public void Report(RepoProgress value)
@@ -51,17 +84,43 @@ internal sealed class ProgressPrinter : IProgress<RepoProgress>
if (value.Status == SyncStatus.Failed)
{
var failure = new SyncFailure(value.RepoName, value.Error ?? "unknown error");
var failure = new SyncFailure(value.RepoName, value.Error ?? "unknown error", MapInvalidPaths(value.InvalidPaths));
_failures.Add(failure);
if (_printFailures)
switch (_mode)
{
Console.Error.WriteLine($"{failure.Repo} Failed {failure.Error}");
case OutputMode.Text or OutputMode.Quiet:
Console.Error.WriteLine($"{failure.Repo} Failed {failure.Error}");
break;
case OutputMode.JsonLines:
WriteProgressLine(value.RepoName, value.Status, failure.Error);
break;
default:
break; // Json: the document carries it
}
return;
}
else if (_printProgress)
switch (_mode)
{
Console.Out.WriteLine($"{value.RepoName} {value.Status}");
case OutputMode.Text:
Console.Out.WriteLine($"{value.RepoName} {value.Status}");
break;
case OutputMode.JsonLines:
WriteProgressLine(value.RepoName, value.Status, null);
break;
default:
break; // Quiet, Json: transitions are not printed
}
}
}
private static void WriteProgressLine(string repo, SyncStatus status, string? error)
=> Console.Out.WriteLine(JsonSerializer.Serialize(
new SyncProgressLine("progress", repo, status.ToString(), error), CliJsonContext.Default.SyncProgressLine));
/// <summary>The full offending-path list for the document; null when the failure was not path validation.</summary>
internal static IReadOnlyList<SyncInvalidPath>? MapInvalidPaths(IReadOnlyList<InvalidPathInfo>? paths)
=> paths is { Count: > 0 }
? paths.Select(p => new SyncInvalidPath(p.RepoPath, p.Reason, p.SuggestedName)).ToList()
: null;
}
+66
View File
@@ -0,0 +1,66 @@
using System.Text.RegularExpressions;
using gclo.Engine;
namespace gclo.Cli;
/// <summary>
/// The repository selection shared by 'gclo sync' and 'gclo repos': any number of
/// '--include' and '--exclude' name globs ('*' and '?', case-insensitive, matched
/// against the whole name) plus '--skip-archived'. A repository is selected when it
/// matches at least one include (or there are none), matches no exclude, and is not
/// archived when archived ones are skipped — the same list-select-sync shape the
/// desktop app's table offers.
/// </summary>
internal sealed class RepoFilterSpec
{
private readonly List<Regex> _include = new();
private readonly List<Regex> _exclude = new();
/// <summary>When true, archived repositories are dropped from the selection.</summary>
public bool SkipArchived { get; set; }
/// <summary>True when no filter was given: every listed repository is selected.</summary>
public bool IsEmpty => _include.Count == 0 && _exclude.Count == 0 && !SkipArchived;
/// <summary>Adds an '--include' glob.</summary>
public void Include(string glob) => _include.Add(GlobToRegex(glob, "--include"));
/// <summary>Adds an '--exclude' glob.</summary>
public void Exclude(string glob) => _exclude.Add(GlobToRegex(glob, "--exclude"));
/// <summary>Whether <paramref name="repo"/> is part of the selection.</summary>
public bool Matches(RepoDescriptor repo)
=> (!SkipArchived || !repo.IsArchived)
&& (_include.Count == 0 || _include.Any(r => r.IsMatch(repo.Name)))
&& !_exclude.Any(r => r.IsMatch(repo.Name));
/// <summary>Human-readable description for the activity log.</summary>
public override string ToString()
{
var parts = new List<string>();
if (_include.Count > 0)
{
parts.Add($"include={_include.Count}");
}
if (_exclude.Count > 0)
{
parts.Add($"exclude={_exclude.Count}");
}
if (SkipArchived)
{
parts.Add("skipArchived");
}
return parts.Count == 0 ? "none" : string.Join(", ", parts);
}
internal static Regex GlobToRegex(string glob, string option)
{
if (string.IsNullOrWhiteSpace(glob))
{
throw new CliUsageException($"{option} expects a repository name pattern such as 'platform-*'.");
}
string pattern = "^" + Regex.Escape(glob.Trim()).Replace("\\*", ".*", StringComparison.Ordinal)
.Replace("\\?", ".", StringComparison.Ordinal) + "$";
return new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
}
}
+157
View File
@@ -0,0 +1,157 @@
using System.Text.Json;
using gclo.Engine;
namespace gclo.Cli;
/// <summary>'gclo repos': list the repositories a sync would see, with the same filters.</summary>
internal static class ReposCommand
{
private const string HelpText = """
Usage: gclo repos --org <name> [options]
Lists the repositories of <name> that 'gclo sync' would process — one per
line: name, default branch ('-' for an empty repository), and 'archived'
when the repository is archived. Sorted by name. Use it to preview a
selection before syncing, or to feed a script.
Options:
--org <name> GitHub organization or user account (required).
--include <glob> Only repositories whose name matches the pattern
('*' and '?' wildcards, case-insensitive). Repeatable;
a repository matches when any include matches.
--exclude <glob> Drop repositories whose name matches. Repeatable;
applied after --include.
--skip-archived Drop archived repositories.
--token-env <VAR> Read the token from environment variable VAR
(default: GITHUB_TOKEN when no token option is given).
--token-file <path> Read the token from the first non-blank line of a file.
--token-stdin Read the token as one line from standard input.
--json Print a single-line JSON array instead:
[{"name":"...","defaultBranch":"main"|null,"archived":bool,"cloneUrl":"..."}]
--help Show this help.
Exit codes:
0 listed successfully (also when the selection is empty)
1 canceled (Ctrl+C)
2 fatal: bad arguments, missing token, organization not found
3 the token was rejected or may not see the organization
4 rate limited; retry later
""";
/// <summary>Composition root: wires the real GitHub lister and file log.</summary>
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
Justification = "Wires the real network lister and file log; delegates to the covered core.")]
public static Task<int> RunAsync(string[] args, CancellationToken cancellationToken)
=> RunAsync(args, new GitHubRepositoryLister(), new FileActivityLog(), cancellationToken);
internal static async Task<int> RunAsync(
string[] args, IRepositoryLister lister, IActivityLog log, CancellationToken cancellationToken)
{
string? org = null;
bool json = false;
var filter = new RepoFilterSpec();
var tokenOptions = new TokenOptions();
var reader = new OptionReader(args);
while (reader.MoveNext())
{
if (tokenOptions.TryConsume(reader))
{
continue;
}
switch (reader.Current)
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
return ExitCodes.Success;
case "--org":
org = reader.RequireValue();
break;
case "--include":
filter.Include(reader.RequireValue());
break;
case "--exclude":
filter.Exclude(reader.RequireValue());
break;
case "--skip-archived":
reader.RejectValue();
filter.SkipArchived = true;
break;
case "--json":
reader.RejectValue();
json = true;
break;
default:
throw new CliUsageException($"Unknown option '{reader.Current}' for 'gclo repos'.");
}
}
if (string.IsNullOrWhiteSpace(org))
{
throw new CliUsageException("--org is required.");
}
log.Info($"repos started: org='{org}', filters={filter}, json={json}");
try
{
string token = tokenOptions.Resolve();
IReadOnlyList<RepoDescriptor> repositories;
try
{
repositories = await lister
.ListOrganizationRepositoriesAsync(org.Trim(), token, cancellationToken)
.ConfigureAwait(false);
}
catch (InvalidOperationException ex)
{
throw CliErrorException.FromEngine(ex);
}
int listed = repositories.Count;
if (!filter.IsEmpty)
{
repositories = repositories.Where(filter.Matches).ToList();
}
log.Info($"repos finished: {listed} listed, {repositories.Count} selected.");
if (json)
{
IReadOnlyList<RepoSummary> summaries = repositories
.Select(r => new RepoSummary(r.Name, r.DefaultBranch, r.IsArchived, r.CloneUrl))
.ToList();
Console.Out.WriteLine(JsonSerializer.Serialize(summaries, CliJsonContext.Default.IReadOnlyListRepoSummary));
return ExitCodes.Success;
}
if (repositories.Count == 0)
{
Console.Error.WriteLine(listed == 0
? $"No repositories visible in '{org}'."
: $"No repositories of '{org}' match the filters ({listed} listed).");
return ExitCodes.Success;
}
int nameWidth = repositories.Max(r => r.Name.Length);
int branchWidth = repositories.Max(r => (r.DefaultBranch ?? "-").Length);
foreach (RepoDescriptor repo in repositories)
{
string branch = (repo.DefaultBranch ?? "-").PadRight(branchWidth);
Console.Out.WriteLine(
$"{repo.Name.PadRight(nameWidth)} {branch}" + (repo.IsArchived ? " archived" : ""));
}
return ExitCodes.Success;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
log.Info("repos canceled.");
throw;
}
catch (Exception ex)
{
log.Error($"repos failed: {ex.Message}", ex);
throw;
}
}
}
+170 -48
View File
@@ -30,7 +30,15 @@ internal static class SyncCommand
account's values; a token option overrides the
stored token. On completion the run's time and
summary are recorded on the account.
--parallel <N> Maximum simultaneous git operations (default 8).
--include <glob> Only repositories whose name matches the pattern
('*' and '?' wildcards, case-insensitive). Repeatable;
a repository is selected when any include matches.
--exclude <glob> Skip repositories whose name matches. Repeatable;
applied after --include.
--skip-archived Skip archived repositories.
--dry-run List what the run would do ('<name> would clone' or
'would update') and exit 0 without touching anything.
--parallel <N> Maximum simultaneous git operations, 1-64 (default 8).
--sanitize-paths When a repository contains paths that are legal in
git but invalid on Windows (reserved device names,
characters like ':', trailing dots or spaces),
@@ -44,8 +52,15 @@ internal static class SyncCommand
--token-file <path> Read the token from the first non-blank line of a file.
--token-stdin Read the token as one line from standard input.
--json Print no progress lines; end with one line of JSON:
{"total":N,"cloned":N,"updated":N,"failed":N,"canceled":N,
"wasCanceled":bool,"failures":[{"repo":"...","error":"..."}]}
{"type":"summary","total":N,"cloned":N,"updated":N,
"failed":N,"canceled":N,"wasCanceled":bool,
"failures":[{"repo":"...","error":"...",
"invalidPaths":null|[{"path","reason","suggestedName"}]}],
"sanitized":[{"repo":"...","renamed":N,"skipped":N,
"skippedPaths":["..."]}]}
--json-lines Print one JSON object per status transition on stdout
({"type":"progress","repo","status","error"}), then the
same summary object as --json as the last line.
--quiet Suppress per-repository progress lines on stdout.
Failures still print to stderr and the summary still prints.
--help Show this help.
@@ -64,10 +79,15 @@ internal static class SyncCommand
repositories are marked Canceled, and the summary still prints.
Exit codes:
0 every repository synced
1 run completed but some repositories failed, or it was canceled
2 fatal: bad arguments, missing or rejected token, organization not
found, or an unknown account or missing account token
0 every selected repository synced (or --dry-run completed)
1 run completed but some repositories failed, or it was canceled
2 fatal: bad arguments, missing token, organization not found, unknown
account or missing account token, unusable target folder
3 the token was rejected (401) or may not see the organization (403):
fix the token, do not retry as is
4 rate limited or temporarily throttled: retry later
70 an unexpected error inside gclo; see the activity log
With --json or --json-lines, exits 2-70 print no document on stdout.
Security:
There is deliberately no '--token <value>' option: process command lines
@@ -97,8 +117,11 @@ internal static class SyncCommand
string? accountName = null;
int? parallel = null;
bool json = false;
bool jsonLines = false;
bool quiet = false;
bool sanitizePaths = false;
bool dryRun = false;
var filter = new RepoFilterSpec();
var tokenOptions = new TokenOptions();
var reader = new OptionReader(args);
@@ -112,7 +135,7 @@ internal static class SyncCommand
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
return 0;
return ExitCodes.Success;
case "--org":
org = reader.RequireValue();
break;
@@ -122,20 +145,31 @@ internal static class SyncCommand
case "--account":
accountName = reader.RequireValue();
break;
case "--include":
filter.Include(reader.RequireValue());
break;
case "--exclude":
filter.Exclude(reader.RequireValue());
break;
case "--skip-archived":
reader.RejectValue();
filter.SkipArchived = true;
break;
case "--dry-run":
reader.RejectValue();
dryRun = true;
break;
case "--parallel":
{
string value = reader.RequireValue();
if (!int.TryParse(value, out int parsed) || parsed < 1)
{
throw new CliUsageException($"--parallel expects a positive integer, got '{value}'.");
}
parallel = parsed;
break;
}
parallel = OptionReader.ParseParallel(reader.RequireValue());
break;
case "--json":
reader.RejectValue();
json = true;
break;
case "--json-lines":
reader.RejectValue();
jsonLines = true;
break;
case "--quiet":
reader.RejectValue();
quiet = true;
@@ -149,6 +183,12 @@ internal static class SyncCommand
}
}
if (json && jsonLines)
{
throw new CliUsageException("Use --json or --json-lines, not both.");
}
bool machineReadable = json || jsonLines;
try
{
// --account: seed org/target/parallel from the saved account. Explicit
@@ -174,39 +214,79 @@ internal static class SyncCommand
throw new CliUsageException("--target is required (or use --account).");
}
int effectiveParallel = parallel ?? AppSettings.DefaultConcurrency;
string organization = org.Trim();
string targetRoot = target.Trim();
log.Info($"sync started: org='{org}', target='{target}', parallel={effectiveParallel}, "
+ $"sanitizePaths={sanitizePaths}, json={json}, quiet={quiet}"
log.Info($"sync started: org='{organization}', target='{targetRoot}', parallel={effectiveParallel}, "
+ $"filters={filter}, dryRun={dryRun}, sanitizePaths={sanitizePaths}, json={json}, jsonLines={jsonLines}, quiet={quiet}"
+ (account is null ? "" : $", account='{account.Name}'"));
string token = ResolveToken(tokenOptions, account, vault);
var printer = new ProgressPrinter(printProgress: !quiet && !json, printFailures: !json);
var engine = new OrgSyncEngine(lister, git);
var request = new SyncRequest(org.Trim(), token, target.Trim(), effectiveParallel);
var progress = new SyncProgressHandler(printer, log, printDetails: !json, collectForSanitize: sanitizePaths);
SyncSummary summary;
// List first (the desktop app's list-select-sync shape): filters and
// --dry-run need the descriptors before anything runs, and the engine's
// subset overload takes exactly that list.
IReadOnlyList<RepoDescriptor> repositories;
bool canceledWhileListing = false;
try
{
summary = await engine
.SyncAsync(request, progress, cancellationToken)
repositories = await lister
.ListOrganizationRepositoriesAsync(organization, token, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Canceled while still listing repositories: nothing was processed yet.
summary = new SyncSummary(0, 0, 0, 0, 0, WasCanceled: true);
repositories = [];
canceledWhileListing = true;
}
catch (InvalidOperationException ex)
{
// The engine's listers translate auth failures, rate limiting, and
// unknown organizations into InvalidOperationException.
throw new CliErrorException(ex.Message, ex);
// The listers translate auth failures, rate limiting, and unknown
// organizations; the kind decides the exit code (2/3/4).
throw CliErrorException.FromEngine(ex);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
int listed = repositories.Count;
if (!filter.IsEmpty)
{
throw new CliErrorException($"Cannot use target folder '{target}': {ex.Message}", ex);
repositories = repositories.Where(filter.Matches).ToList();
}
log.Info($"sync: {listed} repositories listed, {repositories.Count} selected.");
if (dryRun)
{
return PrintDryRun(repositories, git, targetRoot, machineReadable, log);
}
OutputMode mode = jsonLines ? OutputMode.JsonLines
: json ? OutputMode.Json
: quiet ? OutputMode.Quiet
: OutputMode.Text;
var printer = new ProgressPrinter(mode);
var engine = new OrgSyncEngine(lister, git);
var request = new SyncRequest(organization, token, targetRoot, effectiveParallel);
var progress = new SyncProgressHandler(printer, log, printDetails: !machineReadable, collectForSanitize: sanitizePaths);
SyncSummary summary;
if (canceledWhileListing)
{
// Canceled while still listing repositories: nothing was processed yet.
summary = new SyncSummary(0, 0, 0, 0, 0, WasCanceled: true);
}
else
{
try
{
summary = await engine
.SyncAsync(request, repositories, progress, cancellationToken)
.ConfigureAwait(false);
}
// The engine folds cancellation into the summary itself (remaining
// repositories are marked Canceled), so only disk failures surface here.
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
{
throw new CliErrorException($"Cannot use target folder '{targetRoot}': {ex.Message}", ex);
}
}
// --sanitize-paths: repositories whose only problem was Windows-invalid
@@ -215,7 +295,7 @@ internal static class SyncCommand
int sanitized = 0;
foreach (PendingRecovery pending in progress.DrainPendingRecoveries())
{
if (await TrySanitizeAsync(git, progress, log, target.Trim(), pending, cancellationToken)
if (await TrySanitizeAsync(git, progress, printer, log, targetRoot, pending, announce: !machineReadable, cancellationToken)
.ConfigureAwait(false))
{
sanitized++;
@@ -238,11 +318,11 @@ internal static class SyncCommand
string summaryLine = $"{verb}: {clonedText}, {summary.Updated} updated, "
+ $"{summary.Failed} failed, {summary.Canceled} canceled of {summary.Total}.";
if (json)
if (machineReadable)
{
var result = new SyncJsonResult(
summary.Total, summary.Cloned, summary.Updated, summary.Failed,
summary.Canceled, summary.WasCanceled, printer.Failures);
"summary", summary.Total, summary.Cloned, summary.Updated, summary.Failed,
summary.Canceled, summary.WasCanceled, printer.Failures, printer.Sanitized);
Console.Out.WriteLine(JsonSerializer.Serialize(result, CliJsonContext.Default.SyncJsonResult));
}
else
@@ -257,7 +337,7 @@ internal static class SyncCommand
TryRecordSyncResult(store!, account, summaryLine, log);
}
return summary.Failed > 0 || summary.WasCanceled ? 1 : 0;
return summary.Failed > 0 || summary.WasCanceled ? ExitCodes.Partial : ExitCodes.Success;
}
catch (Exception ex)
{
@@ -268,6 +348,36 @@ internal static class SyncCommand
}
}
/// <summary>
/// '--dry-run': says what the run would do with each selected repository and
/// touches nothing. Exit 0 even for an empty selection.
/// </summary>
private static int PrintDryRun(
IReadOnlyList<RepoDescriptor> repositories, IGitClient git, string targetRoot, bool json, IActivityLog log)
{
var entries = repositories
.Select(r => new DryRunEntry(
r.Name,
git.IsValidRepository(Path.Combine(targetRoot, r.Name)) ? "update" : "clone"))
.ToList();
int clones = entries.Count(e => e.Action == "clone");
log.Info($"sync dry run: {clones} would clone, {entries.Count - clones} would update of {entries.Count}.");
if (json)
{
Console.Out.WriteLine(JsonSerializer.Serialize(
(IReadOnlyList<DryRunEntry>)entries, CliJsonContext.Default.IReadOnlyListDryRunEntry));
return ExitCodes.Success;
}
foreach (DryRunEntry entry in entries)
{
Console.Out.WriteLine($"{entry.Repo} would {entry.Action}");
}
Console.Out.WriteLine($"Dry run: {clones} would clone, {entries.Count - clones} would update of {entries.Count}.");
return ExitCodes.Success;
}
/// <summary>
/// The token for this run. Any explicit token option wins; otherwise a saved
/// account's token comes from Windows Credential Manager; plain runs fall back
@@ -286,9 +396,9 @@ internal static class SyncCommand
{
throw new CliErrorException(
$"Account '{account.Name}' has no token in Windows Credential Manager "
+ $"(entry 'gclo:account:{account.Id:N}'). Re-enter the token in the gclo "
+ "desktop app's account wizard, restore the credential entry manually, "
+ "or pass a token with --token-env, --token-file, or --token-stdin.");
+ $"(entry 'gclo:account:{account.Id:N}'). Re-enter the token with "
+ "'gclo accounts edit --name <name> --token-stdin' or in the desktop app's account wizard, "
+ "restore the credential entry manually, or pass a token with --token-env, --token-file, or --token-stdin.");
}
return token;
}
@@ -317,7 +427,7 @@ internal static class SyncCommand
{
IReadOnlyList<Account> all = store.GetAll();
string available = all.Count == 0
? "No accounts exist yet; create one in the gclo desktop app."
? "No accounts exist yet; create one with 'gclo accounts add' or in the gclo desktop app."
: "Available accounts: " + string.Join(", ", all.Select(a => a.Name)) + ".";
return new CliErrorException($"No account named '{name}'. {available}");
}
@@ -326,14 +436,18 @@ internal static class SyncCommand
/// Applies an automatic <see cref="PathRecovery"/> (rename to the suggested name,
/// skip what has none) to one repository that failed with Windows-invalid paths.
/// Reports the real outcome — Done or Failed — through <paramref name="progress"/>,
/// which by now forwards to the console again.
/// which by now forwards to the console again, and records the sanitized shape
/// for the JSON document. The human-readable note is only printed in the text
/// modes (<paramref name="announce"/>); the document carries it otherwise.
/// </summary>
private static async Task<bool> TrySanitizeAsync(
IGitClient git,
SyncProgressHandler progress,
ProgressPrinter printer,
IActivityLog log,
string targetRoot,
PendingRecovery pending,
bool announce,
CancellationToken cancellationToken)
{
string path = Path.Combine(targetRoot, pending.Repo);
@@ -344,10 +458,18 @@ internal static class SyncCommand
await git.ApplyRecoveryAsync(path, recovery, cancellationToken).ConfigureAwait(false);
string detail = DescribeRecovery(recovery);
Console.Error.WriteLine(
$"{pending.Repo} Sanitized {recovery.SegmentRenames.Count} renamed, "
+ $"{recovery.SkippedPaths.Count} skipped: {detail}");
if (announce)
{
Console.Error.WriteLine(
$"{pending.Repo} Sanitized {recovery.SegmentRenames.Count} renamed, "
+ $"{recovery.SkippedPaths.Count} skipped: {detail}");
}
log.Info($"{pending.Repo}: cloned with sanitized paths: {detail}");
printer.RecordSanitized(new SyncSanitized(
pending.Repo,
recovery.SegmentRenames.Count,
recovery.SkippedPaths.Count,
recovery.SkippedPaths.Order(StringComparer.Ordinal).ToList()));
progress.Report(new RepoProgress(pending.Repo, SyncStatus.Done));
return true;
}
@@ -421,7 +543,7 @@ internal static class SyncCommand
private readonly List<PendingRecovery> _pending = new();
private bool _collectForSanitize;
/// <param name="printDetails">Write offending-path lines to stderr (off for --json, which carries the reason in the document).</param>
/// <param name="printDetails">Write offending-path lines to stderr (off for the JSON modes, whose document carries the full list).</param>
/// <param name="collectForSanitize">Hold invalid-path failures back for --sanitize-paths instead of reporting them.</param>
public SyncProgressHandler(ProgressPrinter printer, IActivityLog log, bool printDetails, bool collectForSanitize)
{
+44
View File
@@ -0,0 +1,44 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"CommunityToolkit.Mvvm": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
},
"LibGit2Sharp": {
"type": "Transitive",
"resolved": "0.32.0",
"contentHash": "BL3eMVRnkWWS9zbz6n0H+iDVJ9ZK7hDeiuASnHlsFvwZYTJtDuBFRRVIaH//s4VlF0baOGvCeenXbobE1JLLiQ==",
"dependencies": {
"LibGit2Sharp.NativeBinaries": "[2.0.324]"
}
},
"LibGit2Sharp.NativeBinaries": {
"type": "Transitive",
"resolved": "2.0.324",
"contentHash": "5YZVGNuO9nYFQfzRQGPGY9fdpSsABwlF2jSaZ7ydxCfjvt5Krbj/eHbXkvvgAQzp4lkjvgDrlAuuI4KTmqhDlA=="
},
"Octokit": {
"type": "Transitive",
"resolved": "14.0.0",
"contentHash": "jGOuTH1l+TCpJH+fwYOp7USzHDuGfN1jKbLz3J2COwyn+wL08eynvpnM6rY2qkzIEXum3PN2p2QkP3BW/p9Qcw=="
},
"gclo.engine": {
"type": "Project",
"dependencies": {
"LibGit2Sharp": "[0.32.0, )",
"Octokit": "[14.0.0, )"
}
},
"gclo.viewmodels": {
"type": "Project",
"dependencies": {
"CommunityToolkit.Mvvm": "[8.4.2, )",
"gclo.Engine": "[1.0.1-dev, )"
}
}
}
}
}
@@ -29,7 +29,7 @@ public sealed class AccountWizardViewModelTests : IDisposable
public void Dispose() => GitTestHelpers.TryDeleteDirectory(_root);
private AccountWizardViewModel NewWizard(Account? existing = null, string? existingToken = null)
=> new(_store, _orgs, _defaults, existing, existingToken);
=> new(_store, _orgs, _defaults, existing, () => existingToken);
private static Account MakeAccount(string name) => new()
{
@@ -86,7 +86,7 @@ public sealed class AccountWizardViewModelTests : IDisposable
}
[Fact]
public void EditWizard_SeedsEveryFieldFromTheExistingAccount_AndItsToken()
public void EditWizard_SeedsEveryFieldFromTheExistingAccount_ButNeverItsToken()
{
var account = MakeAccount("Work") with
{
@@ -103,7 +103,7 @@ public sealed class AccountWizardViewModelTests : IDisposable
Assert.Equal("Edit account", wizard.Title);
Assert.Equal("Work", wizard.Name);
Assert.Equal("primary org", wizard.Description);
Assert.Equal("ghp_original", wizard.Token);
Assert.Equal("", wizard.Token); // the stored token is never loaded into the box (#32)
Assert.Equal("acme-inc", wizard.Organization);
Assert.Equal(@"C:\work-repos", wizard.TargetRoot);
Assert.True(wizard.CreateOrgSubfolder);
@@ -111,11 +111,35 @@ public sealed class AccountWizardViewModelTests : IDisposable
}
[Fact]
public void EditWizard_WithNoVaultToken_SeedsAnEmptyTokenBox()
public async Task EditWizard_WithNoVaultToken_CannotPassStepTwoWithAnEmptyBox()
{
var wizard = NewWizard(MakeAccount("Work"), existingToken: null);
Assert.Equal("", wizard.Token);
await AdvanceToStepAsync(wizard, 2);
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal("This account has no stored token. Enter one to continue.", wizard.TokenError);
wizard.Token = "ghp_typed";
Assert.True(await wizard.TryAdvanceAsync());
}
[Fact]
public async Task EditWizard_EmptyBox_ValidatesWithTheStoredToken_WithoutExposingIt()
{
string? tokenSeenByLister = null;
_orgs.Handler = (token, _) =>
{
tokenSeenByLister = token;
return Task.FromResult<IReadOnlyList<string>>(["me"]);
};
var wizard = NewWizard(MakeAccount("Work"), "ghp_stored");
await AdvanceToStepAsync(wizard, 2);
Assert.True(await wizard.TryAdvanceAsync());
Assert.Equal("ghp_stored", tokenSeenByLister); // fetched for the call...
Assert.Equal("", wizard.Token); // ...and still not in the box
}
[Fact]
@@ -259,11 +283,13 @@ public sealed class AccountWizardViewModelTests : IDisposable
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal(3, wizard.Step);
Assert.Equal("Choose an organization from the list, or type one.", wizard.OrganizationError);
wizard.Organization = "acme";
Assert.True(await wizard.TryAdvanceAsync());
Assert.Equal(4, wizard.Step);
Assert.True(wizard.IsLastStep);
Assert.Equal("", wizard.OrganizationError);
}
[Fact]
@@ -274,6 +300,45 @@ public sealed class AccountWizardViewModelTests : IDisposable
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal(4, wizard.Step);
Assert.Equal("Choose a target folder.", wizard.TargetError);
wizard.TargetRoot = @"C:\repos";
Assert.True(await wizard.TryAdvanceAsync());
Assert.Equal("", wizard.TargetError);
}
[Fact]
public async Task SeededWizard_CarriesTheQuickSyncConnectionOver_AndSavesItsToken()
{
var seed = new AccountWizardSeed("ghp_quick", "acme", @"C:\src", CreateOrgSubfolder: true, MaxConcurrency: 12);
var wizard = new AccountWizardViewModel(_store, _orgs, seed);
Assert.Equal(1, wizard.Step);
Assert.False(wizard.IsEditing);
Assert.Equal("", wizard.Name);
Assert.Equal("ghp_quick", wizard.Token);
Assert.Equal("acme", wizard.Organization);
Assert.Equal(@"C:\src", wizard.TargetRoot);
Assert.True(wizard.CreateOrgSubfolder);
Assert.Equal(12, wizard.MaxConcurrency);
Assert.DoesNotContain("ghp_quick", seed.ToString());
Assert.Contains("[redacted]", seed.ToString());
wizard.Name = "Saved from Quick Sync";
await AdvanceToStepAsync(wizard, 4);
Assert.True(await wizard.TryAdvanceAsync());
await wizard.SaveAsync();
var saved = Assert.Single(_store.GetAll());
Assert.Equal("acme", saved.Organization);
Assert.Equal(12, saved.MaxConcurrency);
Assert.Equal("ghp_quick", _vault.TryRetrieve(saved.Id));
}
[Fact]
public void SeededWizard_NullSeed_Throws()
{
Assert.Throws<ArgumentNullException>(() => new AccountWizardViewModel(_store, _orgs, (AccountWizardSeed)null!));
}
[Fact]
+162
View File
@@ -0,0 +1,162 @@
using gclo.Engine;
using gclo.ViewModels;
using static gclo.Engine.Tests.GitTestHelpers;
namespace gclo.Engine.Tests;
/// <summary>
/// Pins the activity-log contract of #40: every user- or app-initiated action in the
/// view-model layer writes a human-readable entry, and no entry ever contains a token.
/// (Shell-level actions — dialogs opened, update flow, settings diff — live in the UI
/// project and are covered by inspection; the log calls there are one-liners.)
/// </summary>
public sealed class ActionLoggingTests : IDisposable
{
private const string Token = "ghp_SECRET_TOKEN_VALUE_1234";
private readonly string _root =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
private readonly RecordingActivityLog _log = new();
private readonly FakeRepositoryLister _lister = new();
private readonly FakeGitClient _git = new();
private readonly FakeOrganizationLister _orgs = new();
public void Dispose() => TryDeleteDirectory(_root);
private sealed class SyncProgress(Action<RepoProgress> handler) : IProgress<RepoProgress>
{
public void Report(RepoProgress value) => handler(value);
}
private WorkspaceViewModel CreateViewModel()
=> new(_lister, _git, _orgs, handler => new SyncProgress(handler), TimeSpan.FromMilliseconds(1), _log);
private void AssertNoTokenLogged()
=> Assert.All(_log.Messages, m => Assert.DoesNotContain(Token, m));
// ---------------------------------------------------------------- workspace
[Fact]
public async Task Workspace_TokenEntry_AndOrganizationLookup_AreLogged_WithoutTheToken()
{
_orgs.Handler = (_, _) => Task.FromResult<IReadOnlyList<string>>(["me", "acme"]);
var vm = CreateViewModel();
vm.Token = Token;
await WaitUntilAsync(() => vm.Organizations.Count == 2, "lookup to finish");
Assert.Contains(_log.Messages, m => m == $"Token entered in workspace 'Quick Sync' ({Token.Length} characters).");
Assert.Contains("Organization lookup started.", _log.Messages);
Assert.Contains("Organization lookup finished: 2 organizations and accounts visible.", _log.Messages);
AssertNoTokenLogged();
vm.Token = "";
Assert.Contains("Token cleared in workspace 'Quick Sync'.", _log.Messages);
}
[Fact]
public async Task Workspace_OrganizationLookupFailure_IsLoggedAsAnError()
{
_orgs.Handler = (_, _) => Task.FromException<IReadOnlyList<string>>(new InvalidOperationException("401"));
var vm = CreateViewModel();
vm.Token = Token;
await WaitUntilAsync(() => vm.LoadErrorOpen, "lookup failure to surface");
Assert.Contains(_log.Entries, e => e.Level == "ERROR" && e.Message == "Organization lookup failed: 401");
AssertNoTokenLogged();
}
[Fact]
public async Task Workspace_RetryFailed_PathRecovery_AndFolderOpened_AreLogged()
{
_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<InvalidPathInfo> { new("bad:name.txt", "invalid on Windows", "bad_name.txt") };
_git.CloneHandler = (_, _, _, _, _) => Task.FromException(new InvalidRepositoryPathsException(invalid));
await vm.SyncCommand.ExecuteAsync(null);
Assert.True(vm.RetryFailedCommand.CanExecute(null));
await vm.RetryFailedCommand.ExecuteAsync(null);
Assert.Contains("Retrying 1 failed repositories.", _log.Messages);
// The user opens recovery and cancels it.
vm.RecoveryInteraction = _ => Task.FromResult<PathRecovery?>(null);
await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]);
Assert.Contains("alpha: path recovery requested (1 invalid paths).", _log.Messages);
Assert.Contains("alpha: path recovery canceled; the repository stays failed.", _log.Messages);
vm.NoteFolderOpened();
Assert.Contains($"Opened folder '{vm.EffectiveTargetRoot}'.", _log.Messages);
AssertNoTokenLogged();
}
// ---------------------------------------------------------------- accounts
[Fact]
public void AccountsStore_SaveUpdateDelete_AndSyncResult_AreLogged_WithoutTheToken()
{
var store = new AccountsStore(new InMemoryVault(), _root, _log);
var account = new Account { Id = Guid.NewGuid(), Name = "Work", Organization = "acme", TargetRoot = @"C:\r" };
store.Save(account, Token);
Assert.Contains("Account 'Work' added with a new token.", _log.Messages);
store.Save(account with { Description = "edited" }, null);
Assert.Contains("Account 'Work' updated (token unchanged).", _log.Messages);
store.RecordSyncResult(account.Id, DateTimeOffset.UtcNow, "1 cloned");
Assert.Contains("Account 'Work': sync result recorded (1 cloned).", _log.Messages);
store.Delete(account.Id);
Assert.Contains("Account 'Work' deleted along with its token.", _log.Messages);
var tokenless = new Account { Id = Guid.NewGuid(), Name = "Bare", Organization = "acme", TargetRoot = @"C:\r" };
store.Save(tokenless, null);
store.Delete(tokenless.Id);
Assert.Contains("Account 'Bare' deleted (it had no token).", _log.Messages);
AssertNoTokenLogged();
}
// ---------------------------------------------------------------- wizard
[Fact]
public async Task Wizard_TokenValidation_IsLogged_WithoutTheToken()
{
var store = new AccountsStore(new InMemoryVault(), _root, _log);
var defaults = new AppSettings();
_orgs.Handler = (_, _) => Task.FromResult<IReadOnlyList<string>>(["me", "acme", "labs"]);
var wizard = new AccountWizardViewModel(store, _orgs, defaults, log: _log) { Name = "Work", Token = Token };
Assert.True(await wizard.TryAdvanceAsync());
Assert.True(await wizard.TryAdvanceAsync());
Assert.Contains("Account wizard: validating the token.", _log.Messages);
Assert.Contains("Account wizard: token accepted; 3 organizations and accounts visible.", _log.Messages);
_orgs.Handler = (_, _) => Task.FromException<IReadOnlyList<string>>(new InvalidOperationException("nope"));
var rejected = new AccountWizardViewModel(store, _orgs, new AccountWizardSeed(Token, "acme", @"C:\r", false, 4), _log) { Name = "Other" };
Assert.True(await rejected.TryAdvanceAsync());
Assert.False(await rejected.TryAdvanceAsync());
Assert.Contains(_log.Entries, e => e.Level == "ERROR" && e.Message == "Account wizard: token rejected: nope");
AssertNoTokenLogged();
}
// ---------------------------------------------------------------- sync all
[Fact]
public async Task SyncAll_QueueIsLogged()
{
var coordinator = new SyncAllCoordinator(_log);
var a = CreateViewModel();
var b = CreateViewModel();
await coordinator.RunAsync([a, b], CancellationToken.None);
Assert.Contains("Sync all: queued 2 accounts: 'Quick Sync', 'Quick Sync'.", _log.Messages);
}
}
+21
View File
@@ -95,6 +95,27 @@ public sealed class FakeGitClient : IGitClient
}
}
/// <summary>
/// An <see cref="IActivityLog"/> that keeps every entry, for asserting that actions
/// are logged (#40) and that no entry ever carries a token.
/// </summary>
public sealed class RecordingActivityLog : IActivityLog
{
private readonly ConcurrentQueue<(string Level, string Message)> _entries = new();
public IReadOnlyList<(string Level, string Message)> Entries => _entries.ToArray();
public IReadOnlyList<string> Messages => _entries.Select(e => e.Message).ToArray();
public void Info(string message) => _entries.Enqueue(("INFO", message));
public void Error(string message, Exception? exception = null) => _entries.Enqueue(("ERROR", message));
public string LogDirectory => "";
public string CurrentLogFilePath => "";
}
/// <summary>An <see cref="IOrganizationLister"/> whose behavior is configured with a delegate.</summary>
public sealed class FakeOrganizationLister : IOrganizationLister
{
+98 -5
View File
@@ -21,6 +21,8 @@ public sealed class GitHubListerTests
public Func<Task<string>> CurrentUser { get; set; } = () => Task.FromResult("me");
public Func<Task<IReadOnlyList<string>>> OrgLogins { get; set; } =
() => Task.FromResult<IReadOnlyList<string>>([]);
public Func<string, Task<GitHubAccountKind>> AccountKind { get; set; } =
_ => Task.FromResult(GitHubAccountKind.User);
public Task<IReadOnlyList<GitHubRepo>> GetOrganizationRepositoriesPageAsync(string organization, int page)
=> OrgPages(organization, page);
@@ -29,6 +31,7 @@ public sealed class GitHubListerTests
=> UserPages(user, page);
public Task<string> GetCurrentUserLoginAsync() => CurrentUser();
public Task<IReadOnlyList<string>> GetOrganizationLoginsAsync() => OrgLogins();
public Task<GitHubAccountKind> GetAccountKindAsync(string login) => AccountKind(login);
}
private readonly FakeGateway _gateway = new();
@@ -59,6 +62,14 @@ public sealed class GitHubListerTests
private static RateLimitExceededException RateLimited()
=> new(new FakeResponse(HttpStatusCode.Forbidden, new RateLimit(60, 0, 1735689600)));
private static SecondaryRateLimitExceededException SecondaryRateLimited()
=> new(new FakeResponse(HttpStatusCode.Forbidden));
private static LoginAttemptsExceededException LoginAttemptsExceeded()
=> new(new FakeResponse(HttpStatusCode.Forbidden));
private static AbuseException Abuse() => new(new FakeResponse(HttpStatusCode.Forbidden));
/// <summary>Octokit's Response type is internal; its IResponse is not.</summary>
private sealed class FakeResponse(HttpStatusCode status, RateLimit? rateLimit = null) : IResponse
{
@@ -121,6 +132,59 @@ public sealed class GitHubListerTests
Assert.Equal("mine", Assert.Single(repos).Name); // login match is case-insensitive
}
[Fact]
public async Task Repos_OrgNotFound_ButItIsAnOrganizationTheTokenCannotSee_FailsInsteadOfListingPublicRepos()
{
// The worst failure mode for a mirror tool: a green sync of the 12 public
// repos of a 400-repo org. The account kind is resolved before any fallback.
_gateway.OrgPages = (_, _) => throw NotFound();
_gateway.AccountKind = _ => Task.FromResult(GitHubAccountKind.Organization);
bool userReposAsked = false;
_gateway.UserPages = (_, _) => { userReposAsked = true; return Task.FromResult<IReadOnlyList<GitHubRepo>>([Repo("public-only")]); };
var ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890"));
Assert.Contains("'acme' is an organization", ex.Message);
Assert.Contains("cannot see its repositories", ex.Message);
Assert.Equal(GitHubAccessKind.Forbidden, ex.Kind);
Assert.Contains("resource owner", ex.Message);
Assert.False(userReposAsked, "the public-repos endpoint must never be consulted for an organization");
}
[Fact]
public async Task Repos_OrgNotFound_AndNoSuchAccount_FailsWithSpellingHint()
{
_gateway.OrgPages = (_, _) => throw NotFound();
_gateway.AccountKind = _ => Task.FromResult(GitHubAccountKind.NotFound);
var ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => RepoLister().ListOrganizationRepositoriesAsync("typo", "tok-1234567890"));
Assert.Contains("neither as an organization nor as a user account", ex.Message);
Assert.Contains("Check the spelling", ex.Message);
Assert.Equal(GitHubAccessKind.NotFound, ex.Kind);
}
[Fact]
public async Task Repos_SecondaryRateLimit_TranslatesWithRetryHint()
{
_gateway.OrgPages = (_, _) => throw SecondaryRateLimited();
var ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890"));
Assert.Contains("secondary rate limit", ex.Message);
Assert.Contains("retry in", ex.Message);
Assert.IsType<SecondaryRateLimitExceededException>(ex.InnerException);
Assert.Equal(GitHubAccessKind.RateLimited, ex.Kind);
_gateway.OrgPages = (_, _) => throw Abuse();
var abuse = await Assert.ThrowsAsync<GitHubAccessException>(
() => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890"));
Assert.Contains("retry in 60 seconds", abuse.Message);
}
[Fact]
public async Task Repos_OrgNotFound_OtherUserAccount_ListsThatUsersRepos()
{
@@ -138,7 +202,7 @@ public sealed class GitHubListerTests
_gateway.OrgPages = (_, _) => throw NotFound();
_gateway.UserPages = (_, _) => throw NotFound();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
var ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => RepoLister().ListOrganizationRepositoriesAsync("ghost", "tok-1234567890"));
Assert.Contains("'ghost'", ex.Message);
@@ -150,7 +214,7 @@ public sealed class GitHubListerTests
{
_gateway.OrgPages = (_, _) => throw Unauthorized();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
var ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890"));
Assert.Contains("401", ex.Message);
@@ -162,7 +226,7 @@ public sealed class GitHubListerTests
{
_gateway.OrgPages = (_, _) => throw RateLimited();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
var ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890"));
Assert.Contains("rate limit", ex.Message);
@@ -225,12 +289,41 @@ public sealed class GitHubListerTests
Assert.Equal(["Kof"], orgs);
}
[Fact]
public async Task Orgs_SecondaryRateLimit_IsNotMistakenForAMissingScope()
{
// A throttle must surface as an error, not silently shrink the org list to
// the personal account (which reads as a permanent scope problem).
_gateway.OrgLogins = () => throw SecondaryRateLimited();
var ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => OrgLister().ListOrganizationsAsync("tok-1234567890"));
Assert.Contains("secondary rate limit", ex.Message);
_gateway.OrgLogins = () => throw Abuse();
var abuse = await Assert.ThrowsAsync<GitHubAccessException>(
() => OrgLister().ListOrganizationsAsync("tok-1234567890"));
Assert.Contains("retry in 60 seconds", abuse.Message);
}
[Fact]
public async Task Orgs_LoginAttemptsExceeded_IsNotMistakenForAMissingScope()
{
_gateway.OrgLogins = () => throw LoginAttemptsExceeded();
var ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => OrgLister().ListOrganizationsAsync("tok-1234567890"));
Assert.Contains("too many failed attempts", ex.Message);
}
[Fact]
public async Task Orgs_BadToken_TranslatesTo401Guidance()
{
_gateway.CurrentUser = () => throw Unauthorized();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
var ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => OrgLister().ListOrganizationsAsync("tok-1234567890"));
Assert.Contains("401", ex.Message);
@@ -241,7 +334,7 @@ public sealed class GitHubListerTests
{
_gateway.OrgLogins = () => throw RateLimited();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
var ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => OrgLister().ListOrganizationsAsync("tok-1234567890"));
Assert.Contains("rate limit", ex.Message);
+107 -1
View File
@@ -108,6 +108,50 @@ public sealed class LibGit2GitClientTests : IDisposable
Assert.True(repo.Config.Get<bool>("gclo.checkoutpending")?.Value);
}
[Fact]
public async Task Clone_CanceledAfterTheFetch_ThrowsOperationCanceled_AndRemovesTheTargetDirectory()
{
// The real cancellation contract: the objects arrived, the token was observed
// before checkout, and the half-made directory does not survive to be mistaken
// for a repository on the next run.
string source = CreateSourceRepo();
string target = NewPath("canceled-mid-clone");
using var cts = new CancellationTokenSource();
_client.AfterFetchForTesting = () =>
{
Assert.True(Directory.Exists(Path.Combine(target, ".git")), "the fetch had completed");
cts.Cancel();
};
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => _client.CloneAsync(source, target, Token, null, cts.Token));
Assert.False(Directory.Exists(target));
}
[Fact]
public async Task FetchAndPull_CanceledAfterTheFetch_ThrowsOperationCanceled_AndMergesNothing()
{
string source = CreateSourceRepo();
string target = NewPath("clone");
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
string tipBefore = HeadSha(target);
CommitFile(source, "update.txt", "new content", "second commit");
using var cts = new CancellationTokenSource();
_client.AfterFetchForTesting = cts.Cancel;
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => _client.FetchAndPullAsync(target, Token, cts.Token));
Assert.Equal(tipBefore, HeadSha(target));
Assert.False(File.Exists(Path.Combine(target, "update.txt")));
// The fetch itself landed: a later uncanceled pull is a plain fast-forward.
_client.AfterFetchForTesting = null;
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
Assert.True(File.Exists(Path.Combine(target, "update.txt")));
}
[Fact]
public async Task Clone_TokenAlreadyCanceled_ThrowsOperationCanceledAndCreatesNothing()
{
@@ -265,8 +309,15 @@ public sealed class LibGit2GitClientTests : IDisposable
CommitFile(source, "from-remote.txt", "remote change", "remote commit");
CommitFile(target, "from-local.txt", "local change", "local commit");
await Assert.ThrowsAsync<NonFastForwardException>(
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
// Actionable, not libgit2's "cannot fast-forward": names the branch, says what
// gclo refuses to do, and gives the three ways out (#30).
Assert.Contains("local commits that origin does not have", ex.Message);
Assert.Contains("never merges", ex.Message);
Assert.Contains("re-clone", ex.Message);
Assert.IsType<NonFastForwardException>(ex.InnerException);
}
[Fact]
@@ -286,6 +337,61 @@ public sealed class LibGit2GitClientTests : IDisposable
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
Assert.Contains("detached", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Check out a branch", ex.Message);
}
[Fact]
public async Task FetchAndPull_UpstreamDefaultBranchRenamed_FollowsTheNewBranch()
{
// The routine master -> main migration: the tracked remote branch disappears,
// and a mirror must follow origin's new default instead of saying "up to
// date" forever against a pruned ref (#31).
string source = CreateSourceRepo();
string target = NewPath("clone");
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
string oldName;
using (var src = new Repository(source))
{
oldName = src.Head.FriendlyName;
src.Branches.Rename(src.Head, "renamed-main");
src.Refs.UpdateTarget("HEAD", "refs/heads/renamed-main");
}
string newSha = CommitFile(source, "after-rename.txt", "after", "commit on the renamed branch");
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
using var after = new Repository(target);
Assert.Equal("renamed-main", after.Head.FriendlyName);
Assert.Equal(newSha, after.Head.Tip.Sha);
Assert.Equal("origin/renamed-main", after.Head.TrackedBranch!.FriendlyName);
Assert.Equal("after", File.ReadAllText(Path.Combine(target, "after-rename.txt")));
Assert.Null(after.Branches[oldName]); // the orphaned local branch is gone
Assert.Null(after.Branches["origin/" + oldName]); // and so is its pruned tracking ref
// The next pull is an ordinary up-to-date pull on the new branch.
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
Assert.Equal(newSha, HeadSha(target));
}
[Fact]
public async Task FetchAndPull_UpstreamBranchRenamed_ButLocalHasOwnCommits_RefusesToDiscardThem()
{
string source = CreateSourceRepo();
string target = NewPath("clone");
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
string localSha = CommitFile(target, "local.txt", "mine", "local work");
using (var src = new Repository(source))
{
src.Branches.Rename(src.Head, "renamed-main");
src.Refs.UpdateTarget("HEAD", "refs/heads/renamed-main");
}
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
Assert.Contains("local commits", ex.Message);
Assert.Contains("never discards local work", ex.Message);
Assert.Equal(localSha, HeadSha(target)); // untouched
}
[Fact]
+44
View File
@@ -187,6 +187,50 @@ public sealed class OrgSyncEngineTests : IDisposable
}
}
[Fact]
public async Task Sync_Cancellation_DoesNotWaitForAGitOperationThatIgnoresTheToken()
{
// A wedged native transport observes no callback and so no cancellation; the
// engine must still honor Cancel promptly by abandoning the await (#31).
_lister.Repositories = Repos("stuck", "queued");
var neverDone = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_git.CloneHandler = (_, _, _, _, _) => neverDone.Task; // ignores ct entirely
using var cts = new CancellationTokenSource();
var syncTask = CreateEngine().SyncAsync(CreateRequest(maxConcurrency: 1), _progress, cts.Token);
await WaitUntilAsync(() => _git.CloneCalls.Count == 1, "the stuck clone to start");
cts.Cancel();
var summary = await syncTask.WaitAsync(TimeSpan.FromSeconds(10));
Assert.True(summary.WasCanceled);
Assert.Equal(new SyncSummary(Total: 2, Cloned: 0, Updated: 0, Failed: 0, Canceled: 2, WasCanceled: true), summary);
Assert.Equal(SyncStatus.Canceled, _progress.LastFor("stuck")!.Status);
Assert.Equal(SyncStatus.Canceled, _progress.LastFor("queued")!.Status);
neverDone.SetResult(); // let the orphan finish; nothing may change
}
[Fact]
public async Task Sync_RepoNamedLikeAWindowsDevice_FailsThatRowWithoutTouchingGit()
{
if (!OperatingSystem.IsWindows())
{
return; // the folder-name rule is a Windows one
}
_lister.Repositories = Repos("alpha", "aux", "bravo");
var summary = await CreateEngine().SyncAsync(CreateRequest(), _progress);
Assert.Equal(new SyncSummary(Total: 3, Cloned: 2, Updated: 0, Failed: 1, Canceled: 0, WasCanceled: false), summary);
var failed = _progress.LastFor("aux")!;
Assert.Equal(SyncStatus.Failed, failed.Status);
Assert.Contains("reserved Windows device name", failed.Error);
Assert.Contains("Rename the repository", failed.Error);
Assert.Null(failed.InvalidPaths); // not a path-recovery case: there is nothing to resolve
Assert.DoesNotContain("aux", _git.ClonedRepoNames);
Assert.DoesNotContain(_git.ValidityChecks, p => Path.GetFileName(p) == "aux");
}
// ---------------------------------------------------------------- (e)
[Fact]
@@ -0,0 +1,83 @@
using gclo.ViewModels;
using static gclo.ViewModels.PathRecoveryPlanner;
namespace gclo.Engine.Tests;
/// <summary>
/// <see cref="PathRecoveryPlanner"/> is the pre-flight behind the recovery dialog's
/// "Apply and check out": it must build exactly the recovery the engine expects and
/// name every choice that would make the apply fail.
/// </summary>
public sealed class PathRecoveryPlannerTests
{
[Fact]
public void Build_RenamesAndSkips_ProduceTheRecovery_WithNoProblems()
{
var plan = Build(
[
new Choice("bad:name.txt", "bad_name.txt", Skip: false),
new Choice("dir/CON", "dir/CON_", Skip: false),
new Choice("Readme.md", null, Skip: true),
]);
Assert.Empty(plan.Problems);
Assert.Equal("bad_name.txt", plan.Recovery.SegmentRenames["bad:name.txt"]);
Assert.Equal("dir/CON_", plan.Recovery.SegmentRenames["dir/CON"]);
Assert.Equal(["Readme.md"], plan.Recovery.SkippedPaths);
}
[Fact]
public void Build_UnchangedRowThatIsNotSkipped_IsAProblem()
{
var plan = Build([new Choice("Readme.md", null, Skip: false)]);
string problem = Assert.Single(plan.Problems);
Assert.Contains("'Readme.md' is still invalid", problem);
Assert.Empty(plan.Recovery.SegmentRenames);
Assert.Empty(plan.Recovery.SkippedPaths);
}
[Fact]
public void Build_ReplacementThatIsItselfInvalid_IsAProblem()
{
var plan = Build([new Choice("bad:name.txt", "still:bad.txt", Skip: false)]);
string problem = Assert.Single(plan.Problems);
Assert.Contains("'still:bad.txt' cannot be used", problem);
Assert.Contains("invalid on Windows", problem);
}
[Fact]
public void Build_TwoRowsRenamedOntoOneDestination_IsAProblem()
{
var plan = Build(
[
new Choice("a:b.txt", "a_b.txt", Skip: false),
new Choice("a?b.txt", "a_b.txt", Skip: false),
]);
string problem = Assert.Single(plan.Problems);
Assert.Contains("more than one path maps to this destination", problem);
}
[Fact]
public void Build_SamePathInTwoRows_LastRenameWins_AndSkipTrumpsNothing()
{
// The validator can list one path twice (invalid segment AND case collision);
// the dialog shows two rows, and the recovery must still be a single mapping.
var plan = Build(
[
new Choice("x:y.txt", "x_y.txt", Skip: false),
new Choice("x:y.txt", "x-y.txt", Skip: false),
]);
Assert.Empty(plan.Problems);
Assert.Equal("x-y.txt", Assert.Single(plan.Recovery.SegmentRenames).Value);
}
[Fact]
public void Build_Null_Throws()
{
Assert.Throws<ArgumentNullException>(() => Build(null!));
}
}
+85
View File
@@ -203,6 +203,91 @@ public sealed class PathRecoveryTests : IDisposable
Assert.Contains("worse_new.txt", json);
}
// ---------------------------------------------------------------- persistence hardening (#31)
[Fact]
public async Task FetchAndPull_UpstreamDeletedAFile_RemovesItFromTheMaterializedTree()
{
string source = CreateForgedRepo(_root, ("bad:file.txt", "bad"), ("gone.txt", "going"), ("keep.txt", "keep"));
string target = await CloneExpectingInvalidPathsAsync(source, "deletion-pass");
await _client.ApplyRecoveryAsync(target, Recovery(renames: [("bad:file.txt", "bad_file.txt")]), CancellationToken.None);
Assert.True(File.Exists(Path.Combine(target, "gone.txt")));
ReplaceForgedEntry(source, remove: "gone.txt", add: ("new/nested.txt", "new"));
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
// A real checkout removes what left the tree; the materialization must too.
Assert.False(File.Exists(Path.Combine(target, "gone.txt")));
Assert.Equal("new", File.ReadAllText(Path.Combine(target, "new", "nested.txt")));
Assert.Equal("keep", File.ReadAllText(Path.Combine(target, "keep.txt")));
// And a directory emptied by a later deletion is pruned too.
ReplaceForgedEntry(source, remove: "new/nested.txt", add: ("other.txt", "o"));
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
Assert.False(Directory.Exists(Path.Combine(target, "new")));
}
[Fact]
public async Task FetchAndPull_RecoveryManagedRepoAlreadyAtTip_DoesNotRewriteTheTree()
{
string source = CreateForgedRepo(_root, ("bad:file.txt", "bad"), ("keep.txt", "keep"));
string target = await CloneExpectingInvalidPathsAsync(source, "no-rewrite");
await _client.ApplyRecoveryAsync(target, Recovery(renames: [("bad:file.txt", "bad_file.txt")]), CancellationToken.None);
// A local edit is the witness: an unchanged tip must leave the tree alone.
File.WriteAllText(Path.Combine(target, "keep.txt"), "locally edited");
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
Assert.Equal("locally edited", File.ReadAllText(Path.Combine(target, "keep.txt")));
// A new upstream commit re-materializes through the mapping as before.
ReplaceForgedEntry(source, remove: "keep.txt", add: ("keep.txt", "upstream edit"));
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
Assert.Equal("upstream edit", File.ReadAllText(Path.Combine(target, "keep.txt")));
}
[Fact]
public async Task CorruptRecoveryFile_FailsWithAnActionableMessage_NotARawJsonError()
{
string source = CreateForgedRepo(_root, ("bad:file.txt", "bad"));
string target = await CloneExpectingInvalidPathsAsync(source, "corrupt-json");
await _client.ApplyRecoveryAsync(target, Recovery(renames: [("bad:file.txt", "bad_file.txt")]), CancellationToken.None);
string file = RecoveryFilePath(target);
File.WriteAllText(file, "{ \"SegmentRenames\": { truncated");
var pullError = await Assert.ThrowsAsync<InvalidOperationException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
Assert.Contains("corrupt", pullError.Message);
Assert.Contains(file, pullError.Message);
Assert.Contains("Delete it", pullError.Message);
var applyError = await Assert.ThrowsAsync<InvalidOperationException>(
() => _client.ApplyRecoveryAsync(target, Recovery(renames: [("bad:file.txt", "bad_file.txt")]), CancellationToken.None));
Assert.Contains("corrupt", applyError.Message);
// "null" is a corrupt document too.
File.WriteAllText(file, "null");
var nullError = await Assert.ThrowsAsync<InvalidOperationException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
Assert.Contains("empty", nullError.Message);
}
[Fact]
public async Task SaveRecovery_WritesAtomically_LeavingNoTempFile_AndRecordsManifestAndTip()
{
string source = CreateForgedRepo(_root, ("bad:file.txt", "bad"), ("dir/keep.txt", "k"));
string target = await CloneExpectingInvalidPathsAsync(source, "atomic");
await _client.ApplyRecoveryAsync(target, Recovery(renames: [("bad:file.txt", "bad_file.txt")]), CancellationToken.None);
string file = RecoveryFilePath(target);
Assert.False(File.Exists(file + ".tmp"));
string json = File.ReadAllText(file);
Assert.Contains("\"MaterializedPaths\"", json);
Assert.Contains("bad_file.txt", json);
Assert.Contains("dir/keep.txt", json);
Assert.Contains(HeadSha(target), json);
}
// ---------------------------------------------------------------- pull semantics
[Fact]
@@ -156,6 +156,122 @@ public sealed class WindowsPathValidationTests : IDisposable
Assert.Equal("deep content", File.ReadAllText(fullPath));
}
// ---------------------------------------------------------------- ValidateIncoming (#30)
[Fact]
public async Task ValidateIncoming_ReportsOnlyWhatThePullIntroduces()
{
// The current tree is already checked out (hence valid); a pull that adds one
// bad path must report exactly that path, and must not re-examine the rest.
string source = CreateForgedRepo(_root, ("ok.txt", "fine"), ("dir/also-ok.txt", "fine"));
string clone = NewPath("clone");
await new LibGit2GitClient().CloneAsync(source, clone, "t", null, CancellationToken.None);
AppendForgedCommit(source, ("dir/bad:name.txt", "x"), ("ok.txt", "modified"));
using var repo = new Repository(clone);
Commands.Fetch(repo, "origin", Array.Empty<string>(), null, null);
var invalid = WindowsPathValidator.ValidateIncoming(repo, repo.Head.Tip.Tree, repo.Head.TrackedBranch.Tip.Tree);
var info = Assert.Single(invalid);
Assert.Equal("dir/bad:name.txt", info.RepoPath);
Assert.Equal("bad_name.txt", info.SuggestedName);
}
[Fact]
public async Task ValidateIncoming_ModifiedFilesOnly_ReturnsEmpty()
{
string source = CreateForgedRepo(_root, ("ok.txt", "fine"));
string clone = NewPath("clone");
await new LibGit2GitClient().CloneAsync(source, clone, "t", null, CancellationToken.None);
AppendForgedCommit(source, ("ok.txt", "modified"));
using var repo = new Repository(clone);
Commands.Fetch(repo, "origin", Array.Empty<string>(), null, null);
Assert.Empty(WindowsPathValidator.ValidateIncoming(repo, repo.Head.Tip.Tree, repo.Head.TrackedBranch.Tip.Tree));
}
[Fact]
public async Task ValidateIncoming_NewPathDifferingOnlyByCaseFromAnExistingOne_IsFlagged()
{
string source = CreateForgedRepo(_root, ("docs/readme.md", "a"));
string clone = NewPath("clone");
await new LibGit2GitClient().CloneAsync(source, clone, "t", null, CancellationToken.None);
// Git is happy to hold both; NTFS is not.
AppendForgedCommit(source, ("Docs/README.md", "b"));
using var repo = new Repository(clone);
Commands.Fetch(repo, "origin", Array.Empty<string>(), null, null);
var invalid = WindowsPathValidator.ValidateIncoming(repo, repo.Head.Tip.Tree, repo.Head.TrackedBranch.Tip.Tree);
// The directory collides first; the file under it is reported once each.
Assert.Contains(invalid, i => i.RepoPath == "Docs" && i.Reason.Contains("'docs'"));
Assert.Contains(invalid, i => i.RepoPath == "Docs/README.md" && i.Reason.Contains("'docs/readme.md'"));
}
[Fact]
public async Task ValidateIncoming_CaseChangeViaDeleteAndAdd_IsAllowed()
{
// Upstream renamed 'readme.md' to 'README.md' (delete + add in one commit): the
// old spelling leaves the disk with the pull, so the new one does not collide.
string source = CreateForgedRepo(_root, ("readme.md", "a"), ("keep.txt", "k"));
string clone = NewPath("clone");
await new LibGit2GitClient().CloneAsync(source, clone, "t", null, CancellationToken.None);
using (var src = new Repository(source))
{
var head = src.Head.Tip;
var definition = TreeDefinition.From(head.Tree);
definition.Remove("readme.md");
definition.Add("README.md", src.ObjectDatabase.CreateBlob(new MemoryStream("different"u8.ToArray())), Mode.NonExecutableFile);
var sig = MakeSignature();
var commit = src.ObjectDatabase.CreateCommit(sig, sig, "recase", src.ObjectDatabase.CreateTree(definition), [head], false);
src.Refs.UpdateTarget(src.Refs.Head.ResolveToDirectReference(), commit.Id);
}
using var repo = new Repository(clone);
Commands.Fetch(repo, "origin", Array.Empty<string>(), null, null);
Assert.Empty(WindowsPathValidator.ValidateIncoming(repo, repo.Head.Tip.Tree, repo.Head.TrackedBranch.Tip.Tree));
}
[Fact]
public async Task ValidateIncoming_DetectedRename_ChecksTheNewPathAndReleasesTheOld()
{
// Same content under a new name is a rename to libgit2: the new path is
// validated (and here is invalid), and the old path no longer counts as
// occupying its spelling.
string source = CreateForgedRepo(_root, ("old-name.txt", "identical content for rename detection"), ("keep.txt", "k"));
string clone = NewPath("clone");
await new LibGit2GitClient().CloneAsync(source, clone, "t", null, CancellationToken.None);
using (var src = new Repository(source))
{
var head = src.Head.Tip;
var definition = TreeDefinition.From(head.Tree);
var blob = head.Tree["old-name.txt"].Target as Blob;
definition.Remove("old-name.txt");
definition.Add("new:name.txt", blob!, Mode.NonExecutableFile);
var sig = MakeSignature();
var commit = src.ObjectDatabase.CreateCommit(sig, sig, "rename", src.ObjectDatabase.CreateTree(definition), [head], false);
src.Refs.UpdateTarget(src.Refs.Head.ResolveToDirectReference(), commit.Id);
}
using var repo = new Repository(clone);
Commands.Fetch(repo, "origin", Array.Empty<string>(), null, null);
var invalid = WindowsPathValidator.ValidateIncoming(repo, repo.Head.Tip.Tree, repo.Head.TrackedBranch.Tip.Tree);
Assert.Equal("new:name.txt", Assert.Single(invalid).RepoPath);
}
[Fact]
public void ValidateIncoming_NullArguments_Throw()
{
string path = NewPath("empty");
Repository.Init(path);
using var repo = new Repository(path);
Assert.Throws<ArgumentNullException>(() => WindowsPathValidator.ValidateIncoming(null!, null!, null!));
Assert.Throws<ArgumentNullException>(() => WindowsPathValidator.ValidateIncoming(repo, null!, null!));
}
[Fact]
public async Task FetchAndPull_IncomingCommitWithInvalidPath_ThrowsTypedException()
{
+311 -31
View File
@@ -9,12 +9,27 @@ namespace gclo.Engine.Tests;
/// (the production default, <see cref="Progress{T}"/>, posts asynchronously and would
/// make assertions racy) and a near-zero org-lookup debounce.
/// </summary>
public sealed class WorkspaceViewModelTests
public sealed class WorkspaceViewModelTests : IDisposable
{
private readonly FakeRepositoryLister _lister = new();
private readonly FakeGitClient _git = new();
private readonly FakeOrganizationLister _orgs = new();
/// <summary>Every view model the test created, disposed and cleaned up after it — pass or fail.</summary>
private readonly List<WorkspaceViewModel> _created = new();
public void Dispose()
{
foreach (WorkspaceViewModel vm in _created)
{
vm.Dispose();
if (vm.TargetFolder.Length > 0 && Path.IsPathRooted(vm.TargetFolder))
{
TryDeleteDirectory(vm.TargetFolder);
}
}
}
/// <summary>Synchronous progress: handler runs inline on the reporting thread.</summary>
private sealed class SyncProgress(Action<RepoProgress> handler) : IProgress<RepoProgress>
{
@@ -37,11 +52,17 @@ public sealed class WorkspaceViewModelTests
Account? account = null,
ITokenVault? vault = null,
AccountsStore? store = null)
=> new(_lister, _git, _orgs,
=> Track(new WorkspaceViewModel(_lister, _git, _orgs,
handler => new SyncProgress(handler),
debounce ?? TimeSpan.FromMilliseconds(1),
new NullActivityLog(),
account, vault, store);
account, vault, store));
private WorkspaceViewModel Track(WorkspaceViewModel vm)
{
_created.Add(vm);
return vm;
}
/// <summary>
/// A view model with valid inputs and the given repositories already loaded into the
@@ -138,6 +159,11 @@ public sealed class WorkspaceViewModelTests
Assert.False(vm.IsLoadingRepos);
Assert.True(vm.LoadReposCommand.CanExecute(null));
Assert.False(vm.SyncCommand.CanExecute(null));
// The failure gets a real error surface, not just the caption live region (#30).
Assert.True(vm.LoadErrorOpen);
Assert.Equal("Could not load repositories", vm.LoadErrorTitle);
Assert.Equal("org not found", vm.LoadErrorMessage);
Assert.Null(vm.LoadedOrganization);
}
// ---------------------------------------------------------------- connect card state
@@ -198,7 +224,6 @@ public sealed class WorkspaceViewModelTests
await run;
Assert.True(vm.CanEditInputs);
Directory.Delete(vm.TargetFolder, recursive: true);
}
// ---------------------------------------------------------------- sync flow
@@ -224,7 +249,6 @@ public sealed class WorkspaceViewModelTests
Assert.True(vm.HasCompletedRun);
Assert.False(vm.IsRunning);
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
@@ -250,7 +274,6 @@ public sealed class WorkspaceViewModelTests
Assert.Contains("1 updated", vm.StatusText);
Assert.Contains("1 failed", vm.StatusText);
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
@@ -272,7 +295,6 @@ public sealed class WorkspaceViewModelTests
Assert.Contains("failed", announcement);
Assert.Contains("boom", announcement);
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
@@ -286,7 +308,6 @@ public sealed class WorkspaceViewModelTests
Assert.Empty(announcements);
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
@@ -323,7 +344,6 @@ public sealed class WorkspaceViewModelTests
r.Status is SyncStatus.Canceled or SyncStatus.Done or SyncStatus.Failed,
$"{r.Name} left in non-terminal state {r.Status}"));
Directory.Delete(vm.TargetFolder, recursive: true);
}
// ---------------------------------------------------------------- run-scoped progress
@@ -346,7 +366,6 @@ public sealed class WorkspaceViewModelTests
Assert.Equal(3, vm.TotalCount);
Assert.Equal(0, vm.CompletedCount);
Directory.Delete(vm.TargetFolder, recursive: true);
}
// ---------------------------------------------------------------- active strip
@@ -384,7 +403,6 @@ public sealed class WorkspaceViewModelTests
Assert.Empty(vm.ActiveRepos); // cleared when the run ends
Assert.All(vm.Repos, r => Assert.Equal(SyncStatus.Done, r.Status));
Directory.Delete(vm.TargetFolder, recursive: true);
}
// ---------------------------------------------------------------- retry failed
@@ -419,7 +437,6 @@ public sealed class WorkspaceViewModelTests
Assert.Equal(1, vm.CompletedCount);
Assert.False(vm.RetryFailedCommand.CanExecute(null)); // nothing failed anymore
Directory.Delete(vm.TargetFolder, recursive: true);
}
// ---------------------------------------------------------------- run results
@@ -437,7 +454,6 @@ public sealed class WorkspaceViewModelTests
Assert.Equal(RunResultKind.Success, vm.ResultKind);
Assert.Contains("2 cloned", vm.ResultMessage);
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
@@ -455,7 +471,6 @@ public sealed class WorkspaceViewModelTests
Assert.Equal(RunResultKind.PartialFailure, vm.ResultKind);
Assert.Contains("1 failed", vm.ResultMessage);
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
@@ -482,7 +497,6 @@ public sealed class WorkspaceViewModelTests
Assert.Equal(RunResultKind.Canceled, vm.ResultKind);
Assert.StartsWith("Canceled", vm.ResultMessage);
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
@@ -538,7 +552,6 @@ public sealed class WorkspaceViewModelTests
Assert.True(vm.ResultOpen);
Assert.Equal(RunResultKind.Success, vm.ResultKind);
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
@@ -554,7 +567,6 @@ public sealed class WorkspaceViewModelTests
Assert.Equal(RunResultKind.None, vm.ResultKind);
Assert.Equal("", vm.ResultMessage);
Directory.Delete(vm.TargetFolder, recursive: true);
}
// ---------------------------------------------------------------- path recovery
@@ -592,7 +604,6 @@ public sealed class WorkspaceViewModelTests
Assert.False(vm.ResolvePathsCommand.CanExecute(byName["bravo"])); // no path issue
Assert.False(vm.ResolvePathsCommand.CanExecute(null));
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
@@ -627,7 +638,6 @@ public sealed class WorkspaceViewModelTests
Assert.False(vm.ResolvePathsCommand.CanExecute(row)); // nothing left to resolve
Assert.False(vm.RetryFailedCommand.CanExecute(null)); // nothing failed anymore
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
@@ -655,7 +665,6 @@ public sealed class WorkspaceViewModelTests
Assert.Equal(SyncStatus.Done, row.Status);
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
@@ -674,7 +683,6 @@ public sealed class WorkspaceViewModelTests
Assert.True(row.HasPathIssue); // payload kept for another attempt
Assert.True(vm.ResolvePathsCommand.CanExecute(row));
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
@@ -701,7 +709,6 @@ public sealed class WorkspaceViewModelTests
Assert.Equal(stillInvalid, row.InvalidPaths); // the fresh list, ready for another round
Assert.True(vm.ResolvePathsCommand.CanExecute(row));
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
@@ -722,7 +729,6 @@ public sealed class WorkspaceViewModelTests
Assert.Equal("disk full", row.Error);
Assert.False(row.HasPathIssue); // renaming again would not help
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
@@ -755,7 +761,6 @@ public sealed class WorkspaceViewModelTests
await run;
Assert.True(vm.ResolvePathsCommand.CanExecute(alpha)); // unlocked again
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
@@ -775,11 +780,132 @@ public sealed class WorkspaceViewModelTests
Assert.False(row.HasPathIssue);
Assert.False(vm.ResolvePathsCommand.CanExecute(row));
Directory.Delete(vm.TargetFolder, recursive: true);
}
// ---------------------------------------------------------------- load errors and stale tables (#30)
[Fact]
public async Task LoadRepos_StartingALoad_ClosesThePreviousLoadError()
{
_lister.ExceptionToThrow = new InvalidOperationException("boom");
var vm = CreateViewModel();
vm.Organization = "acme";
vm.Token = "token-1234567890";
await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle");
await vm.LoadReposCommand.ExecuteAsync(null);
Assert.True(vm.LoadErrorOpen);
_lister.ExceptionToThrow = null;
_lister.Repositories = Repos("alpha");
await vm.LoadReposCommand.ExecuteAsync(null);
Assert.False(vm.LoadErrorOpen);
Assert.Equal("acme", vm.LoadedOrganization);
Assert.True(vm.HasLoadedRepos);
}
[Fact]
public async Task OrgLookupFailure_OpensTheErrorSurface_AndASuccessfulLookupClosesIt()
{
_orgs.Handler = (_, _) => Task.FromException<IReadOnlyList<string>>(
new InvalidOperationException("GitHub rejected the token (401). Check the PAT."));
var vm = CreateViewModel();
vm.Token = "token-1234567890";
await WaitUntilAsync(() => vm.LoadErrorOpen, "lookup failure to surface");
Assert.Equal("Could not list organizations", vm.LoadErrorTitle);
Assert.Contains("401", vm.LoadErrorMessage);
Assert.Equal(vm.LoadErrorMessage, vm.StatusText);
_orgs.Handler = (_, _) => Task.FromResult<IReadOnlyList<string>>(["me", "acme"]);
vm.Token = "token-1234567890-fixed";
await WaitUntilAsync(() => vm.Organizations.Count == 2, "lookup to succeed");
Assert.False(vm.LoadErrorOpen);
}
[Fact]
public async Task EditingTheOrganizationAfterALoad_MarksTheTableStale_AndBlocksSync()
{
var vm = await CreateLoadedViewModelAsync(Repo("alpha"));
Assert.False(vm.IsTableStale);
Assert.Equal("", vm.StaleTableMessage);
Assert.True(vm.SyncCommand.CanExecute(null));
var raised = new List<string?>();
vm.PropertyChanged += (_, e) => raised.Add(e.PropertyName);
vm.Organization = "acme-labs";
Assert.True(vm.IsTableStale);
Assert.Contains("'acme'", vm.StaleTableMessage);
Assert.Contains("'acme-labs'", vm.StaleTableMessage);
Assert.False(vm.SyncCommand.CanExecute(null), "syncing a stale table would clone the wrong org");
Assert.Contains(nameof(WorkspaceViewModel.IsTableStale), raised);
Assert.Contains(nameof(WorkspaceViewModel.StaleTableMessage), raised);
// Putting it back (case and whitespace are not a change) clears the state...
vm.Organization = " ACME ";
Assert.False(vm.IsTableStale);
Assert.True(vm.SyncCommand.CanExecute(null));
// ...and so does reloading under the new name.
vm.Organization = "acme-labs";
await vm.LoadReposCommand.ExecuteAsync(null);
Assert.Equal("acme-labs", vm.LoadedOrganization);
Assert.False(vm.IsTableStale);
Assert.True(vm.SyncCommand.CanExecute(null));
}
[Fact]
public void BeforeAnyLoad_TheTableIsNeverStale()
{
var vm = CreateViewModel();
vm.Organization = "anything";
Assert.False(vm.IsTableStale);
}
// ---------------------------------------------------------------- selection
[Fact]
public async Task AllSelected_ActsOnVisibleRowsOnly_AndReflectsThem()
{
var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie"));
vm.NameFilter = "al"; // alpha only
Assert.Equal(["alpha"], vm.FilteredRepos.Select(r => r.Name));
Assert.True(vm.AllSelected);
vm.AllSelected = false;
// Only the visible row changed; hidden rows keep their selection and still sync.
Assert.False(vm.Repos[0].IsSelected);
Assert.True(vm.Repos[1].IsSelected);
Assert.True(vm.Repos[2].IsSelected);
Assert.Equal(2, vm.SelectedCount);
Assert.True(vm.SyncCommand.CanExecute(null));
// The header reflects the visible set as the filter changes.
vm.NameFilter = "";
Assert.False(vm.AllSelected);
vm.NameFilter = "br";
Assert.True(vm.AllSelected);
vm.AllSelected = true; // no-op on the visible row (already selected)
Assert.False(vm.Repos[0].IsSelected);
}
[Fact]
public async Task AllSelected_HeaderPush_ReQueriesSyncOncePerBatch()
{
var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie"), Repo("delta"));
int canExecuteRaised = 0;
vm.SyncCommand.CanExecuteChanged += (_, _) => canExecuteRaised++;
vm.AllSelected = false;
// One notification for the whole batch, not one per row (#30).
Assert.Equal(1, canExecuteRaised);
}
[Fact]
public async Task AllSelected_TogglesEveryRow_AndFollowsItemChanges()
{
@@ -849,6 +975,26 @@ public sealed class WorkspaceViewModelTests
Assert.False(vm.SortDescending); // direction resets on a new column
}
[Fact]
public async Task Sort_KeepsFilteredReposInTheNewOrder_AndMembershipUpdatesStillLandInPlace()
{
var vm = await CreateLoadedViewModelAsync(Repo("charlie"), Repo("alpha"), Repo("bravo"));
vm.SortCommand.Execute("Name");
Assert.Equal(["alpha", "bravo", "charlie"], vm.FilteredRepos.Select(r => r.Name));
// A progress-driven membership change after a sort must respect the sorted
// order (the position map is rebuilt by Sort).
vm.Filter = RepoFilter.Failed;
Assert.Empty(vm.FilteredRepos);
_git.CloneHandler = (_, path, _, _, _) =>
Path.GetFileName(path) is "charlie" or "alpha"
? Task.FromException(new InvalidOperationException("boom"))
: Task.CompletedTask;
await vm.SyncCommand.ExecuteAsync(null);
Assert.Equal(["alpha", "charlie"], vm.FilteredRepos.Select(r => r.Name));
}
[Fact]
public async Task Sort_ByStatus_OrdersByLifecycle_AndIsStable()
{
@@ -899,6 +1045,93 @@ public sealed class WorkspaceViewModelTests
Assert.Equal(["alpha", "bravo", "charlie", "delta"], vm.FilteredRepos.Select(r => r.Name));
}
[Fact]
public async Task ProgressTransitions_UpdateFilteredReposInPlace_WithoutAReset()
{
var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie"), Repo("delta"));
vm.Filter = RepoFilter.Pending;
Assert.Equal(4, vm.FilteredRepos.Count);
var actions = new List<System.Collections.Specialized.NotifyCollectionChangedAction>();
vm.FilteredRepos.CollectionChanged += (_, e) => actions.Add(e.Action);
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_git.CloneHandler = async (_, path, _, _, _) =>
{
if (Path.GetFileName(path) == "bravo")
{
await gate.Task;
}
};
var run = vm.SyncCommand.ExecuteAsync(null);
await WaitUntilAsync(() => vm.Repos[1].Status == SyncStatus.Cloning, "bravo to start");
// Rows leave Pending one by one as they start: removals, never a Reset.
Assert.DoesNotContain(System.Collections.Specialized.NotifyCollectionChangedAction.Reset, actions);
Assert.DoesNotContain(vm.Repos[1], vm.FilteredRepos);
gate.SetResult();
await run;
Assert.Empty(vm.FilteredRepos);
Assert.DoesNotContain(System.Collections.Specialized.NotifyCollectionChangedAction.Reset, actions);
// And a later transition back into the filtered set inserts in table order.
vm.Filter = RepoFilter.Failed;
_git.CloneHandler = (_, path, _, _, _) =>
Path.GetFileName(path) is "delta" or "alpha"
? Task.FromException(new InvalidOperationException("boom"))
: Task.CompletedTask;
actions.Clear();
await vm.SyncCommand.ExecuteAsync(null);
Assert.Equal(["alpha", "delta"], vm.FilteredRepos.Select(r => r.Name));
Assert.DoesNotContain(System.Collections.Specialized.NotifyCollectionChangedAction.Reset, actions);
}
[Fact]
public async Task FailedReports_AreLoggedOnTheReportingThread_NotTheUiHandler()
{
var log = new ThreadRecordingLog();
_lister.Repositories = Repos("alpha");
var vm = new WorkspaceViewModel(_lister, _git, _orgs,
handler => new SyncProgress(handler), TimeSpan.FromMilliseconds(1), log);
vm.Organization = "acme";
vm.Token = "token-1234567890";
await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle");
vm.TargetFolder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
await vm.LoadReposCommand.ExecuteAsync(null);
int reportingThread = -1;
_git.CloneHandler = (_, _, _, _, _) =>
{
reportingThread = Environment.CurrentManagedThreadId;
return Task.FromException(new InvalidOperationException("boom"));
};
await vm.SyncCommand.ExecuteAsync(null);
var entry = Assert.Single(log.Errors, e => e.Message.StartsWith("alpha failed", StringComparison.Ordinal));
Assert.Contains("boom", entry.Message);
// The engine reports from the thread that ran the git operation; the fake's
// synchronous progress means the log call happened right there, before any
// UI-marshaled handler could have run.
Assert.Equal(reportingThread, entry.Thread);
}
private sealed class ThreadRecordingLog : IActivityLog
{
public List<(string Message, int Thread)> Errors { get; } = [];
public void Info(string message)
{
}
public void Error(string message, Exception? exception = null)
=> Errors.Add((message, Environment.CurrentManagedThreadId));
public string LogDirectory => "";
public string CurrentLogFilePath => "";
}
[Fact]
public async Task Filter_WithNoMatchingRows_LeavesFilteredReposEmpty()
{
@@ -994,7 +1227,6 @@ public sealed class WorkspaceViewModelTests
Assert.Equal(["bravo"], vm.FilteredRepos.Select(r => r.Name));
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
@@ -1128,7 +1360,10 @@ public sealed class WorkspaceViewModelTests
public async Task TokenChange_RapidEdits_OnlyNewestLookupLands()
{
_orgs.Handler = (token, _) => Task.FromResult<IReadOnlyList<string>>([token[^4..]]);
var vm = CreateViewModel(debounce: TimeSpan.FromMilliseconds(120));
// The two assignments below must land inside one debounce window; a
// loaded CI runner can pause a test for hundreds of milliseconds between
// two lines, so the window is generous (this is the one wall-clock test).
var vm = CreateViewModel(debounce: TimeSpan.FromMilliseconds(1500));
vm.Token = "token-1234567890-AAAA";
vm.Token = "token-1234567890-BBBB"; // supersedes within the debounce window
@@ -1138,6 +1373,55 @@ public sealed class WorkspaceViewModelTests
Assert.Equal(1, _orgs.Calls);
}
// ---------------------------------------------------------------- lifecycle
[Fact]
public async Task Dispose_MidOrgLookup_CancelsTheLookup_AndALateResultCannotMutateState()
{
var gate = new TaskCompletionSource<IReadOnlyList<string>>(TaskCreationOptions.RunContinuationsAsynchronously);
CancellationToken seen = default;
_orgs.Handler = (_, ct) =>
{
seen = ct;
return gate.Task.WaitAsync(ct);
};
var vm = CreateViewModel();
vm.Token = "token-1234567890";
await WaitUntilAsync(() => _orgs.Calls == 1, "lookup to start");
vm.Dispose();
Assert.True(seen.IsCancellationRequested, "the in-flight lookup's token must be canceled");
gate.SetResult(["late"]);
await Task.Yield();
Assert.Empty(vm.Organizations); // the canceled wait threw before the result could land
}
[Fact]
public void Dispose_Twice_IsANoOp()
{
var vm = CreateViewModel();
vm.Dispose();
vm.Dispose(); // must not throw (the lookup token source is already gone)
Assert.Empty(vm.Organizations);
}
[Fact]
public async Task RefreshPresentation_ReRaisesStatusForEveryRow_WithoutChangingIt()
{
var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"));
var raised = new List<string>();
foreach (RepoItemViewModel row in vm.Repos)
{
row.PropertyChanged += (sender, e) => raised.Add($"{((RepoItemViewModel)sender!).Name}:{e.PropertyName}");
}
vm.RefreshPresentation();
Assert.Equal(["alpha:Status", "bravo:Status"], raised);
Assert.All(vm.Repos, r => Assert.Equal(SyncStatus.Queued, r.Status));
}
// ---------------------------------------------------------------- account workspaces
/// <summary>An account profile whose repositories land under <paramref name="targetRoot"/>.</summary>
@@ -1253,7 +1537,6 @@ public sealed class WorkspaceViewModelTests
await vm.RetryFailedCommand.ExecuteAsync(null);
Assert.False(vm.HasFailedRepos);
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
@@ -1504,7 +1787,6 @@ public sealed class WorkspaceViewModelTests
Assert.True(vm.SyncCommand.CanExecute(null));
Assert.True(vm.CanEditInputs);
Directory.Delete(vm.TargetFolder, recursive: true);
}
// ---------------------------------------------------------------- residue
@@ -1589,7 +1871,6 @@ public sealed class WorkspaceViewModelTests
Assert.Equal(SyncStatus.Failed, vm.Repos[0].Status); // unchanged
Assert.False(vm.IsResolvingPaths);
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
@@ -1616,6 +1897,5 @@ public sealed class WorkspaceViewModelTests
Assert.DoesNotContain(vm.Repos, r => r.Name == "ghost-repo");
Directory.Delete(vm.TargetFolder, recursive: true);
}
}
+135
View File
@@ -0,0 +1,135 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"coverlet.collector": {
"type": "Direct",
"requested": "[10.1.0, )",
"resolved": "10.1.0",
"contentHash": "SlYCIHnBYxINSUpR75SiM5JEmnx11pqT+Ac/C/fUqTTA0dLPl9eYzmFXKKuxmQhL3ZdTQl4PBD3YdAxAauaKLQ=="
},
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
"requested": "[18.10.1, )",
"resolved": "18.10.1",
"contentHash": "SxCFvJE/2ltUQgCgv6+Ixagohw4sZc9hM7Wid2foaNM7paBscktoHLoRDz91fW8wbHG87Rg9Ku59/8PXumqUrA==",
"dependencies": {
"Microsoft.CodeCoverage": "18.10.1",
"Microsoft.TestPlatform.TestHost": "18.10.1"
}
},
"xunit": {
"type": "Direct",
"requested": "[2.9.3, )",
"resolved": "2.9.3",
"contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==",
"dependencies": {
"xunit.analyzers": "1.18.0",
"xunit.assert": "2.9.3",
"xunit.core": "[2.9.3]"
}
},
"xunit.runner.visualstudio": {
"type": "Direct",
"requested": "[4.0.0, )",
"resolved": "4.0.0",
"contentHash": "kzLFyBYUnoidpGOXvpNOfIo8C92gVgVR6X8ncHwhcrDYugiOut5rrhDkr0L3cWHd7J2pKXf6LgaeXoBBDlx1ag=="
},
"CommunityToolkit.Mvvm": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
},
"LibGit2Sharp": {
"type": "Transitive",
"resolved": "0.32.0",
"contentHash": "BL3eMVRnkWWS9zbz6n0H+iDVJ9ZK7hDeiuASnHlsFvwZYTJtDuBFRRVIaH//s4VlF0baOGvCeenXbobE1JLLiQ==",
"dependencies": {
"LibGit2Sharp.NativeBinaries": "[2.0.324]"
}
},
"LibGit2Sharp.NativeBinaries": {
"type": "Transitive",
"resolved": "2.0.324",
"contentHash": "5YZVGNuO9nYFQfzRQGPGY9fdpSsABwlF2jSaZ7ydxCfjvt5Krbj/eHbXkvvgAQzp4lkjvgDrlAuuI4KTmqhDlA=="
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
"resolved": "18.10.1",
"contentHash": "tV7tCyp/t+DuwQugLkCeUN7NBFFB4fEGRPr0gkMQ2nQgAvQQjmGlZZi1dsLzbyoe/A0mbnKybmkIh8qhT9p4Zw=="
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "18.10.1",
"contentHash": "o+PuLDRVp1iwiVAPBADZItXgm9Ck23XtPhE4kZtZVi0uYnMiiz1Dx9Cjh75kJ+ijfqnORL3VOTOaQVKi0f/ixg=="
},
"Microsoft.TestPlatform.TestHost": {
"type": "Transitive",
"resolved": "18.10.1",
"contentHash": "KYz2omqkoz9E2mWa4fdgT+Ycie1wi5968EmyJlusjlp87RxxtA2FvDm8tcBEYIDzLnkWGnsKjE4d13Z7/rxkQA==",
"dependencies": {
"Microsoft.TestPlatform.ObjectModel": "18.10.1"
}
},
"Octokit": {
"type": "Transitive",
"resolved": "14.0.0",
"contentHash": "jGOuTH1l+TCpJH+fwYOp7USzHDuGfN1jKbLz3J2COwyn+wL08eynvpnM6rY2qkzIEXum3PN2p2QkP3BW/p9Qcw=="
},
"xunit.abstractions": {
"type": "Transitive",
"resolved": "2.0.3",
"contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
},
"xunit.analyzers": {
"type": "Transitive",
"resolved": "1.18.0",
"contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ=="
},
"xunit.assert": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
},
"xunit.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]",
"xunit.extensibility.execution": "[2.9.3]"
}
},
"xunit.extensibility.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==",
"dependencies": {
"xunit.abstractions": "2.0.3"
}
},
"xunit.extensibility.execution": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]"
}
},
"gclo.engine": {
"type": "Project",
"dependencies": {
"LibGit2Sharp": "[0.32.0, )",
"Octokit": "[14.0.0, )"
}
},
"gclo.viewmodels": {
"type": "Project",
"dependencies": {
"CommunityToolkit.Mvvm": "[8.4.2, )",
"gclo.Engine": "[1.0.1-dev, )"
}
}
}
}
}
+36
View File
@@ -0,0 +1,36 @@
namespace gclo.Engine;
/// <summary>Why GitHub refused a request, in the terms a caller can act on.</summary>
public enum GitHubAccessKind
{
/// <summary>The token was rejected (401): expired, revoked, or malformed.</summary>
Unauthorized,
/// <summary>The token is valid but may not see the target (an ungranted organization, missing SSO).</summary>
Forbidden,
/// <summary>A primary or secondary rate limit, or temporary abuse/login throttling: retry later.</summary>
RateLimited,
/// <summary>No organization or user account with that login is visible to the token.</summary>
NotFound,
}
/// <summary>
/// A GitHub API refusal translated into an actionable message. Derives from
/// <see cref="InvalidOperationException"/> so every existing catch keeps working;
/// <see cref="Kind"/> lets a caller tell "fix your token" from "wait and retry"
/// without parsing the message — the CLI maps it to distinct exit codes.
/// </summary>
public sealed class GitHubAccessException : InvalidOperationException
{
/// <summary>The category of refusal.</summary>
public GitHubAccessKind Kind { get; }
/// <summary>Creates the exception with its category, user-facing message, and the Octokit cause.</summary>
public GitHubAccessException(GitHubAccessKind kind, string message, Exception? innerException = null)
: base(message, innerException)
{
Kind = kind;
}
}
+31 -10
View File
@@ -7,16 +7,12 @@ public sealed class GitHubOrganizationLister : IOrganizationLister
{
private readonly Func<string, IGitHubGateway> _gatewayFactory;
/// <summary>Production wiring: a fresh Octokit-backed gateway per call's token.</summary>
/// <summary>Production wiring: the process-wide Octokit gateway for the call's token (connection reuse).</summary>
public GitHubOrganizationLister()
: this(CreateOctokitGateway)
: this(OctokitGatewayCache.Get)
{
}
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
Justification = "Production wiring to the live GitHub API; the offline suite injects a fake gateway.")]
private static IGitHubGateway CreateOctokitGateway(string token) => new OctokitGateway(token);
/// <summary>Test seam: substitute the GitHub API with a fake gateway.</summary>
internal GitHubOrganizationLister(Func<string, IGitHubGateway> gatewayFactory)
=> _gatewayFactory = gatewayFactory ?? throw new ArgumentNullException(nameof(gatewayFactory));
@@ -41,22 +37,47 @@ public sealed class GitHubOrganizationLister : IOrganizationLister
{
organizations = await gateway.GetOrganizationLoginsAsync().ConfigureAwait(false);
}
catch (ForbiddenException ex) when (ex is not RateLimitExceededException)
catch (ForbiddenException ex)
when (ex is not RateLimitExceededException
and not SecondaryRateLimitExceededException
and not AbuseException
and not LoginAttemptsExceededException)
{
// Degraded mode: classic PATs without the read:org scope get 403
// from /user/orgs even though the token is otherwise fine. The
// personal account is still a usable sync target, so return just
// that instead of failing the whole listing.
// that instead of failing the whole listing. Throttling 403s
// (secondary rate limit, abuse detection, login attempts) are NOT a
// scope problem and must not be degraded into "no organizations" (#31).
return [userLogin];
}
}
catch (AuthorizationException ex)
{
throw new InvalidOperationException("GitHub rejected the token (401). Check the PAT.", ex);
throw new GitHubAccessException(GitHubAccessKind.Unauthorized, "GitHub rejected the token (401). Check the PAT.", ex);
}
catch (RateLimitExceededException ex)
{
throw new InvalidOperationException($"GitHub API rate limit exceeded; it resets at {ex.Reset:u}.", ex);
throw new GitHubAccessException(
GitHubAccessKind.RateLimited, $"GitHub API rate limit exceeded; it resets at {ex.Reset:u}.", ex);
}
catch (SecondaryRateLimitExceededException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.RateLimited,
"GitHub's secondary rate limit was hit (too many requests in a short time); retry in a minute.", ex);
}
catch (AbuseException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.RateLimited,
$"GitHub's secondary rate limit was hit (too many requests in a short time); retry in {ex.RetryAfterSeconds ?? 60} seconds.", ex);
}
catch (LoginAttemptsExceededException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.RateLimited,
"GitHub temporarily blocked this token after too many failed attempts; wait a few minutes and retry.", ex);
}
cancellationToken.ThrowIfCancellationRequested();
+47 -13
View File
@@ -7,16 +7,12 @@ public sealed class GitHubRepositoryLister : IRepositoryLister
{
private readonly Func<string, IGitHubGateway> _gatewayFactory;
/// <summary>Production wiring: a fresh Octokit-backed gateway per call's token.</summary>
/// <summary>Production wiring: the process-wide Octokit gateway for the call's token (connection reuse).</summary>
public GitHubRepositoryLister()
: this(CreateOctokitGateway)
: this(OctokitGatewayCache.Get)
{
}
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
Justification = "Production wiring to the live GitHub API; the offline suite injects a fake gateway.")]
private static IGitHubGateway CreateOctokitGateway(string token) => new OctokitGateway(token);
/// <summary>Test seam: substitute the GitHub API with a fake gateway.</summary>
internal GitHubRepositoryLister(Func<string, IGitHubGateway> gatewayFactory)
=> _gatewayFactory = gatewayFactory ?? throw new ArgumentNullException(nameof(gatewayFactory));
@@ -60,9 +56,31 @@ public sealed class GitHubRepositoryLister : IRepositoryLister
}
catch (NotFoundException)
{
// /orgs/{name}/repos 404s for user accounts: the token's own account
// gets its owned repos (including private); any other user account
// yields the repos the token can see there (public).
// /orgs/{name}/repos 404s for user accounts — but ALSO for a real
// organization the token cannot see (an ungranted fine-grained PAT, or
// SSO not authorized). Falling through to /users/{name}/repos for an
// organization would list only its public repositories and report a
// green sync that silently omitted the rest, so the account kind is
// resolved first and an invisible organization is an error (#31).
GitHubAccountKind kind = await gateway.GetAccountKindAsync(organization).ConfigureAwait(false);
if (kind == GitHubAccountKind.Organization)
{
throw new GitHubAccessException(
GitHubAccessKind.Forbidden,
$"'{organization}' is an organization, but this token cannot see its repositories (404). "
+ "Grant the PAT access to the organization — for a fine-grained token choose it as the resource owner, "
+ "for a classic token authorize it for SSO — then try again.");
}
if (kind == GitHubAccountKind.NotFound)
{
throw new GitHubAccessException(
GitHubAccessKind.NotFound,
$"'{organization}' was found neither as an organization nor as a user account (404). Check the spelling.");
}
// A user account: the token's own account gets its owned repos
// (including private); any other user account yields the repos the
// token can see there (public).
string currentUser = await gateway.GetCurrentUserLoginAsync().ConfigureAwait(false);
if (string.Equals(currentUser, organization, StringComparison.OrdinalIgnoreCase))
{
@@ -79,7 +97,8 @@ public sealed class GitHubRepositoryLister : IRepositoryLister
}
catch (NotFoundException ex)
{
throw new InvalidOperationException(
throw new GitHubAccessException(
GitHubAccessKind.NotFound,
$"'{organization}' was found neither as an organization nor as a user account (404) — or the token cannot see it.", ex);
}
}
@@ -87,13 +106,28 @@ public sealed class GitHubRepositoryLister : IRepositoryLister
}
catch (AuthorizationException ex)
{
throw new InvalidOperationException(
throw new GitHubAccessException(
GitHubAccessKind.Unauthorized,
"GitHub rejected the token (401). Check the PAT and make sure it has 'repo' (classic) or repository read access (fine-grained).", ex);
}
catch (RateLimitExceededException ex)
{
throw new InvalidOperationException(
$"GitHub API rate limit exceeded; it resets at {ex.Reset:u}.", ex);
throw new GitHubAccessException(
GitHubAccessKind.RateLimited, $"GitHub API rate limit exceeded; it resets at {ex.Reset:u}.", ex);
}
catch (SecondaryRateLimitExceededException ex)
{
// A burst of requests, not a scope problem; Octokit models it beside (not
// under) AbuseException, so both get the same translation.
throw new GitHubAccessException(
GitHubAccessKind.RateLimited,
"GitHub's secondary rate limit was hit (too many requests in a short time); retry in a minute.", ex);
}
catch (AbuseException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.RateLimited,
$"GitHub's secondary rate limit was hit (too many requests in a short time); retry in {ex.RetryAfterSeconds ?? 60} seconds.", ex);
}
cancellationToken.ThrowIfCancellationRequested();
+54
View File
@@ -6,6 +6,19 @@ namespace gclo.Engine;
/// <summary>One repository as the GitHub API describes it, in the fields gclo uses.</summary>
internal readonly record struct GitHubRepo(string Name, string CloneUrl, string? DefaultBranch, bool IsArchived);
/// <summary>What a GitHub login names, as far as the token can tell.</summary>
internal enum GitHubAccountKind
{
/// <summary>A user account.</summary>
User,
/// <summary>An organization (whether or not the token can see its repositories).</summary>
Organization,
/// <summary>No account with that login is visible to the token.</summary>
NotFound,
}
/// <summary>
/// The seam between the listers and the GitHub REST API: exactly the calls gclo
/// makes, returning plain shapes. Implementations surface Octokit's exception
@@ -33,6 +46,34 @@ internal interface IGitHubGateway
/// <summary>Logins of the organizations visible to the token.</summary>
Task<IReadOnlyList<string>> GetOrganizationLoginsAsync();
/// <summary>Whether <paramref name="login"/> is a user, an organization, or unknown to the token.</summary>
Task<GitHubAccountKind> GetAccountKindAsync(string login);
}
/// <summary>
/// One <see cref="OctokitGateway"/> per token, shared by both listers for the life of
/// the process, so successive org lookups and repository loads ride one warm HTTP
/// connection instead of paying a DNS + TCP + TLS handshake to api.github.com on
/// every interactive action (#30). Octokit clients are thread-safe for concurrent
/// requests with fixed credentials. Bounded: tokens are rotated rarely, so when the
/// cache grows past a handful of entries it is simply emptied.
/// </summary>
[ExcludeFromCodeCoverage(Justification = "Holds live Octokit clients; the offline suite injects fake gateways.")]
internal static class OctokitGatewayCache
{
private const int MaxEntries = 8;
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, OctokitGateway> Gateways =
new(StringComparer.Ordinal);
public static IGitHubGateway Get(string token)
{
if (Gateways.Count >= MaxEntries && !Gateways.ContainsKey(token))
{
Gateways.Clear();
}
return Gateways.GetOrAdd(token, static t => new OctokitGateway(t));
}
}
/// <summary>
@@ -75,6 +116,19 @@ internal sealed class OctokitGateway : IGitHubGateway
return organizations.Select(o => o.Login).ToList();
}
public async Task<GitHubAccountKind> GetAccountKindAsync(string login)
{
try
{
var account = await _client.User.Get(login).ConfigureAwait(false);
return account.Type == AccountType.Organization ? GitHubAccountKind.Organization : GitHubAccountKind.User;
}
catch (NotFoundException)
{
return GitHubAccountKind.NotFound;
}
}
private static ApiOptions Page(int page)
=> new() { PageSize = IGitHubGateway.PageSize, PageCount = 1, StartPage = page };
+225 -24
View File
@@ -45,6 +45,14 @@ public sealed class LibGit2GitClient : IGitClient
private static readonly JsonSerializerOptions RecoveryJsonOptions = new() { WriteIndented = true };
/// <summary>
/// Test seam: invoked right after a fetch completed, before the cancellation
/// check that precedes any working-tree work. Local-transport fixtures fire no
/// transfer callbacks, so this is how the offline suite exercises "canceled
/// between fetch and checkout/merge" deterministically (#31). Null in production.
/// </summary>
internal Action? AfterFetchForTesting { get; set; }
/// <inheritdoc/>
public bool IsValidRepository(string path)
=> Directory.Exists(path) && Repository.IsValid(path);
@@ -69,7 +77,7 @@ public sealed class LibGit2GitClient : IGitClient
() => ApplyRecovery(path, recovery, cancellationToken),
cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default);
private static void Clone(string url, string path, string token, Action<double>? onProgress, CancellationToken ct)
private void Clone(string url, string path, string token, Action<double>? onProgress, CancellationToken ct)
{
bool existedBefore = Directory.Exists(path);
@@ -102,6 +110,7 @@ public sealed class LibGit2GitClient : IGitClient
try
{
Repository.Clone(url, path, options);
AfterFetchForTesting?.Invoke();
using var repo = new Repository(path);
@@ -143,6 +152,10 @@ public sealed class LibGit2GitClient : IGitClient
Commands.Checkout(repo, repo.Head, new CheckoutOptions
{
CheckoutModifiers = CheckoutModifiers.Force, // materialize the fresh working tree
// The only checkout that lacked the hook: a canceled clone used to
// write its whole tree before the token was ever observed (#31).
CheckoutNotifyFlags = CheckoutNotifyFlags.Updated,
OnCheckoutNotify = (_, _) => !ct.IsCancellationRequested,
});
// Only now is the clone complete. Config writes are atomic on disk, so an
@@ -170,7 +183,7 @@ public sealed class LibGit2GitClient : IGitClient
}
}
private static void FetchAndPull(string path, string token, CancellationToken ct)
private void FetchAndPull(string path, string token, CancellationToken ct)
{
using var repo = new Repository(path);
@@ -187,6 +200,10 @@ public sealed class LibGit2GitClient : IGitClient
{
CredentialsProvider = MakeCredentialsProvider(token),
OnTransferProgress = _ => !ct.IsCancellationRequested,
// Drop tracking refs for branches origin deleted. Without this a renamed
// default branch leaves refs/remotes/origin/<old> frozen at its last SHA,
// and every later sync compares against it and says "up to date" (#31).
Prune = true,
};
var refSpecs = remote.FetchRefSpecs.Select(s => s.Specification).ToList();
@@ -198,6 +215,7 @@ public sealed class LibGit2GitClient : IGitClient
{
throw new OperationCanceledException(ct);
}
AfterFetchForTesting?.Invoke();
ct.ThrowIfCancellationRequested();
@@ -214,7 +232,15 @@ public sealed class LibGit2GitClient : IGitClient
// tip through the stored mapping. Freshly-invalid paths the mapping does
// not cover surface as a typed failure listing the effective paths.
AdvanceHeadToTrackedTip(repo);
ApplyRecoveryCore(repo, LoadRecovery(recoveryFile), ct);
RecoveryDocument stored = LoadRecoveryDocument(recoveryFile);
if (stored.MaterializedTip is not null && stored.MaterializedTip == repo.Head.Tip?.Sha)
{
// The tree on disk already reflects this tip: nothing to rewrite. A
// recovery-managed repo used to re-materialize every blob on every
// sync, touching gigabytes and every mtime for zero change (#31).
return;
}
ApplyRecoveryCore(repo, ToRecovery(stored), ct);
return;
}
@@ -226,7 +252,9 @@ public sealed class LibGit2GitClient : IGitClient
if (repo.Info.IsHeadDetached)
{
throw new InvalidOperationException("HEAD is detached; fetched, but nothing was merged.");
throw new InvalidOperationException(
"HEAD is detached (a commit is checked out, not a branch); fetched, but nothing was merged. "
+ "Check out a branch in this repository to resume updates, or delete the folder to re-clone.");
}
if (repo.Info.IsHeadUnborn)
@@ -241,8 +269,15 @@ public sealed class LibGit2GitClient : IGitClient
var tracked = repo.Head.TrackedBranch;
if (tracked?.Tip is null)
{
// No upstream configured: fetch is all we can do.
return;
if (repo.Head.UpstreamBranchCanonicalName is null)
{
return; // no upstream configured: fetch is all we can do
}
// Upstream is configured but gone from origin (the fetch pruned it): the
// default branch was renamed (master -> main) or deleted. Follow origin's
// current default instead of reporting "up to date" forever (#31).
tracked = RetargetToRemoteDefaultBranch(repo, remote, fetchOptions.CredentialsProvider, ct);
}
if (repo.Head.Tip?.Sha == tracked.Tip.Sha)
@@ -251,9 +286,13 @@ public sealed class LibGit2GitClient : IGitClient
}
// Incoming commits can introduce Windows-invalid paths just like a clone can.
// Only what the pull introduces is checked: the current tree was validated
// when it was checked out, and re-walking a 100K-file tree for a one-file
// commit was the dominant engine cost of a daily update run (#30).
if (OperatingSystem.IsWindows())
{
var invalidIncoming = WindowsPathValidator.Validate(tracked.Tip.Tree);
// HEAD cannot be unborn here (handled above), so the current tip exists.
var invalidIncoming = WindowsPathValidator.ValidateIncoming(repo, repo.Head.Tip!.Tree, tracked.Tip.Tree);
if (invalidIncoming.Count > 0)
{
throw new InvalidRepositoryPathsException(invalidIncoming);
@@ -261,7 +300,9 @@ public sealed class LibGit2GitClient : IGitClient
}
// Fast-forward only: a mirror tool must never manufacture merge commits.
// Diverged local history surfaces as NonFastForwardException -> Failed with a clear message.
// Diverged local history is the most common pull failure for this tool's
// audience (someone committed in a synced clone), so it gets a message that
// says what gclo refuses to do and what to do about it (#30).
var signature = new Signature("gclo", "gclo@localhost", DateTimeOffset.Now);
try
{
@@ -276,6 +317,82 @@ public sealed class LibGit2GitClient : IGitClient
{
throw new OperationCanceledException(ct);
}
catch (NonFastForwardException ex)
{
throw new InvalidOperationException(
$"'{repo.Head.FriendlyName}' has local commits that origin does not have; gclo only fast-forwards and never merges. "
+ "Push or reset the local commits, or delete the folder to re-clone.", ex);
}
}
/// <summary>
/// The current branch's upstream no longer exists on origin. Asks origin for its
/// current default branch (the remote HEAD symref), checks it out as the new
/// local branch with upstream wired, and removes the orphaned local branch — but
/// only when every commit of the old branch is already contained in the new
/// default, because a mirror tool never discards local work. Returns the new
/// tracking branch so the normal pull continues from it.
/// </summary>
private static Branch RetargetToRemoteDefaultBranch(
Repository repo, Remote remote, CredentialsHandler credentials, CancellationToken ct)
{
const string prefix = "refs/heads/";
string oldName = repo.Head.FriendlyName;
string? defaultRef = repo.Network.ListReferences(remote, credentials)
.FirstOrDefault(r => r.CanonicalName == "HEAD")?.TargetIdentifier;
if (defaultRef is null || !defaultRef.StartsWith(prefix, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"'{oldName}' no longer exists on origin, and origin has no default branch to follow. "
+ "Check the repository on GitHub, or delete the folder to re-clone.");
}
string newName = defaultRef[prefix.Length..];
var remoteBranch = repo.Branches[$"{remote.Name}/{newName}"];
if (remoteBranch?.Tip is null)
{
throw new InvalidOperationException(
$"'{oldName}' no longer exists on origin; its default branch is now '{newName}', which was not fetched. "
+ "Delete the folder to re-clone.");
}
var oldTip = repo.Head.Tip;
if (oldTip is not null && repo.ObjectDatabase.FindMergeBase(oldTip, remoteBranch.Tip)?.Sha != oldTip.Sha)
{
throw new InvalidOperationException(
$"'{oldName}' no longer exists on origin (the default branch is now '{newName}'), but it has local commits "
+ $"that '{newName}' does not contain; gclo never discards local work. Push or reset them, or delete the folder to re-clone.");
}
var local = repo.Branches[newName] ?? repo.CreateBranch(newName, remoteBranch.Tip);
repo.Branches.Update(local, b =>
{
b.Remote = remote.Name;
b.UpstreamBranch = defaultRef;
});
ct.ThrowIfCancellationRequested();
try
{
Commands.Checkout(repo, local, new CheckoutOptions
{
CheckoutNotifyFlags = CheckoutNotifyFlags.Updated,
OnCheckoutNotify = (_, _) => !ct.IsCancellationRequested,
});
}
catch (UserCancelledException) when (ct.IsCancellationRequested)
{
throw new OperationCanceledException(ct);
}
if (!string.Equals(oldName, newName, StringComparison.Ordinal))
{
repo.Branches.Remove(oldName); // fully contained in the new default; nothing is lost
}
return repo.Head.TrackedBranch
?? throw new InvalidOperationException($"'{newName}' could not be set to track origin.");
}
/// <summary>
@@ -359,9 +476,10 @@ public sealed class LibGit2GitClient : IGitClient
}
string recoveryFile = GetRecoveryFilePath(repo);
if (File.Exists(recoveryFile))
RecoveryDocument? previous = File.Exists(recoveryFile) ? LoadRecoveryDocument(recoveryFile) : null;
if (previous is not null)
{
recovery = MergeRecoveries(stored: LoadRecovery(recoveryFile), incoming: recovery);
recovery = MergeRecoveries(stored: ToRecovery(previous), incoming: recovery);
}
var tip = repo.Head.Tip
@@ -379,6 +497,19 @@ public sealed class LibGit2GitClient : IGitClient
}
string workdir = repo.Info.WorkingDirectory;
// A real checkout removes what left the tree; this materialization must too,
// or a recovery-managed working tree accumulates every file upstream ever
// deleted or renamed (#31). The previous apply's manifest says what it wrote.
var effectiveNow = new HashSet<string>(entries.Select(e => e.EffectivePath), StringComparer.Ordinal);
foreach (string stale in previous?.MaterializedPaths ?? [])
{
if (!effectiveNow.Contains(stale))
{
DeleteMaterializedFile(workdir, stale);
}
}
foreach (var (originalPath, effectivePath, blob) in entries)
{
ct.ThrowIfCancellationRequested();
@@ -398,10 +529,36 @@ public sealed class LibGit2GitClient : IGitClient
content.CopyTo(file);
}
SaveRecovery(repo, recovery);
SaveRecovery(repo, recovery, entries.Select(e => e.EffectivePath), tip.Sha);
repo.Config.Set(CheckoutPendingKey, false, ConfigurationLevel.Local);
}
/// <summary>
/// Removes one file a previous materialization wrote (a user-deleted file is
/// fine) and prunes the directories it leaves empty, stopping at the working
/// directory root.
/// </summary>
private static void DeleteMaterializedFile(string workdir, string effectivePath)
{
string fullPath = Path.Combine(workdir, effectivePath.Replace('/', Path.DirectorySeparatorChar));
if (File.Exists(fullPath))
{
File.SetAttributes(fullPath, FileAttributes.Normal);
File.Delete(fullPath);
}
string root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(workdir));
string? directory = Path.GetDirectoryName(fullPath);
while (directory is not null
&& !string.Equals(Path.TrimEndingDirectorySeparator(directory), root, StringComparison.OrdinalIgnoreCase)
&& Directory.Exists(directory)
&& !Directory.EnumerateFileSystemEntries(directory).Any())
{
Directory.Delete(directory);
directory = Path.GetDirectoryName(directory);
}
}
/// <summary>
/// Overlays <paramref name="incoming"/> onto <paramref name="stored"/>: renames
/// union with the incoming value winning on a shared original path; skips union.
@@ -544,32 +701,76 @@ public sealed class LibGit2GitClient : IGitClient
/// <summary>Serializable shape of <see cref="PathRecovery"/> for .git\gclo-recovery.json.</summary>
/// <param name="SegmentRenames">Original path to replacement path map; null when absent from the file.</param>
/// <param name="SkippedPaths">Paths omitted from the working tree; null when absent from the file.</param>
private sealed record RecoveryDocument(Dictionary<string, string>? SegmentRenames, List<string>? SkippedPaths);
/// <param name="MaterializedPaths">Effective paths the last apply wrote, so the next one can delete what left the tree; null in files from before this field existed.</param>
/// <param name="MaterializedTip">SHA of the commit the last apply materialized; null in older files.</param>
private sealed record RecoveryDocument(
Dictionary<string, string>? SegmentRenames,
List<string>? SkippedPaths,
List<string>? MaterializedPaths,
string? MaterializedTip);
private static void SaveRecovery(Repository repo, PathRecovery recovery)
private static void SaveRecovery(
Repository repo, PathRecovery recovery, IEnumerable<string> materializedPaths, string tipSha)
{
var document = new RecoveryDocument(
new Dictionary<string, string>(recovery.SegmentRenames, StringComparer.Ordinal),
recovery.SkippedPaths.Order(StringComparer.Ordinal).ToList());
File.WriteAllText(GetRecoveryFilePath(repo), JsonSerializer.Serialize(document, RecoveryJsonOptions));
recovery.SkippedPaths.Order(StringComparer.Ordinal).ToList(),
materializedPaths.Order(StringComparer.Ordinal).ToList(),
tipSha);
// Atomic replace: a crash mid-write must not leave a truncated document that
// fails every later pull of this repository (#31).
string path = GetRecoveryFilePath(repo);
string temp = path + ".tmp";
File.WriteAllText(temp, JsonSerializer.Serialize(document, RecoveryJsonOptions));
File.Move(temp, path, overwrite: true);
}
private static PathRecovery LoadRecovery(string filePath)
private static RecoveryDocument LoadRecoveryDocument(string filePath)
{
var document = JsonSerializer.Deserialize<RecoveryDocument>(File.ReadAllText(filePath), RecoveryJsonOptions)
?? throw new InvalidOperationException($"Recovery file '{filePath}' is empty or malformed.");
return new PathRecovery(
try
{
return JsonSerializer.Deserialize<RecoveryDocument>(File.ReadAllText(filePath), RecoveryJsonOptions)
?? throw new JsonException("the document is empty");
}
catch (JsonException ex)
{
throw new InvalidOperationException(
$"The path-recovery file '{filePath}' is corrupt ({ex.Message}). "
+ "Delete it and use Resolve on this repository again to rebuild it.", ex);
}
}
private static PathRecovery ToRecovery(RecoveryDocument document)
=> new(
new Dictionary<string, string>(document.SegmentRenames ?? new(StringComparer.Ordinal), StringComparer.Ordinal),
new HashSet<string>(document.SkippedPaths ?? [], StringComparer.Ordinal));
}
/// <summary>
/// GitHub accepts any username when the PAT is sent as the password. libgit2 asks
/// again after every 401; handing it the same rejected token loops until it gives
/// up with "too many redirects or authentication replays", once per repository.
/// A correct token is never asked for a third time, so the third request means the
/// token is bad and gets the same actionable message the API listers produce (#31).
/// </summary>
private static CredentialsHandler MakeCredentialsProvider(string token)
=> (_, _, _) => new UsernamePasswordCredentials
{
int requests = 0;
return (_, _, _) =>
{
// GitHub accepts any username when the PAT is sent as the password.
Username = "x-access-token",
Password = token,
if (Interlocked.Increment(ref requests) > 2)
{
throw new InvalidOperationException(
"GitHub rejected the token for git over HTTPS (401). Check the PAT: it may have expired, "
+ "been revoked, or lack access to this repository.");
}
return new UsernamePasswordCredentials
{
Username = "x-access-token",
Password = token,
};
};
}
private static void TryDeleteDirectory(string path)
{
+19 -2
View File
@@ -107,10 +107,27 @@ public sealed class OrgSyncEngine
{
token.ThrowIfCancellationRequested();
// A name that is one safe segment (RepositoryPathResolver) can still be
// impossible as a Windows folder: GitHub allows repositories called
// 'aux' or 'con'. Fail the row with a plain reason instead of an opaque
// OS error from the clone (#31).
if (OperatingSystem.IsWindows()
&& WindowsPathValidator.ValidatePaths(new[] { repo.Name }) is { Count: > 0 } invalidName)
{
throw new InvalidOperationException(
$"Repository name '{repo.Name}' cannot be a folder on Windows ({invalidName[0].Reason}). "
+ "Rename the repository on GitHub, or sync it on another platform.");
}
// WaitAsync: a git operation wedged in native transport code (DNS,
// TLS, a black-holed connection) observes no callback and therefore
// no cancellation. Abandoning the await keeps cancellation prompt;
// the orphaned operation finishes (or times out) on its own thread
// and its own cleanup still runs (#31).
if (_git.IsValidRepository(path))
{
progress?.Report(new RepoProgress(repo.Name, SyncStatus.Pulling));
await _git.FetchAndPullAsync(path, request.Token, token).ConfigureAwait(false);
await _git.FetchAndPullAsync(path, request.Token, token).WaitAsync(token).ConfigureAwait(false);
Interlocked.Increment(ref updated);
}
else
@@ -119,7 +136,7 @@ public sealed class OrgSyncEngine
await _git.CloneAsync(
repo.CloneUrl, path, request.Token,
pct => progress?.Report(new RepoProgress(repo.Name, SyncStatus.Cloning, Percent: pct)),
token).ConfigureAwait(false);
token).WaitAsync(token).ConfigureAwait(false);
Interlocked.Increment(ref cloned);
}
+94
View File
@@ -30,6 +30,100 @@ public static class WindowsPathValidator
public static IReadOnlyList<InvalidPathInfo> Validate(Tree tree)
=> ValidatePaths(FlattenPaths(tree));
/// <summary>
/// Validates only what a pull would introduce: the paths added, copied, renamed,
/// or type-changed between <paramref name="current"/> (the checked-out tree, which
/// was validated when it was materialized) and <paramref name="incoming"/>. The new
/// paths get the full segment and collision rules among themselves; case-only
/// collisions are additionally checked against everything that stays on disk
/// (the index minus deleted and renamed-away paths). Modified files keep their
/// already-valid paths and are not re-examined, so a one-file commit on a
/// 100K-file repository costs one diff, not a tree walk.
/// </summary>
public static IReadOnlyList<InvalidPathInfo> ValidateIncoming(Repository repo, Tree current, Tree incoming)
{
ArgumentNullException.ThrowIfNull(repo);
ArgumentNullException.ThrowIfNull(current);
ArgumentNullException.ThrowIfNull(incoming);
var introduced = new List<string>();
var removed = new HashSet<string>(StringComparer.Ordinal);
using (TreeChanges changes = repo.Diff.Compare<TreeChanges>(
current, incoming, new CompareOptions { Similarity = SimilarityOptions.Renames }))
{
foreach (TreeEntryChanges change in changes)
{
if (change.Status == ChangeKind.Deleted)
{
removed.Add(change.Path);
continue;
}
if (change.Status == ChangeKind.Renamed)
{
removed.Add(change.OldPath);
}
if (change.Status is ChangeKind.Added or ChangeKind.Copied or ChangeKind.Renamed or ChangeKind.TypeChanged)
{
introduced.Add(change.Path);
}
}
}
if (introduced.Count == 0)
{
return [];
}
var invalid = new List<InvalidPathInfo>(ValidatePaths(introduced));
// Every path (and directory prefix) that remains on disk, case-insensitively,
// mapped to its exact spelling; a new path whose exact spelling is absent but
// whose casing matches an existing one cannot be created on Windows.
var remaining = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var remainingExact = new HashSet<string>(StringComparer.Ordinal);
foreach (IndexEntry entry in repo.Index)
{
if (!removed.Contains(entry.Path))
{
AddWithPrefixes(entry.Path, remaining, remainingExact);
}
}
var reported = new HashSet<string>(StringComparer.Ordinal);
foreach (string path in introduced)
{
string[] segments = path.Split('/');
string prefix = "";
for (int i = 0; i < segments.Length; i++)
{
prefix = i == 0 ? segments[i] : $"{prefix}/{segments[i]}";
if (!remainingExact.Contains(prefix)
&& remaining.TryGetValue(prefix, out string? existing)
&& reported.Add(prefix))
{
invalid.Add(new InvalidPathInfo(prefix, $"differs only by case from '{existing}'", null));
}
}
}
return invalid;
}
private static void AddWithPrefixes(
string path, Dictionary<string, string> byCase, HashSet<string> exact)
{
string[] segments = path.Split('/');
string prefix = "";
for (int i = 0; i < segments.Length; i++)
{
prefix = i == 0 ? segments[i] : $"{prefix}/{segments[i]}";
if (exact.Add(prefix))
{
byCase.TryAdd(prefix, prefix);
}
}
}
/// <summary>
/// Depth-first flattening of <paramref name="tree"/> into leaf paths (blobs and
/// submodule links). An empty subtree is emitted as its own path so its name is
+8
View File
@@ -9,7 +9,15 @@
<Authors>James Maes</Authors>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<RepositoryUrl>https://github.com/KofTwentyTwo/gclo</RepositoryUrl>
<PackageProjectUrl>https://github.com/KofTwentyTwo/gclo</PackageProjectUrl>
<PackageTags>github;git;clone;mirror;organization;libgit2sharp;octokit</PackageTags>
<PackageReadmeFile>PACKAGE.md</PackageReadmeFile>
<!-- Publish-quality package: SourceLink to the public repo, symbols in a
.snupkg, untracked (generated) sources embedded so step-through works. -->
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
+27
View File
@@ -0,0 +1,27 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"LibGit2Sharp": {
"type": "Direct",
"requested": "[0.32.0, )",
"resolved": "0.32.0",
"contentHash": "BL3eMVRnkWWS9zbz6n0H+iDVJ9ZK7hDeiuASnHlsFvwZYTJtDuBFRRVIaH//s4VlF0baOGvCeenXbobE1JLLiQ==",
"dependencies": {
"LibGit2Sharp.NativeBinaries": "[2.0.324]"
}
},
"Octokit": {
"type": "Direct",
"requested": "[14.0.0, )",
"resolved": "14.0.0",
"contentHash": "jGOuTH1l+TCpJH+fwYOp7USzHDuGfN1jKbLz3J2COwyn+wL08eynvpnM6rY2qkzIEXum3PN2p2QkP3BW/p9Qcw=="
},
"LibGit2Sharp.NativeBinaries": {
"type": "Transitive",
"resolved": "2.0.324",
"contentHash": "5YZVGNuO9nYFQfzRQGPGY9fdpSsABwlF2jSaZ7ydxCfjvt5Krbj/eHbXkvvgAQzp4lkjvgDrlAuuI4KTmqhDlA=="
}
}
}
}
+82
View File
@@ -377,9 +377,91 @@ public sealed class AppSession : IDisposable
+ "Set GCLO_UITEST_EXE to the gclo.exe to test.");
}
private static int _sessionCounter;
/// <summary>
/// Waits until the app's activity log (under the session's data directory)
/// contains <paramref name="fragment"/>. The log is the one deterministic signal
/// for "the app handled that command" when the UI shows no change by design.
/// </summary>
public void WaitForLogLine(string fragment, TimeSpan? timeout = null)
{
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10));
string logs = Path.Combine(DataDirectory, "logs");
while (DateTime.UtcNow < deadline)
{
if (Directory.Exists(logs))
{
foreach (string file in Directory.EnumerateFiles(logs, "*.log"))
{
try
{
using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream);
if (reader.ReadToEnd().Contains(fragment, StringComparison.Ordinal))
{
return;
}
}
catch (IOException)
{
// Mid-write; try again.
}
}
}
Thread.Sleep(50);
}
throw new TimeoutException($"Timed out waiting for the activity log to contain '{fragment}'.");
}
/// <summary>
/// When GCLO_UITEST_ARTIFACTS names a directory (CI does), saves a screenshot of
/// the screen as the session ends and copies the app's activity log beside it, so
/// a failure on a hosted runner has something to diagnose with. Best effort.
/// </summary>
private void TryCaptureArtifacts()
{
string? root = Environment.GetEnvironmentVariable("GCLO_UITEST_ARTIFACTS");
if (string.IsNullOrWhiteSpace(root))
{
return;
}
int number = Interlocked.Increment(ref _sessionCounter);
string stamp = DateTime.Now.ToString("HHmmss", System.Globalization.CultureInfo.InvariantCulture);
try
{
Directory.CreateDirectory(root);
using var image = FlaUI.Core.Capturing.Capture.Screen();
image.ToFile(Path.Combine(root, $"session-{number:D2}-{stamp}.png"));
}
catch
{
// A missing desktop session or a disposed automation must not fail the test.
}
try
{
string logs = Path.Combine(DataDirectory, "logs");
if (Directory.Exists(logs))
{
foreach (string file in Directory.EnumerateFiles(logs))
{
File.Copy(file, Path.Combine(root, $"session-{number:D2}-{Path.GetFileName(file)}"), overwrite: true);
}
}
}
catch
{
// Best effort only.
}
}
/// <inheritdoc/>
public void Dispose()
{
TryCaptureArtifacts();
// Order matters: take the app down first, dispose the automation only after
// the process is gone, then sweep the temp data directory. Every step is
// best-effort so one failure cannot mask the test outcome.
+9 -2
View File
@@ -112,8 +112,11 @@ public sealed partial class SmokeTests : UiTestBase
Session.WaitForText("Step 1 of 4: Identity"); // right dialog, right step
Session.ClickButton("Next");
Thread.Sleep(PostInvokeSettle); // validation runs behind the dialog deferral
// Positive oracle: the step-1 validation message appearing proves the
// Next handler ran and rejected; only then is "still on step 1" meaningful.
Session.WaitForText("Enter a name for this account.");
Assert.NotNull(Session.FindInApp(cf => cf.ByName("Step 1 of 4: Identity")));
Assert.NotNull(Session.WaitForElement("WizardNameBox")); // still open
Assert.False(Session.App.HasExited);
@@ -153,7 +156,11 @@ public sealed partial class SmokeTests : UiTestBase
Session.WaitForElement("AboutVersionText");
Session.InvokeNavItem("NavAddAccount"); // second dialog: must be a no-op
Thread.Sleep(PostInvokeSettle);
// Positive oracle: the shell logs that it declined to open the wizard, so
// the negative assertions below are checked after the command was handled,
// not after an arbitrary pause.
Session.WaitForLogLine("Account wizard not opened: another dialog is open.");
Assert.False(Session.App.HasExited);
Assert.Null(Session.FindInApp(cf => cf.ByAutomationId("WizardNameBox")));
+184
View File
@@ -0,0 +1,184 @@
{
"version": 1,
"dependencies": {
"net10.0-windows10.0.19041": {
"FlaUI.UIA3": {
"type": "Direct",
"requested": "[5.0.0, )",
"resolved": "5.0.0",
"contentHash": "s1Yy1RsRJ0OXvmtxQlNJd0cu4sfvN3PFFu6skBGK8VlwPl/b38E0V5kCbo2gvkuhRIu4Itfxjdo7DUMF7qJw4Q==",
"dependencies": {
"FlaUI.Core": "5.0.0",
"Interop.UIAutomationClient": "10.19041.0"
}
},
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
"requested": "[18.10.1, )",
"resolved": "18.10.1",
"contentHash": "SxCFvJE/2ltUQgCgv6+Ixagohw4sZc9hM7Wid2foaNM7paBscktoHLoRDz91fW8wbHG87Rg9Ku59/8PXumqUrA==",
"dependencies": {
"Microsoft.CodeCoverage": "18.10.1",
"Microsoft.TestPlatform.TestHost": "18.10.1"
}
},
"xunit": {
"type": "Direct",
"requested": "[2.9.3, )",
"resolved": "2.9.3",
"contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==",
"dependencies": {
"xunit.analyzers": "1.18.0",
"xunit.assert": "2.9.3",
"xunit.core": "[2.9.3]"
}
},
"xunit.runner.visualstudio": {
"type": "Direct",
"requested": "[4.0.0, )",
"resolved": "4.0.0",
"contentHash": "kzLFyBYUnoidpGOXvpNOfIo8C92gVgVR6X8ncHwhcrDYugiOut5rrhDkr0L3cWHd7J2pKXf6LgaeXoBBDlx1ag=="
},
"FlaUI.Core": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "VWWYqwjsYkii2BrUsz5+bpXEp27NherKhLt19hTxHwJP4KrfyZoeo1FdBov4ZTh655wZAKDGIjR7NJLDesuzeg==",
"dependencies": {
"System.Diagnostics.PerformanceCounter": "8.0.1",
"System.Drawing.Common": "8.0.10",
"System.Management": "8.0.0",
"System.Security.Permissions": "8.0.0"
}
},
"Interop.UIAutomationClient": {
"type": "Transitive",
"resolved": "10.19041.0",
"contentHash": "Me5vKNZ1JSg6txTsPQXaWx7fVNh+fmcUPPwtDKJtqvbMM0N8aoa/l/QPolkJIRnasCabCNt4v2zG4p1Q9HU98A=="
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
"resolved": "18.10.1",
"contentHash": "tV7tCyp/t+DuwQugLkCeUN7NBFFB4fEGRPr0gkMQ2nQgAvQQjmGlZZi1dsLzbyoe/A0mbnKybmkIh8qhT9p4Zw=="
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "18.10.1",
"contentHash": "o+PuLDRVp1iwiVAPBADZItXgm9Ck23XtPhE4kZtZVi0uYnMiiz1Dx9Cjh75kJ+ijfqnORL3VOTOaQVKi0f/ixg=="
},
"Microsoft.TestPlatform.TestHost": {
"type": "Transitive",
"resolved": "18.10.1",
"contentHash": "KYz2omqkoz9E2mWa4fdgT+Ycie1wi5968EmyJlusjlp87RxxtA2FvDm8tcBEYIDzLnkWGnsKjE4d13Z7/rxkQA==",
"dependencies": {
"Microsoft.TestPlatform.ObjectModel": "18.10.1"
}
},
"Microsoft.Win32.SystemEvents": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "9opKRyOKMCi2xJ7Bj7kxtZ1r9vbzosMvRrdEhVhDz8j8MoBGgB+WmC94yH839NPH+BclAjtQ/pyagvi/8gDLkw=="
},
"System.CodeDom": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "WTlRjL6KWIMr/pAaq3rYqh0TJlzpouaQ/W1eelssHgtlwHAH25jXTkUphTYx9HaIIf7XA6qs/0+YhtLEQRkJ+Q=="
},
"System.Configuration.ConfigurationManager": {
"type": "Transitive",
"resolved": "8.0.1",
"contentHash": "gPYFPDyohW2gXNhdQRSjtmeS6FymL2crg4Sral1wtvEJ7DUqFCDWDVbbLobASbzxfic8U1hQEdC7hmg9LHncMw==",
"dependencies": {
"System.Diagnostics.EventLog": "8.0.1",
"System.Security.Cryptography.ProtectedData": "8.0.0"
}
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "8.0.1",
"contentHash": "n1ZP7NM2Gkn/MgD8+eOT5MulMj6wfeQMNS2Pizvq5GHCZfjlFMXV2irQlQmJhwA2VABC57M0auudO89Iu2uRLg=="
},
"System.Diagnostics.PerformanceCounter": {
"type": "Transitive",
"resolved": "8.0.1",
"contentHash": "9RfEDiEjlUADeThs8IPdDVTXSnPRSqjfgTQJALpmGFPKC0k2mbdufOXnb/9JZ4I0TkmxOfy3VTJxrHOJSs8cXg==",
"dependencies": {
"System.Configuration.ConfigurationManager": "8.0.1"
}
},
"System.Drawing.Common": {
"type": "Transitive",
"resolved": "8.0.10",
"contentHash": "MdajRp3P+FOlThgY6FBjAqnmLiVl5t2yWEC/2AsDMqx1zYbJG3G5TnscFBQ4obqcaGqvN5UnhQHSFaJFG2HftQ==",
"dependencies": {
"Microsoft.Win32.SystemEvents": "8.0.0"
}
},
"System.Management": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "jrK22i5LRzxZCfGb+tGmke2VH7oE0DvcDlJ1HAKYU8cPmD8XnpUT0bYn2Gy98GEhGjtfbR/sxKTVb+dE770pfA==",
"dependencies": {
"System.CodeDom": "8.0.0"
}
},
"System.Security.Cryptography.ProtectedData": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "+TUFINV2q2ifyXauQXRwy4CiBhqvDEDZeVJU7qfxya4aRYOKzVBpN+4acx25VcPB9ywUN6C0n8drWl110PhZEg=="
},
"System.Security.Permissions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "v/BBylw7XevuAsHXoX9dDUUfmBIcUf7Lkz8K3ZXIKz3YRKpw8YftpSir4n4e/jDTKFoaK37AsC3xnk+GNFI1Ow==",
"dependencies": {
"System.Windows.Extensions": "8.0.0"
}
},
"System.Windows.Extensions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "Obg3a90MkOw9mYKxrardLpY2u0axDMrSmy4JCdq2cYbelM2cUwmUir5Bomvd1yxmPL9h5LVHU1tuKBZpUjfASg=="
},
"xunit.abstractions": {
"type": "Transitive",
"resolved": "2.0.3",
"contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
},
"xunit.analyzers": {
"type": "Transitive",
"resolved": "1.18.0",
"contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ=="
},
"xunit.assert": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
},
"xunit.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]",
"xunit.extensibility.execution": "[2.9.3]"
}
},
"xunit.extensibility.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==",
"dependencies": {
"xunit.abstractions": "2.0.3"
}
},
"xunit.extensibility.execution": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]"
}
}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
namespace gclo.ViewModels;
/// <summary>
/// The connection values a Quick Sync workspace hands to the account wizard so a
/// working ad-hoc setup can be saved as an account without re-entering anything
/// (the seeded <see cref="AccountWizardViewModel"/> constructor).
/// </summary>
/// <param name="Token">The token in effect; written to the vault on save.</param>
/// <param name="Organization">The organization (or user) the workspace is connected to.</param>
/// <param name="TargetRoot">The workspace's target folder (not the org subfolder).</param>
/// <param name="CreateOrgSubfolder">Whether clones go under TargetRoot\Organization.</param>
/// <param name="MaxConcurrency">The workspace's parallel clone count.</param>
public sealed record AccountWizardSeed(
string Token,
string Organization,
string TargetRoot,
bool CreateOrgSubfolder,
int MaxConcurrency)
{
/// <summary>Redacts the token: this record must never print it.</summary>
public override string ToString() =>
$"AccountWizardSeed {{ Token = [redacted], Organization = {Organization}, TargetRoot = {TargetRoot}, "
+ $"CreateOrgSubfolder = {CreateOrgSubfolder}, MaxConcurrency = {MaxConcurrency} }}";
}
+104 -26
View File
@@ -15,36 +15,33 @@ public sealed partial class AccountWizardViewModel : ObservableObject
{
private readonly AccountsStore _store;
private readonly IOrganizationLister _orgLister;
private readonly IActivityLog _log;
private readonly Account? _existing;
/// <summary>What the token box was seeded with; unchanged means "leave the vault alone".</summary>
private readonly string _seededToken;
/// <summary>
/// For edits: fetches the account's stored token from the vault, called only when
/// a step actually needs to transmit it (validating on step 2 with the box left
/// empty). The token is never copied into <see cref="Token"/>, so the dialog never
/// holds or shows it; an empty box means "keep the stored token" (#32).
/// </summary>
private readonly Func<string?>? _storedToken;
/// <summary>
/// A wizard for a new account seeded from <paramref name="defaults"/>, or — when
/// <paramref name="existing"/> is given — an edit wizard seeded from that account
/// and <paramref name="existingToken"/> (the vault's current token, or null when
/// the vault has no entry for it).
/// <paramref name="existing"/> is given — an edit wizard seeded from that account.
/// <paramref name="storedToken"/> fetches the vault's current token on demand for
/// an edit (null, or returning null, when the vault has no entry).
/// </summary>
public AccountWizardViewModel(
AccountsStore store,
IOrganizationLister orgLister,
AppSettings defaults,
Account? existing = null,
string? existingToken = null)
Func<string?>? storedToken = null,
IActivityLog? log = null)
: this(store, orgLister, existing, storedToken, log)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(orgLister);
ArgumentNullException.ThrowIfNull(defaults);
_store = store;
_orgLister = orgLister;
_existing = existing;
_seededToken = existingToken ?? "";
Step = 1;
NameError = "";
TokenError = "";
Token = _seededToken;
if (existing is null)
{
@@ -66,6 +63,50 @@ public sealed partial class AccountWizardViewModel : ObservableObject
}
}
/// <summary>
/// A wizard for a NEW account seeded from a working Quick Sync connection
/// (<paramref name="seed"/>): token, organization, folder, subfolder preference,
/// and parallelism are carried over so "save this connection as an account" is a
/// matter of naming it (#30). The token is treated as freshly typed, so it is
/// always written to the vault on save.
/// </summary>
public AccountWizardViewModel(
AccountsStore store, IOrganizationLister orgLister, AccountWizardSeed seed, IActivityLog? log = null)
: this(store, orgLister, existing: null, storedToken: null, log)
{
ArgumentNullException.ThrowIfNull(seed);
Name = "";
Description = "";
Token = seed.Token;
Organization = seed.Organization;
TargetRoot = seed.TargetRoot;
CreateOrgSubfolder = seed.CreateOrgSubfolder;
MaxConcurrency = seed.MaxConcurrency;
}
private AccountWizardViewModel(
AccountsStore store, IOrganizationLister orgLister, Account? existing, Func<string?>? storedToken, IActivityLog? log)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(orgLister);
_store = store;
_orgLister = orgLister;
_log = log ?? new NullActivityLog();
_existing = existing;
_storedToken = existing is null ? null : storedToken;
Step = 1;
NameError = "";
TokenError = "";
OrganizationError = "";
TargetError = "";
Token = "";
Name = "";
Description = "";
Organization = "";
TargetRoot = "";
}
/// <summary>Current wizard step, 1 (identity) through 4 (destination).</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsFirstStep))]
@@ -80,7 +121,11 @@ public sealed partial class AccountWizardViewModel : ObservableObject
[ObservableProperty]
public partial string Description { get; set; }
/// <summary>GitHub personal access token; validated when leaving step 2.</summary>
/// <summary>
/// GitHub personal access token as typed; validated when leaving step 2. For an
/// edit it starts empty and stays empty unless the user types a replacement —
/// the stored token is never loaded into it.
/// </summary>
[ObservableProperty]
public partial string Token { get; set; }
@@ -112,6 +157,14 @@ public sealed partial class AccountWizardViewModel : ObservableObject
[ObservableProperty]
public partial string TokenError { get; set; }
/// <summary>Step 3's validation message; empty once an organization is chosen.</summary>
[ObservableProperty]
public partial string OrganizationError { get; set; }
/// <summary>Step 4's validation message; empty once a target folder is chosen.</summary>
[ObservableProperty]
public partial string TargetError { get; set; }
/// <summary>Organizations the validated token can see; feeds step 3's editable dropdown.</summary>
public ObservableCollection<string> Organizations { get; } = new();
@@ -139,8 +192,9 @@ public sealed partial class AccountWizardViewModel : ObservableObject
/// <summary>
/// Validates the current step. Steps 1-3 advance and return true on success; step 4
/// returns true without advancing (the host then calls <see cref="SaveAsync"/>). On
/// failure the wizard stays put, with the step's error message set where one exists
/// (<see cref="NameError"/> on step 1, <see cref="TokenError"/> on step 2).
/// failure the wizard stays put with the step's error message set
/// (<see cref="NameError"/>, <see cref="TokenError"/>, <see cref="OrganizationError"/>,
/// <see cref="TargetError"/>) — a silent no-op on Next reads as a broken button (#30).
/// </summary>
public async Task<bool> TryAdvanceAsync()
{
@@ -168,20 +222,36 @@ public sealed partial class AccountWizardViewModel : ObservableObject
{
// The lister is the validation: it fails on a rejected or rate-limited
// token and returns the organizations the dropdown offers otherwise.
// An edit with the box left empty validates the STORED token, fetched
// here and used for this one call only.
string candidate = Token.Trim();
if (candidate.Length == 0 && IsEditing)
{
candidate = _storedToken?.Invoke()?.Trim() ?? "";
if (candidate.Length == 0)
{
TokenError = "This account has no stored token. Enter one to continue.";
return false;
}
}
IsValidatingToken = true;
_log.Info("Account wizard: validating the token.");
try
{
var organizations = await _orgLister.ListOrganizationsAsync(Token.Trim());
var organizations = await _orgLister.ListOrganizationsAsync(candidate);
Organizations.Clear();
foreach (string organization in organizations)
{
Organizations.Add(organization);
}
TokenError = "";
_log.Info($"Account wizard: token accepted; {organizations.Count} organizations and accounts visible.");
}
catch (Exception ex)
{
TokenError = ex.Message;
_log.Error($"Account wizard: token rejected: {ex.Message}", ex);
return false;
}
finally
@@ -194,13 +264,22 @@ public sealed partial class AccountWizardViewModel : ObservableObject
case 3:
if (string.IsNullOrWhiteSpace(Organization))
{
return false; // the dropdown allows free text, but not nothing
// The dropdown allows free text, but not nothing.
OrganizationError = "Choose an organization from the list, or type one.";
return false;
}
OrganizationError = "";
Step = 4;
return true;
default:
// Step 4: valid means "ready to save"; the host closes via SaveAsync.
return !string.IsNullOrWhiteSpace(TargetRoot);
if (string.IsNullOrWhiteSpace(TargetRoot))
{
TargetError = "Choose a target folder.";
return false;
}
TargetError = "";
return true;
}
}
@@ -224,9 +303,8 @@ public sealed partial class AccountWizardViewModel : ObservableObject
return Task.CompletedTask;
}
/// <summary>New accounts always persist their token; edits only when it was altered.</summary>
private bool TokenChanged
=> !IsEditing || !string.Equals(Token, _seededToken, StringComparison.Ordinal);
/// <summary>New accounts always persist their token; edits only when one was typed.</summary>
private bool TokenChanged => !IsEditing || Token.Trim().Length > 0;
private Account BuildAccount() => new()
{
+6
View File
@@ -117,10 +117,12 @@ public sealed class AccountsStore
updated.Add(account);
}
string verb = index >= 0 ? "updated" : "added";
if (token is null)
{
Persist(updated); // IO failures propagate; _accounts stays unchanged then.
_accounts = updated;
_log.Info($"Account '{account.Name}' {verb} (token unchanged).");
return;
}
@@ -165,6 +167,7 @@ public sealed class AccountsStore
}
_accounts = updated;
_log.Info($"Account '{account.Name}' {verb} with a new token.");
}
}
@@ -184,6 +187,7 @@ public sealed class AccountsStore
{
return;
}
string name = _accounts.First(a => a.Id == id).Name;
// Vault first: a failing Delete leaves both stores untouched. Keep the
// token in memory only long enough to put it back if the file write fails.
@@ -220,6 +224,7 @@ public sealed class AccountsStore
}
_accounts = remaining;
_log.Info($"Account '{name}' deleted" + (removedToken is null ? " (it had no token)." : " along with its token."));
}
}
@@ -245,6 +250,7 @@ public sealed class AccountsStore
};
Persist(updated);
_accounts = updated;
_log.Info($"Account '{updated[index].Name}': sync result recorded ({summary}).");
}
}
+65
View File
@@ -0,0 +1,65 @@
using gclo.Engine;
namespace gclo.ViewModels;
/// <summary>
/// Turns the per-row choices of the path-recovery UI (rename to this, or skip) into a
/// <see cref="PathRecovery"/>, and reports the choices that would make the apply fail
/// anyway — so the dialog can flag them inline instead of closing, failing in the
/// engine, and re-opening with a fresh list (#30). The engine still re-validates the
/// full effective tree on apply; this is the pre-flight for what the user can see.
/// </summary>
public static class PathRecoveryPlanner
{
/// <summary>One row's choice.</summary>
/// <param name="RepoPath">The invalid repository path, as reported.</param>
/// <param name="ReplacementPath">The full replacement path, or null when the name was left as is.</param>
/// <param name="Skip">True to omit the file or folder from the checkout.</param>
public sealed record Choice(string RepoPath, string? ReplacementPath, bool Skip);
/// <summary>The recovery to apply plus the reasons it would still fail, if any.</summary>
/// <param name="Recovery">Renames and skips, as the engine consumes them.</param>
/// <param name="Problems">Human-readable problems, one per offending row; empty when the plan is sound.</param>
public sealed record Plan(PathRecovery Recovery, IReadOnlyList<string> Problems);
/// <summary>
/// Builds the plan. A row that is neither skipped nor renamed is always a problem
/// (every listed path is invalid by definition), and replacement names go through
/// the same Windows rules as a checkout would, including two rows renamed onto one
/// destination.
/// </summary>
public static Plan Build(IReadOnlyList<Choice> choices)
{
ArgumentNullException.ThrowIfNull(choices);
// Git paths are case-sensitive, so both collections compare ordinally. The same
// repo path can appear in two rows (e.g. an invalid segment that also collides
// by case), which is why renames assign via the indexer instead of Add.
var renames = new Dictionary<string, string>(StringComparer.Ordinal);
var skipped = new HashSet<string>(StringComparer.Ordinal);
var problems = new List<string>();
foreach (Choice choice in choices)
{
if (choice.Skip)
{
skipped.Add(choice.RepoPath);
}
else if (choice.ReplacementPath is { } replacement)
{
renames[choice.RepoPath] = replacement;
}
else
{
problems.Add($"'{choice.RepoPath}' is still invalid: rename it or mark it Skip.");
}
}
foreach (InvalidPathInfo stillInvalid in WindowsPathValidator.ValidatePaths(renames.Values))
{
problems.Add($"'{stillInvalid.RepoPath}' cannot be used: {stillInvalid.Reason}.");
}
return new Plan(new PathRecovery(renames, skipped), problems);
}
}
+7
View File
@@ -81,6 +81,13 @@ public sealed partial class RepoItemViewModel : ObservableObject
OnPropertyChanged(nameof(IsIndeterminate));
}
/// <summary>
/// Re-raises the status change notification without a status change, so views
/// that derive theme-dependent values from <see cref="Status"/> (the row's status
/// brush) re-evaluate — used when the application theme changes mid-session.
/// </summary>
public void RefreshPresentation() => OnPropertyChanged(nameof(Status));
partial void OnPercentChanged(double? value)
{
OnPropertyChanged(nameof(StatusText));
+2
View File
@@ -65,6 +65,8 @@ public sealed class SyncAllCoordinator(IActivityLog log)
{
Announce(workspace, SyncAllAccountState.Queued);
}
_log.Info($"Sync all: queued {accountWorkspaces.Count} accounts: "
+ string.Join(", ", accountWorkspaces.Select(w => $"'{w.DisplayName}'")) + ".");
int ran = 0;
int skipped = 0;
+269 -39
View File
@@ -27,6 +27,16 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
private readonly Dictionary<string, RepoItemViewModel> _itemsByName = new(StringComparer.OrdinalIgnoreCase);
private CancellationTokenSource? _orgLoadCts;
/// <summary>
/// Position of every row in <see cref="Repos"/>; rebuilt on load and sort. Lets
/// progress-driven membership changes find a row's slot in <see cref="FilteredRepos"/>
/// by binary search instead of rebuilding the whole bound list (#30).
/// </summary>
private readonly Dictionary<RepoItemViewModel, int> _reposIndex = new();
/// <summary>Mirror of <see cref="FilteredRepos"/> for O(1) membership checks.</summary>
private readonly HashSet<RepoItemViewModel> _filteredSet = new();
/// <summary>Canceled on dispose so an in-flight path recovery dies with the workspace.</summary>
private readonly CancellationTokenSource _lifetimeCts = new();
@@ -45,6 +55,9 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
/// <summary>Breaks the AllSelected &lt;-&gt; item.IsSelected feedback loop while one side updates the other.</summary>
private bool _syncingSelection;
/// <summary>False until the constructor finished seeding properties; gates user-action logging.</summary>
private readonly bool _constructed;
/// <summary>First repository name from the last load; makes <see cref="TargetPreview"/> concrete.</summary>
private string? _sampleRepoName;
@@ -91,6 +104,8 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
MaxConcurrency = AppSettings.DefaultConcurrency;
StatusText = "";
ResultMessage = "";
LoadErrorTitle = "";
LoadErrorMessage = "";
AllSelected = true;
CanEditInputs = true;
@@ -103,6 +118,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
// The Token setter's existing org lookup fires naturally with the vault token.
Token = tokenVault?.TryRetrieve(account.Id) ?? "";
}
_constructed = true;
}
/// <summary>Id of the account this workspace was created for, or null for Quick Sync.</summary>
@@ -187,10 +203,56 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
[ObservableProperty]
public partial bool CreateOrgSubfolder { get; set; }
/// <summary>Header checkbox state: setting it checks or unchecks every row.</summary>
/// <summary>
/// Header checkbox state: setting it checks or unchecks every VISIBLE row (the rows
/// in <see cref="FilteredRepos"/>), and it reads true when every visible row is
/// selected. With no filter active that is every row; with a filter it makes
/// "filter, then select what you see" the subset workflow (#30). Hidden rows keep
/// their selection and still sync.
/// </summary>
[ObservableProperty]
public partial bool AllSelected { get; set; }
/// <summary>
/// Organization whose repositories the table currently holds (trimmed, as loaded);
/// null before the first successful load.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsTableStale))]
[NotifyPropertyChangedFor(nameof(StaleTableMessage))]
public partial string? LoadedOrganization { get; set; }
/// <summary>
/// True when <see cref="Organization"/> was edited after a load: the table still
/// shows the previous organization's rows while the chip and target path describe
/// the new one. Sync is blocked until a reload replaces the rows (#30).
/// </summary>
public bool IsTableStale
=> LoadedOrganization is not null
&& !string.Equals(Organization.Trim(), LoadedOrganization, StringComparison.OrdinalIgnoreCase);
/// <summary>One-line explanation shown while <see cref="IsTableStale"/>; empty otherwise.</summary>
public string StaleTableMessage
=> IsTableStale
? $"The table still shows '{LoadedOrganization}'. Reload to list '{Organization.Trim()}' before syncing."
: "";
/// <summary>Title of the error surface for a failed load or organization lookup.</summary>
[ObservableProperty]
public partial string LoadErrorTitle { get; set; }
/// <summary>Message of the most recent failed load or organization lookup; empty when none.</summary>
[ObservableProperty]
public partial string LoadErrorMessage { get; set; }
/// <summary>
/// True while the load-error surface is showing. Set by a failed load or
/// organization lookup, cleared when the next load starts or a lookup succeeds; the
/// page two-way binds it so the user can dismiss the bar (#30).
/// </summary>
[ObservableProperty]
public partial bool LoadErrorOpen { get; set; }
/// <summary>True once a sync run has finished (successfully, canceled, or faulted).</summary>
[ObservableProperty]
public partial bool HasCompletedRun { get; set; }
@@ -314,7 +376,22 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
partial void OnTargetFolderChanged(string value) => NotifyTargetPathChanged();
partial void OnOrganizationChanged(string value) => NotifyTargetPathChanged();
partial void OnOrganizationChanged(string value)
{
NotifyTargetPathChanged();
OnPropertyChanged(nameof(IsTableStale));
OnPropertyChanged(nameof(StaleTableMessage));
SyncCommand.NotifyCanExecuteChanged();
}
/// <summary>Records a failed load or lookup on the error surface (and the live region).</summary>
private void ShowLoadError(string title, string message)
{
StatusText = message;
LoadErrorTitle = title;
LoadErrorMessage = message;
LoadErrorOpen = true;
}
partial void OnCreateOrgSubfolderChanged(bool value) => NotifyTargetPathChanged();
@@ -326,11 +403,46 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
// Runs on the UI thread (Token is only set from UI handlers), so the async
// continuations below stay on the UI thread and may touch Organizations directly.
partial void OnTokenChanged(string value) => _ = RefreshOrganizationsAsync();
partial void OnTokenChanged(string value)
{
// The value itself never reaches the log; its length is enough to tell a
// paste from a cleared box when reading back a session (#40). Construction
// seeds the property (empty, or the vault token) and is not a user action.
if (_constructed)
{
_log.Info(value.Length == 0
? $"Token cleared in workspace '{DisplayName}'."
: $"Token entered in workspace '{DisplayName}' ({value.Length} characters).");
}
_ = RefreshOrganizationsAsync();
}
/// <summary>Stops the in-flight org lookup and path recovery, if any.</summary>
/// <summary>Records that the user opened the target folder from the results bar (#40).</summary>
public void NoteFolderOpened() => _log.Info($"Opened folder '{EffectiveTargetRoot}'.");
/// <summary>Asks every row to re-evaluate its theme-dependent presentation (see <see cref="RepoItemViewModel.RefreshPresentation"/>).</summary>
public void RefreshPresentation()
{
foreach (RepoItemViewModel item in Repos)
{
item.RefreshPresentation();
}
}
private bool _disposed;
/// <summary>
/// Stops the in-flight org lookup and path recovery, if any. Idempotent: the shell
/// disposes workspaces on eviction and again on window close, and a second call
/// used to throw ObjectDisposedException from the lifetime token source (#31).
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_orgLoadCts?.Cancel();
_orgLoadCts?.Dispose();
_orgLoadCts = null;
@@ -361,8 +473,10 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
{
await Task.Delay(_orgLookupDebounce, lookupToken); // debounce keystrokes / rapid pastes
IsLoadingOrgs = true;
_log.Info("Organization lookup started.");
var orgs = await _orgLister.ListOrganizationsAsync(token, lookupToken);
lookupToken.ThrowIfCancellationRequested();
_log.Info($"Organization lookup finished: {orgs.Count} organizations and accounts visible.");
// An editable ComboBox resets its Text when its ItemsSource is mutated,
// and the TwoWay binding would wipe a value that was already set — an
@@ -377,6 +491,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
}
Organization = organizationBeforeRefresh;
OnPropertyChanged(nameof(Organization));
LoadErrorOpen = false; // a lookup that works clears a stale lookup failure
// The production lister always lists the token's own account first, so a
// single entry means no organizations were visible.
StatusText = orgs.Count <= 1
@@ -391,7 +506,8 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
{
if (_orgLoadCts == cts)
{
StatusText = ex.Message;
_log.Error($"Organization lookup failed: {ex.Message}", ex);
ShowLoadError("Could not list organizations", ex.Message);
}
}
finally
@@ -442,7 +558,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
_syncingSelection = true;
try
{
foreach (RepoItemViewModel item in Repos)
foreach (RepoItemViewModel item in FilteredRepos)
{
item.IsSelected = value;
}
@@ -469,18 +585,21 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
}
if (!_syncingSelection)
{
// A header push recomputes once after its loop instead of per row.
// A header push (or any other batch) recomputes once after its loop
// instead of per row — including the command re-query, whose CanSync
// scans every row (#30).
RecomputeSelectedCount();
UpdateAllSelectedFromItems();
SyncCommand.NotifyCanExecuteChanged();
}
SyncCommand.NotifyCanExecuteChanged();
}
private void RecomputeSelectedCount() => SelectedCount = Repos.Count(r => r.IsSelected);
/// <summary>Header state follows the VISIBLE rows; see <see cref="AllSelected"/>.</summary>
private void UpdateAllSelectedFromItems()
{
bool all = Repos.All(r => r.IsSelected);
bool all = FilteredRepos.All(r => r.IsSelected);
if (AllSelected != all)
{
_syncingSelection = true;
@@ -510,6 +629,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
{
IsLoadingRepos = true;
ResetRunResult();
LoadErrorOpen = false;
try
{
string organization = Organization.Trim();
@@ -544,14 +664,18 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
SortColumn = null;
SortDescending = false;
_sampleRepoName = Repos.Count > 0 ? Repos[0].Name : null;
UpdateAllSelectedFromItems();
RebuildReposIndex();
LoadedOrganization = organization;
HasLoadedRepos = true;
StatusText = $"{Repos.Count} repositories loaded.";
_log.Info($"Loaded {Repos.Count} repositories for organization '{organization}'.");
}
catch (Exception ex)
{
StatusText = ex.Message;
// The message is good (the listers translate 401/404/rate limits); the
// surface must be too — an InfoBar with Retry, not caption text at the
// bottom of the window (#30).
ShowLoadError("Could not load repositories", ex.Message);
_log.Error($"Loading repositories failed: {ex.Message}", ex);
}
finally
@@ -559,7 +683,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
IsLoadingRepos = false;
NotifyTargetPathChanged();
RecomputeSelectedCount();
RebuildFilteredRepos();
RebuildFilteredRepos(); // also re-derives AllSelected from the visible rows
SyncCommand.NotifyCanExecuteChanged();
RetryFailedCommand.NotifyCanExecuteChanged();
RecomputeHasFailedRepos();
@@ -572,6 +696,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
!IsRunning
&& !IsLoadingRepos
&& !IsResolvingPaths
&& !IsTableStale
&& !string.IsNullOrWhiteSpace(TargetFolder)
&& Repos.Any(r => r.IsSelected);
@@ -597,6 +722,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
_runSet = selected;
TotalCount = selected.Count;
RecomputeCompletedCount();
RebuildFilteredRepos(); // every selected row just went back to Queued
string targetRoot = EffectiveTargetRoot;
StatusText = $"Syncing {selected.Count} repositories...";
@@ -604,7 +730,9 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
// Constructed on the UI thread: Progress<T> captures the WinUI
// SynchronizationContext, so HandleProgress always runs on the UI thread.
var progress = _progressFactory(HandleProgress);
// Failure logging is layered in front of it so the per-entry file I/O
// happens on the engine's worker thread, never on the dispatcher (#30).
var progress = new FailureLoggingProgress(_progressFactory(HandleProgress), _log);
var request = new SyncRequest(
Organization.Trim(), Token.Trim(), targetRoot, MaxConcurrency);
@@ -676,10 +804,24 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
[RelayCommand(CanExecute = nameof(CanRetryFailed))]
private async Task RetryFailedAsync()
{
foreach (RepoItemViewModel item in Repos)
_log.Info($"Retrying {Repos.Count(r => r.Status == SyncStatus.Failed)} failed repositories.");
// One batch: per-row recounts and command re-queries would be O(n^2) (#30).
_syncingSelection = true;
try
{
item.IsSelected = item.Status == SyncStatus.Failed;
foreach (RepoItemViewModel item in Repos)
{
item.IsSelected = item.Status == SyncStatus.Failed;
}
}
finally
{
_syncingSelection = false;
}
RecomputeSelectedCount();
UpdateAllSelectedFromItems();
SyncCommand.NotifyCanExecuteChanged();
// Executing SyncCommand itself keeps SyncCancelCommand working for retry runs.
await SyncCommand.ExecuteAsync(null);
}
@@ -708,9 +850,11 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
return;
}
_log.Info($"{item.Name}: path recovery requested ({item.InvalidPaths?.Count ?? 0} invalid paths).");
PathRecovery? recovery = await interaction(item);
if (recovery is null)
{
_log.Info($"{item.Name}: path recovery canceled; the repository stays failed.");
return; // user canceled; the row keeps its Failed state and payload
}
@@ -744,6 +888,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
IsResolvingPaths = false;
}
RecomputeCompletedCount();
UpdateFilteredMembership(item);
RetryFailedCommand.NotifyCanExecuteChanged();
}
@@ -779,15 +924,15 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
_ => OrderRepos(r => r.IsArchived, Comparer<bool>.Default),
};
// Move (rather than clear + re-add) so list controls keep row containers stable.
for (int target = 0; target < sorted.Count; target++)
// Nothing binds Repos (the table binds FilteredRepos), so a plain reorder is
// enough: O(n) adds instead of the O(n^2) IndexOf/Move choreography that
// used to run here, and the one rebuild below refreshes the bound list (#30).
Repos.Clear();
foreach (RepoItemViewModel item in sorted)
{
int current = Repos.IndexOf(sorted[target]);
if (current != target)
{
Repos.Move(current, target);
}
Repos.Add(item);
}
RebuildReposIndex();
RebuildFilteredRepos(); // the table binds FilteredRepos, which mirrors Repos' order
}
@@ -842,8 +987,8 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
if (report.Status == SyncStatus.Failed)
{
_log.Error($"{report.RepoName} failed: {report.Error}");
// Failures never reach StatusText (which has a live region), so assistive
// Logged already by FailureLoggingProgress on the worker thread. Failures
// never reach StatusText (which has a live region), so assistive
// technology hears them only through this explicit channel.
AnnouncementRequested?.Invoke($"{report.RepoName} failed. {report.Error}");
}
@@ -852,20 +997,39 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
{
RecomputeCompletedCount();
}
else if (statusBefore != item.Status && Filter is RepoFilter.Active or RepoFilter.Pending)
if (statusBefore != item.Status)
{
// Status-dependent filters must follow NON-terminal transitions too (a row
// entering Cloning leaves Pending and joins Active immediately); terminal
// ones already rebuild via RecomputeCompletedCount, and the rebuild's
// sequence check keeps redundant calls cheap and scroll-stable.
RebuildFilteredRepos();
// Status-dependent filters follow every transition (a row entering Cloning
// leaves Pending and joins Active immediately). Only this one row can have
// changed membership, so it is inserted or removed in place — no O(n)
// rebuild, no Reset notification, no scroll jump (#30).
UpdateFilteredMembership(item);
}
}
/// <summary>
/// Logs failed reports on the thread that produced them (the engine's worker), then
/// forwards every report to the UI-marshaled progress. FileActivityLog opens,
/// appends, and closes the file per entry; a mass failure (revoked PAT, dropped
/// network) used to put a thousand of those on the UI thread (#30).
/// </summary>
private sealed class FailureLoggingProgress(IProgress<RepoProgress> inner, IActivityLog log)
: IProgress<RepoProgress>
{
public void Report(RepoProgress value)
{
if (value.Status == SyncStatus.Failed)
{
log.Error($"{value.RepoName} failed: {value.Error}");
}
inner.Report(value);
}
}
/// <summary>
/// Recounts terminal rows within the captured run set (#22) — before the first run
/// the set is empty and the count stays 0. Also the hook for everything that follows
/// a terminal transition: the failed-rows flag and the filtered table.
/// the set is empty and the count stays 0. Also refreshes the failed-rows flag.
/// </summary>
private void RecomputeCompletedCount()
{
@@ -879,7 +1043,6 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
}
CompletedCount = completed;
RecomputeHasFailedRepos();
RebuildFilteredRepos();
}
private void RecomputeHasFailedRepos()
@@ -887,24 +1050,91 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
// ---------------------------------------------------------------- filtering
/// <summary>Refreshes the row -> position map after <see cref="Repos"/> was reloaded or reordered.</summary>
private void RebuildReposIndex()
{
_reposIndex.Clear();
for (int i = 0; i < Repos.Count; i++)
{
_reposIndex[Repos[i]] = i;
}
}
/// <summary>
/// Rebuilds <see cref="FilteredRepos"/> from <see cref="Repos"/>, preserving the
/// table's current sort order. A no-op when the visible set is already correct, so
/// list controls keep their scroll position across progress ticks.
/// table's current sort order, then re-derives the header checkbox from the
/// visible rows. Used when the filter, the table contents, or the sort order
/// change; single-row status transitions go through
/// <see cref="UpdateFilteredMembership"/> instead. A no-op when the visible set
/// is already correct, so list controls keep their scroll position.
/// </summary>
private void RebuildFilteredRepos()
{
List<RepoItemViewModel> desired = Repos.Where(MatchesFilter).ToList();
if (desired.SequenceEqual(FilteredRepos))
if (!desired.SequenceEqual(FilteredRepos))
{
FilteredRepos.Clear();
_filteredSet.Clear();
foreach (RepoItemViewModel repo in desired)
{
FilteredRepos.Add(repo);
_filteredSet.Add(repo);
}
}
UpdateAllSelectedFromItems();
}
/// <summary>
/// Applies one row's current filter match to <see cref="FilteredRepos"/>: inserts it
/// at its table position (binary search on <see cref="_reposIndex"/>) when it became
/// visible, removes it when it became hidden, and does nothing when its membership
/// did not change. O(log n) instead of the former O(n) rebuild per progress report.
/// </summary>
private void UpdateFilteredMembership(RepoItemViewModel item)
{
bool visible = MatchesFilter(item);
bool wasVisible = _filteredSet.Contains(item);
if (visible == wasVisible)
{
return;
}
FilteredRepos.Clear();
foreach (RepoItemViewModel repo in desired)
int slot = FindFilteredSlot(item);
if (visible)
{
FilteredRepos.Add(repo);
FilteredRepos.Insert(slot, item);
_filteredSet.Add(item);
}
else
{
FilteredRepos.RemoveAt(slot);
_filteredSet.Remove(item);
}
UpdateAllSelectedFromItems();
}
/// <summary>
/// Index in <see cref="FilteredRepos"/> where <paramref name="item"/> sits (when
/// present) or belongs (when absent), by its position in <see cref="Repos"/>.
/// </summary>
private int FindFilteredSlot(RepoItemViewModel item)
{
int position = _reposIndex[item];
int low = 0;
int high = FilteredRepos.Count;
while (low < high)
{
int mid = (low + high) / 2;
if (_reposIndex[FilteredRepos[mid]] < position)
{
low = mid + 1;
}
else
{
high = mid;
}
}
return low;
}
private bool MatchesFilter(RepoItemViewModel repo)
+38
View File
@@ -0,0 +1,38 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"CommunityToolkit.Mvvm": {
"type": "Direct",
"requested": "[8.4.2, )",
"resolved": "8.4.2",
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
},
"LibGit2Sharp": {
"type": "Transitive",
"resolved": "0.32.0",
"contentHash": "BL3eMVRnkWWS9zbz6n0H+iDVJ9ZK7hDeiuASnHlsFvwZYTJtDuBFRRVIaH//s4VlF0baOGvCeenXbobE1JLLiQ==",
"dependencies": {
"LibGit2Sharp.NativeBinaries": "[2.0.324]"
}
},
"LibGit2Sharp.NativeBinaries": {
"type": "Transitive",
"resolved": "2.0.324",
"contentHash": "5YZVGNuO9nYFQfzRQGPGY9fdpSsABwlF2jSaZ7ydxCfjvt5Krbj/eHbXkvvgAQzp4lkjvgDrlAuuI4KTmqhDlA=="
},
"Octokit": {
"type": "Transitive",
"resolved": "14.0.0",
"contentHash": "jGOuTH1l+TCpJH+fwYOp7USzHDuGfN1jKbLz3J2COwyn+wL08eynvpnM6rY2qkzIEXum3PN2p2QkP3BW/p9Qcw=="
},
"gclo.engine": {
"type": "Project",
"dependencies": {
"LibGit2Sharp": "[0.32.0, )",
"Octokit": "[14.0.0, )"
}
}
}
}
}
+16 -21
View File
@@ -3,6 +3,7 @@
x:Class="gclo.AboutDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:gclo"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
@@ -79,27 +80,21 @@
<!-- Third-party attributions -->
<StackPanel Spacing="4">
<TextBlock Text="Open source components" Style="{ThemeResource BodyStrongTextBlockStyle}" />
<TextBlock Style="{ThemeResource CaptionTextBlockStyle}" TextWrapping="Wrap">
<Run FontWeight="SemiBold" Text="Octokit" /><Run Text=" — MIT License" />
</TextBlock>
<TextBlock Style="{ThemeResource CaptionTextBlockStyle}" TextWrapping="Wrap">
<Run FontWeight="SemiBold" Text="LibGit2Sharp" /><Run Text=" — MIT License" />
</TextBlock>
<TextBlock Style="{ThemeResource CaptionTextBlockStyle}" TextWrapping="Wrap">
<Run FontWeight="SemiBold" Text="libgit2 (native library)" /><Run Text=" — GPLv2 with Linking Exception" />
</TextBlock>
<TextBlock Style="{ThemeResource CaptionTextBlockStyle}" TextWrapping="Wrap">
<Run FontWeight="SemiBold" Text="CommunityToolkit.Mvvm" /><Run Text=" — MIT License" />
</TextBlock>
<TextBlock Style="{ThemeResource CaptionTextBlockStyle}" TextWrapping="Wrap">
<Run FontWeight="SemiBold" Text="Velopack" /><Run Text=" — MIT License" />
</TextBlock>
<TextBlock Style="{ThemeResource CaptionTextBlockStyle}" TextWrapping="Wrap">
<Run FontWeight="SemiBold" Text="Windows App SDK / WinUI 3" /><Run Text=" — MIT License, with Microsoft binary components" />
</TextBlock>
<TextBlock Style="{ThemeResource CaptionTextBlockStyle}" TextWrapping="Wrap">
<Run FontWeight="SemiBold" Text=".NET Runtime" /><Run Text=" — MIT License" />
</TextBlock>
<!-- Data, not seven copies of one TextBlock: see AboutDialog.Attributions. -->
<ItemsControl ItemsSource="{x:Bind Attributions}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="4" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="local:Attribution">
<TextBlock Style="{ThemeResource CaptionTextBlockStyle}" TextWrapping="Wrap">
<Run FontWeight="SemiBold" Text="{x:Bind Name}" /><Run Text="{x:Bind LicenseText}" />
</TextBlock>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<TextBlock
+23
View File
@@ -1,9 +1,17 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using Microsoft.UI.Xaml.Controls;
namespace gclo
{
/// <summary>One third-party component credited in the About dialog.</summary>
public sealed record Attribution(string Name, string License)
{
/// <summary>The license as rendered after the name.</summary>
public string LicenseText => " — " + License;
}
/// <summary>
/// About dialog: app identity and version, project link, MIT license notice,
/// and third-party attributions. The caller must set <see cref="ContentDialog.XamlRoot"/>
@@ -11,6 +19,21 @@ namespace gclo
/// </summary>
public sealed partial class AboutDialog : ContentDialog
{
/// <summary>
/// The components gclo ships with. A dependency change is a one-line edit here;
/// the dialog renders them uniformly from a single template.
/// </summary>
public IReadOnlyList<Attribution> Attributions { get; } =
[
new("Octokit", "MIT License"),
new("LibGit2Sharp", "MIT License"),
new("libgit2 (native library)", "GPLv2 with Linking Exception"),
new("CommunityToolkit.Mvvm", "MIT License"),
new("Velopack", "MIT License"),
new("Windows App SDK / WinUI 3", "MIT License, with Microsoft binary components"),
new(".NET Runtime", "MIT License"),
];
public AboutDialog()
{
InitializeComponent();
+21 -2
View File
@@ -17,8 +17,11 @@
PrimaryButtonClick="OnPrimaryButtonClick"
SecondaryButtonClick="OnSecondaryButtonClick">
<!-- The four step panels overlay each other; StepVisible shows exactly one. -->
<Grid MinWidth="400">
<!-- The four step panels overlay each other; StepVisible shows exactly one. The
MinHeight is the tallest step (token + hint + validation row + error), so the
dialog keeps one footprint across steps instead of resizing and re-centering
under the pointer on every Next (#31). -->
<Grid MinWidth="400" MinHeight="280">
<!-- ============================================================ Step 1
Identity: the unique display name plus an optional note. -->
@@ -108,6 +111,14 @@
PlaceholderText="Choose from the list, or type a name"
Loaded="OrgBox_Loaded"
Text="{x:Bind ViewModel.Organization, Mode=TwoWay}" />
<TextBlock
x:Name="OrganizationErrorText"
AutomationProperties.AutomationId="WizardOrganizationError"
AutomationProperties.LiveSetting="Polite"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
TextWrapping="Wrap"
Text="{x:Bind ViewModel.OrganizationError, Mode=OneWay}"
Visibility="{x:Bind local:StatusFormat.VisibleIfNotEmpty(ViewModel.OrganizationError), Mode=OneWay}" />
</StackPanel>
<!-- ============================================================ Step 4
@@ -135,6 +146,14 @@
Content="Browse…"
Click="BrowseButton_Click" />
</Grid>
<TextBlock
x:Name="TargetErrorText"
AutomationProperties.AutomationId="WizardTargetError"
AutomationProperties.LiveSetting="Polite"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
TextWrapping="Wrap"
Text="{x:Bind ViewModel.TargetError, Mode=OneWay}"
Visibility="{x:Bind local:StatusFormat.VisibleIfNotEmpty(ViewModel.TargetError), Mode=OneWay}" />
<CheckBox
MinWidth="0"
Content="{x:Bind OrgSubfolderLabel(ViewModel.Organization), Mode=OneWay}"
+10 -13
View File
@@ -42,13 +42,13 @@ namespace gclo
_windowHandleProvider = windowHandleProvider;
InitializeComponent();
// PasswordBox has no reliable two-way binding: seed it with the token in
// effect (editing an account), and mirror edits back by hand. The seed's
// PasswordChanged echo writes the same value back, a no-op set.
if (ViewModel.Token.Length > 0)
{
TokenBox.Password = ViewModel.Token;
}
// PasswordBox has no reliable two-way binding, so edits are mirrored back
// by hand. The stored token of an existing account is deliberately NOT
// loaded into the box: an empty box means "keep it", and the secret never
// sits in an editable control with a reveal button (#32).
TokenBox.PlaceholderText = ViewModel.IsEditing
? "Leave empty to keep the stored token"
: "ghp_…";
// NumberBox.Value is a double; the int is mirrored by hand (see ValueChanged).
ConcurrencyBox.Value = ViewModel.MaxConcurrency;
@@ -192,16 +192,13 @@ namespace gclo
/// </summary>
private void AnnounceStepError()
{
TextBlock? errorText = ViewModel.Step switch
TextBlock errorText = ViewModel.Step switch
{
1 => NameErrorText,
2 => TokenErrorText,
_ => null, // steps 3 and 4 have no message property; the fields show what is required
3 => OrganizationErrorText,
_ => TargetErrorText,
};
if (errorText is null)
{
return;
}
DispatcherQueue.TryEnqueue(() =>
{
+87
View File
@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="utf-8"?>
<UserControl
x:Class="gclo.ConnectionFormControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:gclo"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
IsTabStop="False"
Loaded="OnLoaded">
<!-- The one connection form: token, organization, target folder, subfolder
preference, and the path preview. Hosted by the connect card and by the
chip's Edit flyout (WorkspacePage), both over the same view model, so a
change here lands in both places and the FlaUI suite can target either
through its AutomationIdPrefix. -->
<StackPanel Spacing="12">
<PasswordBox
x:Name="TokenBox"
AutomationProperties.AutomationId="{x:Bind Id('TokenBox')}"
Header="Personal access token"
ToolTipService.ToolTip="Fine-grained or classic PAT with repository read access; used for the GitHub API and git over HTTPS. Never written to disk or logs."
PlaceholderText="ghp_…"
PasswordChanged="TokenBox_PasswordChanged" />
<HyperlinkButton
Padding="0"
Content="How to create a token"
NavigateUri="https://github.com/settings/tokens" />
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ComboBox
Grid.Column="0"
AutomationProperties.AutomationId="{x:Bind Id('OrgBox')}"
HorizontalAlignment="Stretch"
Header="Organization or account"
ToolTipService.ToolTip="The GitHub organization or user account whose repositories are synced. Populated from the token; typing a name also works."
IsEditable="True"
ItemsSource="{x:Bind ViewModel.Organizations, Mode=OneTime}"
PlaceholderText="Paste a token first, or type an org"
Loaded="OrgBox_Loaded"
Text="{x:Bind ViewModel.Organization, Mode=TwoWay}" />
<ProgressRing
Grid.Column="1"
Width="20"
Height="20"
Margin="0,0,0,8"
VerticalAlignment="Bottom"
AutomationProperties.Name="Loading organizations"
IsActive="{x:Bind ViewModel.IsLoadingOrgs, Mode=OneWay}" />
</Grid>
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox
Grid.Column="0"
AutomationProperties.AutomationId="{x:Bind Id('TargetBox')}"
ToolTipService.ToolTip="Local folder each repository is cloned into (one subfolder per repository)"
Header="Target folder"
PlaceholderText="C:\src\my-org"
Text="{x:Bind ViewModel.TargetFolder, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<Button
Grid.Column="1"
AutomationProperties.AutomationId="{x:Bind Id('BrowseButton')}"
ToolTipService.ToolTip="Choose the target folder"
VerticalAlignment="Bottom"
Content="Browse…"
Click="BrowseButton_Click" />
</Grid>
<CheckBox
MinWidth="0"
Content="{x:Bind OrgSubfolderLabel(ViewModel.Organization), Mode=OneWay}"
IsChecked="{x:Bind ViewModel.CreateOrgSubfolder, Mode=TwoWay}" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
Visibility="{x:Bind local:StatusFormat.VisibleIfNotEmpty(ViewModel.TargetPreview), Mode=OneWay}">
<Run Text="Each repository will be placed at:" />
<Run FontWeight="SemiBold" Text="{x:Bind ViewModel.TargetPreview, Mode=OneWay}" />
</TextBlock>
</StackPanel>
</UserControl>
+113
View File
@@ -0,0 +1,113 @@
using System;
using gclo.ViewModels;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace gclo
{
/// <summary>
/// The connection form (token, organization, target folder, org subfolder, path
/// preview) over a <see cref="WorkspaceViewModel"/>. One definition for the connect
/// card and the chip's Edit flyout, which used to carry diverging copies (#31).
/// <see cref="AutomationIdPrefix"/> gives each host its own UIA ids
/// ("ConnectTokenBox", "EditTokenBox", ...).
/// </summary>
public sealed partial class ConnectionFormControl : UserControl
{
/// <summary>The workspace the form edits; set by the host before the control loads.</summary>
public static readonly DependencyProperty ViewModelProperty = DependencyProperty.Register(
nameof(ViewModel), typeof(WorkspaceViewModel), typeof(ConnectionFormControl), new PropertyMetadata(null));
/// <summary>Prefix for the form's AutomationIds, e.g. "Connect" or "Edit".</summary>
public static readonly DependencyProperty AutomationIdPrefixProperty = DependencyProperty.Register(
nameof(AutomationIdPrefix), typeof(string), typeof(ConnectionFormControl), new PropertyMetadata(""));
public ConnectionFormControl()
{
InitializeComponent();
}
/// <summary>The workspace state this form edits; target of every x:Bind.</summary>
public WorkspaceViewModel ViewModel
{
get => (WorkspaceViewModel)GetValue(ViewModelProperty);
set => SetValue(ViewModelProperty, value);
}
/// <summary>Prefix for the form's AutomationIds, e.g. "Connect" or "Edit".</summary>
public string AutomationIdPrefix
{
get => (string)GetValue(AutomationIdPrefixProperty);
set => SetValue(AutomationIdPrefixProperty, value);
}
/// <summary>
/// Supplies the host window's HWND for the folder picker (a UserControl has no
/// HWND of its own). Set by the host; Browse is a no-op without it.
/// </summary>
public Func<nint>? WindowHandleProvider { get; set; }
/// <summary>Composes an AutomationId from the host's prefix.</summary>
public string Id(string suffix) => AutomationIdPrefix + suffix;
/// <summary>Label for the org-subfolder checkbox; names the actual organization once one is chosen.</summary>
public string OrgSubfolderLabel(string organization)
=> string.IsNullOrWhiteSpace(organization)
? "Create org subfolder"
: $"Create {organization.Trim()} subfolder";
// Runs on every host load (the card once, the flyout on each open). A token the
// view model already holds (an account's vault token, or the saved default
// token) is deliberately NOT mirrored into the box: the placeholder says what
// is in effect, and an untouched box keeps it. Only typing replaces (#32).
private void OnLoaded(object sender, RoutedEventArgs e)
{
TokenBox.Password = "";
TokenBox.PlaceholderText = ViewModel is null || ViewModel.Token.Length == 0
? "ghp_…"
: ViewModel.AccountId is null
? "Using the saved default token — type here to replace it"
: "Using the account's stored token — type here to replace it";
}
// PasswordBox does not support reliable two-way x:Bind on Password; mirror it
// into the view model by hand.
private void TokenBox_PasswordChanged(object sender, RoutedEventArgs e)
{
if (ViewModel is not null)
{
ViewModel.Token = ((PasswordBox)sender).Password;
}
}
// An editable ComboBox does not render Text that was set before its template
// loaded, so an account workspace's seeded org would show as blank even though
// the view model holds it — see EditableComboBox.
private void OrgBox_Loaded(object sender, RoutedEventArgs e)
{
if (ViewModel is not null)
{
EditableComboBox.ReapplyText((ComboBox)sender, ViewModel.Organization);
}
}
private async void BrowseButton_Click(object sender, RoutedEventArgs e)
{
if (ViewModel is null || WindowHandleProvider is not { } handle)
{
return;
}
var picker = new Windows.Storage.Pickers.FolderPicker();
picker.FileTypeFilter.Add("*"); // required in packaged apps
WinRT.Interop.InitializeWithWindow.Initialize(picker, handle());
var folder = await picker.PickSingleFolderAsync();
if (folder is not null)
{
ViewModel.TargetFolder = folder.Path;
}
}
}
}
+3
View File
@@ -17,6 +17,9 @@ namespace gclo
{
private static bool _dialogOpen;
/// <summary>True while a dialog shown through <see cref="ShowAsync"/> is on screen.</summary>
public static bool IsDialogOpen => _dialogOpen;
/// <summary>
/// Shows <paramref name="dialog"/> unless another dialog is on screen.
/// Returns the dialog result, or null when the request was ignored —
+26 -8
View File
@@ -9,12 +9,13 @@
mc:Ignorable="d"
Title="gclo">
<Window.SystemBackdrop>
<MicaBackdrop />
</Window.SystemBackdrop>
<Grid>
<!-- The backdrop is chosen in code: Mica where supported (Windows 11), acrylic
where only that is, and a plain themed background on the Windows 10 floor the
app claims to support — a declared MicaBackdrop is silently ignored there and
the semi-transparent card brushes would sit on an unthemed surface (#31). -->
<Grid x:Name="RootGrid">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
@@ -30,7 +31,7 @@
</MenuBarItem>
<MenuBarItem Title="Help" AccessKey="H">
<MenuFlyoutItem Text="View on GitHub" AccessKey="G" Click="GitHubMenuItem_Click" />
<MenuFlyoutItem Text="Check for updates…" AccessKey="C" AutomationProperties.AutomationId="MenuCheckUpdates" Click="CheckForUpdatesMenuItem_Click" />
<MenuFlyoutItem x:Name="CheckUpdatesMenuItem" Text="Check for updates…" AccessKey="C" AutomationProperties.AutomationId="MenuCheckUpdates" Click="CheckForUpdatesMenuItem_Click" />
<MenuFlyoutSeparator />
<MenuFlyoutItem Text="About gclo…" AccessKey="A" AutomationProperties.AutomationId="MenuAbout" Click="AboutMenuItem_Click">
<MenuFlyoutItem.KeyboardAccelerators>
@@ -40,13 +41,30 @@
</MenuBarItem>
</MenuBar>
<!-- Update flow surface (#39): checking / downloading with a progress bar, and
the up-to-date / failure outcomes inline, instead of a silent wait followed by
a modal. Only the "update available" decision still uses a dialog. -->
<InfoBar
x:Name="UpdateBar"
AutomationProperties.AutomationId="UpdateBar"
Grid.Row="1"
Margin="12,8,12,0"
IsClosable="True"
Title="Updates">
<ProgressBar
x:Name="UpdateProgressBar"
Margin="0,0,0,8"
Maximum="100"
Visibility="Collapsed" />
</InfoBar>
<!-- Workspace shell: Quick Sync is pinned first; one item per saved account is
added below the separator in code-behind (the store is runtime data).
Content hosts the selected workspace's cached WorkspacePage. -->
<NavigationView
x:Name="WorkspaceNav"
AutomationProperties.AutomationId="WorkspaceNav"
Grid.Row="1"
Grid.Row="2"
PaneDisplayMode="Left"
IsBackButtonVisible="Collapsed"
IsSettingsVisible="False"
@@ -93,6 +111,6 @@
<local:SplashOverlay
x:Name="StartupSplash"
Grid.Row="0"
Grid.RowSpan="2" />
Grid.RowSpan="3" />
</Grid>
</Window>
+323 -73
View File
@@ -29,12 +29,20 @@ namespace gclo
private const string RepoUrl = "https://github.com/KofTwentyTwo/gclo";
// Smallest logical (DPI-independent) size at which the workspace toolbar,
// connect card, and repo table remain usable.
private const int MinWindowWidth = 700;
// connect card, and repo table remain usable WITH the navigation pane open:
// the pane takes 240, page and card padding 64, the fixed table columns 400,
// and the Name column needs room to show a name (#31).
private const int MinWindowWidth = 900;
private const int MinWindowHeight = 520;
/// <summary>Set once the root's theme-change subscription exists.</summary>
private bool _themeHooked;
private readonly AppSettings _settings;
private readonly UpdateService _updateService = new();
private readonly UpdateService _updateService;
/// <summary>Guards Help > Check for updates against re-entry while a check or download runs.</summary>
private bool _updateInFlight;
// One log, vault, and store shared by every workspace (and the log viewer),
// so all workspaces write to the same file and read the same accounts.
@@ -42,8 +50,23 @@ namespace gclo
private readonly ITokenVault _tokenVault;
private readonly AccountsStore _accountsStore;
/// <summary>
/// A cached workspace: the view model always (it keeps a running sync alive
/// across navigation and costs little), the page only while it is on screen
/// or its view model is busy. A page pins a whole visual tree — connect card,
/// toolbar, flyouts, a ListView's realized rows — so idle pages are released
/// when the user navigates away and rebuilt from the view model on return
/// (only transient scroll position is lost) (#30).
/// </summary>
private sealed class WorkspaceEntry(WorkspaceViewModel viewModel)
{
public WorkspaceViewModel ViewModel { get; } = viewModel;
public WorkspacePage? Page { get; set; }
}
/// <summary>Workspaces created so far, keyed by account id (Guid.Empty = Quick Sync).</summary>
private readonly Dictionary<Guid, (WorkspaceViewModel ViewModel, WorkspacePage Page)> _workspaces = new();
private readonly Dictionary<Guid, WorkspaceEntry> _workspaces = new();
/// <summary>Badge subscriptions per workspace, unhooked before the view models are disposed.</summary>
private readonly Dictionary<Guid, PropertyChangedEventHandler> _badgeHandlers = new();
@@ -68,10 +91,13 @@ namespace gclo
{
_log = new FileActivityLog(System.IO.Path.Combine(GcloPaths.DataRoot, "logs"));
App.CrashLog = _log; // the unhandled-exception net now reaches the activity log
_log.Info($"gclo {BuildVersion.Describe(typeof(MainWindow).Assembly)} starting.");
_updateService = new UpdateService(_log);
_tokenVault = new CredentialManagerVault();
_accountsStore = new AccountsStore(_tokenVault, log: _log);
InitializeComponent();
ApplyBackdrop();
Title = "gclo — Git Clone Large Organizations";
@@ -90,7 +116,7 @@ namespace gclo
// Create and show Quick Sync before selecting it, so NavigationView.Content
// is never empty regardless of when SelectionChanged fires.
ShowWorkspace(Guid.Empty);
ApplySettings();
ApplySettings(seedQuickSyncConcurrency: true);
WorkspaceNav.SelectedItem = QuickSyncNavItem;
// AppWindow.Resize takes physical pixels; scale by the monitor DPI so the
@@ -116,6 +142,7 @@ namespace gclo
{
_logWindow?.Close(); // a log-only process would linger otherwise
DisposeWorkspaces();
_log.Info("gclo closing.");
};
// The splash overlay honors Settings → Advanced: skipped entirely when
@@ -126,23 +153,85 @@ namespace gclo
{
await Task.Delay(_settings.SplashMilliseconds);
await StartupSplash.DismissAsync();
await CheckForUpdatesAtStartupAsync();
});
}
else
{
StartupSplash.Visibility = Visibility.Collapsed;
DispatcherQueue.TryEnqueue(async () => await CheckForUpdatesAtStartupAsync());
}
}
private void ApplySettings()
/// <summary>
/// A background check on every launch (installed builds only): a newer release
/// is offered in the update bar with an "Update and restart…" action that runs
/// the same flow as Help > Check for updates; nothing is shown when up to date
/// or when the check fails (the activity log records both) (#32).
/// </summary>
private async Task CheckForUpdatesAtStartupAsync()
{
if (!_updateService.IsSupported || _updateInFlight)
{
return;
}
UpdateCheckResult result = await _updateService.CheckAsync();
if (result.AvailableVersion is not { } version)
{
return;
}
ShowUpdateBar(InfoBarSeverity.Informational, $"gclo v{version} is available.", busy: false);
var install = new Button { Content = "Update and restart…" };
install.Click += async (_, _) => await RunUpdateFlowAsync();
UpdateBar.ActionButton = install;
}
/// <summary>
/// Applies the theme and seeds the ad-hoc Quick Sync workspace from the settings
/// defaults (account workspaces carry their own configuration). The target
/// folder is only filled when blank; the parallelism default is pushed only
/// when <paramref name="seedQuickSyncConcurrency"/> — at startup, and after a
/// Settings save that actually changed it — so saving an unrelated setting
/// (theme, splash) cannot silently reset a value the user tuned in the
/// workspace's Options flyout (#30).
/// </summary>
/// <summary>
/// Mica where the OS supports it, acrylic where only that does, otherwise the
/// themed page background so the layered card brushes have a surface to sit on.
/// </summary>
private void ApplyBackdrop()
{
if (Microsoft.UI.Composition.SystemBackdrops.MicaController.IsSupported())
{
SystemBackdrop = new MicaBackdrop();
}
else if (Microsoft.UI.Composition.SystemBackdrops.DesktopAcrylicController.IsSupported())
{
SystemBackdrop = new DesktopAcrylicBackdrop();
}
else if (Application.Current.Resources.TryGetValue("ApplicationPageBackgroundThemeBrush", out object? brush)
&& brush is Brush background)
{
RootGrid.Background = background;
}
}
private void ApplySettings(bool seedQuickSyncConcurrency)
{
if (Content is FrameworkElement root)
{
root.RequestedTheme = StatusFormat.ToElementTheme(_settings.Theme);
if (!_themeHooked)
{
// Covers an OS light/dark flip while running in System mode too.
root.ActualThemeChanged += (sender, _) => OnThemeChanged(sender.ActualTheme);
_themeHooked = true;
}
OnThemeChanged(root.ActualTheme);
}
// Settings defaults only seed the ad-hoc Quick Sync workspace; account
// workspaces carry their own configuration.
if (_workspaces.TryGetValue(Guid.Empty, out var quickSync))
{
WorkspaceViewModel viewModel = quickSync.ViewModel;
@@ -151,10 +240,35 @@ namespace gclo
{
viewModel.TargetFolder = _settings.DefaultTargetFolder;
}
viewModel.MaxConcurrency = _settings.DefaultMaxConcurrency;
if (seedQuickSyncConcurrency)
{
viewModel.MaxConcurrency = _settings.DefaultMaxConcurrency;
}
}
}
/// <summary>
/// Keeps everything that is not a ThemeResource reference in step with the
/// effective theme: the function-bound status brushes (resolved from the
/// matching theme dictionary from now on), the rows that cache them (asked to
/// re-evaluate), the pane badges, and the title bar (#31).
/// </summary>
private void OnThemeChanged(ElementTheme actual)
{
StatusFormat.CurrentTheme = actual;
foreach ((Guid id, WorkspaceEntry workspace) in _workspaces)
{
workspace.ViewModel.RefreshPresentation();
OnWorkspaceStateChanged(id);
}
AppWindow.TitleBar.PreferredTheme = actual switch
{
ElementTheme.Light => TitleBarTheme.Light,
ElementTheme.Dark => TitleBarTheme.Dark,
_ => TitleBarTheme.UseDefaultAppMode,
};
}
// ---------------------------------------------------------------- workspaces
private async void WorkspaceNav_SelectionChanged(
@@ -183,10 +297,15 @@ namespace gclo
if (item.Tag is Guid id)
{
if (id != _currentWorkspaceId)
{
ReleasePageIfIdle(_currentWorkspaceId);
}
ShowWorkspace(id);
if (_workspaces.ContainsKey(id))
if (_workspaces.TryGetValue(id, out WorkspaceEntry? shown))
{
_currentWorkspaceId = id;
_log.Info($"Showing workspace '{shown.ViewModel.DisplayName}'.");
}
}
}
@@ -213,25 +332,57 @@ namespace gclo
/// <summary>
/// Puts the workspace for <paramref name="id"/> on screen, creating and caching
/// it on first visit. Cached view models keep running syncs alive across switches.
/// its view model on first visit and (re)building its page when none is cached.
/// Cached view models keep running syncs alive across switches.
/// </summary>
private void ShowWorkspace(Guid id)
{
if (EnsureWorkspace(id) is { } workspace)
{
workspace.Page ??= CreatePage(workspace.ViewModel);
WorkspaceNav.Content = workspace.Page;
}
}
private WorkspacePage CreatePage(WorkspaceViewModel viewModel)
=> new(viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this))
{
EditAccountRequested = ShowEditAccountWizardAsync,
SaveAsAccountRequested = ShowSeededAccountWizardAsync,
};
/// <summary>
/// Drops the page of a workspace the user navigated away from when nothing is
/// running in it; a busy workspace keeps its page (its path-recovery dialog and
/// announcements route through it). The view model stays cached either way.
/// </summary>
private void ReleasePageIfIdle(Guid id)
{
if (!_workspaces.TryGetValue(id, out WorkspaceEntry? workspace) || workspace.Page is null)
{
return;
}
WorkspaceViewModel viewModel = workspace.ViewModel;
if (viewModel.IsRunning || viewModel.IsLoadingRepos || viewModel.IsResolvingPaths)
{
return;
}
workspace.Page.Detach();
workspace.Page = null;
}
/// <summary>
/// Returns the cached workspace for <paramref name="id"/>, creating and caching
/// it (badge subscription included) on first use WITHOUT putting it on screen —
/// 'Sync all' warms unvisited workspaces this way. Null when the account no
/// longer exists in the store.
/// its view model (badge subscription included) on first use WITHOUT building a
/// page or putting it on screen — 'Sync all' warms unvisited workspaces this
/// way and never needs the page. Null when the account no longer exists in the
/// store.
/// </summary>
private (WorkspaceViewModel ViewModel, WorkspacePage Page)? EnsureWorkspace(Guid id)
private WorkspaceEntry? EnsureWorkspace(Guid id)
{
if (_workspaces.TryGetValue(id, out var workspace))
if (_workspaces.TryGetValue(id, out WorkspaceEntry? workspace))
{
return workspace;
}
@@ -242,9 +393,7 @@ namespace gclo
return null; // stale item: the account no longer exists in the store
}
var page = new WorkspacePage(
viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this));
workspace = (viewModel, page);
workspace = new WorkspaceEntry(viewModel);
_workspaces[id] = workspace;
PropertyChangedEventHandler handler = (_, e) =>
@@ -316,7 +465,7 @@ namespace gclo
private void OnWorkspaceStateChanged(Guid id)
{
if (!_navItems.TryGetValue(id, out NavigationViewItem? item)
|| !_workspaces.TryGetValue(id, out var workspace))
|| !_workspaces.TryGetValue(id, out WorkspaceEntry? workspace))
{
return;
}
@@ -372,10 +521,9 @@ namespace gclo
{
badge.Style = style;
}
else if (Application.Current.Resources.TryGetValue(fallbackBrushKey, out object? brushValue)
&& brushValue is Brush brush)
else
{
badge.Background = brush;
badge.Background = StatusFormat.ThemedBrush(fallbackBrushKey);
}
return badge;
}
@@ -453,17 +601,26 @@ namespace gclo
/// </summary>
private async Task ShowAccountWizardAsync(Account? existing)
{
string? existingToken = existing is null ? null : _tokenVault.TryRetrieve(existing.Id);
// The vault is consulted only if step 2 needs the stored token for a
// validation call; the wizard never displays or retains it.
Func<string?>? storedToken = existing is null ? null : () => _tokenVault.TryRetrieve(existing.Id);
var viewModel = new AccountWizardViewModel(
_accountsStore, new GitHubOrganizationLister(), _settings, existing, existingToken);
_accountsStore, new GitHubOrganizationLister(), _settings, existing, storedToken, _log);
var dialog = new AccountWizardDialog(
viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this))
{
XamlRoot = Content.XamlRoot,
};
if (DialogGuard.IsDialogOpen)
{
_log.Info("Account wizard not opened: another dialog is open.");
return;
}
_log.Info(existing is null ? "Account wizard opened (add)." : $"Account wizard opened (edit '{existing.Name}').");
await DialogGuard.ShowAsync(dialog);
if (!dialog.Saved)
{
_log.Info("Account wizard canceled.");
return;
}
@@ -487,6 +644,35 @@ namespace gclo
await ShowAccountWizardAsync(account);
}
/// <summary>
/// "Save as account…" from Quick Sync: the add wizard opens with the working
/// connection already filled in, so the user only names it (#30).
/// </summary>
private async Task ShowSeededAccountWizardAsync(AccountWizardSeed seed)
{
var viewModel = new AccountWizardViewModel(_accountsStore, new GitHubOrganizationLister(), seed, _log);
var dialog = new AccountWizardDialog(
viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this))
{
XamlRoot = Content.XamlRoot,
};
if (DialogGuard.IsDialogOpen)
{
_log.Info("Account wizard not opened: another dialog is open.");
return;
}
_log.Info($"Account wizard opened (save Quick Sync connection to '{seed.Organization}' as an account).");
await DialogGuard.ShowAsync(dialog);
if (dialog.Saved)
{
OnAccountAdded(viewModel);
}
else
{
_log.Info("Account wizard canceled.");
}
}
/// <summary>
/// After the wizard saved a new account: create its pane item (sorted into the
/// account section) and select it, which shows its workspace.
@@ -560,12 +746,13 @@ namespace gclo
};
if (await DialogGuard.ShowAsync(confirm) != ContentDialogResult.Primary)
{
_log.Info($"Delete of account '{account.Name}' canceled.");
return;
}
try
{
_accountsStore.Delete(id);
_accountsStore.Delete(id); // the store logs the deletion
}
catch (Exception ex)
{
@@ -588,7 +775,6 @@ namespace gclo
_currentWorkspaceId = Guid.Empty;
WorkspaceNav.SelectedItem = QuickSyncNavItem; // SelectionChanged shows it
}
_log.Info($"Account '{account.Name}' deleted.");
}
/// <summary>
@@ -598,7 +784,7 @@ namespace gclo
/// </summary>
private void EvictWorkspace(Guid id)
{
if (!_workspaces.TryGetValue(id, out var workspace))
if (!_workspaces.TryGetValue(id, out WorkspaceEntry? workspace))
{
return;
}
@@ -612,6 +798,7 @@ namespace gclo
{
workspace.ViewModel.SyncCancelCommand.Execute(null);
}
workspace.Page?.Detach();
workspace.ViewModel.Dispose();
_workspaces.Remove(id);
@@ -633,9 +820,11 @@ namespace gclo
{
if (_cancelSyncAll is not null)
{
_log.Info("Sync all: cancel requested; the account in flight will finish.");
_cancelSyncAll();
return;
}
_log.Info("Sync all requested.");
// Account workspaces in pane order, created (not shown) when never visited.
var accountWorkspaces = new List<WorkspaceViewModel>();
@@ -713,7 +902,7 @@ namespace gclo
{
_cancelSyncAll?.Invoke(); // no further accounts start while the window tears down
foreach ((Guid id, var workspace) in _workspaces)
foreach ((Guid id, WorkspaceEntry workspace) in _workspaces)
{
if (_badgeHandlers.TryGetValue(id, out PropertyChangedEventHandler? handler))
{
@@ -723,6 +912,7 @@ namespace gclo
{
workspace.ViewModel.SyncCancelCommand.Execute(null);
}
workspace.Page?.Detach();
workspace.ViewModel.Dispose();
}
_badgeHandlers.Clear();
@@ -734,14 +924,20 @@ namespace gclo
private async void SettingsMenuItem_Click(object sender, RoutedEventArgs e)
{
var dialog = new SettingsDialog(
_settings, _tokenVault, () => WinRT.Interop.WindowNative.GetWindowHandle(this))
_settings, _tokenVault, () => WinRT.Interop.WindowNative.GetWindowHandle(this), _log)
{
XamlRoot = Content.XamlRoot,
};
_log.Info("Settings opened.");
int concurrencyBefore = _settings.DefaultMaxConcurrency;
if (await DialogGuard.ShowAsync(dialog) == ContentDialogResult.Primary)
{
dialog.ApplyAndSave();
ApplySettings();
dialog.ApplyAndSave(); // logs what changed
ApplySettings(seedQuickSyncConcurrency: _settings.DefaultMaxConcurrency != concurrencyBefore);
}
else
{
_log.Info("Settings closed without saving.");
}
}
@@ -758,69 +954,123 @@ namespace gclo
{
_logWindow = new LogWindow(_log);
_logWindow.Closed += (_, _) => _logWindow = null;
_log.Info("Activity log window opened.");
}
_logWindow.Activate();
}
private async void GitHubMenuItem_Click(object sender, RoutedEventArgs e)
{
_log.Info($"Opening {RepoUrl} in the browser.");
await Windows.System.Launcher.LaunchUriAsync(new Uri(RepoUrl));
}
private async void AboutMenuItem_Click(object sender, RoutedEventArgs e)
{
_log.Info("About dialog opened.");
var dialog = new AboutDialog { XamlRoot = Content.XamlRoot };
await DialogGuard.ShowAsync(dialog);
}
/// <summary>
/// Help > Check for updates: every phase is visible in <c>UpdateBar</c> (#39) —
/// checking with an indeterminate bar, the outcome inline (up to date / failed),
/// then, after the user confirms, a determinate download bar until the process
/// restarts. The menu item is disabled for the duration so it cannot be re-run.
/// Only "update available" still asks with a dialog, because that is a decision.
/// </summary>
private async void CheckForUpdatesMenuItem_Click(object sender, RoutedEventArgs e)
=> await RunUpdateFlowAsync();
private async Task RunUpdateFlowAsync()
{
if (!_updateService.IsSupported)
{
await ShowMessageAsync(
"Check for updates",
"Updates are only available in installed builds.");
return;
}
var result = await _updateService.CheckAsync();
if (result.Error is not null)
{
await ShowMessageAsync(
"Check for updates",
$"Could not check for updates.\n{result.Error}");
return;
}
if (result.AvailableVersion is null)
{
string current = _updateService.CurrentVersion is string v ? $" (v{v})" : "";
await ShowMessageAsync("Check for updates", $"You are up to date{current}.");
return;
}
var confirm = new ContentDialog
{
Title = "Update available",
Content = $"gclo v{result.AvailableVersion} is available. "
+ "The app will restart to finish installing the update.",
PrimaryButtonText = "Update and restart",
CloseButtonText = "Not now",
DefaultButton = ContentDialogButton.Primary,
XamlRoot = Content.XamlRoot,
};
if (await DialogGuard.ShowAsync(confirm) != ContentDialogResult.Primary)
if (_updateInFlight)
{
return;
}
// On success this exits the process to restart into the new version,
// so reaching the line below means the update did not go through.
string? error = await _updateService.DownloadAndApplyAsync();
if (error is not null)
_updateInFlight = true;
CheckUpdatesMenuItem.IsEnabled = false;
try
{
await ShowMessageAsync("Update failed", error);
if (!_updateService.IsSupported)
{
_log.Info("Update check requested, but this is not an installed build.");
ShowUpdateBar(InfoBarSeverity.Informational, "Updates are only available in installed builds.", busy: false);
return;
}
ShowUpdateBar(InfoBarSeverity.Informational, "Checking for updates…", busy: true);
UpdateCheckResult result = await _updateService.CheckAsync();
if (result.Error is not null)
{
ShowUpdateBar(InfoBarSeverity.Error, $"Could not check for updates. {result.Error}", busy: false);
return;
}
if (result.AvailableVersion is null)
{
string current = _updateService.CurrentVersion is string v ? $" (v{v})" : "";
ShowUpdateBar(InfoBarSeverity.Success, $"You are up to date{current}.", busy: false);
return;
}
ShowUpdateBar(
InfoBarSeverity.Informational,
$"gclo v{result.AvailableVersion} is available.",
busy: false);
var confirm = new ContentDialog
{
Title = "Update available",
Content = $"gclo v{result.AvailableVersion} is available. "
+ "The app will restart to finish installing the update.",
PrimaryButtonText = "Update and restart",
CloseButtonText = "Not now",
DefaultButton = ContentDialogButton.Primary,
XamlRoot = Content.XamlRoot,
};
if (await DialogGuard.ShowAsync(confirm) != ContentDialogResult.Primary)
{
_log.Info($"Update to v{result.AvailableVersion} declined for now.");
ShowUpdateBar(
InfoBarSeverity.Informational,
$"gclo v{result.AvailableVersion} is available. Run Help > Check for updates again when you are ready to install it.",
busy: false);
return;
}
string version = result.AvailableVersion;
ShowUpdateBar(InfoBarSeverity.Informational, $"Downloading gclo v{version}…", busy: true, determinate: true);
var progress = new Progress<int>(percent =>
{
UpdateProgressBar.Value = percent;
UpdateBar.Message = $"Downloading gclo v{version}… {percent}%";
});
// On success this exits the process to restart into the new version,
// so reaching the line below means the update did not go through.
string? error = await _updateService.DownloadAndApplyAsync(progress);
if (error is not null)
{
ShowUpdateBar(InfoBarSeverity.Error, $"Update failed. {error}", busy: false);
}
}
finally
{
_updateInFlight = false;
CheckUpdatesMenuItem.IsEnabled = true;
}
}
private void ShowUpdateBar(InfoBarSeverity severity, string message, bool busy, bool determinate = false)
{
UpdateBar.ActionButton = null; // only the startup offer carries one
UpdateBar.Severity = severity;
UpdateBar.Message = message;
UpdateBar.IsClosable = !busy;
UpdateProgressBar.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
UpdateProgressBar.IsIndeterminate = busy && !determinate;
UpdateProgressBar.Value = 0;
UpdateBar.IsOpen = true;
}
private async Task ShowMessageAsync(string title, string message)
+9
View File
@@ -20,6 +20,15 @@
<StackPanel MinWidth="480" Spacing="12">
<TextBlock x:Name="IntroText" TextWrapping="Wrap" />
<!-- Pre-flight problems with the choices below (a row neither renamed nor
skipped, a replacement that is itself invalid, two rows renamed onto one
name). Shown instead of closing and failing in the engine. -->
<InfoBar
x:Name="ProblemsBar"
AutomationProperties.AutomationId="PathRecoveryProblems"
Severity="Error"
IsClosable="False"
Title="These choices would fail" />
<ScrollViewer MaxHeight="400" VerticalScrollBarVisibility="Auto">
<ItemsControl x:Name="RowsControl">
<ItemsControl.ItemTemplate>
+23 -17
View File
@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using gclo.Engine;
using gclo.ViewModels;
using Microsoft.UI.Xaml.Controls;
namespace gclo
@@ -9,8 +11,13 @@ namespace gclo
/// <summary>
/// Asks the user how to recover a repository whose tree contains Windows-invalid
/// paths: rename each offending name (prefilled with the validator's suggestion when
/// there is one) or skip the file or folder entirely. <see cref="Result"/> carries
/// the chosen <see cref="PathRecovery"/> after "Apply and check out" and stays null
/// there is one) or skip the file or folder entirely. Rows the validator has no safe
/// rename for (case-only collisions, duplicate destinations) start as Skip — the
/// same default the CLI's --sanitize-paths applies — so accepting the defaults
/// never produces a plan that is guaranteed to fail (#30). "Apply and check out"
/// pre-flights the choices through <see cref="PathRecoveryPlanner"/> and keeps the
/// dialog open with the problems listed when they would fail. <see cref="Result"/>
/// carries the chosen <see cref="PathRecovery"/> after a sound apply and stays null
/// when the dialog is dismissed ("Skip this repo").
///
/// As with every ContentDialog, the caller must set <c>XamlRoot</c> before
@@ -43,25 +50,21 @@ namespace gclo
private void OnPrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args)
{
// Git paths are case-sensitive, so both collections compare ordinally. The same
// repo path can appear in two rows (e.g. an invalid segment that also collides
// by case), which is why renames assigns via the indexer instead of Add.
var renames = new Dictionary<string, string>(StringComparer.Ordinal);
var skipped = new HashSet<string>(StringComparer.Ordinal);
PathRecoveryPlanner.Plan plan = PathRecoveryPlanner.Build(
_rows.Select(row => new PathRecoveryPlanner.Choice(row.RepoPath, row.BuildReplacementPath(), row.Skip))
.ToList());
foreach (PathRecoveryRow row in _rows)
if (plan.Problems.Count > 0)
{
if (row.Skip)
{
skipped.Add(row.RepoPath);
}
else if (row.BuildReplacementPath() is { } replacement)
{
renames[row.RepoPath] = replacement;
}
// Stay open and say which rows need a different choice.
args.Cancel = true;
ProblemsBar.Message = string.Join(Environment.NewLine, plan.Problems);
ProblemsBar.IsOpen = true;
return;
}
Result = new PathRecovery(renames, skipped);
ProblemsBar.IsOpen = false;
Result = plan.Recovery;
}
}
@@ -91,6 +94,9 @@ namespace gclo
_originalSegment = info.RepoPath[(slash + 1)..];
_prefill = info.SuggestedName ?? _originalSegment;
NewName = _prefill;
// No safe rename exists (case-only collision, duplicate destination): the
// original name would fail again, so the row defaults to Skip.
Skip = info.SuggestedName is null;
}
/// <summary>Full repo path (forward slashes) of the offending file or folder.</summary>
+34 -7
View File
@@ -19,14 +19,23 @@ public sealed record UpdateCheckResult(string? AvailableVersion, string? Error);
/// Updates only work in Velopack-installed builds: under F5, loose unpackaged builds, or
/// MSIX packages <see cref="IsSupported"/> is false. Every member is guarded — failures are
/// reported as strings and must never crash the app over something as optional as an update.
/// Every transition of the flow is written to the activity log (#40); the version strings
/// are the only payload, never a URL with credentials or a token.
/// </summary>
public sealed class UpdateService
{
private const string RepoUrl = "https://github.com/KofTwentyTwo/gclo";
private readonly IActivityLog _log;
private UpdateManager? _manager;
private UpdateInfo? _pendingUpdate;
/// <summary>Creates the service; <paramref name="log"/> receives every update-flow transition.</summary>
public UpdateService(IActivityLog? log = null)
{
_log = log ?? new NullActivityLog();
}
/// <summary>True when the app was installed by Velopack and can check for and apply updates.</summary>
public bool IsSupported
{
@@ -72,51 +81,69 @@ public sealed class UpdateService
manager = GetManager();
if (!manager.IsInstalled)
{
_log.Info("Update check skipped: not an installed build.");
return new UpdateCheckResult(null, "Updates are only available in installed builds.");
}
}
catch (Exception ex)
{
_log.Error($"Update check failed before contacting GitHub: {ex.Message}", ex);
return new UpdateCheckResult(null, ex.Message);
}
string current = manager.CurrentVersion?.ToString() ?? "unknown";
_log.Info($"Update check started (installed v{current}).");
try
{
UpdateInfo? update = await manager.CheckForUpdatesAsync().ConfigureAwait(false);
_pendingUpdate = update;
return update is null
? new UpdateCheckResult(null, null)
: new UpdateCheckResult(update.TargetFullRelease.Version.ToString(), null);
if (update is null)
{
_log.Info($"Up to date: v{current} is the latest on this channel.");
return new UpdateCheckResult(null, null);
}
string available = update.TargetFullRelease.Version.ToString();
_log.Info($"Update available: v{available} (installed v{current}).");
return new UpdateCheckResult(available, null);
}
catch (Exception ex)
{
_pendingUpdate = null;
_log.Error($"Update check failed: {ex.Message}", ex);
return new UpdateCheckResult(null, ex.Message);
}
}
/// <summary>
/// Downloads the update found by the last successful <see cref="CheckAsync"/>, applies it,
/// and restarts the app. On success this never returns (the process exits to restart);
/// otherwise it returns an error message. Never throws.
/// and restarts the app, reporting download completion (0-100) through
/// <paramref name="progress"/> when given. On success this never returns (the process
/// exits to restart); otherwise it returns an error message. Never throws.
/// </summary>
public async Task<string?> DownloadAndApplyAsync()
public async Task<string?> DownloadAndApplyAsync(IProgress<int>? progress = null)
{
UpdateManager? manager = _manager;
UpdateInfo? update = _pendingUpdate;
if (manager is null || update is null)
{
_log.Error("Update apply requested with no update ready; check for updates first.");
return "No update is ready to install; check for updates first.";
}
string version = update.TargetFullRelease.Version.ToString();
_log.Info($"Downloading update v{version}.");
try
{
await manager.DownloadUpdatesAsync(update, null, CancellationToken.None).ConfigureAwait(false);
await manager.DownloadUpdatesAsync(
update, percent => progress?.Report(percent), CancellationToken.None).ConfigureAwait(false);
_log.Info($"Update v{version} downloaded; restarting to apply it.");
manager.ApplyUpdatesAndRestart(update.TargetFullRelease);
return null;
}
catch (Exception ex)
{
_log.Error($"Update to v{version} failed: {ex.Message}", ex);
return ex.Message;
}
}
+47 -7
View File
@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using gclo.Engine;
using gclo.ViewModels;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@@ -25,20 +27,26 @@ namespace gclo
{
private readonly AppSettings _settings;
private readonly ITokenVault _vault;
private readonly IActivityLog _log;
// A ContentDialog has no HWND of its own; the host window supplies one for pickers.
private readonly Func<nint> _windowHandleProvider;
/// <summary>Set by the Remove link; the deletion happens on Save.</summary>
private bool _removeSavedToken;
/// <summary>Creates the dialog and populates the controls from <paramref name="settings"/>.</summary>
public SettingsDialog(AppSettings settings, ITokenVault vault, Func<nint> windowHandleProvider)
/// <summary>
/// Creates the dialog and populates the controls from <paramref name="settings"/>;
/// <paramref name="log"/> records what a save changed (field names and values,
/// never the token).
/// </summary>
public SettingsDialog(AppSettings settings, ITokenVault vault, Func<nint> windowHandleProvider, IActivityLog? log = null)
{
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(vault);
ArgumentNullException.ThrowIfNull(windowHandleProvider);
_settings = settings;
_vault = vault;
_log = log ?? new NullActivityLog();
_windowHandleProvider = windowHandleProvider;
InitializeComponent();
@@ -66,31 +74,57 @@ namespace gclo
/// </summary>
public void ApplyAndSave()
{
_settings.DefaultTargetFolder = TargetFolderBox.Text.Trim();
var changes = new List<string>();
string folder = TargetFolderBox.Text.Trim();
if (folder != _settings.DefaultTargetFolder)
{
changes.Add($"default folder '{_settings.DefaultTargetFolder}' -> '{folder}'");
_settings.DefaultTargetFolder = folder;
}
// NumberBox.Value is NaN when the field was cleared; keep the previous value then.
double value = ConcurrencyBox.Value;
if (!double.IsNaN(value))
{
_settings.DefaultMaxConcurrency = Math.Clamp(
int concurrency = Math.Clamp(
(int)Math.Round(value), AppSettings.MinConcurrency, AppSettings.MaxConcurrency);
if (concurrency != _settings.DefaultMaxConcurrency)
{
changes.Add($"default parallelism {_settings.DefaultMaxConcurrency} -> {concurrency}");
_settings.DefaultMaxConcurrency = concurrency;
}
}
_settings.Theme = ThemeBox.SelectedIndex switch
string theme = ThemeBox.SelectedIndex switch
{
1 => "Light",
2 => "Dark",
_ => "System",
};
if (theme != _settings.Theme)
{
changes.Add($"theme {_settings.Theme} -> {theme}");
_settings.Theme = theme;
}
_settings.ShowSplashScreen = SplashToggle.IsOn;
if (SplashToggle.IsOn != _settings.ShowSplashScreen)
{
changes.Add($"splash screen {(SplashToggle.IsOn ? "on" : "off")}");
_settings.ShowSplashScreen = SplashToggle.IsOn;
}
double splash = SplashDurationBox.Value;
if (!double.IsNaN(splash))
{
_settings.SplashMilliseconds = Math.Clamp(
int milliseconds = Math.Clamp(
(int)Math.Round(splash),
AppSettings.MinSplashMilliseconds,
AppSettings.MaxSplashMilliseconds);
if (milliseconds != _settings.SplashMilliseconds)
{
changes.Add($"splash duration {_settings.SplashMilliseconds} -> {milliseconds} ms");
_settings.SplashMilliseconds = milliseconds;
}
}
_settings.Save();
@@ -99,11 +133,17 @@ namespace gclo
if (typed.Length > 0)
{
_vault.Store(AppSettings.DefaultTokenVaultId, typed);
changes.Add("default token stored");
}
else if (_removeSavedToken)
{
_vault.Delete(AppSettings.DefaultTokenVaultId);
changes.Add("default token removed");
}
_log.Info(changes.Count == 0
? "Settings saved (no changes)."
: "Settings saved: " + string.Join("; ", changes) + ".");
}
private void RemoveTokenButton_Click(object sender, RoutedEventArgs e)
+54 -4
View File
@@ -11,9 +11,18 @@ namespace gclo;
public static class StatusFormat
{
/// <summary>
/// Theme- and high-contrast-aware brush for a status. Resolved from the app's
/// resources on every call so the current theme's instance is returned; x:Bind
/// re-invokes this whenever the bound Status changes.
/// The theme the window actually shows, kept current by MainWindow. Function-bound
/// brushes resolve against it: Application.Current.Resources follows the
/// application theme fixed at startup, so after an in-session switch (or an OS
/// flip in System mode) it would keep handing out the old theme's colors (#31).
/// </summary>
public static ElementTheme CurrentTheme { get; set; } = ElementTheme.Default;
/// <summary>
/// Theme- and high-contrast-aware brush for a status. Resolved from the theme
/// dictionary matching <see cref="CurrentTheme"/> on every call; x:Bind
/// re-invokes this whenever the bound Status changes, and MainWindow asks every
/// row to re-evaluate when the theme changes.
/// </summary>
public static Brush BrushFor(SyncStatus status) => ResourceBrush(status switch
{
@@ -43,12 +52,53 @@ public static class StatusFormat
public static string ResolveAutomationName(string repoName)
=> $"Resolve invalid paths for {repoName}";
/// <summary>A brush resource resolved for <see cref="CurrentTheme"/> (see <see cref="BrushFor"/>).</summary>
public static Brush ThemedBrush(string key) => ResourceBrush(key);
private static Brush ResourceBrush(string key)
=> Application.Current.Resources.TryGetValue(key, out object? value) && value is Brush brush
{
// Explicit Light/Dark: read the matching theme dictionary directly. The WinUI
// control resources merge their brushes under "Light" and "Default" (dark);
// high contrast and Default fall back to the application-theme lookup.
string? dictionary = CurrentTheme switch
{
ElementTheme.Light => "Light",
ElementTheme.Dark => "Default",
_ => null,
};
if (dictionary is not null && TryFindThemed(Application.Current.Resources, dictionary, key, out Brush? themed))
{
return themed;
}
return Application.Current.Resources.TryGetValue(key, out object? value) && value is Brush brush
? brush
// Unreachable with XamlControlsResources merged; a visible-but-neutral
// fallback beats crashing the row template if a key ever disappears.
: new SolidColorBrush(Colors.Gray);
}
private static bool TryFindThemed(
ResourceDictionary resources, string theme, string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Brush? brush)
{
if (resources.ThemeDictionaries.TryGetValue(theme, out object? themed)
&& themed is ResourceDictionary themeDictionary
&& themeDictionary.TryGetValue(key, out object? value)
&& value is Brush found)
{
brush = found;
return true;
}
foreach (ResourceDictionary merged in resources.MergedDictionaries)
{
if (TryFindThemed(merged, theme, key, out brush))
{
return true;
}
}
brush = null;
return false;
}
public static Visibility VisibleIf(bool value)
=> value ? Visibility.Visible : Visibility.Collapsed;
+130 -165
View File
@@ -27,10 +27,19 @@
<Setter Property="Padding" Value="4" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<!-- Table column widths, declared once and referenced by both the header grid
and the row template (WinUI has no SharedSizeGroup): editing a width here
keeps header and cells aligned by construction. -->
<GridLength x:Key="SelectColumnWidth">40</GridLength>
<GridLength x:Key="NameColumnWidth">2*</GridLength>
<GridLength x:Key="StatusColumnWidth">160</GridLength>
<GridLength x:Key="BranchColumnWidth">120</GridLength>
<GridLength x:Key="ArchivedColumnWidth">80</GridLength>
</UserControl.Resources>
<Grid Padding="20" RowSpacing="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
@@ -57,10 +66,29 @@
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
<!-- Failed load / organization lookup. Lives outside both states so a first
load that fails (connect card still showing) and a refresh that fails
(table showing) get the same red, dismissable surface with a Retry — not
caption text at the bottom of the window. -->
<InfoBar
Grid.Row="0"
AutomationProperties.AutomationId="LoadErrorBar"
Severity="Error"
IsOpen="{x:Bind ViewModel.LoadErrorOpen, Mode=TwoWay}"
Title="{x:Bind ViewModel.LoadErrorTitle, Mode=OneWay}"
Message="{x:Bind ViewModel.LoadErrorMessage, Mode=OneWay}">
<InfoBar.ActionButton>
<Button
AutomationProperties.AutomationId="LoadErrorRetryButton"
Content="Retry"
Command="{x:Bind ViewModel.LoadReposCommand, Mode=OneTime}" />
</InfoBar.ActionButton>
</InfoBar>
<!-- ============================================================ State 1
Connect card, shown until the first successful load in this workspace. -->
<ScrollViewer
Grid.Row="0"
Grid.Row="1"
VerticalScrollBarVisibility="Auto"
Visibility="{x:Bind local:StatusFormat.VisibleIfNot(ViewModel.HasLoadedRepos), Mode=OneWay}">
<Border
@@ -75,81 +103,18 @@
<StackPanel Spacing="12">
<TextBlock Style="{ThemeResource SubtitleTextBlockStyle}" Text="Connect to GitHub" />
<!-- ContentControl: panels have no IsEnabled; a disabled Control disables its subtree. -->
<ContentControl
HorizontalContentAlignment="Stretch"
<local:ConnectionFormControl
x:Name="ConnectForm"
AutomationIdPrefix="Connect"
IsEnabled="{x:Bind ViewModel.CanEditInputs, Mode=OneWay}"
IsTabStop="False">
<StackPanel Spacing="12">
<PasswordBox
x:Name="TokenBox"
AutomationProperties.AutomationId="ConnectTokenBox"
Header="Personal access token"
ToolTipService.ToolTip="Fine-grained or classic PAT with repository read access; used for the GitHub API and git over HTTPS. Never written to disk or logs."
PlaceholderText="ghp_…"
PasswordChanged="TokenBox_PasswordChanged" />
<HyperlinkButton
Padding="0"
Content="How to create a token"
NavigateUri="https://github.com/settings/tokens" />
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ComboBox
Grid.Column="0"
AutomationProperties.AutomationId="ConnectOrgBox"
HorizontalAlignment="Stretch"
Header="Organization or account"
ToolTipService.ToolTip="The GitHub organization or user account whose repositories are synced. Populated from the token; typing a name also works."
IsEditable="True"
ItemsSource="{x:Bind ViewModel.Organizations, Mode=OneTime}"
PlaceholderText="Paste a token first, or type an org"
Loaded="OrgBox_Loaded"
Text="{x:Bind ViewModel.Organization, Mode=TwoWay}" />
<ProgressRing
Grid.Column="1"
Width="20"
Height="20"
Margin="0,0,0,8"
VerticalAlignment="Bottom"
AutomationProperties.Name="Loading organizations"
IsActive="{x:Bind ViewModel.IsLoadingOrgs, Mode=OneWay}" />
</Grid>
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox
Grid.Column="0"
AutomationProperties.AutomationId="ConnectTargetBox"
ToolTipService.ToolTip="Local folder each repository is cloned into (one subfolder per repository)"
Header="Target folder"
PlaceholderText="C:\src\my-org"
Text="{x:Bind ViewModel.TargetFolder, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<Button
Grid.Column="1"
AutomationProperties.AutomationId="ConnectBrowseButton"
ToolTipService.ToolTip="Choose the target folder"
VerticalAlignment="Bottom"
Content="Browse…"
Click="BrowseButton_Click" />
</Grid>
<CheckBox
MinWidth="0"
Content="{x:Bind OrgSubfolderLabel(ViewModel.Organization), Mode=OneWay}"
IsChecked="{x:Bind ViewModel.CreateOrgSubfolder, Mode=TwoWay}" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
Visibility="{x:Bind local:StatusFormat.VisibleIfNotEmpty(ViewModel.TargetPreview), Mode=OneWay}">
<Run Text="Each repository will be placed at:" />
<Run FontWeight="SemiBold" Text="{x:Bind ViewModel.TargetPreview, Mode=OneWay}" />
</TextBlock>
</StackPanel>
</ContentControl>
ViewModel="{x:Bind ViewModel, Mode=OneTime}" />
<TextBlock
Style="{ThemeResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
Text="Once this connection works, save it as an account (Edit on the connection chip) so the token is kept for you."
Visibility="{x:Bind local:StatusFormat.VisibleIfNot(IsAccountWorkspace), Mode=OneTime}" />
<StackPanel Orientation="Horizontal" Spacing="12">
<Button
@@ -177,10 +142,11 @@
<!-- ============================================================ State 2
Connection chip + hero table card, once repositories have loaded. -->
<Grid
Grid.Row="0"
Grid.Row="1"
RowSpacing="12"
Visibility="{x:Bind local:StatusFormat.VisibleIf(ViewModel.HasLoadedRepos), Mode=OneWay}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
@@ -233,80 +199,40 @@
Content="Edit"
Click="EditButton_Click">
<FlyoutBase.AttachedFlyout>
<Flyout x:Name="EditFlyout" Placement="Bottom" Opening="EditFlyout_Opening">
<Flyout x:Name="EditFlyout" Placement="Bottom">
<StackPanel Width="360" Spacing="12">
<TextBlock
Style="{ThemeResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
Text="Account settings are edited in the account wizard (next update)."
Visibility="{x:Bind local:StatusFormat.VisibleIf(IsAccountWorkspace), Mode=OneTime}" />
<ContentControl
HorizontalContentAlignment="Stretch"
IsEnabled="{x:Bind ConnectFieldsEnabled(ViewModel.CanEditInputs), Mode=OneWay}"
IsTabStop="False">
<StackPanel Spacing="12">
<PasswordBox
x:Name="EditTokenBox"
Header="Personal access token"
PlaceholderText="ghp_…"
PasswordChanged="TokenBox_PasswordChanged" />
<HyperlinkButton
Padding="0"
Content="How to create a token"
NavigateUri="https://github.com/settings/tokens" />
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ComboBox
Grid.Column="0"
HorizontalAlignment="Stretch"
Header="Organization or account"
IsEditable="True"
ItemsSource="{x:Bind ViewModel.Organizations, Mode=OneTime}"
PlaceholderText="Paste a token first, or type an org"
Loaded="OrgBox_Loaded"
Text="{x:Bind ViewModel.Organization, Mode=TwoWay}" />
<ProgressRing
Grid.Column="1"
Width="20"
Height="20"
Margin="0,0,0,8"
VerticalAlignment="Bottom"
AutomationProperties.Name="Loading organizations"
IsActive="{x:Bind ViewModel.IsLoadingOrgs, Mode=OneWay}" />
</Grid>
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox
Grid.Column="0"
Header="Target folder"
PlaceholderText="C:\src\my-org"
Text="{x:Bind ViewModel.TargetFolder, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<Button
Grid.Column="1"
VerticalAlignment="Bottom"
Content="Browse…"
Click="BrowseButton_Click" />
</Grid>
<CheckBox
MinWidth="0"
Content="{x:Bind OrgSubfolderLabel(ViewModel.Organization), Mode=OneWay}"
IsChecked="{x:Bind ViewModel.CreateOrgSubfolder, Mode=TwoWay}" />
<!-- Account workspace: the connection is the saved profile; the one
visible Edit affordance must lead to the wizard that edits it. -->
<StackPanel
Spacing="12"
Visibility="{x:Bind local:StatusFormat.VisibleIf(IsAccountWorkspace), Mode=OneTime}">
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
Visibility="{x:Bind local:StatusFormat.VisibleIfNotEmpty(ViewModel.TargetPreview), Mode=OneWay}">
<Run Text="Each repository will be placed at:" />
<Run FontWeight="SemiBold" Text="{x:Bind ViewModel.TargetPreview, Mode=OneWay}" />
</TextBlock>
Text="This workspace is a saved account. Its token, organization, folder, and parallelism are edited in the account wizard." />
<Button
AutomationProperties.AutomationId="ChipEditAccountButton"
Style="{StaticResource AccentButtonStyle}"
Content="Edit account…"
IsEnabled="{x:Bind ViewModel.CanEditInputs, Mode=OneWay}"
Click="EditAccountButton_Click" />
</StackPanel>
<StackPanel
Spacing="12"
Visibility="{x:Bind local:StatusFormat.VisibleIfNot(IsAccountWorkspace), Mode=OneTime}">
<local:ConnectionFormControl
x:Name="EditForm"
AutomationIdPrefix="Edit"
IsEnabled="{x:Bind ViewModel.CanEditInputs, Mode=OneWay}"
ViewModel="{x:Bind ViewModel, Mode=OneTime}" />
<!-- The bridge from a working ad-hoc setup to a saved account:
the wizard opens seeded with everything above. -->
<Button
AutomationProperties.AutomationId="ChipSaveAsAccountButton"
Content="Save as account…"
ToolTipService.ToolTip="Keep this token, organization, and folder as a named account in the navigation pane"
Click="SaveAsAccountButton_Click" />
</StackPanel>
</ContentControl>
</StackPanel>
</Flyout>
</FlyoutBase.AttachedFlyout>
@@ -314,9 +240,28 @@
</StackPanel>
</Border>
<!-- The organization was edited after the load: the rows no longer match the
chip and target path, so syncing is blocked until a reload. Not closable;
it goes away by reloading (or by putting the organization back). -->
<InfoBar
Grid.Row="1"
AutomationProperties.AutomationId="StaleTableBar"
Severity="Warning"
IsClosable="False"
IsOpen="{x:Bind ViewModel.IsTableStale, Mode=OneWay}"
Title="Organization changed"
Message="{x:Bind ViewModel.StaleTableMessage, Mode=OneWay}">
<InfoBar.ActionButton>
<Button
AutomationProperties.AutomationId="StaleTableReloadButton"
Content="Reload repositories"
Command="{x:Bind ViewModel.LoadReposCommand, Mode=OneTime}" />
</InfoBar.ActionButton>
</InfoBar>
<!-- Hero table card: toolbar, active strip, results InfoBar, header, rows. -->
<Grid
Grid.Row="1"
Grid.Row="2"
Padding="12"
Background="{ThemeResource LayerFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
@@ -468,15 +413,19 @@
</Grid>
<!-- Active strip: exactly the repos cloning/pulling right now, bounded by
the parallelism setting, so the table never moves under the user. -->
<ItemsControl
x:Name="ActiveStrip"
the parallelism setting, so the table never moves under the user.
Three whole rows of chips are visible; beyond that (parallelism up
to 64) it scrolls instead of clipping chips to a sliver (#31). -->
<ScrollViewer
Grid.Row="1"
Margin="0,8,0,0"
MaxHeight="64"
AutomationProperties.Name="Repositories syncing now"
ItemsSource="{x:Bind ViewModel.ActiveRepos, Mode=OneTime}"
MaxHeight="102"
VerticalScrollBarVisibility="Auto"
Visibility="{x:Bind local:StatusFormat.VisibleIf(ViewModel.IsRunning), Mode=OneWay}">
<ItemsControl
x:Name="ActiveStrip"
AutomationProperties.Name="Repositories syncing now"
ItemsSource="{x:Bind ViewModel.ActiveRepos, Mode=OneTime}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VariableSizedWrapGrid Orientation="Horizontal" ItemWidth="208" ItemHeight="30" />
@@ -515,6 +464,7 @@
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!-- Run results: opens when a run ends, user-dismissable (two-way IsOpen). -->
<InfoBar
@@ -552,19 +502,19 @@
CornerRadius="4,4,0,0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="40" />
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="160" />
<ColumnDefinition Width="120" />
<ColumnDefinition Width="80" />
<ColumnDefinition Width="{StaticResource SelectColumnWidth}" />
<ColumnDefinition Width="{StaticResource NameColumnWidth}" />
<ColumnDefinition Width="{StaticResource StatusColumnWidth}" />
<ColumnDefinition Width="{StaticResource BranchColumnWidth}" />
<ColumnDefinition Width="{StaticResource ArchivedColumnWidth}" />
</Grid.ColumnDefinitions>
<CheckBox
Grid.Column="0"
MinWidth="0"
Padding="0"
VerticalAlignment="Center"
ToolTipService.ToolTip="Select all"
AutomationProperties.Name="Select all repositories"
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}" />
<Grid Grid.Column="1">
@@ -784,11 +734,11 @@
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="40" />
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="160" />
<ColumnDefinition Width="120" />
<ColumnDefinition Width="80" />
<ColumnDefinition Width="{StaticResource SelectColumnWidth}" />
<ColumnDefinition Width="{StaticResource NameColumnWidth}" />
<ColumnDefinition Width="{StaticResource StatusColumnWidth}" />
<ColumnDefinition Width="{StaticResource BranchColumnWidth}" />
<ColumnDefinition Width="{StaticResource ArchivedColumnWidth}" />
</Grid.ColumnDefinitions>
<CheckBox
Grid.Row="0"
@@ -875,6 +825,21 @@
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="No repositories match this filter."
Visibility="Collapsed" />
<!-- Shown when a load succeeded but returned nothing: a wrong name, an
empty org, or a token that cannot see its repositories all list as
empty rather than failing, and a blank table explains none of them. -->
<TextBlock
x:Name="EmptyTableText"
AutomationProperties.AutomationId="EmptyTableText"
Grid.Row="4"
MaxWidth="480"
HorizontalAlignment="Center"
VerticalAlignment="Center"
TextAlignment="Center"
TextWrapping="Wrap"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Visibility="Collapsed" />
</Grid>
</Grid>
@@ -883,7 +848,7 @@
<TextBlock
x:Name="StatusTextBlock"
AutomationProperties.AutomationId="StatusTextBlock"
Grid.Row="1"
Grid.Row="2"
AutomationProperties.LiveSetting="Polite"
Style="{ThemeResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
+66 -51
View File
@@ -27,6 +27,22 @@ namespace gclo
/// <summary>The workspace state this page renders; target of every x:Bind.</summary>
public WorkspaceViewModel ViewModel { get; }
/// <summary>
/// Raised by the chip's "Edit account…" (account workspaces only) with the account
/// id; the shell answers by opening the edit wizard. Null when nothing is wired.
/// </summary>
public Func<Guid, Task>? EditAccountRequested { get; set; }
/// <summary>
/// Raised by the chip's "Save as account…" (Quick Sync only) with the connection
/// in effect; the shell answers by opening the add wizard seeded with it.
/// </summary>
public Func<AccountWizardSeed, Task>? SaveAsAccountRequested { get; set; }
// Handlers kept so Detach can unhook exactly what the constructor hooked.
private readonly System.Collections.Specialized.NotifyCollectionChangedEventHandler _onReposChanged;
private readonly System.Collections.Specialized.NotifyCollectionChangedEventHandler _onFilteredChanged;
/// <summary>
/// Wires the page to its (caller-owned) view model. The
/// <paramref name="windowHandleProvider"/> returns the host window's HWND,
@@ -46,22 +62,20 @@ namespace gclo
// The chip's repo count, the toolbar's selection summary, and the
// empty-filter placeholder are set from code: they derive from collection
// counts, which raise no property change an x:Bind function could ride on.
ViewModel.Repos.CollectionChanged += (_, _) => UpdateDerivedTexts();
ViewModel.FilteredRepos.CollectionChanged += (_, _) => UpdateDerivedTexts();
_onReposChanged = (_, _) => UpdateDerivedTexts();
_onFilteredChanged = (_, _) => UpdateDerivedTexts();
ViewModel.Repos.CollectionChanged += _onReposChanged;
ViewModel.FilteredRepos.CollectionChanged += _onFilteredChanged;
// The view model stays UI-free: when ResolvePathsCommand needs the user's
// path-recovery choices, it calls back through here and the page answers
// with PathRecoveryDialog.
ViewModel.RecoveryInteraction = ShowPathRecoveryDialogAsync;
// An account-seeded view model already carries its vault token; mirror it
// into the box so the UI shows what is in effect. The resulting
// PasswordChanged echoes the same value back, which the Token setter
// ignores as a no-op.
if (ViewModel.Token.Length > 0)
{
TokenBox.Password = ViewModel.Token;
}
// Both hosts of the shared connection form need the window handle for
// their folder picker.
ConnectForm.WindowHandleProvider = _windowHandleProvider;
EditForm.WindowHandleProvider = _windowHandleProvider;
// SelectorBar starts with no selection; the view model's filter default is
// All, so select that item (the resulting SelectionChanged is a no-op set).
@@ -70,7 +84,8 @@ namespace gclo
}
/// <summary>
/// Label for the org-subfolder checkbox; names the actual organization once one is chosen.
/// Label for the org-subfolder checkbox in the Options flyout; names the actual
/// organization once one is chosen (the connection form has its own copy).
/// </summary>
public string OrgSubfolderLabel(string organization)
=> string.IsNullOrWhiteSpace(organization)
@@ -81,12 +96,19 @@ namespace gclo
public bool IsAccountWorkspace => ViewModel.AccountId is not null;
/// <summary>
/// Whether the edit flyout's connection fields accept input: Quick Sync follows
/// <see cref="WorkspaceViewModel.CanEditInputs"/>; account workspaces are always
/// read-only here (their settings are edited in the account wizard).
/// Unhooks every subscription this page made on its (longer-lived) view model, so
/// a page the shell releases while its workspace is idle can be collected: the
/// view model's event lists would otherwise pin the whole visual tree (#30).
/// The view model itself is untouched; a fresh page re-hydrates from it.
/// </summary>
public bool ConnectFieldsEnabled(bool canEditInputs)
=> canEditInputs && ViewModel.AccountId is null;
public void Detach()
{
ViewModel.AnnouncementRequested -= AnnounceToAssistiveTechnology;
ViewModel.PropertyChanged -= OnViewModelPropertyChanged;
ViewModel.Repos.CollectionChanged -= _onReposChanged;
ViewModel.FilteredRepos.CollectionChanged -= _onFilteredChanged;
ViewModel.RecoveryInteraction = null;
}
/// <summary>
/// Sort-direction arrow (visual only; the header button's automation name
@@ -119,7 +141,8 @@ namespace gclo
// it here. Enqueued so the binding has pushed the new text first.
DispatcherQueue.TryEnqueue(RaiseStatusLiveRegionChanged);
}
else if (e.PropertyName == nameof(WorkspaceViewModel.SelectedCount))
else if (e.PropertyName is nameof(WorkspaceViewModel.SelectedCount)
or nameof(WorkspaceViewModel.LoadedOrganization))
{
UpdateDerivedTexts();
}
@@ -152,6 +175,13 @@ namespace gclo
ViewModel.FilteredRepos.Count == 0 && ViewModel.Repos.Count > 0
? Visibility.Visible
: Visibility.Collapsed;
bool loadedNothing = ViewModel.HasLoadedRepos && ViewModel.Repos.Count == 0;
EmptyTableText.Text = loadedNothing
? $"No repositories found in '{ViewModel.LoadedOrganization}'. "
+ "Check the name, or make sure the token can see its repositories."
: "";
EmptyTableText.Visibility = loadedNothing ? Visibility.Visible : Visibility.Collapsed;
}
private void FilterSelectorBar_SelectionChanged(
@@ -242,13 +272,6 @@ namespace gclo
null => "Filter by archived",
};
// An editable ComboBox does not render Text that was set before its template
// loaded, so an account workspace's seeded org shows as blank even though
// the view model holds it — see EditableComboBox. Runs on every Loaded:
// the connect card's box on page creation, the chip flyout's on each open.
private void OrgBox_Loaded(object sender, RoutedEventArgs e)
=> EditableComboBox.ReapplyText((ComboBox)sender, ViewModel.Organization);
private void Toolbar_SizeChanged(object sender, SizeChangedEventArgs e)
=> UpdateToolbarLayout();
@@ -278,13 +301,26 @@ namespace gclo
private void EditButton_Click(object sender, RoutedEventArgs e)
=> FlyoutBase.ShowAttachedFlyout((FrameworkElement)sender);
// PasswordBox has no reliable two-way binding, so the flyout's token box is
// synchronized on open; it then always shows the token in effect.
private void EditFlyout_Opening(object? sender, object e)
private async void EditAccountButton_Click(object sender, RoutedEventArgs e)
{
if (EditTokenBox.Password != ViewModel.Token)
EditFlyout.Hide();
if (ViewModel.AccountId is Guid id && EditAccountRequested is { } edit)
{
EditTokenBox.Password = ViewModel.Token;
await edit(id);
}
}
private async void SaveAsAccountButton_Click(object sender, RoutedEventArgs e)
{
EditFlyout.Hide();
if (SaveAsAccountRequested is { } save)
{
await save(new AccountWizardSeed(
ViewModel.Token,
ViewModel.Organization,
ViewModel.TargetFolder,
ViewModel.CreateOrgSubfolder,
ViewModel.MaxConcurrency));
}
}
@@ -334,33 +370,12 @@ namespace gclo
return dialog.Result; // blocked by another open dialog reads as dismissed
}
// PasswordBox does not support reliable two-way x:Bind on Password;
// mirror it into the view model by hand (shared by the connect card's box
// and the edit flyout's box).
private void TokenBox_PasswordChanged(object sender, RoutedEventArgs e)
{
ViewModel.Token = ((PasswordBox)sender).Password;
}
private async void BrowseButton_Click(object sender, RoutedEventArgs e)
{
var picker = new Windows.Storage.Pickers.FolderPicker();
picker.FileTypeFilter.Add("*"); // required in packaged apps
WinRT.Interop.InitializeWithWindow.Initialize(picker, _windowHandleProvider());
var folder = await picker.PickSingleFolderAsync();
if (folder is not null)
{
ViewModel.TargetFolder = folder.Path;
}
}
private async void OpenFolderButton_Click(object sender, RoutedEventArgs e)
{
string root = ViewModel.EffectiveTargetRoot;
if (Directory.Exists(root))
{
ViewModel.NoteFolderOpened();
await Windows.System.Launcher.LaunchFolderPathAsync(root);
}
}
+275
View File
@@ -0,0 +1,275 @@
{
"version": 1,
"dependencies": {
"net10.0-windows10.0.19041": {
"CommunityToolkit.Mvvm": {
"type": "Direct",
"requested": "[8.4.2, )",
"resolved": "8.4.2",
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
},
"Microsoft.Windows.SDK.BuildTools": {
"type": "Direct",
"requested": "[10.0.28000.2705, )",
"resolved": "10.0.28000.2705",
"contentHash": "TZQ96xtu7+fyFsyJCgj6Hazru0H8G1kiE1CeZLGpsqT4xKQmkxWu9yRuxYLeHJECjqEHN7duZUTcdbt8zlQ8OQ=="
},
"Microsoft.WindowsAppSDK": {
"type": "Direct",
"requested": "[2.5.1, )",
"resolved": "2.5.1",
"contentHash": "WgKPzT1H2s17azODD1jNZje69z1hGz8VSh3bIylTtXHVg7hLyrn8Y+rswFqF41UX8ea7ELeoyqN80zZ6EHwCFA==",
"dependencies": {
"Microsoft.WindowsAppSDK.AI": "2.5.5",
"Microsoft.WindowsAppSDK.Base": "2.0.4",
"Microsoft.WindowsAppSDK.DWrite": "2.1.0",
"Microsoft.WindowsAppSDK.Foundation": "2.3.12",
"Microsoft.WindowsAppSDK.InteractiveExperiences": "2.1.9",
"Microsoft.WindowsAppSDK.ML": "2.1.94",
"Microsoft.WindowsAppSDK.Runtime": "[2.5.1]",
"Microsoft.WindowsAppSDK.Search": "2.5.5",
"Microsoft.WindowsAppSDK.Widgets": "2.0.5",
"Microsoft.WindowsAppSDK.WinUI": "2.3.9"
}
},
"Velopack": {
"type": "Direct",
"requested": "[1.2.161, )",
"resolved": "1.2.161",
"contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg=="
},
"LibGit2Sharp": {
"type": "Transitive",
"resolved": "0.32.0",
"contentHash": "BL3eMVRnkWWS9zbz6n0H+iDVJ9ZK7hDeiuASnHlsFvwZYTJtDuBFRRVIaH//s4VlF0baOGvCeenXbobE1JLLiQ==",
"dependencies": {
"LibGit2Sharp.NativeBinaries": "[2.0.324]"
}
},
"LibGit2Sharp.NativeBinaries": {
"type": "Transitive",
"resolved": "2.0.324",
"contentHash": "5YZVGNuO9nYFQfzRQGPGY9fdpSsABwlF2jSaZ7ydxCfjvt5Krbj/eHbXkvvgAQzp4lkjvgDrlAuuI4KTmqhDlA=="
},
"Microsoft.Web.WebView2": {
"type": "Transitive",
"resolved": "1.0.3719.77",
"contentHash": "t+ucyKw5NTwMjsUrDF6R9Lk40lpcKQD1/HgyGFxl49tdA4h9dKlsj6FYGEmDRpFNfnTpENuTypMcdbrlkqBdDA=="
},
"Microsoft.Windows.AI.MachineLearning": {
"type": "Transitive",
"resolved": "2.1.74",
"contentHash": "/Z1yAQ0J53eD1RrOl5anTDS0UQOgneERY//hLljwGcWeUX9ynCvOl5qUxAsfWvwsZZGcvV4WKjAvnAtA6N5i+w==",
"dependencies": {
"System.Numerics.Tensors": "9.0.0"
}
},
"Microsoft.Windows.SDK.BuildTools.MSIX": {
"type": "Transitive",
"resolved": "1.7.251221100",
"contentHash": "f4aIZJ0NUth2403oxrpR+9rxVzZVI6dabqB21u8ncnk8eJAKCs9m77E4iYAnvP1YwrRe4axz3f8+yUcttNSfEA=="
},
"Microsoft.WindowsAppSDK.AI": {
"type": "Transitive",
"resolved": "2.5.5",
"contentHash": "f6XA05BsTAmDDsWGiFQdcAujNlbET5QbqxhBo7iHPJJix+RJajXKv1XlO19M78GtEpi2AYGEv08dPxER8ESYAw==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.4",
"Microsoft.WindowsAppSDK.Foundation": "2.3.12"
}
},
"Microsoft.WindowsAppSDK.Base": {
"type": "Transitive",
"resolved": "2.0.4",
"contentHash": "QXSy2llX2/Bx9dYWGUEsb10F40q94ZiDnPMzqa2/qRmTG4y9EXxGp6uHITb7c0b4FCa+6KFBlgh6D53M0QEMEg==",
"dependencies": {
"Microsoft.Windows.SDK.BuildTools": "10.0.26100.4654",
"Microsoft.Windows.SDK.BuildTools.MSIX": "1.7.251221100"
}
},
"Microsoft.WindowsAppSDK.DWrite": {
"type": "Transitive",
"resolved": "2.1.0",
"contentHash": "dgix7NSeo7Z8SZPyrsOqYm/6OUvBveHCiVyorYecmtDYoZW26dJ6/i9yveIxgWvmFpgfKXPeIuQBCvhbetHiYg==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.3"
}
},
"Microsoft.WindowsAppSDK.Foundation": {
"type": "Transitive",
"resolved": "2.3.12",
"contentHash": "UflrNbXoZmHPt8lLGDk4gQ+yoGfQlCHd3TVTy7YwCMZ/aVGWAiLDqGCEjtWmNsI8ySy6imx3ZlenKfd2/VMPEg==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.4",
"Microsoft.WindowsAppSDK.InteractiveExperiences": "2.1.8"
}
},
"Microsoft.WindowsAppSDK.InteractiveExperiences": {
"type": "Transitive",
"resolved": "2.1.9",
"contentHash": "59nvb3pQYsBTTGrobeHkB4bkxASHtpaORJl7r6Zvg8PSIDCupEZTPKrIhnUknHDGfAtuBsnORjgvRi2WXGaRhA==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.4"
}
},
"Microsoft.WindowsAppSDK.ML": {
"type": "Transitive",
"resolved": "2.1.94",
"contentHash": "b21CWVubc3WqWNJ9+fy7n+s83LgwBpIzYrOuSUmHAqPB0pH+ttjgiknnDA/oF7wCbjiaUb4KDXVV7Mya3f18Ug==",
"dependencies": {
"Microsoft.Windows.AI.MachineLearning": "[2.1.74, 3.0.0)",
"Microsoft.WindowsAppSDK.Base": "2.0.4",
"Microsoft.WindowsAppSDK.Foundation": "2.3.12"
}
},
"Microsoft.WindowsAppSDK.Runtime": {
"type": "Transitive",
"resolved": "2.5.1",
"contentHash": "EDzGf6Tq9njNfNsTfqKdiwmSzFF/caHItuDa99UTQiOvLXwOjCL1k75/PMLSoHbCo1cSlHh/sSqZVssJ/Z0NzA==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.4"
}
},
"Microsoft.WindowsAppSDK.Search": {
"type": "Transitive",
"resolved": "2.5.5",
"contentHash": "uvYkXi9TB4rk/kgWn5XHEkMNMU+xhjY+S0QWsxEs8wYT/dXAjfHvW30+JVoW/THZ/9jIMinEn4gliCSVRFV0Sw==",
"dependencies": {
"Microsoft.WindowsAppSDK.AI": "[2.5.5]",
"Microsoft.WindowsAppSDK.Base": "2.0.4",
"Microsoft.WindowsAppSDK.Foundation": "2.3.12"
}
},
"Microsoft.WindowsAppSDK.Widgets": {
"type": "Transitive",
"resolved": "2.0.5",
"contentHash": "lRz+8+QU65gV8hRzLVE+GRsPKZ42lnzqkmsw96HzQd/DuFYfr4JYpUU7ZZ5YrkV2EfQe4BCmoRKAeZ3WwalMcg==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.3"
}
},
"Microsoft.WindowsAppSDK.WinUI": {
"type": "Transitive",
"resolved": "2.3.9",
"contentHash": "GiuYUQqMYD5lycT+X2AGX1rlbHU/zf8xoPeZNXZbJj4mZcSr4STsxeRUMxnTUQLAmmjjQaPrTIRjn78vbPhAvg==",
"dependencies": {
"Microsoft.Web.WebView2": "1.0.3719.77",
"Microsoft.WindowsAppSDK.Base": "2.0.4",
"Microsoft.WindowsAppSDK.Foundation": "2.3.12",
"Microsoft.WindowsAppSDK.InteractiveExperiences": "2.1.8"
}
},
"Octokit": {
"type": "Transitive",
"resolved": "14.0.0",
"contentHash": "jGOuTH1l+TCpJH+fwYOp7USzHDuGfN1jKbLz3J2COwyn+wL08eynvpnM6rY2qkzIEXum3PN2p2QkP3BW/p9Qcw=="
},
"System.Numerics.Tensors": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "hyJB4UlpAi19Xr9AXzu2NuagKC4lPfHObNMEAA0HmqFz2rX7wKgzeYzO/jM/eBHDhnUGFFEjk5cOoJaxqg5J4A=="
},
"gclo.engine": {
"type": "Project",
"dependencies": {
"LibGit2Sharp": "[0.32.0, )",
"Octokit": "[14.0.0, )"
}
},
"gclo.viewmodels": {
"type": "Project",
"dependencies": {
"CommunityToolkit.Mvvm": "[8.4.2, )",
"gclo.Engine": "[1.0.1-dev, )"
}
}
},
"net10.0-windows10.0.19041/win-arm64": {
"LibGit2Sharp.NativeBinaries": {
"type": "Transitive",
"resolved": "2.0.324",
"contentHash": "5YZVGNuO9nYFQfzRQGPGY9fdpSsABwlF2jSaZ7ydxCfjvt5Krbj/eHbXkvvgAQzp4lkjvgDrlAuuI4KTmqhDlA=="
},
"Microsoft.Web.WebView2": {
"type": "Transitive",
"resolved": "1.0.3719.77",
"contentHash": "t+ucyKw5NTwMjsUrDF6R9Lk40lpcKQD1/HgyGFxl49tdA4h9dKlsj6FYGEmDRpFNfnTpENuTypMcdbrlkqBdDA=="
},
"Microsoft.Windows.AI.MachineLearning": {
"type": "Transitive",
"resolved": "2.1.74",
"contentHash": "/Z1yAQ0J53eD1RrOl5anTDS0UQOgneERY//hLljwGcWeUX9ynCvOl5qUxAsfWvwsZZGcvV4WKjAvnAtA6N5i+w==",
"dependencies": {
"System.Numerics.Tensors": "9.0.0"
}
},
"Microsoft.WindowsAppSDK.Foundation": {
"type": "Transitive",
"resolved": "2.3.12",
"contentHash": "UflrNbXoZmHPt8lLGDk4gQ+yoGfQlCHd3TVTy7YwCMZ/aVGWAiLDqGCEjtWmNsI8ySy6imx3ZlenKfd2/VMPEg==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.4",
"Microsoft.WindowsAppSDK.InteractiveExperiences": "2.1.8"
}
}
},
"net10.0-windows10.0.19041/win-x64": {
"LibGit2Sharp.NativeBinaries": {
"type": "Transitive",
"resolved": "2.0.324",
"contentHash": "5YZVGNuO9nYFQfzRQGPGY9fdpSsABwlF2jSaZ7ydxCfjvt5Krbj/eHbXkvvgAQzp4lkjvgDrlAuuI4KTmqhDlA=="
},
"Microsoft.Web.WebView2": {
"type": "Transitive",
"resolved": "1.0.3719.77",
"contentHash": "t+ucyKw5NTwMjsUrDF6R9Lk40lpcKQD1/HgyGFxl49tdA4h9dKlsj6FYGEmDRpFNfnTpENuTypMcdbrlkqBdDA=="
},
"Microsoft.Windows.AI.MachineLearning": {
"type": "Transitive",
"resolved": "2.1.74",
"contentHash": "/Z1yAQ0J53eD1RrOl5anTDS0UQOgneERY//hLljwGcWeUX9ynCvOl5qUxAsfWvwsZZGcvV4WKjAvnAtA6N5i+w==",
"dependencies": {
"System.Numerics.Tensors": "9.0.0"
}
},
"Microsoft.WindowsAppSDK.Foundation": {
"type": "Transitive",
"resolved": "2.3.12",
"contentHash": "UflrNbXoZmHPt8lLGDk4gQ+yoGfQlCHd3TVTy7YwCMZ/aVGWAiLDqGCEjtWmNsI8ySy6imx3ZlenKfd2/VMPEg==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.4",
"Microsoft.WindowsAppSDK.InteractiveExperiences": "2.1.8"
}
}
},
"net10.0-windows10.0.19041/win-x86": {
"LibGit2Sharp.NativeBinaries": {
"type": "Transitive",
"resolved": "2.0.324",
"contentHash": "5YZVGNuO9nYFQfzRQGPGY9fdpSsABwlF2jSaZ7ydxCfjvt5Krbj/eHbXkvvgAQzp4lkjvgDrlAuuI4KTmqhDlA=="
},
"Microsoft.Web.WebView2": {
"type": "Transitive",
"resolved": "1.0.3719.77",
"contentHash": "t+ucyKw5NTwMjsUrDF6R9Lk40lpcKQD1/HgyGFxl49tdA4h9dKlsj6FYGEmDRpFNfnTpENuTypMcdbrlkqBdDA=="
},
"Microsoft.Windows.AI.MachineLearning": {
"type": "Transitive",
"resolved": "2.1.74",
"contentHash": "/Z1yAQ0J53eD1RrOl5anTDS0UQOgneERY//hLljwGcWeUX9ynCvOl5qUxAsfWvwsZZGcvV4WKjAvnAtA6N5i+w==",
"dependencies": {
"System.Numerics.Tensors": "9.0.0"
}
},
"Microsoft.WindowsAppSDK.Foundation": {
"type": "Transitive",
"resolved": "2.3.12",
"contentHash": "UflrNbXoZmHPt8lLGDk4gQ+yoGfQlCHd3TVTy7YwCMZ/aVGWAiLDqGCEjtWmNsI8ySy6imx3ZlenKfd2/VMPEg==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.4",
"Microsoft.WindowsAppSDK.InteractiveExperiences": "2.1.8"
}
}
}
}
}