From 2f23065efb5158ca48b3c8a099c48bbcdc1f4480 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 3 Jul 2026 08:43:31 -0500 Subject: [PATCH 1/9] Add working TODO as the operating plan Every owner request as an individual tracked task: done / in progress / queued (execution order) / deferred / needs-owner-action. Co-Authored-By: Claude Fable 5 --- TODO.md | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..9d412d9 --- /dev/null +++ b/TODO.md @@ -0,0 +1,117 @@ +# gclo — working TODO + +The operating plan for gclo. Every item traces to an owner request. Statuses: +`[x]` done · `[~]` in progress · `[ ]` queued. Ordering within "Queued" is the +planned execution order. + +## Done + +- [x] **T1. WinUI 3 desktop app (.NET 10, packaged)** — clones AND updates every repo + in a GitHub org; bounded parallelism (default 8); live per-repo status + (queued/cloning/pulling/done/failed + error text); overall progress bar; failures + on one repo never abort the others; cancellation. +- [x] **T2. Business logic in a shared library (`gclo.Engine`)** — Octokit listing + (manual pagination, cancellable, deduped), LibGit2Sharp clone / fetch + + fast-forward-only pull, orchestrator. No UI dependencies. (See T20 for the + remaining view-model extraction.) +- [x] **T3. Headless unit tests** — 20 xunit tests: clone-vs-pull routing, concurrency + bound, failure isolation, cancellation, progress ordering, validation. +- [x] **T4. Clean build + verified launch** — `dotnet build gclo.slnx -p:Platform=x64` + with 0 errors / 0 warnings; app launch verified. +- [x] **T5. Multi-agent development flow** — research, development, adversarial review + (13 confirmed findings fixed), and feature workflows run by agent sets. +- [x] **T6. Published to GitHub** — public repo , + MIT, description + topics set. +- [x] **T7. Token-first flow with org dropdown** — PAT entered first; editable + dropdown auto-populates (debounced, cancellable) with what the token can access; + manual entry still works for tokens that cannot enumerate orgs. +- [x] **T8. Personal accounts as sync targets** — the token's own login is listed + first in the dropdown; repo listing falls back for user accounts (own account + includes private repos via owner affiliation; other accounts list public repos). +- [x] **T9. Menu bar** — File (Settings…, Exit), Help (View on GitHub, + Check for updates…, About + F1). +- [x] **T10. Settings dialog** — default target folder, default parallelism, + Light/Dark/System theme; persisted to `%LOCALAPPDATA%\gclo\settings.json` + (works packaged and unpackaged); applied at startup and on save. +- [x] **T11. About dialog** — version, MIT notice, full open-source attributions + (Octokit, LibGit2Sharp, libgit2 GPLv2 + linking exception, MVVM Toolkit, + Windows App SDK, .NET). +- [x] **T12. Open-source marketing/branding** — MIT LICENSE, README with badges and + attribution table, CONTRIBUTING.md, generated sync-glyph icon at all asset sizes, + manifest description, repo description + topics. +- [x] **T13. Repo security & branch model** — `dev` (integration) + `main` (stable, + PR-only) with rulesets (no force-push/deletion); Dependabot alerts + automated + security-update PRs; secret scanning with push protection; private vulnerability + reporting; Actions tokens read-only by default. + +## In progress (productionize agent workflow) + +- [~] **T14. Full automated test coverage** — real-git integration tests for the + LibGit2Sharp layer against local fixture repos (clone, FF pull, unborn-HEAD + repair, diverged, detached, cleanup, cancellation) + coverage gate in CI. + View-model coverage arrives with T20. +- [~] **T15. CLI / scriptable version (`gclo.Cli`)** — `gclo sync`, `gclo orgs`, + `--json`, exit codes (0 ok / 1 partial / 2 fatal), token via env/file/stdin + (never a bare argument), Ctrl+C cancellation, docs/CLI.md. +- [~] **T16. GitHub CI/CD** — Actions: PR/push gates; tag-driven releases + (`v1.2.3` = stable, `v1.2.3-beta.1` = dev prerelease); publish to GitHub Releases + (installer + portable + CLI zip), winget (gated on WINGET_TOKEN secret), NuGet for + the engine (gated on NUGET_API_KEY secret); docs/RELEASING.md. +- [~] **T17. Installer** — Velopack Setup.exe (chosen over MSI/MSIX: MSIX needs a paid + signing cert, MSI has no self-update; Velopack gives installer + delta updates + from GitHub Releases). +- [~] **T18. Self-update from the GUI** — Help → "Check for updates…" via Velopack + UpdateManager against the repo's releases; update-and-restart; inert in dev builds. +- [~] **T19. CI quality & security gates** — `-warnaserror` (also fails on vulnerable + NuGet packages), tests + engine coverage threshold, `dotnet format` gate, CodeQL, + dependency review on PRs, Dependabot config. + +## Queued (execution order) + +- [ ] **T20. Extract `gclo.ViewModels` library** — move MainViewModel, + RepoItemViewModel, AppSettings persistence out of the UI project; add view-model + unit tests; UI project keeps only XAML/dialogs/pickers/brushes/update plumbing. + Rule: no business logic in the UI layer, ever. +- [ ] **T21. End-to-end smoke test with real credentials** — engine + CLI against + live GitHub (via `gh auth token`): list orgs incl. personal account, clone a real + set, re-run to verify pulls. +- [ ] **T22. Windows-invalid path validation + recovery (spec)** — clone with + Checkout=false; validate tree paths (trailing space/dot, invalid chars, reserved + names, case-insensitive collisions, full-path length); structured result with + per-path reasons + suggested sanitized names; recovery via rename/skip mapping + with manual blob materialization; mapped repos fetch + re-materialize on update; + abort keeps the existing didn't-exist-before cleanup; plumbing-built test fixtures. +- [ ] **T23. UX overhaul (main screen)** — two-phase flow (Load repos → selectable + table → Sync selected); checkbox column + select-all; sortable headers; per-row + progress bar (determinate clone %, indeterminate pull); always-on activity log in + the shared library (`%LOCALAPPDATA%\gclo\logs`) with View → Activity log viewer + + open-logs-folder; clean error capture with Retry-failed (engine overload syncing + an explicit repo list) and the T22 recovery dialog; Open-folder button after runs; + live target-path preview + optional "create org subfolder" toggle. +- [ ] **T24. Best-practices / KISS audit + enforcement** — agent audit of design + simplicity, coding standards, docs accuracy, comment quality (adversarially + verified, then fixed); enforcement: .editorconfig + Directory.Build.props + (warnings-as-errors locally, analyzers, EnforceCodeStyleInBuild), CODEOWNERS, + PR/issue templates, SECURITY.md, required status checks added to the `main` + ruleset once CI check names exist. +- [ ] **T25. First release** — tag a beta, watch the pipeline produce the GitHub + Release (Setup.exe + portable + CLI zip), install, and verify in-app self-update + end to end; then first stable tag. + +## Deferred / follow-ups + +- [ ] **T26. "Clone in WSL" recovery option** — UI verb exists in the T22 recovery + dialog contract; actual WSL execution (wsl.exe + path translation) deferred. +- [ ] **T27. Extra package managers** — Chocolatey / Scoop manifests once releases + are flowing. +- [ ] **T28. README screenshot** — placeholder awaits a real capture once the T23 UI + lands. + +## Needs owner action + +- [ ] **O1.** Add `NUGET_API_KEY` repo secret to enable NuGet publishing of the + engine library. +- [ ] **O2.** Add `WINGET_TOKEN` repo secret + one-time manual first winget + submission (`wingetcreate new`); CI automates subsequent versions. +- [ ] **O3.** Enable Windows Developer Mode for packaged (F5/MSIX) debugging — + unpackaged run and the installed Velopack build do not need it. -- 2.54.0 From cbdce913633f1b9b08fa3b36bee3e0f08deb41d5 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 3 Jul 2026 08:55:12 -0500 Subject: [PATCH 2/9] Productionize: integration tests, CLI, CI/CD, Velopack self-update - 15 real-git integration tests for LibGit2GitClient against local fixture repos (35 tests total; engine line coverage 60.6%) - gclo.Cli: scriptable head over the engine (sync/orgs, --json, exit codes 0/1/2, token via env/file/stdin only) + docs/CLI.md; verified against live GitHub (orgs incl. personal account, parallel clone, pull path) - Velopack 1.2.0: custom Main entry point, UpdateService (GithubSource), Help > Check for updates with update-and-restart flow - GitHub Actions: CI (x64 -warnaserror, tests, coverage gate >=60%, dotnet format gate), CodeQL, dependency review, Dependabot, tag-driven release pipeline (Velopack pack/upload, CLI zip, gated NuGet + winget) + docs/RELEASING.md - TODO.md status updates; T22 (Windows-invalid paths) prioritized next Co-Authored-By: Claude Fable 5 --- .github/dependabot.yml | 32 ++ .github/workflows/ci.yml | 100 ++++++ .github/workflows/codeql.yml | 55 ++++ .github/workflows/dependency-review.yml | 50 +++ .github/workflows/release.yml | 148 +++++++++ TODO.md | 58 ++-- docs/CLI.md | 181 +++++++++++ docs/RELEASING.md | 133 ++++++++ gclo.Cli/CliJson.cs | 25 ++ gclo.Cli/CommandLine.cs | 94 ++++++ gclo.Cli/OrgsCommand.cs | 91 ++++++ gclo.Cli/Program.cs | 117 +++++++ gclo.Cli/ProgressPrinter.cs | 67 ++++ gclo.Cli/SyncCommand.cs | 153 ++++++++++ gclo.Cli/TokenOptions.cs | 115 +++++++ gclo.Cli/gclo.Cli.csproj | 18 ++ gclo.Engine.Tests/LibGit2GitClientTests.cs | 339 +++++++++++++++++++++ gclo.slnx | 1 + gclo/MainWindow.xaml | 1 + gclo/MainWindow.xaml.cs | 64 ++++ gclo/Program.cs | 31 ++ gclo/Services/UpdateService.cs | 140 +++++++++ gclo/gclo.csproj | 3 + 23 files changed, 1991 insertions(+), 25 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/dependency-review.yml create mode 100644 .github/workflows/release.yml create mode 100644 docs/CLI.md create mode 100644 docs/RELEASING.md create mode 100644 gclo.Cli/CliJson.cs create mode 100644 gclo.Cli/CommandLine.cs create mode 100644 gclo.Cli/OrgsCommand.cs create mode 100644 gclo.Cli/Program.cs create mode 100644 gclo.Cli/ProgressPrinter.cs create mode 100644 gclo.Cli/SyncCommand.cs create mode 100644 gclo.Cli/TokenOptions.cs create mode 100644 gclo.Cli/gclo.Cli.csproj create mode 100644 gclo.Engine.Tests/LibGit2GitClientTests.cs create mode 100644 gclo/Program.cs create mode 100644 gclo/Services/UpdateService.cs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ec903be --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,32 @@ +# Weekly dependency updates for NuGet packages and the GitHub Actions pinned in +# .github/workflows. Minor/patch bumps are grouped into a single PR to keep the +# noise down; major bumps still arrive as individual PRs for closer review. +version: 2 +updates: + - package-ecosystem: nuget + directory: "/" + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: America/New_York + groups: + nuget-minor-patch: + applies-to: version-updates + patterns: + - "*" + update-types: + - minor + - patch + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: America/New_York + groups: + actions-all: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5e7fd56 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,100 @@ +# Continuous integration: build + test + coverage gate, and a formatting gate. +# +# Build bar: `dotnet build gclo.slnx -p:Platform=x64` must produce ZERO warnings, +# which -warnaserror enforces (this also fails the build on NuGetAudit +# vulnerability warnings, so known-vulnerable packages break CI). +name: CI + +on: + push: + branches: [main, dev] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build-test: + name: Build and test (x64) + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Build solution (x64, warnings as errors) + run: dotnet build gclo.slnx -p:Platform=x64 -warnaserror + + - name: Run tests with coverage + # The solution build above ran with the x64 *solution* platform, but + # gclo.Engine.Tests is an AnyCPU-only test project, so exactly where its + # outputs land depends on how the .slnx maps solution platform -> project + # platform. Rather than couple `dotnet test --no-build` to that mapping, + # rebuild just the test project (plus gclo.Engine) with its default + # AnyCPU platform here - it is a small, deterministic incremental build. + run: dotnet test gclo.Engine.Tests --collect:"XPlat Code Coverage" --results-directory TestResults + + - name: Enforce gclo.Engine line coverage >= 60% + shell: pwsh + run: | + $files = Get-ChildItem -Path TestResults -Recurse -Filter coverage.cobertura.xml + if (-not $files) { + Write-Error 'No coverage.cobertura.xml found under TestResults. Did the coverage collector run?' + exit 1 + } + $file = $files | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + Write-Host "Parsing coverage report: $($file.FullName)" + [xml]$cobertura = Get-Content -LiteralPath $file.FullName + $packages = @($cobertura.coverage.packages.package) + $engine = $packages | Where-Object { $_.name -eq 'gclo.Engine' } + if (-not $engine) { + $names = ($packages | ForEach-Object { $_.name }) -join ', ' + Write-Error "Package 'gclo.Engine' not found in the coverage report. Packages present: $names" + exit 1 + } + $lineRate = [double]::Parse($engine.'line-rate', [System.Globalization.CultureInfo]::InvariantCulture) + $percent = [math]::Round($lineRate * 100, 2) + Write-Host "gclo.Engine line coverage: $percent% (line-rate $lineRate, required >= 0.60)" + if ($lineRate -lt 0.60) { + Write-Error "Coverage gate FAILED: gclo.Engine line-rate $lineRate is below the required 0.60." + exit 1 + } + Write-Host 'Coverage gate passed.' + + - name: Upload coverage artifact + if: always() + uses: actions/upload-artifact@v7 + with: + name: coverage-cobertura + path: TestResults/**/coverage.cobertura.xml + if-no-files-found: warn + + format: + name: Format (style gate) + runs-on: windows-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Restore + run: dotnet restore gclo.slnx + + # Kept at --severity error so this gates real issues (analyzer errors and + # error-severity style rules), not whitespace preferences. + - name: Verify formatting + run: dotnet format gclo.slnx --verify-no-changes --severity error diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..8e8b90f --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,55 @@ +# CodeQL static analysis for C#. +# +# C# is a compiled language, so CodeQL needs a real build. We use build-mode +# "manual" and run the same x64 solution build the CI workflow uses (without +# -warnaserror - the CI workflow already gates warnings; this workflow's job is +# security analysis, and it should still scan code that has a pending warning). +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # Weekly scan (Mondays 06:30 UTC) so new CodeQL queries run even when the + # code has not changed. + - cron: '30 6 * * 1' + +permissions: + contents: read + +jobs: + analyze: + name: Analyze (csharp) + runs-on: windows-latest + timeout-minutes: 120 + permissions: + # Required for github/codeql-action to upload scan results. + security-events: write + # Required to fetch public CodeQL packs. + packages: read + actions: read + contents: read + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: csharp + build-mode: manual + + - name: Build solution (x64) + run: dotnet build gclo.slnx -p:Platform=x64 + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v4 + with: + category: '/language:csharp' diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..741bc5e --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,50 @@ +# Blocks pull requests that add dependencies with known vulnerabilities or with +# licenses outside the allowlist below. Only dependencies *added or changed* by +# the PR are checked (via the GitHub dependency graph diff), so existing +# dependencies never retroactively fail this gate. +name: Dependency Review + +on: + pull_request: + +permissions: + contents: read + +jobs: + dependency-review: + name: Dependency review + # No build happens here, so the cheaper Linux runner is fine. + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + # Needed for comment-summary-in-pr. + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Dependency review + uses: actions/dependency-review-action@v5 + with: + # Fail on ANY known vulnerability, even low severity. + fail-on-severity: low + comment-summary-in-pr: on-failure + # Anything not on this list fails the check. This repo is MIT, so we + # accept permissive licenses plus GPL-2.0 "with linking exception" + # style grants (e.g. libgit2's license). Extend deliberately - a new + # license here is a legal decision, not a build fix. + allow-licenses: >- + MIT, + MIT-0, + Apache-2.0, + BSD-2-Clause, + BSD-3-Clause, + 0BSD, + ISC, + MS-PL, + Unlicense, + CC0-1.0, + GPL-2.0-only WITH Classpath-exception-2.0, + GPL-2.0-or-later WITH Classpath-exception-2.0, + GPL-2.0-only WITH GCC-exception-2.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2ebe122 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,148 @@ +# Release pipeline, triggered by pushing a tag that starts with 'v'. +# +# v1.2.3 -> stable release -> Velopack channel 'stable' +# v1.2.3-beta.1 -> prerelease -> Velopack channel 'dev' +# +# See docs/RELEASING.md for the full release runbook, including the secrets that +# enable the optional NuGet and winget steps. +name: Release + +on: + push: + tags: + - 'v*' + +# contents: write is required so vpk / gh can create the GitHub Release and +# upload assets to it. The default GITHUB_TOKEN is sufficient for both. +permissions: + contents: write + +jobs: + release: + name: Build, package, and publish + runs-on: windows-latest + timeout-minutes: 90 + 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 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Derive version from tag + id: version + shell: pwsh + run: | + $tag = $env:GITHUB_REF_NAME + # Strict semver 2.0 (major.minor.patch with optional prerelease + # identifiers). Build metadata ('+...') is deliberately rejected: it + # is not meaningful to NuGet or Velopack asset naming. + $semverPattern = '^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?$' + if ($tag -notmatch $semverPattern) { + Write-Error "Tag '$tag' is not a valid release tag. Expected vMAJOR.MINOR.PATCH with an optional prerelease suffix, e.g. v1.2.3 or v1.2.3-beta.1." + exit 1 + } + $version = $tag.Substring(1) + $prerelease = $version.Contains('-') + $channel = if ($prerelease) { 'dev' } else { 'stable' } + Write-Host "Tag: $tag | Version: $version | Prerelease: $prerelease | Velopack channel: $channel" + Add-Content -Path $env:GITHUB_OUTPUT -Value "version=$version" + Add-Content -Path $env:GITHUB_OUTPUT -Value "prerelease=$($prerelease.ToString().ToLower())" + Add-Content -Path $env:GITHUB_OUTPUT -Value "channel=$channel" + + # Same quality gates as CI: zero-warning build and green tests. + - name: Build solution (x64, warnings as errors) + run: dotnet build gclo.slnx -p:Platform=x64 -warnaserror + + - name: Run tests + run: dotnet test gclo.Engine.Tests + + # ---------------------------------------------------------------------- + # (a) CLI: self-contained single-file publish, zipped as a release asset. + # ---------------------------------------------------------------------- + - name: Publish CLI (win-x64, single file) + run: dotnet publish gclo.Cli -c Release -r win-x64 --self-contained -p:Version=${{ steps.version.outputs.version }} -p:PublishSingleFile=true -o publish/cli + + - name: Zip CLI + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path artifacts | Out-Null + Compress-Archive -Path publish/cli/* -DestinationPath artifacts/gclo-cli-win-x64.zip + + # ---------------------------------------------------------------------- + # (b) WinUI app: unpackaged (no MSIX) self-contained publish, then + # packaged with Velopack. vpk upload creates the GitHub Release itself + # and attaches the Setup, portable, and delta assets to it. + # ---------------------------------------------------------------------- + - name: Publish app for Velopack (unpackaged win-x64) + run: dotnet publish gclo/gclo.csproj -c Release -p:Platform=x64 -r win-x64 --self-contained -p:Version=${{ steps.version.outputs.version }} -p:WindowsPackageType=None -p:WindowsAppSDKSelfContained=true -p:PublishTrimmed=false -o publish/app + + - name: Install Velopack CLI + run: dotnet tool install -g vpk + + - name: Download previous Velopack release (delta baseline) + # The very first release on a channel has nothing to download; that is + # fine - vpk pack simply produces no delta package - so a failure here + # must not fail the release. + continue-on-error: true + shell: pwsh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + $preFlag = @() + if ('${{ steps.version.outputs.prerelease }}' -eq 'true') { $preFlag = @('--pre') } + vpk download github --repoUrl $env:REPO_URL --token $env:GITHUB_TOKEN --channel ${{ steps.version.outputs.channel }} @preFlag + + - name: Velopack pack + run: vpk pack --packId gclo --packVersion ${{ steps.version.outputs.version }} --packDir publish/app --mainExe gclo.exe --channel ${{ steps.version.outputs.channel }} + + - name: Velopack upload (creates the GitHub Release) + shell: pwsh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + $preFlag = @() + if ('${{ steps.version.outputs.prerelease }}' -eq 'true') { $preFlag = @('--pre') } + vpk upload github --repoUrl $env:REPO_URL --token $env:GITHUB_TOKEN --channel ${{ steps.version.outputs.channel }} --publish --releaseName "gclo ${{ steps.version.outputs.version }}" --tag $env:GITHUB_REF_NAME @preFlag + + - name: Attach CLI zip to the release + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload $env:GITHUB_REF_NAME artifacts/gclo-cli-win-x64.zip --clobber + + # ---------------------------------------------------------------------- + # (c) NuGet: pack gclo.Engine and push to nuget.org - only when the + # NUGET_API_KEY secret is configured on the repository. + # ---------------------------------------------------------------------- + - name: Pack gclo.Engine + run: dotnet pack gclo.Engine -c Release -p:Version=${{ steps.version.outputs.version }} -o artifacts/nuget + + - name: Push gclo.Engine to nuget.org + if: env.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 + + # ---------------------------------------------------------------------- + # (d) winget: update the KofTwentyTwo.gclo manifest for STABLE releases + # only, and only when the WINGET_TOKEN secret is configured. + # + # NOTE: the FIRST winget submission cannot be automated with `update` - + # the package must already exist in microsoft/winget-pkgs. Bootstrap it + # once manually with `wingetcreate new ` (see + # docs/RELEASING.md), then this step keeps it current. + # ---------------------------------------------------------------------- + - name: Submit winget manifest update + if: steps.version.outputs.prerelease == 'false' && env.WINGET_TOKEN != '' + shell: pwsh + run: | + Invoke-WebRequest https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe + # Velopack names the installer '--Setup.exe'. + $setupUrl = "$env:REPO_URL/releases/download/$env:GITHUB_REF_NAME/gclo-stable-Setup.exe" + .\wingetcreate.exe update KofTwentyTwo.gclo --urls $setupUrl --version ${{ steps.version.outputs.version }} --submit --token $env:WINGET_TOKEN diff --git a/TODO.md b/TODO.md index 9d412d9..f880bb7 100644 --- a/TODO.md +++ b/TODO.md @@ -46,41 +46,49 @@ planned execution order. ## In progress (productionize agent workflow) -- [~] **T14. Full automated test coverage** — real-git integration tests for the - LibGit2Sharp layer against local fixture repos (clone, FF pull, unborn-HEAD - repair, diverged, detached, cleanup, cancellation) + coverage gate in CI. - View-model coverage arrives with T20. -- [~] **T15. CLI / scriptable version (`gclo.Cli`)** — `gclo sync`, `gclo orgs`, +- [x] **T14. Full automated test coverage** — 35 tests: 15 new real-git integration + tests for the LibGit2Sharp layer against local fixture repos (clone, FF pull, + unborn-HEAD repair, diverged, detached, cleanup, cancellation); engine line + coverage 60.6% (LibGit2GitClient 76%, orchestrator 100%; Octokit wrappers are + network code). View-model coverage arrives with T20. +- [x] **T15. CLI / scriptable version (`gclo.Cli`)** — `gclo sync`, `gclo orgs`, `--json`, exit codes (0 ok / 1 partial / 2 fatal), token via env/file/stdin - (never a bare argument), Ctrl+C cancellation, docs/CLI.md. -- [~] **T16. GitHub CI/CD** — Actions: PR/push gates; tag-driven releases - (`v1.2.3` = stable, `v1.2.3-beta.1` = dev prerelease); publish to GitHub Releases - (installer + portable + CLI zip), winget (gated on WINGET_TOKEN secret), NuGet for - the engine (gated on NUGET_API_KEY secret); docs/RELEASING.md. -- [~] **T17. Installer** — Velopack Setup.exe (chosen over MSI/MSIX: MSIX needs a paid - signing cert, MSI has no self-update; Velopack gives installer + delta updates - from GitHub Releases). -- [~] **T18. Self-update from the GUI** — Help → "Check for updates…" via Velopack - UpdateManager against the repo's releases; update-and-restart; inert in dev builds. + (never a bare argument), Ctrl+C cancellation, docs/CLI.md. Verified against live + GitHub (see T21). +- [~] **T16. GitHub CI/CD** — workflows written (PR/push gates; tag-driven releases: + `v1.2.3` = stable, `v1.2.3-beta.1` = dev prerelease; GitHub Releases + winget + gated on WINGET_TOKEN + NuGet gated on NUGET_API_KEY; docs/RELEASING.md). + In progress until the first real Actions runs are green. +- [~] **T17. Installer** — Velopack 1.2.0 wired (chosen over MSI/MSIX: MSIX needs a + paid signing cert, MSI has no self-update; Velopack gives installer + delta + updates from GitHub Releases). In progress until T25 produces a real Setup.exe. +- [x] **T18. Self-update from the GUI** — Help → "Check for updates…" via Velopack + UpdateManager against the repo's releases; update-and-restart; degrades cleanly + in dev builds (verified). Full loop proven at T25. - [~] **T19. CI quality & security gates** — `-warnaserror` (also fails on vulnerable - NuGet packages), tests + engine coverage threshold, `dotnet format` gate, CodeQL, - dependency review on PRs, Dependabot config. + NuGet packages), tests + engine coverage threshold, `dotnet format` gate (verified + locally), CodeQL, dependency review on PRs, Dependabot config. In progress until + green on GitHub. +- [x] **T21. End-to-end smoke test with real credentials** — verified live: `orgs` + lists personal account first + all 8 orgs; MMLT-Holdings cloned in parallel + (exit 0); re-run took the pull path (updated=2, correct JSON). ## Queued (execution order) -- [ ] **T20. Extract `gclo.ViewModels` library** — move MainViewModel, - RepoItemViewModel, AppSettings persistence out of the UI project; add view-model - unit tests; UI project keeps only XAML/dialogs/pickers/brushes/update plumbing. - Rule: no business logic in the UI layer, ever. -- [ ] **T21. End-to-end smoke test with real credentials** — engine + CLI against - live GitHub (via `gh auth token`): list orgs incl. personal account, clone a real - set, re-run to verify pulls. -- [ ] **T22. Windows-invalid path validation + recovery (spec)** — clone with +- [ ] **T22. Windows-invalid path validation + recovery (spec)** — **PRIORITY: owner + reports frequent real-world failures on Linux-fine/Windows-invalid paths and + too-long paths.** Quick win first: two-phase clone (Checkout=false) enables + setting `core.longpaths=true` before checkout, which should eliminate the + path-length class outright; then the full validation/recovery below. Clone with Checkout=false; validate tree paths (trailing space/dot, invalid chars, reserved names, case-insensitive collisions, full-path length); structured result with per-path reasons + suggested sanitized names; recovery via rename/skip mapping with manual blob materialization; mapped repos fetch + re-materialize on update; abort keeps the existing didn't-exist-before cleanup; plumbing-built test fixtures. +- [ ] **T20. Extract `gclo.ViewModels` library** — move MainViewModel, + RepoItemViewModel, AppSettings persistence out of the UI project; add view-model + unit tests; UI project keeps only XAML/dialogs/pickers/brushes/update plumbing. + Rule: no business logic in the UI layer, ever. - [ ] **T23. UX overhaul (main screen)** — two-phase flow (Load repos → selectable table → Sync selected); checkbox column + select-all; sortable headers; per-row progress bar (determinate clone %, indeterminate pull); always-on activity log in diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 0000000..73f4d55 --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,181 @@ +# gclo CLI + +`gclo` is a scriptable command-line head over the same engine (`gclo.Engine`) that +powers the gclo desktop app. It clones every repository of a GitHub organization +(or user account) into a local folder, and fast-forwards the ones that already +exist there. + +## Building and running + +```powershell +dotnet build gclo.slnx -p:Platform=x64 +.\gclo.Cli\bin\Debug\net10.0\gclo.exe --help +``` + +Or run it straight from the project: + +```powershell +dotnet run --project gclo.Cli -- sync --org contoso --target C:\src\contoso +``` + +The executable is plain `net10.0` (no Windows-specific target), so it also builds +and runs on Linux/macOS hosts with the .NET 10 SDK. + +## Commands + +``` +gclo sync --org --target [--parallel N] + [--token-env VAR | --token-file PATH | --token-stdin] + [--json] [--quiet] +gclo orgs [--token-env VAR | --token-file PATH | --token-stdin] [--json] +gclo --version +gclo --help (each command also accepts --help) +``` + +Options may be written as `--name value` or `--name=value`. + +### `gclo sync` + +Clones every repository of `--org` into `\`. 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). + +`--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). + +Progress output is one line per repository status transition: + +``` +gclo Queued +gclo Cloning +gclo Done +old-repo Pulling +broken-repo Failed remote authentication failed +``` + +- Non-failure lines (`Queued`, `Cloning`, `Pulling`, `Done`, `Canceled`) go to + **stdout**; `Failed` lines go to **stderr** so they survive redirection. +- Clone percentage updates are never printed — only transitions. +- `--quiet` suppresses the stdout progress lines; failures (stderr) and the final + summary still print. +- A summary line always ends the run: + `Finished: 3 cloned, 41 updated, 1 failed, 0 canceled of 45.` + +**Ctrl+C** cancels gracefully: in-flight git operations stop, remaining +repositories are marked `Canceled`, and the summary still prints. A second +Ctrl+C aborts the process immediately. + +#### `--json` + +Suppresses all progress lines and prints a single line of JSON to stdout when the +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"}]} +``` + +### `gclo orgs` + +Prints the logins the token can sync, one per line: the token's own account login +first, then its organizations alphabetically. With `--json` it prints a +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. + +## Providing the token + +gclo needs a GitHub Personal Access Token for both the API and the git transport. + +> **Why is there no `--token ` option?** +> Command-line arguments are visible to every other process on the machine — +> Task Manager, `ps`, `wmic process`, `/proc//cmdline` — and often end up in +> shell history and logs. A token passed as a plain argument would leak to every +> local user and program. gclo therefore only accepts tokens through channels +> that stay off the command line. + +| Option | Behavior | +| --- | --- | +| *(none)* | Reads the `GITHUB_TOKEN` environment variable. | +| `--token-env VAR` | Reads environment variable `VAR`. | +| `--token-file PATH` | Reads the first non-blank content line of `PATH` (trimmed). | +| `--token-stdin` | Reads one line from standard input — made for piping from a secret store. | + +The options are mutually exclusive. A missing or empty token prints an error to +stderr and exits with code 2. + +## 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, or organization not found. | + +## Examples + +### PowerShell + +```powershell +# Default token source: the GITHUB_TOKEN environment variable +$env:GITHUB_TOKEN = (Get-Secret -Name GitHubPat -AsPlainText) # SecretManagement module +gclo sync --org contoso --target C:\src\contoso + +# A differently named environment variable +gclo sync --org contoso --target C:\src\contoso --token-env GH_WORK_TOKEN + +# Token stored in a file (keep it out of the repo and readable only by you) +gclo sync --org contoso --target C:\src\contoso --token-file $HOME\.config\gclo\token + +# Pipe the token from a secret store — it never touches a command line or disk +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 + +# 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)" } } + +# Nightly mirror job: quiet, check 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" } +``` + +### bash + +```bash +# Default token source: the GITHUB_TOKEN environment variable +export GITHUB_TOKEN="$(pass show github/pat)" +gclo sync --org contoso --target ~/src/contoso + +# Token file +gclo sync --org contoso --target ~/src/contoso --token-file ~/.config/gclo/token + +# Pipe the token from a secret store +pass show github/pat | gclo sync --org contoso --target ~/src/contoso --token-stdin +op read "op://Private/GitHub PAT/token" | gclo orgs --token-stdin +gh auth token | gclo sync --org contoso --target ~/src/contoso --token-stdin + +# Machine-readable result with jq +gclo sync --org contoso --target ~/src/contoso --json | + jq -r '.failures[] | "\(.repo): \(.error)"' + +# 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 $?" + +# List orgs as JSON and sync each one +for org in $(gclo orgs --json | jq -r '.[]'); do + gclo sync --org "$org" --target ~/src/"$org" --quiet +done +``` + +## Version + +```console +$ gclo --version +0.1.0 +``` diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..8903def --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,133 @@ +# Releasing gclo + +Releases are fully automated by [`.github/workflows/release.yml`](../.github/workflows/release.yml). +Pushing a tag that starts with `v` builds, tests, packages, and publishes +everything. This document explains how versioning works, what gets published +where, and how to cut a release start to finish. + +## Versioning and channels + +The version is the tag with the leading `v` stripped. Tags must be strict +[semver](https://semver.org) (`vMAJOR.MINOR.PATCH` plus an optional prerelease +suffix). Build metadata (`+...`) is rejected. + +| Tag | Version | GitHub Release | Velopack channel | +| -------------- | -------------- | -------------- | ---------------- | +| `v1.2.3` | `1.2.3` | stable | `stable` | +| `v1.2.3-beta.1`| `1.2.3-beta.1` | prerelease | `dev` | + +Any prerelease identifier works (`-alpha`, `-beta.2`, `-rc.1`, ...) — the rule +is simply: **a `-` in the version means prerelease, which means the `dev` +channel**. + +### How self-update channels map + +The desktop app updates itself with [Velopack](https://velopack.io). Each +release is packed with `vpk pack --channel `, which stamps the +channel into the installed app. An installed app only ever sees updates from +its own channel: + +- Users who installed from a **stable** release (`gclo-stable-Setup.exe`) get + updates only when you tag a new stable release. +- Users who installed from a **dev** release (`gclo-dev-Setup.exe`) get every + prerelease you tag. + +Velopack asset names include the channel. A stable release `v1.2.3` carries: + +- `gclo-stable-Setup.exe` — the installer +- `gclo-stable-Portable.zip` — portable build +- `gclo-1.2.3-stable-full.nupkg` — full update package +- `gclo-1.2.3-stable-delta.nupkg` — delta from the previous stable release + (absent on the first release of a channel) +- `gclo-cli-win-x64.zip` — self-contained single-file CLI + +The workflow runs `vpk download github` before packing so deltas can be +generated against the previous release on the same channel; on the very first +release of a channel that step finds nothing and is allowed to fail — you just +get a release without a delta package, which is normal. + +## What gets published, and which secrets enable it + +| Target | Condition | Secret | +| ------ | --------- | ------ | +| GitHub Release (Setup, portable, full/delta packages, CLI zip) | always | none — the built-in `GITHUB_TOKEN` is enough | +| nuget.org (`gclo.Engine` package) | `NUGET_API_KEY` secret is set | `NUGET_API_KEY` — an API key from nuget.org with push rights for `gclo.Engine` | +| winget (`KofTwentyTwo.gclo`) | stable releases only, and `WINGET_TOKEN` secret is set | `WINGET_TOKEN` — a GitHub personal access token (classic, `public_repo` scope) used by `wingetcreate` to fork/PR [microsoft/winget-pkgs](https://github.com/microsoft/winget-pkgs) | + +If a secret is not configured, the corresponding step is skipped cleanly — the +release still succeeds. Configure secrets under +**Settings → Secrets and variables → Actions**. + +## First winget submission (one-time, manual) + +`wingetcreate update` can only update a package that already exists in +[microsoft/winget-pkgs](https://github.com/microsoft/winget-pkgs). The first +submission must be done by hand, once, after your first stable GitHub release +exists: + +```powershell +# From any Windows machine: +Invoke-WebRequest https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe + +# Interactive: prompts for package id (use KofTwentyTwo.gclo), publisher, +# license, description, etc., then opens a PR against microsoft/winget-pkgs. +.\wingetcreate.exe new https://github.com/KofTwentyTwo/gclo/releases/download/v1.0.0/gclo-stable-Setup.exe --token +``` + +Once that first PR is merged and the package is live, set the `WINGET_TOKEN` +secret and every subsequent stable release updates the manifest automatically +via `wingetcreate update KofTwentyTwo.gclo ... --submit`. + +## Cutting a release, start to finish + +1. **Make sure `main` is green.** The CI, CodeQL, and (for PRs) dependency + review workflows must all pass. The release workflow re-runs the build with + `-warnaserror` and the test suite, so a red `main` will fail the release + anyway — just later and more annoyingly. + +2. **Pick the version.** Follow semver: breaking change → major, new feature → + minor, fix → patch. Add a prerelease suffix (`-beta.1`) if this should go to + the `dev` channel only. + +3. **Tag and push:** + + ```powershell + git checkout main + git pull + git tag v1.2.3 # or v1.2.3-beta.1 for a dev-channel prerelease + git push origin v1.2.3 + ``` + +4. **Watch the workflow.** The `Release` workflow appears under the Actions + tab. It will, in order: + - validate the tag and derive version/channel, + - build the solution (x64, warnings as errors) and run the tests, + - publish and zip the CLI, + - publish the WinUI app unpackaged and pack it with Velopack, + - create the GitHub Release (marked prerelease for `dev`) and upload all + assets, + - push `gclo.Engine` to nuget.org (if `NUGET_API_KEY` is set), + - submit the winget manifest update (stable only, if `WINGET_TOKEN` is set). + +5. **Verify.** Check the new release on the + [releases page](https://github.com/KofTwentyTwo/gclo/releases): the Setup + exe, portable zip, full package, CLI zip (and a delta package after the + first release) should all be attached. If the winget step ran, check your + PR on microsoft/winget-pkgs. + +6. **If something failed** after the release was created (for example the + winget submission), fix the cause and re-run only the failed job from the + Actions UI. The NuGet push uses `--skip-duplicate`, and asset uploads use + `--clobber`, so re-runs are safe. + +### Deleting a bad release + +If a release is broken, delete the GitHub Release *and* the tag, fix the +problem, and tag again with a **new** patch version. Never reuse a version +number that was published — installed apps and package managers may have +already seen it. + +```powershell +gh release delete v1.2.3 --yes +git push origin :refs/tags/v1.2.3 +``` diff --git a/gclo.Cli/CliJson.cs b/gclo.Cli/CliJson.cs new file mode 100644 index 0000000..38dc493 --- /dev/null +++ b/gclo.Cli/CliJson.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; + +namespace gclo.Cli; + +/// One failed repository in the JSON summary. +internal sealed record SyncFailure(string Repo, string Error); + +/// The single-line JSON document printed by 'gclo sync --json'. +internal sealed record SyncJsonResult( + int Total, + int Cloned, + int Updated, + int Failed, + int Canceled, + bool WasCanceled, + IReadOnlyList Failures); + +/// +/// Source-generated System.Text.Json serialization: no runtime reflection, so the +/// project stays free of trim/AOT warnings. +/// +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(SyncJsonResult))] +[JsonSerializable(typeof(IReadOnlyList))] +internal sealed partial class CliJsonContext : JsonSerializerContext; diff --git a/gclo.Cli/CommandLine.cs b/gclo.Cli/CommandLine.cs new file mode 100644 index 0000000..2c4ebab --- /dev/null +++ b/gclo.Cli/CommandLine.cs @@ -0,0 +1,94 @@ +namespace gclo.Cli; + +/// +/// A command-line usage error. The message is printed to stderr followed by a +/// help hint, and the process exits with code 2. +/// +internal sealed class CliUsageException : Exception +{ + public CliUsageException(string message) : base(message) + { + } +} + +/// +/// 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. +/// +internal sealed class CliErrorException : Exception +{ + public CliErrorException(string message, Exception? inner = null) : base(message, inner) + { + } +} + +/// +/// Sequential reader over a command's arguments supporting both +/// '--name value' and '--name=value' forms. +/// +internal sealed class OptionReader +{ + private readonly string[] _args; + private int _index; + private string? _inlineValue; + + public OptionReader(string[] args) => _args = args; + + /// The current token, without any inline '=value' part. + public string Current { get; private set; } = ""; + + public bool MoveNext() + { + _inlineValue = null; + if (_index >= _args.Length) + { + return false; + } + + string token = _args[_index++]; + if (token.StartsWith("--", StringComparison.Ordinal)) + { + int equals = token.IndexOf('='); + if (equals >= 0) + { + _inlineValue = token[(equals + 1)..]; + token = token[..equals]; + } + } + Current = token; + return true; + } + + /// + /// The value for : its inline '=value' part or the next + /// argument. A following token that itself starts with '--' is treated as a + /// missing value (use '--name=--literal' to pass such a value literally). + /// + public string RequireValue() + { + if (_inlineValue is not null) + { + if (_inlineValue.Length == 0) + { + throw new CliUsageException($"Option '{Current}' requires a value."); + } + return _inlineValue; + } + + if (_index >= _args.Length || _args[_index].StartsWith("--", StringComparison.Ordinal)) + { + throw new CliUsageException($"Option '{Current}' requires a value."); + } + return _args[_index++]; + } + + /// Rejects '--name=value' written for a flag that takes no value. + public void RejectValue() + { + if (_inlineValue is not null) + { + throw new CliUsageException($"Option '{Current}' does not take a value."); + } + } +} diff --git a/gclo.Cli/OrgsCommand.cs b/gclo.Cli/OrgsCommand.cs new file mode 100644 index 0000000..807557d --- /dev/null +++ b/gclo.Cli/OrgsCommand.cs @@ -0,0 +1,91 @@ +using System.Text.Json; +using gclo.Engine; + +namespace gclo.Cli; + +/// 'gclo orgs': list the account and organization logins a token can see. +internal static class OrgsCommand +{ + private const string HelpText = """ + Usage: gclo orgs [options] + + Lists the logins the token can sync, one per line: the token's own account + first, then its organizations alphabetically. A token that cannot list + organizations (fine-grained PAT, or a classic PAT without read:org) still + prints its own account login. + + Options: + --token-env Read the token from environment variable VAR + (default: GITHUB_TOKEN when no token option is given). + --token-file Read the token from the first line of a file. + --token-stdin Read the token as one line from standard input. + --json Print a single-line JSON array instead. + --help Show this help. + + 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 + + Security: + There is deliberately no '--token ' option: process command lines + are visible to other processes on the machine, so a token passed as an + argument would leak. Use --token-env, --token-file, or --token-stdin. + """; + + public static async Task RunAsync(string[] args, CancellationToken cancellationToken) + { + bool json = 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 0; + case "--json": + reader.RejectValue(); + json = true; + break; + default: + throw new CliUsageException($"Unknown option '{reader.Current}' for 'gclo orgs'."); + } + } + + string token = tokenOptions.Resolve(); + + IReadOnlyList logins; + try + { + logins = await new GitHubOrganizationLister() + .ListOrganizationsAsync(token, cancellationToken) + .ConfigureAwait(false); + } + catch (InvalidOperationException ex) + { + // The lister translates auth failures and rate limiting into + // InvalidOperationException. + throw new CliErrorException(ex.Message, ex); + } + + if (json) + { + Console.Out.WriteLine(JsonSerializer.Serialize(logins, CliJsonContext.Default.IReadOnlyListString)); + } + else + { + foreach (string login in logins) + { + Console.Out.WriteLine(login); + } + } + return 0; + } +} diff --git a/gclo.Cli/Program.cs b/gclo.Cli/Program.cs new file mode 100644 index 0000000..f61f399 --- /dev/null +++ b/gclo.Cli/Program.cs @@ -0,0 +1,117 @@ +using System.Reflection; + +namespace gclo.Cli; + +/// Entry point: command dispatch, Ctrl+C wiring, and top-level error handling. +internal static class Program +{ + private const string RootHelp = """ + gclo - clone or update every repository of a GitHub organization or user account. + + Usage: + gclo sync --org --target [options] + gclo orgs [options] + gclo --version + gclo --help + + Commands: + sync Clone every repository of an organization; fast-forward ones that + already exist locally. + orgs List the account and organization logins the token can see. + + Run 'gclo sync --help' or 'gclo orgs --help' for command options. + + Token: + There is deliberately no '--token ' option: command-line arguments + are visible to every other process on the machine (Task Manager, 'ps', + WMI queries), so a token passed that way would leak. Provide it with: + --token-env read it from environment variable VAR + --token-file read the first line of a file + --token-stdin read one line from standard input + (pipe it from a secret store) + When no token option is given, the GITHUB_TOKEN environment variable is used. + """; + + public static async Task Main(string[] args) + { + using var cts = new CancellationTokenSource(); + Console.CancelKeyPress += (_, e) => + { + if (!cts.IsCancellationRequested) + { + // Finish gracefully: in-flight git operations stop, remaining + // repositories are marked Canceled, and the summary still prints. + e.Cancel = true; + cts.Cancel(); + Console.Error.WriteLine("Canceling... (press Ctrl+C again to abort immediately)"); + } + // Second Ctrl+C: e.Cancel stays false and the process terminates. + }; + + try + { + return await RunAsync(args, cts.Token).ConfigureAwait(false); + } + catch (CliUsageException ex) + { + Console.Error.WriteLine(ex.Message); + Console.Error.WriteLine("Run 'gclo --help' for usage."); + return 2; + } + catch (CliErrorException ex) + { + Console.Error.WriteLine(ex.Message); + return 2; + } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + Console.Error.WriteLine("Canceled."); + return 1; + } + catch (Exception ex) + { + Console.Error.WriteLine(ex.Message); + return 2; + } + } + + private static Task RunAsync(string[] args, CancellationToken cancellationToken) + { + if (args.Length == 0) + { + throw new CliUsageException("No command given."); + } + + string command = args[0]; + string[] rest = args[1..]; + switch (command) + { + case "sync": + return SyncCommand.RunAsync(rest, cancellationToken); + case "orgs": + return OrgsCommand.RunAsync(rest, cancellationToken); + case "--help" or "-h" or "help": + Console.Out.WriteLine(RootHelp); + return Task.FromResult(0); + case "--version": + Console.Out.WriteLine(Version()); + return Task.FromResult(0); + default: + throw new CliUsageException($"Unknown command '{command}'."); + } + } + + private static string Version() + { + Assembly assembly = typeof(Program).Assembly; + string version = + assembly.GetCustomAttribute()?.InformationalVersion + ?? assembly.GetName().Version?.ToString() + ?? "unknown"; + + // Strip the '+' suffix (e.g. a SourceLink commit hash); it is + // noise for a scripted version check. + int plus = version.IndexOf('+'); + return plus >= 0 ? version[..plus] : version; + } +} diff --git a/gclo.Cli/ProgressPrinter.cs b/gclo.Cli/ProgressPrinter.cs new file mode 100644 index 0000000..ad47fef --- /dev/null +++ b/gclo.Cli/ProgressPrinter.cs @@ -0,0 +1,67 @@ +using gclo.Engine; + +namespace gclo.Cli; + +/// +/// Prints one line per repository status transition and records failures for the +/// final summary. Clone percent updates arrive as repeated +/// 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. +/// +internal sealed class ProgressPrinter : IProgress +{ + private readonly object _gate = new(); + private readonly Dictionary _lastStatus = new(StringComparer.OrdinalIgnoreCase); + private readonly List _failures = new(); + private readonly bool _printProgress; + private readonly bool _printFailures; + + /// Write non-failure transitions to stdout (off for --quiet and --json). + /// Write failure lines to stderr (off for --json, which carries failures in the document). + public ProgressPrinter(bool printProgress, bool printFailures) + { + _printProgress = printProgress; + _printFailures = printFailures; + } + + /// Failed repositories in the order they failed. + public IReadOnlyList Failures + { + get + { + lock (_gate) + { + return _failures.ToArray(); + } + } + } + + // The engine reports from worker threads; the lock keeps lines whole and in + // a single consistent order. + public void Report(RepoProgress value) + { + lock (_gate) + { + if (_lastStatus.TryGetValue(value.RepoName, out SyncStatus previous) && previous == value.Status) + { + return; // e.g. clone percent updates re-report Cloning + } + _lastStatus[value.RepoName] = value.Status; + + if (value.Status == SyncStatus.Failed) + { + var failure = new SyncFailure(value.RepoName, value.Error ?? "unknown error"); + _failures.Add(failure); + if (_printFailures) + { + Console.Error.WriteLine($"{failure.Repo} Failed {failure.Error}"); + } + } + else if (_printProgress) + { + Console.Out.WriteLine($"{value.RepoName} {value.Status}"); + } + } + } +} diff --git a/gclo.Cli/SyncCommand.cs b/gclo.Cli/SyncCommand.cs new file mode 100644 index 0000000..596fff8 --- /dev/null +++ b/gclo.Cli/SyncCommand.cs @@ -0,0 +1,153 @@ +using System.Text.Json; +using gclo.Engine; + +namespace gclo.Cli; + +/// 'gclo sync': clone or fast-forward every repository of an organization. +internal static class SyncCommand +{ + private const string HelpText = """ + Usage: gclo sync --org --target [options] + + Clones every repository of into \. Repositories that + already exist locally are fetched and fast-forwarded instead. Repositories + fail independently; one failure never stops the rest. + + Options: + --org GitHub organization or user account to sync (required). + --target Local root folder; each repository becomes a subfolder + (required; created if missing). + --parallel Maximum simultaneous git operations (default 8). + --token-env Read the token from environment variable VAR + (default: GITHUB_TOKEN when no token option is given). + --token-file Read the token from the first 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":"..."}]} + --quiet Suppress per-repository progress lines on stdout. + Failures still print to stderr and the summary still prints. + --help Show this help. + + Output: + One line per repository status transition, ' ', with + statuses Queued, Cloning, Pulling, Done, Canceled on stdout and + ' Failed ' on stderr. Clone percent updates are not printed. + + Ctrl+C cancels gracefully: in-flight git operations stop, remaining + 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 + + Security: + There is deliberately no '--token ' option: process command lines + are visible to other processes on the machine, so a token passed as an + argument would leak. Use --token-env, --token-file, or --token-stdin. + """; + + public static async Task RunAsync(string[] args, CancellationToken cancellationToken) + { + string? org = null; + string? target = null; + int parallel = 8; + bool json = false; + bool quiet = 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 0; + case "--org": + org = reader.RequireValue(); + break; + case "--target": + target = reader.RequireValue(); + break; + case "--parallel": + { + string value = reader.RequireValue(); + if (!int.TryParse(value, out parallel) || parallel < 1) + { + throw new CliUsageException($"--parallel expects a positive integer, got '{value}'."); + } + break; + } + case "--json": + reader.RejectValue(); + json = true; + break; + case "--quiet": + reader.RejectValue(); + quiet = true; + break; + default: + throw new CliUsageException($"Unknown option '{reader.Current}' for 'gclo sync'."); + } + } + + if (string.IsNullOrWhiteSpace(org)) + { + throw new CliUsageException("--org is required."); + } + if (string.IsNullOrWhiteSpace(target)) + { + throw new CliUsageException("--target is required."); + } + + string token = tokenOptions.Resolve(); + + var printer = new ProgressPrinter(printProgress: !quiet && !json, printFailures: !json); + var engine = new OrgSyncEngine(new GitHubRepositoryLister(), new LibGit2GitClient()); + var request = new SyncRequest(org.Trim(), token, target.Trim(), parallel); + + SyncSummary summary; + try + { + summary = await engine.SyncAsync(request, printer, 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); + } + catch (InvalidOperationException ex) + { + // The engine's listers translate auth failures, rate limiting, and + // unknown organizations into InvalidOperationException. + throw new CliErrorException(ex.Message, ex); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + throw new CliErrorException($"Cannot use target folder '{target}': {ex.Message}", ex); + } + + if (json) + { + var result = new SyncJsonResult( + summary.Total, summary.Cloned, summary.Updated, summary.Failed, + summary.Canceled, summary.WasCanceled, printer.Failures); + Console.Out.WriteLine(JsonSerializer.Serialize(result, CliJsonContext.Default.SyncJsonResult)); + } + else + { + string verb = summary.WasCanceled ? "Canceled" : "Finished"; + Console.Out.WriteLine( + $"{verb}: {summary.Cloned} cloned, {summary.Updated} updated, " + + $"{summary.Failed} failed, {summary.Canceled} canceled of {summary.Total}."); + } + + return summary.Failed > 0 || summary.WasCanceled ? 1 : 0; + } +} diff --git a/gclo.Cli/TokenOptions.cs b/gclo.Cli/TokenOptions.cs new file mode 100644 index 0000000..6ff578b --- /dev/null +++ b/gclo.Cli/TokenOptions.cs @@ -0,0 +1,115 @@ +namespace gclo.Cli; + +/// +/// Collects and resolves the GitHub token source options shared by all commands. +/// There is deliberately no plain '--token <value>' option: process command +/// lines are visible to other processes on the machine (Task Manager, 'ps', WMI +/// queries), so a token passed as an argument would leak to every local user and +/// program. The supported sources — environment variable, file, stdin — never put +/// the secret on a command line. +/// +internal sealed class TokenOptions +{ + /// Environment variable consulted when no token option is given. + public const string DefaultVariable = "GITHUB_TOKEN"; + + private string? _envVariable; + private string? _filePath; + private bool _useStdin; + + /// Consumes the reader's current option when it is a token option. + public bool TryConsume(OptionReader reader) + { + switch (reader.Current) + { + case "--token-env": + _envVariable = reader.RequireValue(); + return true; + case "--token-file": + _filePath = reader.RequireValue(); + return true; + case "--token-stdin": + reader.RejectValue(); + _useStdin = true; + return true; + default: + return false; + } + } + + /// + /// Resolves the token from the single configured source, or from the + /// environment variable when none was given. + /// Throws with an actionable message when the token is missing or empty. + /// + public string Resolve() + { + int sources = (_envVariable is null ? 0 : 1) + (_filePath is null ? 0 : 1) + (_useStdin ? 1 : 0); + if (sources > 1) + { + throw new CliUsageException("Use only one of --token-env, --token-file, --token-stdin."); + } + + if (_useStdin) + { + return FromStdin(); + } + if (_filePath is not null) + { + return FromFile(_filePath); + } + return FromEnvironment(_envVariable ?? DefaultVariable, wasExplicit: _envVariable is not null); + } + + private static string FromEnvironment(string variable, bool wasExplicit) + { + string? token = Environment.GetEnvironmentVariable(variable)?.Trim(); + if (string.IsNullOrEmpty(token)) + { + throw new CliErrorException(wasExplicit + ? $"Environment variable '{variable}' is not set or is empty." + : $"No token source given and the default environment variable '{variable}' is not set. " + + "Provide a token with --token-env, --token-file, or --token-stdin."); + } + return token; + } + + private static string FromFile(string path) + { + string? line; + try + { + // First line only: token files often end with a newline, and some + // editors append trailing blank lines. + line = File.ReadLines(path).FirstOrDefault(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + throw new CliErrorException($"Cannot read token file '{path}': {ex.Message}", ex); + } + + line = line?.Trim(); + if (string.IsNullOrEmpty(line)) + { + throw new CliErrorException($"Token file '{path}' is empty."); + } + return line; + } + + private static string FromStdin() + { + if (!Console.IsInputRedirected) + { + // Interactive use: make it obvious the process is waiting. The prompt + // goes to stderr so redirected stdout stays clean. + Console.Error.Write("Token: "); + } + + string? line = Console.In.ReadLine()?.Trim(); + if (string.IsNullOrEmpty(line)) + { + throw new CliErrorException("No token arrived on standard input (expected one line)."); + } + return line; + } +} diff --git a/gclo.Cli/gclo.Cli.csproj b/gclo.Cli/gclo.Cli.csproj new file mode 100644 index 0000000..31b9fc5 --- /dev/null +++ b/gclo.Cli/gclo.Cli.csproj @@ -0,0 +1,18 @@ + + + + Exe + net10.0 + enable + enable + gclo + gclo.Cli + true + 0.1.0 + + + + + + + diff --git a/gclo.Engine.Tests/LibGit2GitClientTests.cs b/gclo.Engine.Tests/LibGit2GitClientTests.cs new file mode 100644 index 0000000..7f6d044 --- /dev/null +++ b/gclo.Engine.Tests/LibGit2GitClientTests.cs @@ -0,0 +1,339 @@ +using gclo.Engine; +using LibGit2Sharp; + +namespace gclo.Engine.Tests; + +/// +/// Integration tests for against real repositories on the +/// local file system. No network is involved: fixture repositories are created with +/// LibGit2Sharp and cloned/pushed through libgit2's local-path transport, which ignores +/// credentials — any non-empty token works. +/// +public sealed class LibGit2GitClientTests : IDisposable +{ + private const string Token = "test-token"; + + private readonly string _root = + Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + + private readonly LibGit2GitClient _client = new(); + + public LibGit2GitClientTests() + { + Directory.CreateDirectory(_root); + } + + public void Dispose() => TryDeleteDirectory(_root); + + // ---------------------------------------------------------------- clone + + [Fact] + public async Task Clone_LocalSource_PopulatesWorkingTreeAndReportsProgressInRange() + { + string source = CreateSourceRepo(); + string target = NewPath("clone-target"); + var progressValues = new List(); + + await _client.CloneAsync(source, target, Token, progressValues.Add, CancellationToken.None); + + Assert.Equal("hello from source", File.ReadAllText(Path.Combine(target, "readme.txt"))); + Assert.True(_client.IsValidRepository(target)); + // Local-transport clones may fire few (or even zero) transfer callbacks, so do not + // require that any arrived — only that every value is a valid completion fraction. + Assert.All(progressValues, v => Assert.InRange(v, 0.0, 1.0)); + } + + [Fact] + public async Task Clone_SourceMissing_ThrowsAndLeavesNoTargetDirectory() + { + string missingSource = NewPath("no-such-source"); + string target = NewPath("failed-clone"); + + await Assert.ThrowsAnyAsync( + () => _client.CloneAsync(missingSource, target, Token, null, CancellationToken.None)); + + // A failed clone into a directory that did not exist before must clean up after + // itself, or the next run would treat the leftovers as an existing local repo. + Assert.False(Directory.Exists(target)); + } + + [Fact] + public async Task Clone_CheckoutFails_CleansUpPartialCloneWithReadOnlyPackFiles() + { + // A tree entry whose name is invalid on Windows makes the clone fail during + // checkout — after the fetch has already written read-only pack files under + // .git — exercising the attribute-clearing delete path. + string source = CreateRepoWithWindowsInvalidFileName(); + string target = NewPath("partial-clone"); + + await Assert.ThrowsAnyAsync( + () => _client.CloneAsync(source, target, Token, null, CancellationToken.None)); + + Assert.False(Directory.Exists(target)); + } + + [Fact] + public async Task Clone_TokenAlreadyCanceled_ThrowsOperationCanceledAndCreatesNothing() + { + string source = CreateSourceRepo(); + string target = NewPath("canceled-clone"); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync( + () => _client.CloneAsync(source, target, Token, null, cts.Token)); + + Assert.False(Directory.Exists(target)); + } + + // ---------------------------------------------------------------- IsValidRepository + + [Fact] + public void IsValidRepository_MissingDirectory_ReturnsFalse() + { + Assert.False(_client.IsValidRepository(NewPath("does-not-exist"))); + } + + [Fact] + public void IsValidRepository_EmptyDirectory_ReturnsFalse() + { + string path = NewPath("empty"); + Directory.CreateDirectory(path); + + Assert.False(_client.IsValidRepository(path)); + } + + [Fact] + public void IsValidRepository_DirectoryWithUnrelatedFiles_ReturnsFalse() + { + string path = NewPath("not-a-repo"); + Directory.CreateDirectory(Path.Combine(path, "sub")); + File.WriteAllText(Path.Combine(path, "notes.txt"), "just a file"); + File.WriteAllText(Path.Combine(path, "sub", "data.bin"), "more bytes"); + + Assert.False(_client.IsValidRepository(path)); + } + + [Fact] + public void IsValidRepository_RealRepository_ReturnsTrue() + { + string source = CreateSourceRepo(); + + Assert.True(_client.IsValidRepository(source)); + } + + // ---------------------------------------------------------------- fetch & pull + + [Fact] + public async Task FetchAndPull_RemoteAhead_FastForwardsToRemoteTip() + { + string source = CreateSourceRepo(); + string target = NewPath("clone"); + await _client.CloneAsync(source, target, Token, null, CancellationToken.None); + + string newSha = CommitFile(source, "update.txt", "new content", "second commit"); + + await _client.FetchAndPullAsync(target, Token, CancellationToken.None); + + Assert.Equal(newSha, HeadSha(target)); + Assert.Equal("new content", File.ReadAllText(Path.Combine(target, "update.txt"))); + } + + [Fact] + public async Task FetchAndPull_AlreadyUpToDate_CompletesAndLeavesTipUnchanged() + { + string source = CreateSourceRepo(); + string target = NewPath("clone"); + await _client.CloneAsync(source, target, Token, null, CancellationToken.None); + string tipBefore = HeadSha(target); + + await _client.FetchAndPullAsync(target, Token, CancellationToken.None); + + Assert.Equal(tipBefore, HeadSha(target)); + } + + [Fact] + public async Task FetchAndPull_DivergedHistories_ThrowsNonFastForward() + { + string source = CreateSourceRepo(); + string target = NewPath("clone"); + await _client.CloneAsync(source, target, Token, null, CancellationToken.None); + + // Both sides advance from the shared base: no fast-forward is possible, and a + // mirror tool must never manufacture a merge commit. + CommitFile(source, "from-remote.txt", "remote change", "remote commit"); + CommitFile(target, "from-local.txt", "local change", "local commit"); + + await Assert.ThrowsAsync( + () => _client.FetchAndPullAsync(target, Token, CancellationToken.None)); + } + + [Fact] + public async Task FetchAndPull_DetachedHead_ThrowsInvalidOperationMentioningDetached() + { + string source = CreateSourceRepo(); + string target = NewPath("clone"); + await _client.CloneAsync(source, target, Token, null, CancellationToken.None); + + using (var repo = new Repository(target)) + { + Commands.Checkout(repo, repo.Head.Tip.Sha); + Assert.True(repo.Info.IsHeadDetached); + } + + var ex = await Assert.ThrowsAsync( + () => _client.FetchAndPullAsync(target, Token, CancellationToken.None)); + + Assert.Contains("detached", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task FetchAndPull_BranchWithoutUpstream_FetchesButMergesNothing() + { + string source = CreateSourceRepo(); + string target = NewPath("clone"); + await _client.CloneAsync(source, target, Token, null, CancellationToken.None); + string tipBefore = HeadSha(target); + + string defaultBranch; + using (var repo = new Repository(target)) + { + defaultBranch = repo.Head.FriendlyName; + var localOnly = repo.CreateBranch("local-only"); + Commands.Checkout(repo, localOnly); + Assert.Null(repo.Head.TrackedBranch); + } + + string remoteSha = CommitFile(source, "later.txt", "later content", "remote commit"); + + await _client.FetchAndPullAsync(target, Token, CancellationToken.None); + + using var after = new Repository(target); + Assert.Equal("local-only", after.Head.FriendlyName); + Assert.Equal(tipBefore, after.Head.Tip.Sha); // nothing was merged... + Assert.Equal(remoteSha, after.Branches["origin/" + defaultBranch].Tip.Sha); // ...but the fetch ran + Assert.False(File.Exists(Path.Combine(target, "later.txt"))); + } + + [Fact] + public async Task FetchAndPull_UnbornHead_MaterializesBranchUpstreamAndWorkingTree() + { + // A clone of an empty origin leaves HEAD unborn: pointing at a branch with no commits. + string origin = NewPath("bare-origin"); + Repository.Init(origin, isBare: true); + + string target = NewPath("unborn-clone"); + await _client.CloneAsync(origin, target, Token, null, CancellationToken.None); + using (var repo = new Repository(target)) + { + Assert.True(repo.Info.IsHeadUnborn); + } + + // Seed the origin through a third working copy — a local push needs no credentials. + string seeder = NewPath("seeder"); + await _client.CloneAsync(origin, seeder, Token, null, CancellationToken.None); + string seededSha = CommitFile(seeder, "seed.txt", "seeded content", "initial commit"); + using (var repo = new Repository(seeder)) + { + repo.Network.Push(repo.Network.Remotes["origin"], repo.Head.CanonicalName, new PushOptions()); + } + + await _client.FetchAndPullAsync(target, Token, CancellationToken.None); + + using var after = new Repository(target); + Assert.False(after.Info.IsHeadUnborn); + Assert.Equal(seededSha, after.Head.Tip.Sha); + Assert.NotNull(after.Head.TrackedBranch); + Assert.Equal("seeded content", File.ReadAllText(Path.Combine(target, "seed.txt"))); + } + + [Fact] + public async Task FetchAndPull_UnbornHeadAndRemoteStillEmpty_ReturnsWithoutError() + { + string origin = NewPath("bare-origin"); + Repository.Init(origin, isBare: true); + + string target = NewPath("unborn-clone"); + await _client.CloneAsync(origin, target, Token, null, CancellationToken.None); + + // Nothing to pull yet: there is no branch to materialize, and that is not an error. + await _client.FetchAndPullAsync(target, Token, CancellationToken.None); + + using var after = new Repository(target); + Assert.True(after.Info.IsHeadUnborn); + } + + // ---------------------------------------------------------------- fixture helpers + + private string NewPath(string name) => Path.Combine(_root, name); + + /// Initializes a non-bare repository under the test root with one commit. + private string CreateSourceRepo(string name = "source") + { + string path = NewPath(name); + Repository.Init(path); + CommitFile(path, "readme.txt", "hello from source", "initial commit"); + return path; + } + + /// Writes a file into the working tree, stages it, and commits; returns the new SHA. + private static string CommitFile(string workdir, string fileName, string content, string message) + { + using var repo = new Repository(workdir); + File.WriteAllText(Path.Combine(workdir, fileName), content); + Commands.Stage(repo, fileName); + var signature = new Signature("tester", "tester@example.test", DateTimeOffset.Now); + return repo.Commit(message, signature, signature).Sha; + } + + /// + /// Builds a repository whose single commit contains a file name that Windows rejects. + /// The tree is written through the object database, so nothing ever touches the disk + /// under that name until a clone tries to check it out. + /// + private string CreateRepoWithWindowsInvalidFileName() + { + string path = NewPath("invalid-name-source"); + Repository.Init(path); + using var repo = new Repository(path); + + var blob = repo.ObjectDatabase.CreateBlob(new MemoryStream("unwritable on Windows"u8.ToArray())); + var treeDefinition = new TreeDefinition().Add("bad:name.txt", blob, Mode.NonExecutableFile); + var tree = repo.ObjectDatabase.CreateTree(treeDefinition); + var signature = new Signature("tester", "tester@example.test", DateTimeOffset.Now); + var commit = repo.ObjectDatabase.CreateCommit( + signature, signature, "commit with a Windows-invalid file name", tree, [], prettifyMessage: false); + repo.Refs.Add(repo.Refs.Head.TargetIdentifier, commit.Id); + return path; + } + + private static string HeadSha(string workdir) + { + using var repo = new Repository(workdir); + return repo.Head.Tip.Sha; + } + + /// + /// Same approach as the class under test: pack files under .git are written read-only + /// on Windows, so clear attributes before deleting. + /// + private static void TryDeleteDirectory(string path) + { + try + { + if (!Directory.Exists(path)) + { + return; + } + foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) + { + File.SetAttributes(file, FileAttributes.Normal); + } + Directory.Delete(path, recursive: true); + } + catch + { + // Best effort; stray temp dirs are harmless. + } + } +} diff --git a/gclo.slnx b/gclo.slnx index 47ff812..9b4718e 100644 --- a/gclo.slnx +++ b/gclo.slnx @@ -4,6 +4,7 @@ + diff --git a/gclo/MainWindow.xaml b/gclo/MainWindow.xaml index 186a7ee..22f5639 100644 --- a/gclo/MainWindow.xaml +++ b/gclo/MainWindow.xaml @@ -28,6 +28,7 @@ + diff --git a/gclo/MainWindow.xaml.cs b/gclo/MainWindow.xaml.cs index 8d713df..83743af 100644 --- a/gclo/MainWindow.xaml.cs +++ b/gclo/MainWindow.xaml.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using gclo.Services; @@ -12,6 +13,7 @@ namespace gclo private const string RepoUrl = "https://github.com/KofTwentyTwo/gclo"; private readonly AppSettings _settings; + private readonly UpdateService _updateService = new(); public MainViewModel ViewModel { get; } @@ -79,6 +81,68 @@ namespace gclo await dialog.ShowAsync(); } + private async void CheckForUpdatesMenuItem_Click(object sender, RoutedEventArgs e) + { + if (!_updateService.IsSupported) + { + await ShowUpdateMessageAsync( + "Check for updates", + "Updates are only available in installed builds."); + return; + } + + var result = await _updateService.CheckAsync(); + if (result.Error is not null) + { + await ShowUpdateMessageAsync( + "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 ShowUpdateMessageAsync("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 confirm.ShowAsync() != ContentDialogResult.Primary) + { + 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) + { + await ShowUpdateMessageAsync("Update failed", error); + } + } + + private async Task ShowUpdateMessageAsync(string title, string message) + { + var dialog = new ContentDialog + { + Title = title, + Content = message, + CloseButtonText = "OK", + XamlRoot = Content.XamlRoot, + }; + await dialog.ShowAsync(); + } + [System.Runtime.InteropServices.DllImport("user32.dll")] private static extern uint GetDpiForWindow(IntPtr hwnd); diff --git a/gclo/Program.cs b/gclo/Program.cs new file mode 100644 index 0000000..6c47355 --- /dev/null +++ b/gclo/Program.cs @@ -0,0 +1,31 @@ +using System; +using Velopack; + +namespace gclo +{ + /// + /// Custom entry point. The XAML-generated Main is disabled (DISABLE_XAML_GENERATED_MAIN + /// in gclo.csproj) so Velopack can run before any UI exists: during install, update, and + /// uninstall Velopack launches the exe with special arguments, handles them in + /// VelopackApp.Run, and may exit the process. In non-Velopack contexts (F5, + /// loose builds, MSIX packages) that call is inert and the app starts normally. + /// + public static class Program + { + [STAThread] + static void Main(string[] args) + { + // Must be first: handles Velopack install/update/uninstall hooks and may exit. + VelopackApp.Build().Run(); + + // The remainder mirrors the XAML-generated Main (obj\...\App.g.i.cs) verbatim. + global::WinRT.ComWrappersSupport.InitializeComWrappers(); + global::Microsoft.UI.Xaml.Application.Start((p) => + { + var context = new global::Microsoft.UI.Dispatching.DispatcherQueueSynchronizationContext(global::Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread()); + global::System.Threading.SynchronizationContext.SetSynchronizationContext(context); + new App(); + }); + } + } +} diff --git a/gclo/Services/UpdateService.cs b/gclo/Services/UpdateService.cs new file mode 100644 index 0000000..de1db8d --- /dev/null +++ b/gclo/Services/UpdateService.cs @@ -0,0 +1,140 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Velopack; +using Velopack.Sources; + +namespace gclo.Services; + +/// +/// Result of an update check. is the newer version found, +/// or null when already up to date; is true when that update is +/// already staged locally (e.g. by a previous session) and only needs a restart to apply; +/// is non-null when the check itself failed. +/// +public sealed record UpdateCheckResult(string? AvailableVersion, bool IsDownloaded, string? Error); + +/// +/// Self-update via Velopack, with releases hosted on the app's public GitHub repository. +/// Updates only work in Velopack-installed builds: under F5, loose unpackaged builds, or +/// MSIX packages is false. Every member is guarded — failures are +/// reported as strings and must never crash the app over something as optional as an update. +/// +public sealed class UpdateService +{ + private const string RepoUrl = "https://github.com/KofTwentyTwo/gclo"; + + private UpdateManager? _manager; + private UpdateInfo? _pendingUpdate; + + /// True when the app was installed by Velopack and can check for and apply updates. + public bool IsSupported + { + get + { + try + { + return GetManager().IsInstalled; + } + catch + { + return false; + } + } + } + + /// The installed version as Velopack sees it, or null outside installed builds. + public string? CurrentVersion + { + get + { + try + { + return GetManager().CurrentVersion?.ToString(); + } + catch + { + return null; + } + } + } + + /// + /// Checks GitHub releases for a newer version and remembers it for + /// . Never throws: failures come back in + /// . + /// + public async Task CheckAsync() + { + UpdateManager manager; + try + { + manager = GetManager(); + if (!manager.IsInstalled) + { + return new UpdateCheckResult(null, false, "Updates are only available in installed builds."); + } + } + catch (Exception ex) + { + return new UpdateCheckResult(null, false, ex.Message); + } + + try + { + UpdateInfo? update = await manager.CheckForUpdatesAsync().ConfigureAwait(false); + _pendingUpdate = update; + if (update is null) + { + return new UpdateCheckResult(null, false, null); + } + + bool downloaded = manager.UpdatePendingRestart?.Version == update.TargetFullRelease.Version; + return new UpdateCheckResult(update.TargetFullRelease.Version.ToString(), downloaded, null); + } + catch (Exception ex) + { + _pendingUpdate = null; + return new UpdateCheckResult(null, false, ex.Message); + } + } + + /// + /// Downloads the update found by the last successful , applies it, + /// and restarts the app. On success this never returns (the process exits to restart); + /// otherwise it returns an error message. Never throws. + /// + /// Optional download progress callback, called with 0-100. + /// Optional token to abandon the download. + public async Task DownloadAndApplyAsync(Action? progress = null, CancellationToken cancellationToken = default) + { + UpdateManager? manager = _manager; + UpdateInfo? update = _pendingUpdate; + if (manager is null || update is null) + { + return "No update is ready to install; check for updates first."; + } + + try + { + await manager.DownloadUpdatesAsync(update, progress, cancellationToken).ConfigureAwait(false); + manager.ApplyUpdatesAndRestart(update.TargetFullRelease); + return null; + } + catch (OperationCanceledException) + { + return "The update was canceled."; + } + catch (Exception ex) + { + return ex.Message; + } + } + + /// + /// Created lazily so merely constructing the service can never fail; callers wrap this + /// in try/catch and treat a throwing manager as "updates not supported". + /// + private UpdateManager GetManager() => + _manager ??= new UpdateManager(new GithubSource(RepoUrl, null, false)); +} diff --git a/gclo/gclo.csproj b/gclo/gclo.csproj index 0523ff9..aaf91ed 100644 --- a/gclo/gclo.csproj +++ b/gclo/gclo.csproj @@ -12,6 +12,8 @@ false true enable + + $(DefineConstants);DISABLE_XAML_GENERATED_MAIN @@ -40,6 +42,7 @@ + -- 2.54.0 From 7fe0d5b1efe49d214ce0ebc908555fbac00f4eae Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 3 Jul 2026 09:00:04 -0500 Subject: [PATCH 3/9] Move development tracking to GitHub Issues TODO.md becomes a pointer; roadmap lives in issue #11, priority work in #2. Co-Authored-By: Claude Fable 5 --- TODO.md | 130 ++++---------------------------------------------------- 1 file changed, 9 insertions(+), 121 deletions(-) diff --git a/TODO.md b/TODO.md index f880bb7..9983a21 100644 --- a/TODO.md +++ b/TODO.md @@ -1,125 +1,13 @@ -# gclo — working TODO +# gclo — development tracking -The operating plan for gclo. Every item traces to an owner request. Statuses: -`[x]` done · `[~]` in progress · `[ ]` queued. Ordering within "Queued" is the -planned execution order. +**Development is tracked in [GitHub Issues](https://github.com/KofTwentyTwo/gclo/issues).** -## Done +- Roadmap / execution order: [#11 — Development roadmap](https://github.com/KofTwentyTwo/gclo/issues/11) +- Current priority: [#2 — Windows-invalid path clone failures](https://github.com/KofTwentyTwo/gclo/issues/2) +- Owner action items: [#7 — Owner setup checklist](https://github.com/KofTwentyTwo/gclo/issues/7) -- [x] **T1. WinUI 3 desktop app (.NET 10, packaged)** — clones AND updates every repo - in a GitHub org; bounded parallelism (default 8); live per-repo status - (queued/cloning/pulling/done/failed + error text); overall progress bar; failures - on one repo never abort the others; cancellation. -- [x] **T2. Business logic in a shared library (`gclo.Engine`)** — Octokit listing - (manual pagination, cancellable, deduped), LibGit2Sharp clone / fetch + - fast-forward-only pull, orchestrator. No UI dependencies. (See T20 for the - remaining view-model extraction.) -- [x] **T3. Headless unit tests** — 20 xunit tests: clone-vs-pull routing, concurrency - bound, failure isolation, cancellation, progress ordering, validation. -- [x] **T4. Clean build + verified launch** — `dotnet build gclo.slnx -p:Platform=x64` - with 0 errors / 0 warnings; app launch verified. -- [x] **T5. Multi-agent development flow** — research, development, adversarial review - (13 confirmed findings fixed), and feature workflows run by agent sets. -- [x] **T6. Published to GitHub** — public repo , - MIT, description + topics set. -- [x] **T7. Token-first flow with org dropdown** — PAT entered first; editable - dropdown auto-populates (debounced, cancellable) with what the token can access; - manual entry still works for tokens that cannot enumerate orgs. -- [x] **T8. Personal accounts as sync targets** — the token's own login is listed - first in the dropdown; repo listing falls back for user accounts (own account - includes private repos via owner affiliation; other accounts list public repos). -- [x] **T9. Menu bar** — File (Settings…, Exit), Help (View on GitHub, - Check for updates…, About + F1). -- [x] **T10. Settings dialog** — default target folder, default parallelism, - Light/Dark/System theme; persisted to `%LOCALAPPDATA%\gclo\settings.json` - (works packaged and unpackaged); applied at startup and on save. -- [x] **T11. About dialog** — version, MIT notice, full open-source attributions - (Octokit, LibGit2Sharp, libgit2 GPLv2 + linking exception, MVVM Toolkit, - Windows App SDK, .NET). -- [x] **T12. Open-source marketing/branding** — MIT LICENSE, README with badges and - attribution table, CONTRIBUTING.md, generated sync-glyph icon at all asset sizes, - manifest description, repo description + topics. -- [x] **T13. Repo security & branch model** — `dev` (integration) + `main` (stable, - PR-only) with rulesets (no force-push/deletion); Dependabot alerts + automated - security-update PRs; secret scanning with push protection; private vulnerability - reporting; Actions tokens read-only by default. +Labels: `priority` (work first), `needs-owner` (blocked on the owner), `deferred` +(agreed follow-up, unscheduled), `quality`, `release`, `refactor`. -## In progress (productionize agent workflow) - -- [x] **T14. Full automated test coverage** — 35 tests: 15 new real-git integration - tests for the LibGit2Sharp layer against local fixture repos (clone, FF pull, - unborn-HEAD repair, diverged, detached, cleanup, cancellation); engine line - coverage 60.6% (LibGit2GitClient 76%, orchestrator 100%; Octokit wrappers are - network code). View-model coverage arrives with T20. -- [x] **T15. CLI / scriptable version (`gclo.Cli`)** — `gclo sync`, `gclo orgs`, - `--json`, exit codes (0 ok / 1 partial / 2 fatal), token via env/file/stdin - (never a bare argument), Ctrl+C cancellation, docs/CLI.md. Verified against live - GitHub (see T21). -- [~] **T16. GitHub CI/CD** — workflows written (PR/push gates; tag-driven releases: - `v1.2.3` = stable, `v1.2.3-beta.1` = dev prerelease; GitHub Releases + winget - gated on WINGET_TOKEN + NuGet gated on NUGET_API_KEY; docs/RELEASING.md). - In progress until the first real Actions runs are green. -- [~] **T17. Installer** — Velopack 1.2.0 wired (chosen over MSI/MSIX: MSIX needs a - paid signing cert, MSI has no self-update; Velopack gives installer + delta - updates from GitHub Releases). In progress until T25 produces a real Setup.exe. -- [x] **T18. Self-update from the GUI** — Help → "Check for updates…" via Velopack - UpdateManager against the repo's releases; update-and-restart; degrades cleanly - in dev builds (verified). Full loop proven at T25. -- [~] **T19. CI quality & security gates** — `-warnaserror` (also fails on vulnerable - NuGet packages), tests + engine coverage threshold, `dotnet format` gate (verified - locally), CodeQL, dependency review on PRs, Dependabot config. In progress until - green on GitHub. -- [x] **T21. End-to-end smoke test with real credentials** — verified live: `orgs` - lists personal account first + all 8 orgs; MMLT-Holdings cloned in parallel - (exit 0); re-run took the pull path (updated=2, correct JSON). - -## Queued (execution order) - -- [ ] **T22. Windows-invalid path validation + recovery (spec)** — **PRIORITY: owner - reports frequent real-world failures on Linux-fine/Windows-invalid paths and - too-long paths.** Quick win first: two-phase clone (Checkout=false) enables - setting `core.longpaths=true` before checkout, which should eliminate the - path-length class outright; then the full validation/recovery below. Clone with - Checkout=false; validate tree paths (trailing space/dot, invalid chars, reserved - names, case-insensitive collisions, full-path length); structured result with - per-path reasons + suggested sanitized names; recovery via rename/skip mapping - with manual blob materialization; mapped repos fetch + re-materialize on update; - abort keeps the existing didn't-exist-before cleanup; plumbing-built test fixtures. -- [ ] **T20. Extract `gclo.ViewModels` library** — move MainViewModel, - RepoItemViewModel, AppSettings persistence out of the UI project; add view-model - unit tests; UI project keeps only XAML/dialogs/pickers/brushes/update plumbing. - Rule: no business logic in the UI layer, ever. -- [ ] **T23. UX overhaul (main screen)** — two-phase flow (Load repos → selectable - table → Sync selected); checkbox column + select-all; sortable headers; per-row - progress bar (determinate clone %, indeterminate pull); always-on activity log in - the shared library (`%LOCALAPPDATA%\gclo\logs`) with View → Activity log viewer + - open-logs-folder; clean error capture with Retry-failed (engine overload syncing - an explicit repo list) and the T22 recovery dialog; Open-folder button after runs; - live target-path preview + optional "create org subfolder" toggle. -- [ ] **T24. Best-practices / KISS audit + enforcement** — agent audit of design - simplicity, coding standards, docs accuracy, comment quality (adversarially - verified, then fixed); enforcement: .editorconfig + Directory.Build.props - (warnings-as-errors locally, analyzers, EnforceCodeStyleInBuild), CODEOWNERS, - PR/issue templates, SECURITY.md, required status checks added to the `main` - ruleset once CI check names exist. -- [ ] **T25. First release** — tag a beta, watch the pipeline produce the GitHub - Release (Setup.exe + portable + CLI zip), install, and verify in-app self-update - end to end; then first stable tag. - -## Deferred / follow-ups - -- [ ] **T26. "Clone in WSL" recovery option** — UI verb exists in the T22 recovery - dialog contract; actual WSL execution (wsl.exe + path translation) deferred. -- [ ] **T27. Extra package managers** — Chocolatey / Scoop manifests once releases - are flowing. -- [ ] **T28. README screenshot** — placeholder awaits a real capture once the T23 UI - lands. - -## Needs owner action - -- [ ] **O1.** Add `NUGET_API_KEY` repo secret to enable NuGet publishing of the - engine library. -- [ ] **O2.** Add `WINGET_TOKEN` repo secret + one-time manual first winget - submission (`wingetcreate new`); CI automates subsequent versions. -- [ ] **O3.** Enable Windows Developer Mode for packaged (F5/MSIX) debugging — - unpackaged run and the installed Velopack build do not need it. +This file intentionally holds no task content — the issue tracker is the single +source of truth. -- 2.54.0 From 47b102bc6ca463cfd7203f386e006ff78da1204f Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 3 Jul 2026 09:06:04 -0500 Subject: [PATCH 4/9] Validate Windows-invalid paths before checkout; enable core.longpaths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the owner-reported class of clone failures on repos that are fine on Linux but break on Windows (#2): - Clones are two-phase (fetch, then checkout) so core.longpaths=true is set before any file touches disk — deep paths past MAX_PATH now clone fine - WindowsPathValidator walks the tree before every checkout (clone, pull merge, unborn repair): invalid characters, trailing space/dot, reserved device names, >255-char segments, case-only collisions - Failures surface as InvalidRepositoryPathsException with per-path reasons and suggested sanitized names instead of raw libgit2 errors - FetchAndPull also sets core.longpaths and validates incoming trees 12 new tests (47 total), fixtures forged via ObjectDatabase plumbing. Recovery UX (rename/skip mapping) tracked in #2 for the UX overhaul. Co-Authored-By: Claude Fable 5 --- gclo.Engine.Tests/LibGit2GitClientTests.cs | 14 +- .../WindowsPathValidationTests.cs | 227 ++++++++++++++++++ gclo.Engine/InvalidPathInfo.cs | 7 + .../InvalidRepositoryPathsException.cs | 25 ++ gclo.Engine/LibGit2GitClient.cs | 51 +++- gclo.Engine/WindowsPathValidator.cs | 96 ++++++++ 6 files changed, 410 insertions(+), 10 deletions(-) create mode 100644 gclo.Engine.Tests/WindowsPathValidationTests.cs create mode 100644 gclo.Engine/InvalidPathInfo.cs create mode 100644 gclo.Engine/InvalidRepositoryPathsException.cs create mode 100644 gclo.Engine/WindowsPathValidator.cs diff --git a/gclo.Engine.Tests/LibGit2GitClientTests.cs b/gclo.Engine.Tests/LibGit2GitClientTests.cs index 7f6d044..310401c 100644 --- a/gclo.Engine.Tests/LibGit2GitClientTests.cs +++ b/gclo.Engine.Tests/LibGit2GitClientTests.cs @@ -58,17 +58,21 @@ public sealed class LibGit2GitClientTests : IDisposable } [Fact] - public async Task Clone_CheckoutFails_CleansUpPartialCloneWithReadOnlyPackFiles() + public async Task Clone_WindowsInvalidPath_ThrowsTypedExceptionAndCleansUp() { - // A tree entry whose name is invalid on Windows makes the clone fail during - // checkout — after the fetch has already written read-only pack files under - // .git — exercising the attribute-clearing delete path. + // A tree entry whose name is invalid on Windows must surface as the structured + // InvalidRepositoryPathsException (not a raw libgit2 error), after the fetch has + // already written read-only pack files — exercising the attribute-clearing delete. string source = CreateRepoWithWindowsInvalidFileName(); string target = NewPath("partial-clone"); - await Assert.ThrowsAnyAsync( + var ex = await Assert.ThrowsAsync( () => _client.CloneAsync(source, target, Token, null, CancellationToken.None)); + var info = Assert.Single(ex.Paths); + Assert.Equal("bad:name.txt", info.RepoPath); + Assert.Contains("invalid on Windows", info.Reason); + Assert.Equal("bad_name.txt", info.SuggestedName); Assert.False(Directory.Exists(target)); } diff --git a/gclo.Engine.Tests/WindowsPathValidationTests.cs b/gclo.Engine.Tests/WindowsPathValidationTests.cs new file mode 100644 index 0000000..3e290d0 --- /dev/null +++ b/gclo.Engine.Tests/WindowsPathValidationTests.cs @@ -0,0 +1,227 @@ +using gclo.Engine; +using LibGit2Sharp; + +namespace gclo.Engine.Tests; + +/// +/// Tests for Windows path validation on clone/pull: paths that are legal in git but +/// invalid on Windows must surface as a structured +/// before checkout, and long-but-valid paths must clone successfully (core.longpaths). +/// Offending tree entries are forged through the object database — they can never be +/// created on a Windows working tree directly. +/// +public sealed class WindowsPathValidationTests : IDisposable +{ + private const string Token = "test-token"; + + private readonly string _root = + Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + + private readonly LibGit2GitClient _client = new(); + + public WindowsPathValidationTests() + { + Directory.CreateDirectory(_root); + } + + public void Dispose() => TryDeleteDirectory(_root); + + // ---------------------------------------------------------------- validator + + [Theory] + [InlineData("trailing-space ", "trailing space or dot", "trailing-space")] + [InlineData("trailing-dot.", "trailing space or dot", "trailing-dot")] + [InlineData("bad:colon.txt", "invalid on Windows", "bad_colon.txt")] + [InlineData("CON", "reserved Windows device name", "CON_")] + [InlineData("COM3.txt", "reserved Windows device name", "COM3.txt_")] + public void Validate_FlagsInvalidSegment_WithReasonAndSuggestion( + string entryName, string expectedReasonFragment, string expectedSuggestion) + { + var invalid = ValidateNames(entryName); + + var info = Assert.Single(invalid); + Assert.Equal(entryName, info.RepoPath); + Assert.Contains(expectedReasonFragment, info.Reason); + Assert.Equal(expectedSuggestion, info.SuggestedName); + } + + [Fact] + public void Validate_FlagsCaseOnlyCollision() + { + var invalid = ValidateNames("README.md", "readme.md"); + + var info = Assert.Single(invalid); + Assert.Contains("differs only by case", info.Reason); + } + + [Fact] + public void Validate_FlagsOverlongSegment() + { + var invalid = ValidateNames(new string('x', 256)); + + var info = Assert.Single(invalid); + Assert.Contains("longer than 255", info.Reason); + } + + [Fact] + public void Validate_FlagsInvalidDirectorySegment_InNestedPath() + { + var invalid = ValidateNames("bad-dir /nested.txt"); + + var info = Assert.Single(invalid); + Assert.Equal("bad-dir ", info.RepoPath); + Assert.Contains("trailing space or dot", info.Reason); + } + + [Fact] + public void Validate_ValidTree_ReturnsEmpty() + { + var invalid = ValidateNames("readme.txt", "src/main.cs", ".github/workflows/ci.yml", "a.b.c"); + + Assert.Empty(invalid); + } + + // ---------------------------------------------------------------- clone behavior + + [Fact] + public async Task Clone_MixedValidAndInvalidPaths_ReportsOnlyInvalidOnes_AndCleansUp() + { + string source = CreateForgedRepo(("good.txt", true), ("bad:name.txt", true), ("NUL.log", true)); + string target = NewPath("mixed-clone"); + + var ex = await Assert.ThrowsAsync( + () => _client.CloneAsync(source, target, Token, null, CancellationToken.None)); + + Assert.Equal(2, ex.Paths.Count); + Assert.DoesNotContain(ex.Paths, p => p.RepoPath == "good.txt"); + Assert.False(Directory.Exists(target)); + } + + [Fact] + public async Task Clone_PathLongerThanLegacyMaxPath_Succeeds() + { + // Regression for owner-reported "filename or folder too long" failures: + // core.longpaths is set before checkout, so a deep-but-valid tree clones fine. + string segment = new string('d', 60); + string repoPath = string.Join('/', Enumerable.Repeat(segment, 4)) + "/deep-file.txt"; + string source = CreateSourceRepoWithLongPath(repoPath); + string target = NewPath("long-clone"); + Assert.True((target + "/" + repoPath).Length > 260); // the scenario is real + + await _client.CloneAsync(source, target, Token, null, CancellationToken.None); + + string fullPath = Path.Combine(target, repoPath.Replace('/', Path.DirectorySeparatorChar)); + Assert.Equal("deep content", File.ReadAllText(fullPath)); + } + + [Fact] + public async Task FetchAndPull_IncomingCommitWithInvalidPath_ThrowsTypedException() + { + string source = CreateForgedRepo(("original.txt", true)); + string target = NewPath("pull-target"); + await _client.CloneAsync(source, target, Token, null, CancellationToken.None); + + AppendForgedCommit(source, "bad:incoming.txt"); + + var ex = await Assert.ThrowsAsync( + () => _client.FetchAndPullAsync(target, Token, CancellationToken.None)); + + Assert.Contains(ex.Paths, p => p.RepoPath == "bad:incoming.txt"); + // The valid original working tree must be untouched. + Assert.Equal("content", File.ReadAllText(Path.Combine(target, "original.txt"))); + } + + // ---------------------------------------------------------------- fixture helpers + + private string NewPath(string name) => Path.Combine(_root, name); + + /// Runs the validator over a tree containing exactly the given entry names. + private IReadOnlyList ValidateNames(params string[] entryNames) + { + string path = NewPath("validator-" + Guid.NewGuid().ToString("N")[..8]); + Repository.Init(path, isBare: true); + using var repo = new Repository(path); + + var blob = repo.ObjectDatabase.CreateBlob(new MemoryStream("x"u8.ToArray())); + var definition = new TreeDefinition(); + foreach (string name in entryNames) + { + definition.Add(name, blob, Mode.NonExecutableFile); + } + return WindowsPathValidator.Validate(repo.ObjectDatabase.CreateTree(definition)); + } + + /// Builds a repo whose single commit holds the given forged entry names. + private string CreateForgedRepo(params (string Name, bool _)[] entries) + { + string path = NewPath("forged-" + Guid.NewGuid().ToString("N")[..8]); + Repository.Init(path); + using var repo = new Repository(path); + + var blob = repo.ObjectDatabase.CreateBlob(new MemoryStream("content"u8.ToArray())); + var definition = new TreeDefinition(); + foreach (var (name, _) in entries) + { + definition.Add(name, blob, Mode.NonExecutableFile); + } + var tree = repo.ObjectDatabase.CreateTree(definition); + var signature = new Signature("tester", "tester@example.test", DateTimeOffset.Now); + var commit = repo.ObjectDatabase.CreateCommit( + signature, signature, "forged commit", tree, [], prettifyMessage: false); + repo.Refs.Add(repo.Refs.Head.TargetIdentifier, commit.Id); + return path; + } + + /// Adds a commit on top of HEAD introducing a forged entry name. + private static void AppendForgedCommit(string workdir, string entryName) + { + using var repo = new Repository(workdir); + var head = repo.Head.Tip; + + var blob = repo.ObjectDatabase.CreateBlob(new MemoryStream("x"u8.ToArray())); + var definition = TreeDefinition.From(head.Tree).Add(entryName, blob, Mode.NonExecutableFile); + var tree = repo.ObjectDatabase.CreateTree(definition); + var signature = new Signature("tester", "tester@example.test", DateTimeOffset.Now); + var commit = repo.ObjectDatabase.CreateCommit( + signature, signature, "forged follow-up", tree, [head], prettifyMessage: false); + repo.Refs.UpdateTarget(repo.Refs.Head.ResolveToDirectReference(), commit.Id); + } + + /// Creates a repo containing one file at a deep path exceeding legacy MAX_PATH. + private string CreateSourceRepoWithLongPath(string repoPath) + { + string path = NewPath("long-source"); + Repository.Init(path); + using var repo = new Repository(path); + // The fixture itself needs long-path support to stage the file. + repo.Config.Set("core.longpaths", true, ConfigurationLevel.Local); + + string fullPath = Path.Combine(path, repoPath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, "deep content"); + Commands.Stage(repo, repoPath); + var signature = new Signature("tester", "tester@example.test", DateTimeOffset.Now); + repo.Commit("long path commit", signature, signature); + return path; + } + + private static void TryDeleteDirectory(string path) + { + try + { + if (!Directory.Exists(path)) + { + return; + } + foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) + { + File.SetAttributes(file, FileAttributes.Normal); + } + Directory.Delete(path, recursive: true); + } + catch + { + // Best effort; stray temp dirs are harmless. + } + } +} diff --git a/gclo.Engine/InvalidPathInfo.cs b/gclo.Engine/InvalidPathInfo.cs new file mode 100644 index 0000000..0be96ae --- /dev/null +++ b/gclo.Engine/InvalidPathInfo.cs @@ -0,0 +1,7 @@ +namespace gclo.Engine; + +/// One repository path that cannot be created on a Windows file system. +/// Path inside the repository, forward-slash separated. +/// Human-readable reason the path is invalid on Windows. +/// A sanitized replacement for the offending segment, when one can be derived. +public sealed record InvalidPathInfo(string RepoPath, string Reason, string? SuggestedName); diff --git a/gclo.Engine/InvalidRepositoryPathsException.cs b/gclo.Engine/InvalidRepositoryPathsException.cs new file mode 100644 index 0000000..42ba83b --- /dev/null +++ b/gclo.Engine/InvalidRepositoryPathsException.cs @@ -0,0 +1,25 @@ +namespace gclo.Engine; + +/// +/// Thrown instead of a raw libgit2 checkout error when a repository contains paths +/// that are legal in git but cannot be created on Windows (invalid characters, +/// reserved device names, trailing spaces/dots, case-only collisions). +/// +public sealed class InvalidRepositoryPathsException : Exception +{ + public IReadOnlyList Paths { get; } + + public InvalidRepositoryPathsException(IReadOnlyList paths) + : base(BuildMessage(paths)) + { + Paths = paths; + } + + private static string BuildMessage(IReadOnlyList paths) + { + var examples = string.Join("; ", paths.Take(3).Select(p => $"'{p.RepoPath}' ({p.Reason})")); + var suffix = paths.Count > 3 ? $" and {paths.Count - 3} more" : ""; + return $"{paths.Count} path(s) in this repository cannot be created on Windows: {examples}{suffix}. " + + "Nothing was checked out."; + } +} diff --git a/gclo.Engine/LibGit2GitClient.cs b/gclo.Engine/LibGit2GitClient.cs index 21d182d..c60c43e 100644 --- a/gclo.Engine/LibGit2GitClient.cs +++ b/gclo.Engine/LibGit2GitClient.cs @@ -25,7 +25,13 @@ public sealed class LibGit2GitClient : IGitClient { bool existedBefore = Directory.Exists(path); - var options = new CloneOptions(); + var options = new CloneOptions + { + // Two-phase: fetch only, then checkout after core.longpaths is set and + // every tree path is validated against Windows rules — a repo that is + // fine on Linux must fail with a structured error, not a libgit2 one. + Checkout = false, + }; options.FetchOptions.CredentialsProvider = MakeCredentialsProvider(token); int lastPercent = -1; options.FetchOptions.OnTransferProgress = tp => @@ -44,13 +50,32 @@ public sealed class LibGit2GitClient : IGitClient } return !ct.IsCancellationRequested; }; - // Note: CloneOptions in LibGit2Sharp 0.31 exposes no cancelable checkout - // callback (OnCheckoutProgress returns void), so cancellation covers the - // transfer/indexing phase — by far the longest — but not the final checkout. - try { Repository.Clone(url, path, options); + + using var repo = new Repository(path); + // Lifts the 260-character path limit before anything touches the + // working tree; libgit2 honors it for checkout. + repo.Config.Set("core.longpaths", true, ConfigurationLevel.Local); + + var tip = repo.Head.Tip; + if (tip is null) + { + return; // empty repository — nothing to check out + } + + var invalidPaths = WindowsPathValidator.Validate(tip.Tree); + if (invalidPaths.Count > 0) + { + throw new InvalidRepositoryPathsException(invalidPaths); + } + + ct.ThrowIfCancellationRequested(); + Commands.Checkout(repo, repo.Head, new CheckoutOptions + { + CheckoutModifiers = CheckoutModifiers.Force, // materialize the fresh working tree + }); } catch (Exception ex) { @@ -73,6 +98,9 @@ public sealed class LibGit2GitClient : IGitClient { using var repo = new Repository(path); + // Idempotent; also covers repositories that were cloned by other tools. + repo.Config.Set("core.longpaths", true, ConfigurationLevel.Local); + var remote = repo.Network.Remotes["origin"] ?? throw new InvalidOperationException("Repository has no 'origin' remote."); @@ -120,6 +148,13 @@ public sealed class LibGit2GitClient : IGitClient return; // already up to date } + // Incoming commits can introduce Windows-invalid paths just like a clone can. + var invalidIncoming = WindowsPathValidator.Validate(tracked.Tip.Tree); + if (invalidIncoming.Count > 0) + { + throw new InvalidRepositoryPathsException(invalidIncoming); + } + // Fast-forward only: a mirror tool must never manufacture merge commits. // Diverged local history surfaces as NonFastForwardException -> Failed with a clear message. var signature = new Signature("gclo", "gclo@localhost", DateTimeOffset.Now); @@ -159,6 +194,12 @@ public sealed class LibGit2GitClient : IGitClient return; // the remote is still empty; nothing to pull } + var invalidPaths = WindowsPathValidator.Validate(remoteBranch.Tip.Tree); + if (invalidPaths.Count > 0) + { + throw new InvalidRepositoryPathsException(invalidPaths); + } + repo.Refs.Add(targetRef, remoteBranch.Tip.Id); var local = repo.Branches[branchName]; repo.Branches.Update(local, b => diff --git a/gclo.Engine/WindowsPathValidator.cs b/gclo.Engine/WindowsPathValidator.cs new file mode 100644 index 0000000..746e910 --- /dev/null +++ b/gclo.Engine/WindowsPathValidator.cs @@ -0,0 +1,96 @@ +using LibGit2Sharp; + +namespace gclo.Engine; + +/// +/// Validates every path in a git tree against Windows file-system rules before a +/// checkout is attempted, so failures surface as structured, actionable results +/// instead of raw libgit2 errors. Path *length* is not validated here: clones and +/// pulls run with core.longpaths=true, which lifts the 260-character limit; only +/// the per-segment limit (255) still applies. +/// +public static class WindowsPathValidator +{ + private const int MaxSegmentLength = 255; + + private static readonly char[] InvalidChars = Path.GetInvalidFileNameChars(); + + private static readonly HashSet ReservedNames = new(StringComparer.OrdinalIgnoreCase) + { + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + }; + + /// Returns every Windows-invalid path in ; empty when all are fine. + public static IReadOnlyList Validate(Tree tree) + { + var invalid = new List(); + // Lowered path -> first-seen original casing, to catch case-only collisions + // (git allows README.md and readme.md; NTFS cannot hold both). + var seen = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var stack = new Stack<(Tree Tree, string Prefix)>(); + stack.Push((tree, "")); + + while (stack.Count > 0) + { + var (current, prefix) = stack.Pop(); + foreach (var entry in current) + { + string repoPath = prefix.Length == 0 ? entry.Name : $"{prefix}/{entry.Name}"; + + if (ValidateSegment(entry.Name) is { } problem) + { + invalid.Add(new InvalidPathInfo(repoPath, problem.Reason, problem.Suggestion)); + } + + if (seen.TryGetValue(repoPath, out string? original)) + { + invalid.Add(new InvalidPathInfo(repoPath, $"differs only by case from '{original}'", null)); + } + else + { + seen[repoPath] = repoPath; + } + + if (entry.TargetType == TreeEntryTargetType.Tree) + { + stack.Push(((Tree)entry.Target, repoPath)); + } + } + } + + return invalid; + } + + private static (string Reason, string? Suggestion)? ValidateSegment(string segment) + { + if (segment.Length == 0) + { + return ("empty path segment", "_"); + } + if (segment.Length > MaxSegmentLength) + { + return ($"segment longer than {MaxSegmentLength} characters", segment[..MaxSegmentLength]); + } + if (segment.EndsWith(' ') || segment.EndsWith('.')) + { + var trimmed = segment.TrimEnd(' ', '.'); + return ("trailing space or dot", trimmed.Length == 0 ? "_" : trimmed); + } + if (segment.IndexOfAny(InvalidChars) >= 0) + { + var sanitized = new string(segment.Select(c => InvalidChars.Contains(c) ? '_' : c).ToArray()); + return ("contains a character that is invalid on Windows", sanitized); + } + + string baseName = segment.Split('.')[0]; + if (ReservedNames.Contains(baseName)) + { + return ($"'{baseName}' is a reserved Windows device name", segment + "_"); + } + + return null; + } +} -- 2.54.0 From 7c1c98f94a432dbcdb81e11801ef2dd993812c00 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 3 Jul 2026 09:08:57 -0500 Subject: [PATCH 5/9] gclo = Git Clone Large Organizations; auto-changelogs on releases - Official name expansion applied: README, CLI help, About dialog, window title, CLI docs - Release pipeline now attaches auto-generated changelogs (categorized by labels via .github/release.yml) to every GitHub Release Co-Authored-By: Claude Fable 5 --- .github/release.yml | 19 +++++++++++++++++++ .github/workflows/release.yml | 9 +++++++++ README.md | 4 ++-- docs/CLI.md | 2 +- gclo.Cli/Program.cs | 2 +- gclo/AboutDialog.xaml | 2 +- gclo/MainWindow.xaml.cs | 2 +- 7 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 .github/release.yml diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..e99370b --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,19 @@ +# Categories for GitHub's auto-generated release notes (uses issue/PR labels). +changelog: + exclude: + labels: [deferred] + categories: + - title: Fixed + labels: [bug] + - title: Added + labels: [enhancement] + - title: Quality & internals + labels: [quality, refactor] + - title: Release & packaging + labels: [release] + - title: Documentation + labels: [documentation] + - title: Dependencies + labels: [dependencies] + - title: Other changes + labels: ["*"] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ebe122..7e023d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -118,6 +118,15 @@ jobs: GH_TOKEN: ${{ github.token }} run: gh release upload $env:GITHUB_REF_NAME artifacts/gclo-cli-win-x64.zip --clobber + # Auto-generated changelog (categorized by labels via .github/release.yml), + # appended to the release vpk just created. + - name: Generate release changelog + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api "repos/${{ github.repository }}/releases/generate-notes" -f tag_name=$env:GITHUB_REF_NAME --jq .body | Out-File -FilePath notes.md -Encoding utf8 + gh release edit $env:GITHUB_REF_NAME --notes-file notes.md + # ---------------------------------------------------------------------- # (c) NuGet: pack gclo.Engine and push to nuget.org - only when the # NUGET_API_KEY secret is configured on the repository. diff --git a/README.md b/README.md index ecb925a..cb7e84e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# gclo +# gclo — Git Clone Large Organizations **Clone an entire GitHub organization. Keep it up to date. In parallel.** @@ -6,7 +6,7 @@ [![Platform: Windows 11](https://img.shields.io/badge/platform-Windows%2011-blue)](https://github.com/KofTwentyTwo/gclo) [![.NET 10](https://img.shields.io/badge/.NET-10-blueviolet)](https://dotnet.microsoft.com/) -gclo is a Windows 11 desktop app (WinUI 3, Windows App SDK) that mirrors a whole GitHub organization to a local folder in one pass. Point it at an org, give it a personal access token and a target folder, and it clones the repositories that are missing and fast-forwards the ones that already exist — with live per-repo progress and bounded parallelism. +**gclo** (**G**it **C**lone **L**arge **O**rganizations) is a Windows 11 desktop app (WinUI 3, Windows App SDK) that mirrors a whole GitHub organization to a local folder in one pass. Point it at an org, give it a personal access token and a target folder, and it clones the repositories that are missing and fast-forwards the ones that already exist — with live per-repo progress and bounded parallelism. diff --git a/docs/CLI.md b/docs/CLI.md index 73f4d55..29d929a 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1,4 +1,4 @@ -# gclo CLI +# gclo CLI — Git Clone Large Organizations `gclo` is a scriptable command-line head over the same engine (`gclo.Engine`) that powers the gclo desktop app. It clones every repository of a GitHub organization diff --git a/gclo.Cli/Program.cs b/gclo.Cli/Program.cs index f61f399..e078330 100644 --- a/gclo.Cli/Program.cs +++ b/gclo.Cli/Program.cs @@ -6,7 +6,7 @@ namespace gclo.Cli; internal static class Program { private const string RootHelp = """ - gclo - clone or update every repository of a GitHub organization or user account. + gclo (Git Clone Large Organizations) - clone or update every repository of a GitHub organization or user account. Usage: gclo sync --org --target [options] diff --git a/gclo/AboutDialog.xaml b/gclo/AboutDialog.xaml index 775f626..4a6b4e2 100644 --- a/gclo/AboutDialog.xaml +++ b/gclo/AboutDialog.xaml @@ -15,7 +15,7 @@ - + diff --git a/gclo/MainWindow.xaml.cs b/gclo/MainWindow.xaml.cs index 83743af..2911705 100644 --- a/gclo/MainWindow.xaml.cs +++ b/gclo/MainWindow.xaml.cs @@ -22,7 +22,7 @@ namespace gclo ViewModel = new MainViewModel(); InitializeComponent(); - Title = "gclo - GitHub organization sync"; + Title = "gclo — Git Clone Large Organizations"; _settings = AppSettings.Load(); ApplySettings(); -- 2.54.0 From 7e9b898ccbf29d5af60870c3e5a1e561a0b4208f Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 3 Jul 2026 09:13:28 -0500 Subject: [PATCH 6/9] Usage-first README + complete community files - README: Download & install up top (Releases assets, channels), PAT scopes table, GUI walkthrough with screenshot placeholders, CLI section with examples + exit codes, build-from-source, attribution incl. Velopack - SECURITY.md: private vulnerability reporting channel, token-handling guarantees; CONTRIBUTING.md verified against real commands + CI table + branch model; CODEOWNERS; bug/feature issue forms; PR template - About dialog: Velopack added to attributions Co-Authored-By: Claude Fable 5 --- .github/CODEOWNERS | 2 + .github/ISSUE_TEMPLATE/bug_report.yml | 77 ++++++++++ .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.yml | 46 ++++++ .github/PULL_REQUEST_TEMPLATE.md | 17 +++ CONTRIBUTING.md | 51 ++++++- README.md | 167 ++++++++++++++------- SECURITY.md | 42 ++++++ gclo/AboutDialog.xaml | 3 + 9 files changed, 345 insertions(+), 65 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 SECURITY.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..b0f0a3f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# gclo is maintained by a single owner; all changes route to them for review. +* @KofTwentyTwo diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..95d6078 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,77 @@ +name: Bug report +description: Something in gclo (desktop app or CLI) is broken +labels: [bug] +body: + - type: input + id: version + attributes: + label: gclo version + description: >- + Desktop app: **Help → About gclo…** shows the version. + CLI: run `gclo --version`. + placeholder: "e.g. 0.1.0" + validations: + required: true + + - type: dropdown + id: interface + attributes: + label: Where does it happen? + options: + - Desktop app (GUI) + - CLI + - Both + validations: + required: true + + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What went wrong, including any exact error message (for a failed repo, the error text shown next to it). + validations: + required: true + + - type: textarea + id: expected + attributes: + label: What did you expect to happen? + validations: + required: true + + - type: textarea + id: repro + attributes: + label: Steps to reproduce + description: >- + As specifically as you can — org size, target folder, parallelism, whether the + repos already existed locally, the exact CLI command line, etc. + placeholder: | + 1. Paste token, pick org "..." + 2. Choose target folder D:\src + 3. Press Sync + 4. ... + validations: + required: true + + - type: textarea + id: log + attributes: + label: Relevant output / log excerpt + description: >- + Paste the relevant part of the CLI output (`--json` output, stderr failure lines) + or the error text from the app. **Before pasting, make sure it contains no + tokens.** gclo never prints your PAT, but your shell command, environment, or + surrounding output might include one — redact anything that looks like a + credential (`ghp_...`, `github_pat_...`). + render: text + validations: + required: false + + - type: input + id: windows-version + attributes: + label: Windows version + description: Run `winver` — e.g. "Windows 11 24H2 (build 26100)". For the CLI on Linux/macOS, name the OS instead. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..9d34d58 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Development roadmap + url: https://github.com/KofTwentyTwo/gclo/issues/11 + about: Check the roadmap before filing a feature request — it may already be planned. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..0f22047 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,46 @@ +name: Feature request +description: Suggest an improvement to gclo +labels: [enhancement] +body: + - type: markdown + attributes: + value: >- + Before filing, please check the + [development roadmap (#11)](https://github.com/KofTwentyTwo/gclo/issues/11) — + your idea may already be planned, or deliberately out of scope. + + - type: textarea + id: problem + attributes: + label: What problem would this solve? + description: Describe the situation where gclo currently falls short for you. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: What you would like gclo to do instead. + validations: + required: true + + - type: dropdown + id: interface + attributes: + label: Which part of gclo? + options: + - Desktop app (GUI) + - CLI + - Both / engine + - Not sure + validations: + required: false + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Workarounds you use today, or other approaches you thought about. + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..bf3d6e1 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,17 @@ + + +## What + + + +## Why + + + +## Checklist + +- [ ] `dotnet build gclo.slnx -p:Platform=x64 -warnaserror` succeeds with **0 warnings** +- [ ] `dotnet test gclo.Engine.Tests` passes (new engine behavior has tests) +- [ ] `dotnet format gclo.slnx --verify-no-changes --severity error` is clean +- [ ] No business logic in the `gclo` UI project — engine work lives in `gclo.Engine` +- [ ] Docs updated (`README.md`, `docs/CLI.md`) if user-visible behavior changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1002a97..0ddccc3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,33 +1,68 @@ # Contributing to gclo -Thanks for your interest in contributing. This document covers what you need to get set up and what is expected of a pull request. +Thanks for your interest in contributing. This document covers what you need to get set up, how changes flow through the repository, and what is expected of a pull request. + +Before starting on a feature, check the [development roadmap (#11)](https://github.com/KofTwentyTwo/gclo/issues/11) — it may already be planned (or deliberately out of scope). For anything non-trivial, open an issue first so the approach can be agreed before you invest time. ## Prerequisites - Windows 11, or Windows 10 version 1809 (build 17763) or later - [.NET 10 SDK](https://dotnet.microsoft.com/) -- Visual Studio 2026 with the Windows App SDK / WinUI workload (recommended for working on the app UI; the engine and its tests only need the SDK) +- Visual Studio 2026 with the Windows App SDK / WinUI workload (recommended for working on the app UI; the engine, the CLI, and the tests only need the SDK) + +## 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`. + +Fork the repository, branch from `dev`, and open your PR against `dev`. ## Building and testing +All commands run from the repository root: + ```powershell -# Packaged build (what F5 in Visual Studio does) +# Packaged build (what F5 in Visual Studio does). +# CI runs this with -warnaserror — the build must produce ZERO warnings. dotnet build gclo.slnx -p:Platform=x64 -# Unpackaged build — verify this still works before opening a PR +# 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 -# Run the test suite -dotnet test +# Run the engine test suite +dotnet test gclo.Engine.Tests + +# Formatting/style gate — must report no changes needed +dotnet format gclo.slnx --verify-no-changes --severity error ``` +## What CI enforces + +Every pull request must pass: + +| Check | What it gates | +| --- | --- | +| **Build and test (x64)** | `dotnet build gclo.slnx -p:Platform=x64 -warnaserror` — zero warnings, including NuGetAudit vulnerability warnings; then `dotnet test gclo.Engine.Tests` with coverage, and `gclo.Engine` line coverage must stay **≥ 60%** | +| **Format (style gate)** | `dotnet format gclo.slnx --verify-no-changes --severity error` | +| **Dependency review** | New/changed dependencies must have no known vulnerabilities (any severity fails) and a license on the repo's allowlist | +| **CodeQL** | Static security analysis (runs on PRs targeting `main` and weekly) | + +Running the build, test, and format commands above locally before pushing will catch almost everything CI would. + ## Pull request expectations - **Zero warnings.** The build must complete with 0 warnings. Treat any new warning as a failure. -- **Tests pass.** `dotnet test` must be green. Engine changes should come with corresponding xunit tests in `gclo.Engine.Tests`. +- **Tests pass.** `dotnet test gclo.Engine.Tests` must be green. Engine changes should come with corresponding xunit tests. +- **Tests stay offline.** The engine tests exercise real git operations against **local fixture repositories** created on disk (via LibGit2Sharp) — no network access, no GitHub calls, no tokens. Keep new tests that way; fake `IRepositoryLister` / `IGitClient` implementations live in `gclo.Engine.Tests/Fakes.cs`. - **Unpackaged mode keeps working.** The app must run when built with `-p:WindowsPackageType=None`. Do not call APIs that require package identity (for example `Windows.Storage.ApplicationData` or `Package.Current`) without a try/catch fallback. +- **Docs follow behavior.** If your change alters user-visible behavior, update the affected docs (`README.md`, `docs/CLI.md`) in the same PR. - Keep changes focused; unrelated refactors belong in separate PRs. +## Architecture rule: no business logic in the UI project + +The `gclo` WinUI project contains views, view models, and app-level services (settings, self-update) — **nothing else**. All sync/business logic lives in `gclo.Engine`, a plain class library behind the `IRepositoryLister` / `IGitClient` / `IOrganizationLister` interfaces, so it stays testable without the app and is shared by the CLI (`gclo.Cli`). If you find yourself writing GitHub or git logic in the `gclo` project, it belongs in the engine. + ## Code style Match the existing code. In particular: @@ -43,7 +78,7 @@ Match the existing code. In particular: Do not use the field-based form (it emits MVVMTK0045 warnings). -- Engine code stays UI-free: `gclo.Engine` is a plain class library behind `IRepositoryLister` / `IGitClient` interfaces so it remains testable without the app. +- Never write a token to disk, logs, or process output, and never accept one as a command-line argument. See [SECURITY.md](SECURITY.md). ## License diff --git a/README.md b/README.md index cb7e84e..48d76c4 100644 --- a/README.md +++ b/README.md @@ -6,95 +6,147 @@ [![Platform: Windows 11](https://img.shields.io/badge/platform-Windows%2011-blue)](https://github.com/KofTwentyTwo/gclo) [![.NET 10](https://img.shields.io/badge/.NET-10-blueviolet)](https://dotnet.microsoft.com/) -**gclo** (**G**it **C**lone **L**arge **O**rganizations) is a Windows 11 desktop app (WinUI 3, Windows App SDK) that mirrors a whole GitHub organization to a local folder in one pass. Point it at an org, give it a personal access token and a target folder, and it clones the repositories that are missing and fast-forwards the ones that already exist — with live per-repo progress and bounded parallelism. +**gclo** (**G**it **C**lone **L**arge **O**rganizations) mirrors every repository of a GitHub organization or user account into a local folder in one pass: it clones the repositories that are missing and fast-forwards the ones that already exist, with live per-repo progress and bounded parallelism. It ships as a Windows 11 desktop app (WinUI 3) and a scriptable CLI built on the same engine. - + -## Features +## Download & install -- Syncs an entire GitHub organization in one pass: clones new repositories, fast-forwards existing ones -- Parallel git operations with bounded concurrency (default 8) -- Live per-repo status: Queued, Cloning (with transfer percentage), Pulling, Done, Failed, Canceled -- Overall progress bar and end-of-run summary (total / cloned / updated / failed / canceled) -- Cancellation: in-flight repos stop cleanly, unstarted repos are marked Canceled -- Per-repo failure isolation — one broken repo never aborts the rest -- Failed clones are cleaned up so a partial checkout is never mistaken for a valid repo on the next run -- Testable core: the sync engine is a plain .NET class library behind `IRepositoryLister` / `IGitClient` interfaces, covered by xunit tests +All builds are published on the [**GitHub Releases** page](https://github.com/KofTwentyTwo/gclo/releases). -## Getting started +**Desktop app (GUI)** — download and run `gclo-stable-Setup.exe`. It installs per-user (no admin rights needed) and updates itself in-app via **Help > Check for updates**. A portable build (`gclo-stable-Portable.zip`) is also attached to each release; portable builds do not self-update. -### Requirements +**CLI** — download `gclo-cli-win-x64.zip` and unzip it anywhere. It is a self-contained single `gclo.exe` — no .NET runtime required. -- Windows 11, or Windows 10 version 1809 (build 17763) or later -- [.NET 10 SDK](https://dotnet.microsoft.com/) -- Visual Studio 2026 with the Windows App SDK / WinUI workload (for F5 debugging of the packaged app) -- A GitHub personal access token (see [Usage](#usage) for the required scopes) +Plain version tags (`v1.2.3`) are **stable** releases; pre-release tags (`v1.2.3-beta.1`) publish to the **dev** channel, and an installed app only ever updates within its own channel. -### Run from Visual Studio +Installation via `winget` is coming soon. -1. Clone the repository: +## Getting a token - ```powershell - git clone https://github.com/KofTwentyTwo/gclo.git - ``` - -2. Open `gclo.slnx`. -3. Set `gclo` as the startup project and select the `x64` platform. -4. Press F5 (packaged deployment). - -### Build from the command line - -```powershell -# Packaged build -dotnet build gclo.slnx -p:Platform=x64 - -# Unpackaged build — runs without MSIX deployment or package identity -dotnet build gclo.slnx -p:Platform=x64 -p:WindowsPackageType=None - -# Run the engine test suite -dotnet test -``` - -The unpackaged build produces a plain executable under `gclo\bin\x64\Debug\` that you can launch directly — no installer or package registration required. - -## Usage - -1. **Paste your token first.** The organization dropdown fills with the orgs the token can see; if the token cannot list your orgs, type the org name manually. -2. **Pick the organization** from the dropdown. -3. **Choose a target folder.** Each repository syncs into a subfolder named after the repo. -4. **Press Sync.** Watch per-repo progress live; cancel at any time. - -### Token scopes +gclo authenticates to GitHub with a personal access token — it is used for both the API (listing repositories) and the git transport (cloning and fetching). Create one under [GitHub Settings > Developer settings > Personal access tokens](https://github.com/settings/tokens). | Token type | What you need | | --- | --- | -| Classic PAT | `repo` scope (read access to the org's repositories); optionally `read:org` so the organization dropdown can list your orgs | +| Classic PAT | `repo` scope (read access to the org's repositories); optionally `read:org` so the organization dropdown / `gclo orgs` can list your orgs | | Fine-grained PAT | Repository read access for the organization | The token is used in memory for the duration of a sync and is never stored on disk. +## Using the GUI + + + +1. **Paste your token first.** After a moment, the **Organization or account** dropdown fills with everything the token can sync — your personal account is listed first, then your organizations alphabetically. If the token cannot list organizations (a fine-grained PAT, or a classic PAT without `read:org`), the status line says so; the dropdown is editable, so just type the name. +2. **Pick the organization or account** from the dropdown. + + + +3. **Choose a target folder** — type a path or click **Browse...**. Each repository syncs into `\`. +4. **Set parallelism** if you like (1–64, default 8): how many git operations run at once. +5. **Press Sync.** Every repository appears in the list and moves through its states live: Queued, Cloning (with transfer percentage), Pulling, Done, Failed, or Canceled. The overall progress bar, completed/total counter, and an end-of-run summary (`Finished: 3 cloned, 41 updated, 1 failed, 0 canceled of 45.`) track the whole run. + + + +6. **Cancel** at any time: in-flight repos stop cleanly, unstarted repos are marked Canceled, and the summary still appears. + +**When something goes wrong:** failures are isolated per repository — one broken repo never stops the rest. A failed repo shows its error message inline in the list, and failed clones are cleaned up so a partial checkout is never mistaken for a valid repo on the next run. gclo also checks every path in a repository against Windows file-system rules *before* checkout: paths that are legal in git but impossible on Windows (invalid characters, reserved device names like `CON`, trailing spaces or dots, names that differ only by case) mark the repo Failed with a per-path list of reasons instead of a cryptic checkout error — and nothing half-written is left on disk. Long paths beyond the classic 260-character limit are handled automatically (`core.longpaths`). + +**Settings** (File > Settings…) let you set the default target folder, the default parallelism, and the theme (System, Light, or Dark). + + + +**Updates:** Help > Check for updates… downloads and applies the latest release for your channel. Self-update is only available in installed builds (not the portable zip or a local debug build). + +## Using the CLI + +The CLI drives the same engine from scripts and scheduled jobs. There is deliberately no `--token ` option — command-line arguments leak to other processes — so the token comes from the `GITHUB_TOKEN` environment variable by default, or from `--token-env`, `--token-file`, or `--token-stdin`. + +PowerShell: + +```powershell +# List everything the token can sync (your account first, then orgs) +$env:GITHUB_TOKEN = (Get-Secret -Name GitHubPat -AsPlainText) +gclo orgs + +# Mirror an organization, 16 git operations at a time +gclo sync --org contoso --target C:\src\contoso --parallel 16 + +# 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 + +# 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)" } } +``` + +bash: + +```bash +# List orgs, then mirror one +export GITHUB_TOKEN="$(pass show github/pat)" +gclo orgs +gclo sync --org contoso --target ~/src/contoso + +# Pipe the token from a secret store +op read "op://Private/GitHub PAT/token" | gclo sync --org contoso --target ~/src/contoso --token-stdin + +# Machine-readable result with jq +gclo sync --org contoso --target ~/src/contoso --json | + jq -r '.failures[] | "\(.repo): \(.error)"' +``` + +`--org` accepts an organization or a user account login. `--json` suppresses progress output and prints a single JSON summary line; combined with the exit code it makes gclo easy to wire into cron/scheduled tasks: + +| Exit 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, or organization not found. | + +Full reference — all options, output streams, Ctrl+C behavior, and more examples: [docs/CLI.md](docs/CLI.md). + ## How it works -The engine first lists every repository in the organization through the GitHub REST API using Octokit, following pagination to the end (100 repos per page). Each repo is reported as Queued, then `OrgSyncEngine` runs the set through `Parallel.ForEachAsync` bounded by `MaxConcurrency`. If the target folder already contains a valid git repository, it is updated; otherwise it is cloned. Every repo ends in exactly one terminal state — Done, Failed (with the error message), or Canceled — and per-repo exceptions are contained so the rest of the run continues. +The engine lists every repository through the GitHub REST API (Octokit, paginated 100 per page), then runs the set through `Parallel.ForEachAsync` bounded by the parallelism setting. Git operations use LibGit2Sharp: missing repos are cloned with object-transfer progress; existing repos get a fetch from `origin` followed by a **fast-forward-only** merge — if a local branch has diverged from its upstream, the repo is reported Failed rather than auto-merged, so gclo never manufactures merge commits. Archived repositories are included and sync normally. Every repo ends in exactly one terminal state — Done, Failed (with the error), or Canceled — and per-repo exceptions are contained so the rest of the run continues. The PAT is the only credential; on the git side it is sent as HTTPS basic credentials with username `x-access-token`. -Git operations are performed with LibGit2Sharp: clones report object-transfer progress, and updates do a fetch from `origin` followed by a fast-forward-only merge of the current branch onto its upstream. For both the API and git transport, the PAT is the only credential — on the git side it is sent as HTTPS basic credentials with username `x-access-token` and the token as the password. +## Build from source -A few behavioral notes: +Requirements: Windows 11 (or Windows 10 1809+), the [.NET 10 SDK](https://dotnet.microsoft.com/), and — for the desktop app — Visual Studio 2026 with the Windows App SDK / WinUI workload. -- Updates are fast-forward only: if a local branch has diverged from its upstream, the repo is reported as Failed rather than auto-merged. gclo never manufactures merge commits. -- Archived repositories are included in the sync; they are read-only on GitHub but still clone and update normally. +```powershell +git clone https://github.com/KofTwentyTwo/gclo.git +cd gclo + +# Desktop app (packaged build) +dotnet build gclo.slnx -p:Platform=x64 + +# Desktop app, unpackaged — a plain exe under gclo\bin\x64\Debug\, no MSIX deployment +dotnet build gclo.slnx -p:Platform=x64 -p:WindowsPackageType=None + +# CLI only (plain net10.0 — also builds on Linux/macOS) +dotnet run --project gclo.Cli -- sync --org contoso --target C:\src\contoso + +# Tests +dotnet test +``` + +To F5-debug the packaged app in Visual Studio, open `gclo.slnx`, set `gclo` as the startup project, select the `x64` platform, and make sure Windows **Developer Mode** is enabled (Settings > System > For developers) — packaged deployment requires it. ## Project layout | Project | Description | | --- | --- | | `gclo` | WinUI 3 packaged desktop app (Windows App SDK, CommunityToolkit.Mvvm) | +| `gclo.Cli` | Cross-platform command-line head over the engine ([docs/CLI.md](docs/CLI.md)) | | `gclo.Engine` | Core sync engine: `OrgSyncEngine`, Octokit-based repository lister, LibGit2Sharp git client | | `gclo.Engine.Tests` | xunit tests for the engine, using fake `IRepositoryLister` / `IGitClient` implementations | ## Contributing -Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for prerequisites, build and test commands, and pull request expectations. +Contributions are welcome. Development happens on the `dev` branch; `main` is the stable branch and only advances via pull requests with passing status checks — so base your PRs on `dev`. See [CONTRIBUTING.md](CONTRIBUTING.md) for prerequisites, build and test commands, and pull request expectations (zero-warning builds, green tests, unpackaged mode kept working). ## License @@ -109,3 +161,4 @@ gclo builds on the following open-source projects: | [libgit2](https://github.com/libgit2/libgit2) (native library used by LibGit2Sharp) | GPLv2 with linking exception | | [CommunityToolkit.Mvvm](https://github.com/CommunityToolkit/dotnet) | MIT | | [Windows App SDK](https://github.com/microsoft/WindowsAppSDK) | MIT | +| [Velopack](https://github.com/velopack/velopack) (self-update) | MIT | diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e8fd0f9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,42 @@ +# Security Policy + +## Supported versions + +gclo is pre-1.0 software. Only the **latest release** receives security fixes — there are no maintenance branches for older versions. + +| Version | Supported | +| --- | --- | +| Latest release ([releases page](https://github.com/KofTwentyTwo/gclo/releases)) | Yes | +| Anything older | No — update to the latest release | + +Installed desktop builds can update in place via **Help → Check for updates**; the CLI is updated by downloading the latest `gclo-cli-win-x64.zip` from the releases page. + +## Reporting a vulnerability + +**Please do not open a public issue for security problems.** + +This repository has GitHub **private vulnerability reporting** enabled. To report a vulnerability: + +1. Go to the repository's [Security tab](https://github.com/KofTwentyTwo/gclo/security). +2. Click **"Report a vulnerability"** (or use the direct link: [new security advisory](https://github.com/KofTwentyTwo/gclo/security/advisories/new)). +3. Describe the issue, how to reproduce it, and the impact you see. A minimal reproduction is enormously helpful. + +That is the only reporting channel — there is no security email address. + +### What to expect + +gclo is maintained by a single person in their spare time, so response times are best-effort rather than contractual: + +- You should normally get an acknowledgment within **7 days**. +- Confirmed vulnerabilities are fixed as fast as the maintainer reasonably can, and the fix ships in a new release (there is no backporting — see supported versions above). +- Please allow a fix to be released before disclosing publicly. You will be credited in the advisory unless you ask not to be. + +## How gclo handles your GitHub token + +gclo works with GitHub Personal Access Tokens, so token safety is part of the design: + +- **The PAT is kept in memory only.** It is used for the GitHub API and as the git HTTPS credential for the duration of a run, and is never written to disk, settings, or logs. The app's persisted settings (`%LOCALAPPDATA%\gclo\settings.json`) contain only preferences such as the default folder, parallelism, and theme — never the token. +- **The CLI refuses tokens on the command line.** There is deliberately no `--token ` 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. diff --git a/gclo/AboutDialog.xaml b/gclo/AboutDialog.xaml index 4a6b4e2..ab703ed 100644 --- a/gclo/AboutDialog.xaml +++ b/gclo/AboutDialog.xaml @@ -57,6 +57,9 @@ + + + -- 2.54.0 From c101b44c5388c51fd94a63eb68217df7e7fc5076 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 3 Jul 2026 09:35:44 -0500 Subject: [PATCH 7/9] Extract gclo.ViewModels: no business logic in the UI project (#3) - New UI-framework-free library holds MainViewModel, RepoItemViewModel, and AppSettings persistence (theme mapping stays in the app) - Testability seams: injectable progress factory (synchronous in tests; Progress in the app) and org-lookup debounce interval - 14 new view-model tests (61 total): command gating, sync flow with per-repo outcomes, cancellation, debounced org lookup incl. supersede and failure paths, concurrency clamping, item status formatting - The WinUI project now contains only XAML, dialogs, pickers, brushes, and update plumbing Co-Authored-By: Claude Fable 5 --- README.md | 7 +- gclo.Engine.Tests/Fakes.cs | 17 ++ gclo.Engine.Tests/MainViewModelTests.cs | 247 ++++++++++++++++++ gclo.Engine.Tests/gclo.Engine.Tests.csproj | 1 + .../AppSettings.cs | 8 - .../MainViewModel.cs | 21 +- .../RepoItemViewModel.cs | 0 gclo.ViewModels/gclo.ViewModels.csproj | 17 ++ gclo.slnx | 1 + gclo/MainWindow.xaml.cs | 2 +- gclo/StatusFormat.cs | 8 + gclo/gclo.csproj | 1 + 12 files changed, 314 insertions(+), 16 deletions(-) create mode 100644 gclo.Engine.Tests/MainViewModelTests.cs rename {gclo/Services => gclo.ViewModels}/AppSettings.cs (91%) rename {gclo/ViewModels => gclo.ViewModels}/MainViewModel.cs (87%) rename {gclo/ViewModels => gclo.ViewModels}/RepoItemViewModel.cs (100%) create mode 100644 gclo.ViewModels/gclo.ViewModels.csproj diff --git a/README.md b/README.md index 48d76c4..1b70506 100644 --- a/README.md +++ b/README.md @@ -139,10 +139,11 @@ To F5-debug the packaged app in Visual Studio, open `gclo.slnx`, set `gclo` as t | Project | Description | | --- | --- | -| `gclo` | WinUI 3 packaged desktop app (Windows App SDK, CommunityToolkit.Mvvm) | +| `gclo` | WinUI 3 packaged desktop app — XAML, dialogs, pickers, and update plumbing only; no business logic | | `gclo.Cli` | Cross-platform command-line head over the engine ([docs/CLI.md](docs/CLI.md)) | -| `gclo.Engine` | Core sync engine: `OrgSyncEngine`, Octokit-based repository lister, LibGit2Sharp git client | -| `gclo.Engine.Tests` | xunit tests for the engine, using fake `IRepositoryLister` / `IGitClient` implementations | +| `gclo.Engine` | Core sync engine: `OrgSyncEngine`, Octokit-based listers, LibGit2Sharp git client, Windows path validation | +| `gclo.ViewModels` | UI-framework-free view models and settings persistence, shared presentation logic | +| `gclo.Engine.Tests` | xunit tests: engine (with fakes), real-git integration suite, and view-model tests | ## Contributing diff --git a/gclo.Engine.Tests/Fakes.cs b/gclo.Engine.Tests/Fakes.cs index 30216f6..9058750 100644 --- a/gclo.Engine.Tests/Fakes.cs +++ b/gclo.Engine.Tests/Fakes.cs @@ -82,6 +82,23 @@ public sealed class FakeGitClient : IGitClient } } +/// An whose behavior is configured with a delegate. +public sealed class FakeOrganizationLister : IOrganizationLister +{ + private int _calls; + + public Func>> Handler { get; set; } + = (_, _) => Task.FromResult>(Array.Empty()); + + public int Calls => _calls; + + public Task> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _calls); + return Handler(token, cancellationToken); + } +} + /// /// Records every report synchronously into a thread-safe queue. /// Deliberately NOT , which posts callbacks to a sync context diff --git a/gclo.Engine.Tests/MainViewModelTests.cs b/gclo.Engine.Tests/MainViewModelTests.cs new file mode 100644 index 0000000..158141e --- /dev/null +++ b/gclo.Engine.Tests/MainViewModelTests.cs @@ -0,0 +1,247 @@ +using System.Diagnostics; +using gclo.Engine; +using gclo.ViewModels; + +namespace gclo.Engine.Tests; + +/// +/// Headless tests for using a synchronous progress factory +/// (the production default, , posts asynchronously and would +/// make assertions racy) and a near-zero org-lookup debounce. +/// +public sealed class MainViewModelTests +{ + private readonly FakeRepositoryLister _lister = new(); + private readonly FakeGitClient _git = new(); + private readonly FakeOrganizationLister _orgs = new(); + + /// Synchronous progress: handler runs inline on the reporting thread. + private sealed class SyncProgress(Action handler) : IProgress + { + public void Report(RepoProgress value) => handler(value); + } + + private MainViewModel CreateViewModel(TimeSpan? debounce = null) + => new(_lister, _git, _orgs, + handler => new SyncProgress(handler), + debounce ?? TimeSpan.FromMilliseconds(1)); + + private static RepoDescriptor Repo(string name) + => new(name, $"https://example.test/acme/{name}.git", "main", IsArchived: false); + + private static async Task WaitUntilAsync(Func condition, string description) + { + var stopwatch = Stopwatch.StartNew(); + while (!condition()) + { + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(15), + $"Timed out after {stopwatch.Elapsed} waiting for: {description}"); + await Task.Delay(10); + } + } + + // ---------------------------------------------------------------- command gating + + [Fact] + public void SyncCommand_RequiresAllInputs_AndNotRunning() + { + var vm = CreateViewModel(); + Assert.False(vm.SyncCommand.CanExecute(null)); + + vm.Organization = "acme"; + vm.Token = "token-1234567890"; + Assert.False(vm.SyncCommand.CanExecute(null)); + + vm.TargetFolder = @"C:\src\acme"; + Assert.True(vm.SyncCommand.CanExecute(null)); + } + + // ---------------------------------------------------------------- sync flow + + [Fact] + public async Task Sync_PopulatesItems_TracksCounts_AndSummarizesFailures() + { + _lister.Repositories = [Repo("alpha"), Repo("bravo"), Repo("charlie")]; + _git.IsValidRepositoryHandler = path => Path.GetFileName(path) == "alpha"; // alpha pulls + _git.CloneHandler = (url, path, token, onProgress, ct) => + Path.GetFileName(path) == "charlie" + ? Task.FromException(new InvalidOperationException("boom")) + : Task.CompletedTask; + + var vm = CreateViewModel(); + vm.Organization = "acme"; + vm.Token = "token-1234567890"; + vm.TargetFolder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + vm.MaxConcurrencyValue = 1; // serialize progress: the sync fake reports inline across threads + + await vm.SyncCommand.ExecuteAsync(null); + + Assert.Equal(3, vm.Repos.Count); + Assert.Equal(3, vm.TotalCount); + Assert.Equal(3, vm.CompletedCount); + Assert.False(vm.IsRunning); + + var byName = vm.Repos.ToDictionary(r => r.Name); + Assert.Equal(SyncStatus.Done, byName["alpha"].Status); + Assert.Equal(SyncStatus.Done, byName["bravo"].Status); + Assert.Equal(SyncStatus.Failed, byName["charlie"].Status); + Assert.Contains("boom", byName["charlie"].Error); + Assert.Contains("1 failed", vm.StatusText); + + Directory.Delete(vm.TargetFolder, recursive: true); + } + + [Fact] + public async Task Sync_ListerFailure_SurfacesMessage_AndResetsRunning() + { + _lister.ExceptionToThrow = new InvalidOperationException("org not found"); + + var vm = CreateViewModel(); + vm.Organization = "acme"; + vm.Token = "token-1234567890"; + vm.TargetFolder = @"C:\src\acme"; + + await vm.SyncCommand.ExecuteAsync(null); + + Assert.Equal("org not found", vm.StatusText); + Assert.Empty(vm.Repos); + Assert.False(vm.IsRunning); + Assert.True(vm.SyncCommand.CanExecute(null)); + } + + [Fact] + public async Task Sync_CancelCommand_CancelsRun_AndReportsCanceled() + { + _lister.Repositories = [Repo("alpha"), Repo("bravo")]; + var gate = new TaskCompletionSource(); + int started = 0; + _git.CloneHandler = async (url, path, token, onProgress, ct) => + { + Interlocked.Increment(ref started); + await gate.Task.WaitAsync(TimeSpan.FromSeconds(30)); + ct.ThrowIfCancellationRequested(); + }; + + var vm = CreateViewModel(); + vm.Organization = "acme"; + vm.Token = "token-1234567890"; + vm.TargetFolder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + vm.MaxConcurrencyValue = 1; + + Task run = vm.SyncCommand.ExecuteAsync(null); + await WaitUntilAsync(() => started > 0, "first clone to start"); + + vm.SyncCancelCommand.Execute(null); + gate.SetResult(); + await run; + + Assert.StartsWith("Canceled", vm.StatusText); + Assert.False(vm.IsRunning); + Assert.All(vm.Repos, r => Assert.True( + 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); + } + + // ---------------------------------------------------------------- org lookup + + [Fact] + public async Task TokenChange_PopulatesOrganizations_AfterDebounce() + { + _orgs.Handler = (_, _) => Task.FromResult>(["KofTwentyTwo", "acme"]); + var vm = CreateViewModel(); + + vm.Token = "token-1234567890"; + + await WaitUntilAsync(() => vm.Organizations.Count == 2, "organizations to load"); + Assert.Equal(["KofTwentyTwo", "acme"], vm.Organizations); + Assert.Contains("Found 2", vm.StatusText); + Assert.False(vm.IsLoadingOrgs); + } + + [Fact] + public async Task TokenChange_EmptyOrgList_ExplainsAndAllowsManualEntry() + { + var vm = CreateViewModel(); + + vm.Token = "token-1234567890"; + + await WaitUntilAsync(() => vm.StatusText.Length > 0, "status text"); + Assert.Contains("read:org", vm.StatusText); + Assert.Empty(vm.Organizations); + } + + [Fact] + public async Task TokenChange_LookupFailure_SurfacesMessage_WithoutThrowing() + { + _orgs.Handler = (_, _) => Task.FromException>( + new InvalidOperationException("GitHub rejected the token (401). Check the PAT.")); + var vm = CreateViewModel(); + + vm.Token = "token-1234567890"; + + await WaitUntilAsync(() => vm.StatusText.Contains("401"), "error to surface"); + Assert.False(vm.IsLoadingOrgs); + } + + [Fact] + public async Task TokenChange_TooShort_ClearsListWithoutLookup() + { + _orgs.Handler = (_, _) => Task.FromResult>(["acme"]); + var vm = CreateViewModel(); + vm.Token = "token-1234567890"; + await WaitUntilAsync(() => vm.Organizations.Count == 1, "initial load"); + + vm.Token = "short"; + + Assert.Empty(vm.Organizations); + Assert.Equal(1, _orgs.Calls); // no second lookup for an implausible token + } + + [Fact] + public async Task TokenChange_RapidEdits_OnlyNewestLookupLands() + { + _orgs.Handler = (token, _) => Task.FromResult>([token[^4..]]); + var vm = CreateViewModel(debounce: TimeSpan.FromMilliseconds(120)); + + vm.Token = "token-1234567890-AAAA"; + vm.Token = "token-1234567890-BBBB"; // supersedes within the debounce window + + await WaitUntilAsync(() => vm.Organizations.Count == 1, "debounced load"); + Assert.Equal("BBBB", vm.Organizations[0]); + Assert.Equal(1, _orgs.Calls); + } + + // ---------------------------------------------------------------- misc bindings + + [Theory] + [InlineData(100.0, 64)] + [InlineData(0.0, 1)] + [InlineData(12.0, 12)] + [InlineData(double.NaN, 8)] + public void MaxConcurrencyValue_ClampsIntoValidRange(double input, int expected) + { + var vm = CreateViewModel(); + + vm.MaxConcurrencyValue = input; + + Assert.Equal(expected, vm.MaxConcurrency); + } + + [Fact] + public void RepoItem_StatusText_ShowsClonePercent_AndErrorFlag() + { + var item = new RepoItemViewModel("alpha"); + Assert.Equal("Queued", item.StatusText); + Assert.False(item.HasError); + + item.Status = SyncStatus.Cloning; + item.Percent = 0.42; + Assert.Equal("Cloning 42%", item.StatusText); + + item.Status = SyncStatus.Failed; + item.Error = "boom"; + Assert.True(item.HasError); + } +} diff --git a/gclo.Engine.Tests/gclo.Engine.Tests.csproj b/gclo.Engine.Tests/gclo.Engine.Tests.csproj index db4d938..211803d 100644 --- a/gclo.Engine.Tests/gclo.Engine.Tests.csproj +++ b/gclo.Engine.Tests/gclo.Engine.Tests.csproj @@ -20,6 +20,7 @@ + \ No newline at end of file diff --git a/gclo/Services/AppSettings.cs b/gclo.ViewModels/AppSettings.cs similarity index 91% rename from gclo/Services/AppSettings.cs rename to gclo.ViewModels/AppSettings.cs index cadc2f6..828ef10 100644 --- a/gclo/Services/AppSettings.cs +++ b/gclo.ViewModels/AppSettings.cs @@ -2,7 +2,6 @@ using System; using System.IO; using System.Text.Json; using System.Text.Json.Serialization; -using Microsoft.UI.Xaml; namespace gclo.Services; @@ -83,13 +82,6 @@ public sealed class AppSettings } } - /// Maps the persisted theme string to a XAML theme ("System"/unknown => Default). - public static ElementTheme ToElementTheme(string theme) => theme switch - { - "Light" => ElementTheme.Light, - "Dark" => ElementTheme.Dark, - _ => ElementTheme.Default, - }; private void Sanitize() { diff --git a/gclo/ViewModels/MainViewModel.cs b/gclo.ViewModels/MainViewModel.cs similarity index 87% rename from gclo/ViewModels/MainViewModel.cs rename to gclo.ViewModels/MainViewModel.cs index 5b8efe5..471cc34 100644 --- a/gclo/ViewModels/MainViewModel.cs +++ b/gclo.ViewModels/MainViewModel.cs @@ -15,15 +15,28 @@ public sealed partial class MainViewModel : ObservableObject private readonly IRepositoryLister _lister; private readonly IGitClient _git; private readonly IOrganizationLister _orgLister; + private readonly Func, IProgress> _progressFactory; + private readonly TimeSpan _orgLookupDebounce; private readonly Dictionary _itemsByName = new(StringComparer.OrdinalIgnoreCase); private CancellationTokenSource? _orgLoadCts; - /// Production dependencies by default; pass fakes for testing. - public MainViewModel(IRepositoryLister? lister = null, IGitClient? git = null, IOrganizationLister? orgLister = null) + /// + /// Production dependencies by default; pass fakes for testing. The default progress + /// factory is , which marshals via the SynchronizationContext + /// captured at construction (the UI thread in the app); tests inject a synchronous one. + /// + public MainViewModel( + IRepositoryLister? lister = null, + IGitClient? git = null, + IOrganizationLister? orgLister = null, + Func, IProgress>? progressFactory = null, + TimeSpan? orgLookupDebounce = null) { _lister = lister ?? new GitHubRepositoryLister(); _git = git ?? new LibGit2GitClient(); _orgLister = orgLister ?? new GitHubOrganizationLister(); + _progressFactory = progressFactory ?? (handler => new Progress(handler)); + _orgLookupDebounce = orgLookupDebounce ?? TimeSpan.FromMilliseconds(600); Organization = ""; Token = ""; TargetFolder = ""; @@ -86,7 +99,7 @@ public sealed partial class MainViewModel : ObservableObject try { - await Task.Delay(600, cts.Token); // debounce keystrokes / rapid pastes + await Task.Delay(_orgLookupDebounce, cts.Token); // debounce keystrokes / rapid pastes IsLoadingOrgs = true; var orgs = await _orgLister.ListOrganizationsAsync(token, cts.Token); cts.Token.ThrowIfCancellationRequested(); @@ -164,7 +177,7 @@ public sealed partial class MainViewModel : ObservableObject // Constructed on the UI thread: Progress captures the WinUI // SynchronizationContext, so HandleProgress always runs on the UI thread. - var progress = new Progress(HandleProgress); + var progress = _progressFactory(HandleProgress); var engine = new OrgSyncEngine(_lister, _git); var request = new SyncRequest( diff --git a/gclo/ViewModels/RepoItemViewModel.cs b/gclo.ViewModels/RepoItemViewModel.cs similarity index 100% rename from gclo/ViewModels/RepoItemViewModel.cs rename to gclo.ViewModels/RepoItemViewModel.cs diff --git a/gclo.ViewModels/gclo.ViewModels.csproj b/gclo.ViewModels/gclo.ViewModels.csproj new file mode 100644 index 0000000..a72c419 --- /dev/null +++ b/gclo.ViewModels/gclo.ViewModels.csproj @@ -0,0 +1,17 @@ + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/gclo.slnx b/gclo.slnx index 9b4718e..af49b94 100644 --- a/gclo.slnx +++ b/gclo.slnx @@ -7,6 +7,7 @@ + diff --git a/gclo/MainWindow.xaml.cs b/gclo/MainWindow.xaml.cs index 2911705..b7cd3aa 100644 --- a/gclo/MainWindow.xaml.cs +++ b/gclo/MainWindow.xaml.cs @@ -48,7 +48,7 @@ namespace gclo { if (Content is FrameworkElement root) { - root.RequestedTheme = AppSettings.ToElementTheme(_settings.Theme); + root.RequestedTheme = StatusFormat.ToElementTheme(_settings.Theme); } if (string.IsNullOrWhiteSpace(ViewModel.TargetFolder) && !string.IsNullOrWhiteSpace(_settings.DefaultTargetFolder)) diff --git a/gclo/StatusFormat.cs b/gclo/StatusFormat.cs index 3e1a150..eca0684 100644 --- a/gclo/StatusFormat.cs +++ b/gclo/StatusFormat.cs @@ -27,6 +27,14 @@ public static class StatusFormat public static Visibility VisibleIf(bool value) => value ? Visibility.Visible : Visibility.Collapsed; + /// Maps the persisted theme string to a XAML theme ("System"/unknown => Default). + public static ElementTheme ToElementTheme(string theme) => theme switch + { + "Light" => ElementTheme.Light, + "Dark" => ElementTheme.Dark, + _ => ElementTheme.Default, + }; + public static string Fraction(int completed, int total) => $"{completed} / {total}"; } diff --git a/gclo/gclo.csproj b/gclo/gclo.csproj index aaf91ed..96ff07c 100644 --- a/gclo/gclo.csproj +++ b/gclo/gclo.csproj @@ -46,6 +46,7 @@ + + + + FileSystem + ARM64 + win-arm64 + bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\ + true + False + + \ No newline at end of file diff --git a/gclo/Properties/PublishProfiles/win-x64.pubxml b/gclo/Properties/PublishProfiles/win-x64.pubxml new file mode 100644 index 0000000..cd99561 --- /dev/null +++ b/gclo/Properties/PublishProfiles/win-x64.pubxml @@ -0,0 +1,14 @@ + + + + + FileSystem + x64 + win-x64 + bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\ + true + False + + \ No newline at end of file diff --git a/gclo/Properties/PublishProfiles/win-x86.pubxml b/gclo/Properties/PublishProfiles/win-x86.pubxml new file mode 100644 index 0000000..a70c694 --- /dev/null +++ b/gclo/Properties/PublishProfiles/win-x86.pubxml @@ -0,0 +1,14 @@ + + + + + FileSystem + x86 + win-x86 + bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\ + true + False + + \ No newline at end of file -- 2.54.0 From beceada68a985aaefaa2e8dfffe8e432ee23ac2a Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 3 Jul 2026 09:54:38 -0500 Subject: [PATCH 9/9] UX overhaul: repo table, selection, per-row progress, activity log (#4) - Two-phase flow: Load repos fills a selectable, sortable table (checkbox column + select-all, Name/Status/Branch/Archived headers); Sync selected runs only checked repos via a new engine overload that takes an explicit repository list - Per-row progress bars: determinate clone percent, indeterminate pull - Always-on activity log (IActivityLog/FileActivityLog in the engine, %LOCALAPPDATA%\gclo\logs, never throws) wired into GUI and CLI; View > Activity log menu opens a viewer with open-logs-folder - Retry failed re-runs exactly the failed repos; Open folder appears after a run; live target-path preview + optional org subfolder toggle - 29 new tests (90 total); also replaces a StatusText race in the old view-model suite that CI caught (org-lookup message could overwrite the sync summary on slow runners) Co-Authored-By: Claude Fable 5 --- gclo.Cli/OrgsCommand.cs | 63 +++-- gclo.Cli/SyncCommand.cs | 111 +++++--- gclo.Engine.Tests/ActivityLogTests.cs | 191 +++++++++++++ gclo.Engine.Tests/MainViewModelTests.cs | 328 ++++++++++++++++++---- gclo.Engine.Tests/SyncOverloadTests.cs | 149 ++++++++++ gclo.Engine/FileActivityLog.cs | 81 ++++++ gclo.Engine/IActivityLog.cs | 21 ++ gclo.Engine/OrgSyncEngine.cs | 27 +- gclo.ViewModels/MainViewModel.cs | 345 ++++++++++++++++++++++-- gclo.ViewModels/RepoItemViewModel.cs | 48 +++- gclo/LogViewerDialog.xaml | 34 +++ gclo/LogViewerDialog.xaml.cs | 82 ++++++ gclo/MainWindow.xaml | 212 +++++++++++++-- gclo/MainWindow.xaml.cs | 60 ++++- 14 files changed, 1589 insertions(+), 163 deletions(-) create mode 100644 gclo.Engine.Tests/ActivityLogTests.cs create mode 100644 gclo.Engine.Tests/SyncOverloadTests.cs create mode 100644 gclo.Engine/FileActivityLog.cs create mode 100644 gclo.Engine/IActivityLog.cs create mode 100644 gclo/LogViewerDialog.xaml create mode 100644 gclo/LogViewerDialog.xaml.cs diff --git a/gclo.Cli/OrgsCommand.cs b/gclo.Cli/OrgsCommand.cs index 807557d..1aee487 100644 --- a/gclo.Cli/OrgsCommand.cs +++ b/gclo.Cli/OrgsCommand.cs @@ -59,33 +59,54 @@ internal static class OrgsCommand } } - string token = tokenOptions.Resolve(); + // One activity log per invocation; the token itself is never written to it. + var log = new FileActivityLog(); + log.Info($"orgs started: json={json}"); - IReadOnlyList logins; try { - logins = await new GitHubOrganizationLister() - .ListOrganizationsAsync(token, cancellationToken) - .ConfigureAwait(false); - } - catch (InvalidOperationException ex) - { - // The lister translates auth failures and rate limiting into - // InvalidOperationException. - throw new CliErrorException(ex.Message, ex); - } + string token = tokenOptions.Resolve(); - if (json) - { - Console.Out.WriteLine(JsonSerializer.Serialize(logins, CliJsonContext.Default.IReadOnlyListString)); - } - else - { - foreach (string login in logins) + IReadOnlyList logins; + try { - Console.Out.WriteLine(login); + logins = await new GitHubOrganizationLister() + .ListOrganizationsAsync(token, cancellationToken) + .ConfigureAwait(false); } + catch (InvalidOperationException ex) + { + // The lister translates auth failures and rate limiting into + // InvalidOperationException. + throw new CliErrorException(ex.Message, ex); + } + + log.Info($"orgs finished: {logins.Count} login(s) listed."); + + if (json) + { + Console.Out.WriteLine(JsonSerializer.Serialize(logins, CliJsonContext.Default.IReadOnlyListString)); + } + else + { + foreach (string login in logins) + { + Console.Out.WriteLine(login); + } + } + return 0; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + log.Info("orgs canceled."); + throw; + } + catch (Exception ex) + { + // Fatal path: missing or rejected token. Program prints the message; + // the log keeps it. + log.Error($"orgs failed: {ex.Message}", ex); + throw; } - return 0; } } diff --git a/gclo.Cli/SyncCommand.cs b/gclo.Cli/SyncCommand.cs index 596fff8..c088a6f 100644 --- a/gclo.Cli/SyncCommand.cs +++ b/gclo.Cli/SyncCommand.cs @@ -106,48 +106,95 @@ internal static class SyncCommand throw new CliUsageException("--target is required."); } - string token = tokenOptions.Resolve(); + // One activity log per invocation. Only non-secret parameters are ever + // written to it; the token is not. + var log = new FileActivityLog(); + log.Info($"sync started: org='{org}', target='{target}', parallel={parallel}, json={json}, quiet={quiet}"); - var printer = new ProgressPrinter(printProgress: !quiet && !json, printFailures: !json); - var engine = new OrgSyncEngine(new GitHubRepositoryLister(), new LibGit2GitClient()); - var request = new SyncRequest(org.Trim(), token, target.Trim(), parallel); - - SyncSummary summary; try { - summary = await engine.SyncAsync(request, printer, cancellationToken).ConfigureAwait(false); + string token = tokenOptions.Resolve(); + + var printer = new ProgressPrinter(printProgress: !quiet && !json, printFailures: !json); + var engine = new OrgSyncEngine(new GitHubRepositoryLister(), new LibGit2GitClient()); + var request = new SyncRequest(org.Trim(), token, target.Trim(), parallel); + + SyncSummary summary; + try + { + summary = await engine + .SyncAsync(request, new FailureLoggingProgress(printer, log), 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); + } + catch (InvalidOperationException ex) + { + // The engine's listers translate auth failures, rate limiting, and + // unknown organizations into InvalidOperationException. + throw new CliErrorException(ex.Message, ex); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + throw new CliErrorException($"Cannot use target folder '{target}': {ex.Message}", ex); + } + + log.Info( + $"sync finished: total={summary.Total}, cloned={summary.Cloned}, updated={summary.Updated}, " + + $"failed={summary.Failed}, canceled={summary.Canceled}, wasCanceled={summary.WasCanceled}"); + + if (json) + { + var result = new SyncJsonResult( + summary.Total, summary.Cloned, summary.Updated, summary.Failed, + summary.Canceled, summary.WasCanceled, printer.Failures); + Console.Out.WriteLine(JsonSerializer.Serialize(result, CliJsonContext.Default.SyncJsonResult)); + } + else + { + string verb = summary.WasCanceled ? "Canceled" : "Finished"; + Console.Out.WriteLine( + $"{verb}: {summary.Cloned} cloned, {summary.Updated} updated, " + + $"{summary.Failed} failed, {summary.Canceled} canceled of {summary.Total}."); + } + + return summary.Failed > 0 || summary.WasCanceled ? 1 : 0; } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + catch (Exception ex) { - // Canceled while still listing repositories: nothing was processed yet. - summary = new SyncSummary(0, 0, 0, 0, 0, WasCanceled: true); + // Fatal path: missing or rejected token, unusable target folder, + // unknown organization. Program prints the message; the log keeps it. + log.Error($"sync failed: {ex.Message}", ex); + throw; } - catch (InvalidOperationException ex) + } + + /// + /// Forwards every engine report to the console printer unchanged and mirrors + /// failures into the activity log, so console output stays exactly what the + /// printer alone would have produced. + /// + private sealed class FailureLoggingProgress : IProgress + { + private readonly ProgressPrinter _printer; + private readonly IActivityLog _log; + + public FailureLoggingProgress(ProgressPrinter printer, IActivityLog log) { - // The engine's listers translate auth failures, rate limiting, and - // unknown organizations into InvalidOperationException. - throw new CliErrorException(ex.Message, ex); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) - { - throw new CliErrorException($"Cannot use target folder '{target}': {ex.Message}", ex); + _printer = printer; + _log = log; } - if (json) + public void Report(RepoProgress value) { - var result = new SyncJsonResult( - summary.Total, summary.Cloned, summary.Updated, summary.Failed, - summary.Canceled, summary.WasCanceled, printer.Failures); - Console.Out.WriteLine(JsonSerializer.Serialize(result, CliJsonContext.Default.SyncJsonResult)); + if (value.Status == SyncStatus.Failed) + { + _log.Error($"{value.RepoName} failed: {value.Error ?? "unknown error"}"); + } + _printer.Report(value); } - else - { - string verb = summary.WasCanceled ? "Canceled" : "Finished"; - Console.Out.WriteLine( - $"{verb}: {summary.Cloned} cloned, {summary.Updated} updated, " - + $"{summary.Failed} failed, {summary.Canceled} canceled of {summary.Total}."); - } - - return summary.Failed > 0 || summary.WasCanceled ? 1 : 0; } } diff --git a/gclo.Engine.Tests/ActivityLogTests.cs b/gclo.Engine.Tests/ActivityLogTests.cs new file mode 100644 index 0000000..a16ed15 --- /dev/null +++ b/gclo.Engine.Tests/ActivityLogTests.cs @@ -0,0 +1,191 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using gclo.Engine; + +namespace gclo.Engine.Tests; + +/// +/// Tests for (line format, daily file naming, lazy +/// directory creation, swallowing IO failures, thread safety) and +/// (pure no-op). +/// +public sealed class ActivityLogTests : IDisposable +{ + /// "yyyy-MM-dd HH:mm:ss.fff [LEVEL] " prefix every entry line must carry. + private static readonly Regex LinePattern = new( + @"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} \[(INFO|ERROR)\] ", RegexOptions.Compiled); + + private readonly string _root = + Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + + public void Dispose() + { + try + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + catch + { + // Best effort; stray empty temp dirs are harmless. + } + } + + // ---------------------------------------------------------------- line format + + [Fact] + public void Info_WritesOneTimestampedLine_WithInfoTag() + { + var log = new FileActivityLog(_root); + + log.Info("hello world"); + + string line = Assert.Single(File.ReadAllLines(log.CurrentLogFilePath)); + Assert.Matches(LinePattern, line); + Assert.Contains("[INFO] ", line); + Assert.EndsWith("hello world", line); + } + + [Fact] + public void Error_WithoutException_WritesOneLineWithErrorTag() + { + var log = new FileActivityLog(_root); + + log.Error("something failed"); + + string line = Assert.Single(File.ReadAllLines(log.CurrentLogFilePath)); + Assert.Matches(LinePattern, line); + Assert.Contains("[ERROR] ", line); + Assert.EndsWith("something failed", line); + } + + [Fact] + public void Error_WithException_AppendsExceptionTextOnFollowingLines() + { + var log = new FileActivityLog(_root); + + log.Error("sync blew up", new InvalidOperationException("kaboom")); + + string[] lines = File.ReadAllLines(log.CurrentLogFilePath); + Assert.True(lines.Length >= 2, "exception text belongs on lines after the message"); + Assert.Matches(LinePattern, lines[0]); + Assert.EndsWith("sync blew up", lines[0]); + Assert.Contains("InvalidOperationException", lines[1]); + Assert.Contains("kaboom", lines[1]); + } + + [Fact] + public void Writes_Accumulate_InTheSameFile() + { + var log = new FileActivityLog(_root); + + log.Info("first"); + log.Error("second"); + log.Info("third"); + + string[] lines = File.ReadAllLines(log.CurrentLogFilePath); + Assert.Equal(3, lines.Length); + Assert.All(lines, line => Assert.Matches(LinePattern, line)); + Assert.EndsWith("first", lines[0]); + Assert.EndsWith("second", lines[1]); + Assert.EndsWith("third", lines[2]); + } + + // ---------------------------------------------------------------- paths + + [Fact] + public void CurrentLogFilePath_IsTodaysDailyFile_UnderLogDirectory() + { + var log = new FileActivityLog(_root); + + string expected = Path.Combine( + _root, + "gclo-" + DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + ".log"); + Assert.Equal(_root, log.LogDirectory); + Assert.Equal(expected, log.CurrentLogFilePath); + } + + [Fact] + public void DefaultDirectory_IsLocalAppDataGcloLogs() + { + var log = new FileActivityLog(); + + string expected = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "gclo", "logs"); + Assert.Equal(expected, log.LogDirectory); + } + + [Fact] + public void Directory_IsCreatedLazily_OnFirstWriteNotInConstructor() + { + string directory = Path.Combine(_root, "lazy"); + + var log = new FileActivityLog(directory); + Assert.False(Directory.Exists(directory), "constructor must not create the directory"); + + log.Info("first write"); + + Assert.True(Directory.Exists(directory)); + Assert.True(File.Exists(log.CurrentLogFilePath)); + } + + // ---------------------------------------------------------------- never throws + + [Fact] + public void Write_WhenDirectoryIsUnwritable_NeverThrows() + { + // A file sitting where the log directory should go makes CreateDirectory + // throw; the log must swallow that on every call. + Directory.CreateDirectory(_root); + string fileAsDirectory = Path.Combine(_root, "not-a-directory"); + File.WriteAllText(fileAsDirectory, "blocks directory creation"); + + var log = new FileActivityLog(fileAsDirectory); + + log.Info("goes nowhere"); + log.Error("still nowhere", new InvalidOperationException("boom")); + + // The properties still answer; only the writes are lost. + Assert.Equal(fileAsDirectory, log.LogDirectory); + Assert.StartsWith(fileAsDirectory, log.CurrentLogFilePath); + } + + // ---------------------------------------------------------------- thread safety + + [Fact] + public void ParallelWrites_ProduceOnlyWholeWellFormedLines() + { + var log = new FileActivityLog(_root); + const int writers = 4; + const int perWriter = 25; + + Parallel.For(0, writers, writer => + { + for (int i = 0; i < perWriter; i++) + { + log.Info($"writer {writer} message {i}"); + } + }); + + string[] lines = File.ReadAllLines(log.CurrentLogFilePath); + Assert.Equal(writers * perWriter, lines.Length); + Assert.All(lines, line => Assert.Matches(LinePattern, line)); + } + + // ---------------------------------------------------------------- NullActivityLog + + [Fact] + public void NullActivityLog_NoOps_AndReturnsEmptyStrings() + { + var log = new NullActivityLog(); + + log.Info("ignored"); + log.Error("ignored too", new InvalidOperationException("ignored as well")); + + Assert.Equal("", log.LogDirectory); + Assert.Equal("", log.CurrentLogFilePath); + } +} diff --git a/gclo.Engine.Tests/MainViewModelTests.cs b/gclo.Engine.Tests/MainViewModelTests.cs index 158141e..b6a2958 100644 --- a/gclo.Engine.Tests/MainViewModelTests.cs +++ b/gclo.Engine.Tests/MainViewModelTests.cs @@ -24,10 +24,30 @@ public sealed class MainViewModelTests private MainViewModel CreateViewModel(TimeSpan? debounce = null) => new(_lister, _git, _orgs, handler => new SyncProgress(handler), - debounce ?? TimeSpan.FromMilliseconds(1)); + debounce ?? TimeSpan.FromMilliseconds(1), + new NullActivityLog()); - private static RepoDescriptor Repo(string name) - => new(name, $"https://example.test/acme/{name}.git", "main", IsArchived: false); + private static RepoDescriptor Repo(string name, string? branch = "main", bool archived = false) + => new(name, $"https://example.test/acme/{name}.git", branch, archived); + + /// + /// A view model with valid inputs and the given repositories already loaded into the + /// table. Waits out the token-triggered org lookup first so its status message cannot + /// land on top of a later assertion. + /// + private async Task CreateLoadedViewModelAsync(params RepoDescriptor[] repos) + { + _lister.Repositories = repos; + var vm = CreateViewModel(); + 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")); + vm.MaxConcurrencyValue = 1; // serialize progress: the sync fake reports inline across threads + + await vm.LoadReposCommand.ExecuteAsync(null); + return vm; + } private static async Task WaitUntilAsync(Func condition, string description) { @@ -43,76 +63,136 @@ public sealed class MainViewModelTests // ---------------------------------------------------------------- command gating [Fact] - public void SyncCommand_RequiresAllInputs_AndNotRunning() + public void LoadReposCommand_RequiresOrganizationAndToken() { var vm = CreateViewModel(); - Assert.False(vm.SyncCommand.CanExecute(null)); + Assert.False(vm.LoadReposCommand.CanExecute(null)); vm.Organization = "acme"; + Assert.False(vm.LoadReposCommand.CanExecute(null)); + vm.Token = "token-1234567890"; + Assert.True(vm.LoadReposCommand.CanExecute(null)); + } + + [Fact] + public void SyncCommand_DisabledBeforeAnyReposLoaded() + { + var vm = CreateViewModel(); + vm.Organization = "acme"; + vm.Token = "token-1234567890"; + vm.TargetFolder = @"C:\src\acme"; + + Assert.False(vm.SyncCommand.CanExecute(null)); // nothing loaded, so nothing selected + Assert.False(vm.RetryFailedCommand.CanExecute(null)); // and nothing has failed + } + + [Fact] + public async Task SyncCommand_RequiresTargetFolder_AndASelectedRepo() + { + var vm = await CreateLoadedViewModelAsync(Repo("alpha")); + Assert.True(vm.SyncCommand.CanExecute(null)); + + vm.Repos[0].IsSelected = false; Assert.False(vm.SyncCommand.CanExecute(null)); - vm.TargetFolder = @"C:\src\acme"; - Assert.True(vm.SyncCommand.CanExecute(null)); + vm.Repos[0].IsSelected = true; + vm.TargetFolder = " "; + Assert.False(vm.SyncCommand.CanExecute(null)); + } + + // ---------------------------------------------------------------- load flow + + [Fact] + public async Task LoadRepos_FillsTable_AllSelected_AllQueued() + { + var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); + + Assert.Equal(3, vm.Repos.Count); + Assert.Equal(3, vm.TotalCount); + Assert.Equal(0, vm.CompletedCount); + Assert.Equal("3 repositories loaded.", vm.StatusText); + Assert.False(vm.IsLoadingRepos); + Assert.True(vm.AllSelected); + Assert.All(vm.Repos, r => Assert.True(r.IsSelected)); + Assert.All(vm.Repos, r => Assert.Equal(SyncStatus.Queued, r.Status)); + Assert.Empty(_git.CloneCalls); // loading must not touch git + Assert.Empty(_git.PullCalls); + } + + [Fact] + public async Task LoadRepos_ListerFailure_SurfacesMessage_AndResetsLoading() + { + _lister.ExceptionToThrow = new InvalidOperationException("org not found"); + 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.Equal("org not found", vm.StatusText); + Assert.Empty(vm.Repos); + Assert.False(vm.IsLoadingRepos); + Assert.True(vm.LoadReposCommand.CanExecute(null)); + Assert.False(vm.SyncCommand.CanExecute(null)); } // ---------------------------------------------------------------- sync flow [Fact] - public async Task Sync_PopulatesItems_TracksCounts_AndSummarizesFailures() + public async Task LoadThenSync_SyncsOnlySelectedRepos() + { + var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); + var byName = vm.Repos.ToDictionary(r => r.Name); + byName["bravo"].IsSelected = false; + + await vm.SyncCommand.ExecuteAsync(null); + + Assert.Equal(["alpha", "charlie"], _git.ClonedRepoNames); + Assert.DoesNotContain(_git.ValidityChecks, p => Path.GetFileName(p) == "bravo"); + Assert.Empty(_git.PullCalls); + Assert.Equal(SyncStatus.Done, byName["alpha"].Status); + Assert.Equal(SyncStatus.Queued, byName["bravo"].Status); // untouched by the run + Assert.Equal(SyncStatus.Done, byName["charlie"].Status); + Assert.Equal(2, vm.CompletedCount); + Assert.Equal(3, vm.TotalCount); + Assert.Contains("2 cloned", vm.StatusText); + Assert.True(vm.HasCompletedRun); + Assert.False(vm.IsRunning); + + Directory.Delete(vm.TargetFolder, recursive: true); + } + + [Fact] + public async Task Sync_TracksPulls_AndSummarizesFailures() { - _lister.Repositories = [Repo("alpha"), Repo("bravo"), Repo("charlie")]; _git.IsValidRepositoryHandler = path => Path.GetFileName(path) == "alpha"; // alpha pulls _git.CloneHandler = (url, path, token, onProgress, ct) => Path.GetFileName(path) == "charlie" ? Task.FromException(new InvalidOperationException("boom")) : Task.CompletedTask; - var vm = CreateViewModel(); - vm.Organization = "acme"; - vm.Token = "token-1234567890"; - vm.TargetFolder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); - vm.MaxConcurrencyValue = 1; // serialize progress: the sync fake reports inline across threads + var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); await vm.SyncCommand.ExecuteAsync(null); - Assert.Equal(3, vm.Repos.Count); - Assert.Equal(3, vm.TotalCount); - Assert.Equal(3, vm.CompletedCount); - Assert.False(vm.IsRunning); - var byName = vm.Repos.ToDictionary(r => r.Name); + Assert.Equal(["alpha"], _git.PulledRepoNames); Assert.Equal(SyncStatus.Done, byName["alpha"].Status); Assert.Equal(SyncStatus.Done, byName["bravo"].Status); Assert.Equal(SyncStatus.Failed, byName["charlie"].Status); Assert.Contains("boom", byName["charlie"].Error); + Assert.Equal(3, vm.CompletedCount); + Assert.Contains("1 updated", vm.StatusText); Assert.Contains("1 failed", vm.StatusText); Directory.Delete(vm.TargetFolder, recursive: true); } - [Fact] - public async Task Sync_ListerFailure_SurfacesMessage_AndResetsRunning() - { - _lister.ExceptionToThrow = new InvalidOperationException("org not found"); - - var vm = CreateViewModel(); - vm.Organization = "acme"; - vm.Token = "token-1234567890"; - vm.TargetFolder = @"C:\src\acme"; - - await vm.SyncCommand.ExecuteAsync(null); - - Assert.Equal("org not found", vm.StatusText); - Assert.Empty(vm.Repos); - Assert.False(vm.IsRunning); - Assert.True(vm.SyncCommand.CanExecute(null)); - } - [Fact] public async Task Sync_CancelCommand_CancelsRun_AndReportsCanceled() { - _lister.Repositories = [Repo("alpha"), Repo("bravo")]; var gate = new TaskCompletionSource(); int started = 0; _git.CloneHandler = async (url, path, token, onProgress, ct) => @@ -122,21 +202,23 @@ public sealed class MainViewModelTests ct.ThrowIfCancellationRequested(); }; - var vm = CreateViewModel(); - vm.Organization = "acme"; - vm.Token = "token-1234567890"; - vm.TargetFolder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); - vm.MaxConcurrencyValue = 1; + var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); Task run = vm.SyncCommand.ExecuteAsync(null); await WaitUntilAsync(() => started > 0, "first clone to start"); + Assert.True(vm.IsRunning); + Assert.False(vm.SyncCommand.CanExecute(null)); // all three commands lock while running + Assert.False(vm.LoadReposCommand.CanExecute(null)); + Assert.False(vm.RetryFailedCommand.CanExecute(null)); + vm.SyncCancelCommand.Execute(null); gate.SetResult(); await run; Assert.StartsWith("Canceled", vm.StatusText); Assert.False(vm.IsRunning); + Assert.True(vm.HasCompletedRun); Assert.All(vm.Repos, r => Assert.True( r.Status is SyncStatus.Canceled or SyncStatus.Done or SyncStatus.Failed, $"{r.Name} left in non-terminal state {r.Status}")); @@ -144,6 +226,136 @@ public sealed class MainViewModelTests Directory.Delete(vm.TargetFolder, recursive: true); } + // ---------------------------------------------------------------- retry failed + + [Fact] + public async Task RetryFailed_ReRunsOnlyTheFailedRepos() + { + _git.CloneHandler = (url, path, token, onProgress, ct) => + Path.GetFileName(path) == "bravo" + ? Task.FromException(new InvalidOperationException("boom")) + : Task.CompletedTask; + + var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); + await vm.SyncCommand.ExecuteAsync(null); + + var byName = vm.Repos.ToDictionary(r => r.Name); + Assert.Equal(SyncStatus.Failed, byName["bravo"].Status); + Assert.True(vm.RetryFailedCommand.CanExecute(null)); + int clonesBeforeRetry = _git.CloneCalls.Count; + + _git.CloneHandler = (_, _, _, _, _) => Task.CompletedTask; // transient failure is gone + await vm.RetryFailedCommand.ExecuteAsync(null); + + Assert.Equal(["bravo"], _git.ClonedRepoNames.Skip(clonesBeforeRetry)); + Assert.Equal(SyncStatus.Done, byName["bravo"].Status); + Assert.Null(byName["bravo"].Error); + Assert.Equal(SyncStatus.Done, byName["alpha"].Status); // untouched by the retry + Assert.True(byName["bravo"].IsSelected); // retry selected exactly the failures + Assert.False(byName["alpha"].IsSelected); + Assert.False(byName["charlie"].IsSelected); + Assert.Equal(3, vm.CompletedCount); + Assert.False(vm.RetryFailedCommand.CanExecute(null)); // nothing failed anymore + + Directory.Delete(vm.TargetFolder, recursive: true); + } + + // ---------------------------------------------------------------- selection + + [Fact] + public async Task AllSelected_TogglesEveryRow_AndFollowsItemChanges() + { + var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); + Assert.True(vm.AllSelected); + + vm.AllSelected = false; + Assert.All(vm.Repos, r => Assert.False(r.IsSelected)); + Assert.False(vm.SyncCommand.CanExecute(null)); // nothing selected + + vm.AllSelected = true; + Assert.All(vm.Repos, r => Assert.True(r.IsSelected)); + Assert.True(vm.SyncCommand.CanExecute(null)); + + vm.Repos[1].IsSelected = false; + Assert.False(vm.AllSelected); + Assert.True(vm.Repos[0].IsSelected); // header recompute must not cascade to other rows + Assert.True(vm.Repos[2].IsSelected); + + vm.Repos[1].IsSelected = true; + Assert.True(vm.AllSelected); + } + + // ---------------------------------------------------------------- sorting + + [Fact] + public async Task Sort_ByName_TogglesDirection_AndSwitchingColumnResetsIt() + { + var vm = await CreateLoadedViewModelAsync(Repo("bravo"), Repo("alpha"), Repo("charlie")); + + vm.SortCommand.Execute("Name"); + Assert.Equal(["alpha", "bravo", "charlie"], vm.Repos.Select(r => r.Name)); + Assert.Equal("Name", vm.SortColumn); + Assert.False(vm.SortDescending); + + vm.SortCommand.Execute("Name"); + Assert.Equal(["charlie", "bravo", "alpha"], vm.Repos.Select(r => r.Name)); + Assert.True(vm.SortDescending); + + vm.SortCommand.Execute("Status"); + Assert.Equal("Status", vm.SortColumn); + Assert.False(vm.SortDescending); // direction resets on a new column + } + + [Fact] + public async Task Sort_ByStatus_OrdersByLifecycle_AndIsStable() + { + var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); + var byName = vm.Repos.ToDictionary(r => r.Name); + byName["alpha"].Status = SyncStatus.Done; + byName["bravo"].Status = SyncStatus.Failed; + byName["charlie"].Status = SyncStatus.Done; + + vm.SortCommand.Execute("Status"); + + // Done sorts before Failed; the two Done rows keep their load order (stable sort). + Assert.Equal(["alpha", "charlie", "bravo"], vm.Repos.Select(r => r.Name)); + } + + // ---------------------------------------------------------------- target preview + + [Fact] + public void TargetPreview_ReflectsOrgSubfolder_AndRaisesChange() + { + var vm = CreateViewModel(); + var raised = new List(); + vm.PropertyChanged += (_, e) => raised.Add(e.PropertyName); + + vm.Organization = "acme"; + vm.TargetFolder = @"C:\src"; + + Assert.Equal(@"C:\src", vm.EffectiveTargetRoot); + Assert.Equal(@"C:\src\my-repo", vm.TargetPreview); + + raised.Clear(); + vm.CreateOrgSubfolder = true; + + Assert.Equal(@"C:\src\acme", vm.EffectiveTargetRoot); + Assert.Equal(@"C:\src\acme\my-repo", vm.TargetPreview); + Assert.Contains(nameof(MainViewModel.EffectiveTargetRoot), raised); + Assert.Contains(nameof(MainViewModel.TargetPreview), raised); + } + + [Fact] + public async Task TargetPreview_UsesFirstLoadedRepoName() + { + var vm = await CreateLoadedViewModelAsync(Repo("zulu"), Repo("alpha")); + + Assert.Equal(Path.Combine(vm.TargetFolder, "zulu"), vm.TargetPreview); + + vm.CreateOrgSubfolder = true; + Assert.Equal(Path.Combine(vm.TargetFolder, "acme", "zulu"), vm.TargetPreview); + } + // ---------------------------------------------------------------- org lookup [Fact] @@ -230,18 +442,44 @@ public sealed class MainViewModelTests } [Fact] - public void RepoItem_StatusText_ShowsClonePercent_AndErrorFlag() + public void RepoItem_ExposesDescriptorFields_AndProgressShape() { - var item = new RepoItemViewModel("alpha"); + var descriptor = new RepoDescriptor( + "alpha", "https://example.test/acme/alpha.git", "develop", IsArchived: true); + var item = new RepoItemViewModel(descriptor); + + Assert.Same(descriptor, item.Descriptor); + Assert.Equal("alpha", item.Name); + Assert.Equal("develop", item.BranchText); + Assert.True(item.IsArchived); + Assert.True(item.IsSelected); // rows start selected Assert.Equal("Queued", item.StatusText); + Assert.False(item.ShowProgress); Assert.False(item.HasError); + Assert.Equal(0, item.ProgressValue); item.Status = SyncStatus.Cloning; item.Percent = 0.42; Assert.Equal("Cloning 42%", item.StatusText); + Assert.True(item.ShowProgress); + Assert.False(item.IsIndeterminate); + Assert.Equal(0.42, item.ProgressValue); + + item.Status = SyncStatus.Pulling; + Assert.True(item.ShowProgress); + Assert.True(item.IsIndeterminate); // pulls report no percentage item.Status = SyncStatus.Failed; item.Error = "boom"; Assert.True(item.HasError); + Assert.False(item.ShowProgress); + } + + [Fact] + public void RepoItem_EmptyRepository_HasEmptyBranchText() + { + var item = new RepoItemViewModel(Repo("empty", branch: null)); + + Assert.Equal("", item.BranchText); } } diff --git a/gclo.Engine.Tests/SyncOverloadTests.cs b/gclo.Engine.Tests/SyncOverloadTests.cs new file mode 100644 index 0000000..18baa20 --- /dev/null +++ b/gclo.Engine.Tests/SyncOverloadTests.cs @@ -0,0 +1,149 @@ +using gclo.Engine; + +namespace gclo.Engine.Tests; + +/// +/// Tests for the overload that takes a caller-supplied +/// repository list: it must never hit the lister, and must otherwise behave exactly like +/// the listing overload (dedupe, Queued-first reporting, root creation, counting). +/// +public sealed class SyncOverloadTests : IDisposable +{ + private readonly string _targetRoot = + Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + + private readonly FakeRepositoryLister _lister = new(); + private readonly FakeGitClient _git = new(); + private readonly RecordingProgress _progress = new(); + + public void Dispose() + { + try + { + if (Directory.Exists(_targetRoot)) + { + Directory.Delete(_targetRoot, recursive: true); + } + } + catch + { + // Best effort; stray empty temp dirs are harmless. + } + } + + private OrgSyncEngine CreateEngine() => new(_lister, _git); + + private SyncRequest CreateRequest(int maxConcurrency = 8) + => new("acme", "test-token", _targetRoot, maxConcurrency); + + private static RepoDescriptor Repo(string name) + => new(name, $"https://example.test/acme/{name}.git", "main", IsArchived: false); + + private static RepoDescriptor[] Repos(params string[] names) => names.Select(Repo).ToArray(); + + [Fact] + public async Task SyncWithList_NeverCallsTheLister() + { + // Give the lister a different answer so any accidental call is caught. + _lister.Repositories = Repos("from-lister"); + + var summary = await CreateEngine().SyncAsync(CreateRequest(), Repos("alpha", "bravo"), _progress); + + Assert.Empty(_lister.Calls); + Assert.Equal(new[] { "alpha", "bravo" }, _git.ClonedRepoNames.Order().ToArray()); + Assert.Equal(new SyncSummary(Total: 2, Cloned: 2, Updated: 0, Failed: 0, Canceled: 0, WasCanceled: false), summary); + } + + [Fact] + public async Task SyncWithList_SyncsExactlyTheGivenSelection() + { + // A caller that filtered a larger set (deselected repos) passes only the + // selection; nothing outside it may be touched or reported. + _lister.Repositories = Repos("alpha", "bravo", "charlie", "delta"); + + var summary = await CreateEngine().SyncAsync(CreateRequest(), Repos("bravo", "delta"), _progress); + + Assert.Equal(new[] { "bravo", "delta" }, _git.ClonedRepoNames.Order().ToArray()); + Assert.All(_progress.Reports, r => Assert.Contains(r.RepoName, new[] { "bravo", "delta" })); + Assert.Equal(2, summary.Total); + Assert.Empty(_lister.Calls); + } + + [Fact] + public async Task SyncWithList_ReportsQueuedForEveryRepo_BeforeAnyGitWork() + { + var names = new[] { "alpha", "bravo", "charlie", "delta", "echo" }; + + await CreateEngine().SyncAsync(CreateRequest(), Repos(names), _progress); + + // The engine queues the full list synchronously before the parallel phase, + // so the first N reports must be Queued and cover every repo exactly once. + var first = _progress.Reports.Take(names.Length).ToArray(); + Assert.All(first, r => Assert.Equal(SyncStatus.Queued, r.Status)); + Assert.Equal(names.Order().ToArray(), first.Select(r => r.RepoName).Order().ToArray()); + } + + [Fact] + public async Task SyncWithList_DuplicateRepoNames_AreProcessedOnce() + { + RepoDescriptor[] repositories = + [ + Repo("alpha"), + new RepoDescriptor("Alpha", "https://example.test/acme/Alpha.git", "main", IsArchived: false), + ]; + + var summary = await CreateEngine().SyncAsync(CreateRequest(), repositories, _progress); + + Assert.Equal(1, summary.Total); + Assert.Single(_git.CloneCalls); + } + + [Fact] + public async Task SyncWithList_CountsClonesUpdatesAndFailures() + { + var locallyValid = new HashSet { "alpha", "charlie" }; + _git.IsValidRepositoryHandler = path => locallyValid.Contains(Path.GetFileName(path)!); + _git.CloneHandler = (_, path, _, _, _) => + Path.GetFileName(path) == "echo" + ? Task.FromException(new InvalidOperationException("boom")) + : Task.CompletedTask; + + var summary = await CreateEngine().SyncAsync( + CreateRequest(), Repos("alpha", "bravo", "charlie", "delta", "echo"), _progress); + + Assert.Equal(new SyncSummary(Total: 5, Cloned: 2, Updated: 2, Failed: 1, Canceled: 0, WasCanceled: false), summary); + Assert.Equal(new[] { "alpha", "charlie" }, _git.PulledRepoNames.Order().ToArray()); + Assert.Equal(SyncStatus.Failed, _progress.LastFor("echo")!.Status); + Assert.Equal(SyncStatus.Done, _progress.LastFor("bravo")!.Status); + Assert.Equal(SyncStatus.Done, _progress.LastFor("delta")!.Status); + } + + [Fact] + public async Task SyncWithList_CreatesTargetRoot() + { + await CreateEngine().SyncAsync(CreateRequest(), Repos("alpha"), _progress); + + Assert.True(Directory.Exists(_targetRoot), "engine should create TargetRoot"); + } + + [Fact] + public async Task SyncWithList_EmptyList_ReturnsZeroTotalsAndMakesNoGitCalls() + { + var summary = await CreateEngine().SyncAsync(CreateRequest(), Array.Empty(), _progress); + + Assert.Equal(new SyncSummary(Total: 0, Cloned: 0, Updated: 0, Failed: 0, Canceled: 0, WasCanceled: false), summary); + Assert.Empty(_git.CloneCalls); + Assert.Empty(_git.PullCalls); + Assert.Empty(_git.ValidityChecks); + Assert.Empty(_progress.Reports); + Assert.Empty(_lister.Calls); + } + + [Fact] + public async Task SyncWithList_NullRepositories_ThrowsArgumentNullException() + { + await Assert.ThrowsAsync( + () => CreateEngine().SyncAsync(CreateRequest(), (IReadOnlyList)null!)); + } +} diff --git a/gclo.Engine/FileActivityLog.cs b/gclo.Engine/FileActivityLog.cs new file mode 100644 index 0000000..8b1b71a --- /dev/null +++ b/gclo.Engine/FileActivityLog.cs @@ -0,0 +1,81 @@ +using System.Globalization; + +namespace gclo.Engine; + +/// +/// An that appends timestamped lines to a daily file, +/// gclo-yyyy-MM-dd.log. Writes are serialized by a lock and each one opens, +/// appends, and closes the file, so every entry reaches disk even if the process +/// dies right after. Logging must never take the application down: all IO +/// failures (unwritable directory, locked file, full disk) are swallowed. The +/// directory is created lazily on the first write, never in the constructor. +/// +public sealed class FileActivityLog : IActivityLog +{ + private readonly object _gate = new(); + + /// + /// Folder for the log files; defaults to %LOCALAPPDATA%\gclo\logs when null or blank. + /// + public FileActivityLog(string? directory = null) + { + LogDirectory = string.IsNullOrWhiteSpace(directory) + ? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "gclo", "logs") + : directory; + } + + public string LogDirectory { get; } + + /// Today's log file; entries roll over to a new file at midnight. + public string CurrentLogFilePath + => Path.Combine( + LogDirectory, + "gclo-" + DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + ".log"); + + public void Info(string message) => Write("INFO", message, exception: null); + + public void Error(string message, Exception? exception = null) => Write("ERROR", message, exception); + + private void Write(string level, string message, Exception? exception) + { + try + { + lock (_gate) + { + Directory.CreateDirectory(LogDirectory); + + string timestamp = DateTime.Now.ToString( + "yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); + string entry = $"{timestamp} [{level}] {message}"; + if (exception is not null) + { + entry += Environment.NewLine + exception; + } + + File.AppendAllText(CurrentLogFilePath, entry + Environment.NewLine); + } + } + catch + { + // A logging failure must never take the operation it describes down. + } + } +} + +/// An that discards everything. +public sealed class NullActivityLog : IActivityLog +{ + public string LogDirectory => ""; + + public string CurrentLogFilePath => ""; + + public void Info(string message) + { + } + + public void Error(string message, Exception? exception = null) + { + } +} diff --git a/gclo.Engine/IActivityLog.cs b/gclo.Engine/IActivityLog.cs new file mode 100644 index 0000000..a3d0244 --- /dev/null +++ b/gclo.Engine/IActivityLog.cs @@ -0,0 +1,21 @@ +namespace gclo.Engine; + +/// +/// Application activity log for human-readable operational events. Implementations +/// must be safe to call from any thread and must never throw from a logging call; +/// secrets (tokens) must never be written to it. +/// +public interface IActivityLog +{ + /// Records an informational event. + void Info(string message); + + /// Records a failure, with the exception's full text when one is given. + void Error(string message, Exception? exception = null); + + /// Folder that holds the log files. + string LogDirectory { get; } + + /// Full path of the file new entries are currently appended to. + string CurrentLogFilePath { get; } +} diff --git a/gclo.Engine/OrgSyncEngine.cs b/gclo.Engine/OrgSyncEngine.cs index e128c0f..367df64 100644 --- a/gclo.Engine/OrgSyncEngine.cs +++ b/gclo.Engine/OrgSyncEngine.cs @@ -18,6 +18,7 @@ public sealed class OrgSyncEngine _git = git ?? throw new ArgumentNullException(nameof(git)); } + /// Lists the organization's repositories, then syncs all of them. public async Task SyncAsync( SyncRequest request, IProgress? progress = null, @@ -28,9 +29,31 @@ public sealed class OrgSyncEngine ArgumentException.ThrowIfNullOrWhiteSpace(request.Token); ArgumentException.ThrowIfNullOrWhiteSpace(request.TargetRoot); - var repos = (await _lister + var repositories = await _lister .ListOrganizationRepositoriesAsync(request.Organization, request.Token, cancellationToken) - .ConfigureAwait(false)) + .ConfigureAwait(false); + + return await SyncAsync(request, repositories, progress, cancellationToken).ConfigureAwait(false); + } + + /// + /// Syncs a caller-supplied repository list without hitting the GitHub API. + /// Use this when the repositories were already listed (and possibly filtered + /// down to a selection) by the caller; semantics are otherwise identical to + /// . + /// + public async Task SyncAsync( + SyncRequest request, + IReadOnlyList repositories, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(repositories); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Token); + ArgumentException.ThrowIfNullOrWhiteSpace(request.TargetRoot); + + var repos = repositories // Names key the pending dictionary and the target folders; a duplicate // (possible from pagination shifts) must not crash the run or race two // git operations into the same directory. diff --git a/gclo.ViewModels/MainViewModel.cs b/gclo.ViewModels/MainViewModel.cs index 471cc34..a988f5a 100644 --- a/gclo.ViewModels/MainViewModel.cs +++ b/gclo.ViewModels/MainViewModel.cs @@ -1,6 +1,9 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.ComponentModel; +using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; @@ -9,7 +12,11 @@ using gclo.Engine; namespace gclo.ViewModels; -/// Drives the org-sync UI: inputs, the Sync/Cancel commands, and live per-repo progress. +/// +/// Drives the org-sync UI in two phases: fills the repository +/// table, then clones or updates the selected subset with live +/// per-repo progress. re-runs just the failures. +/// public sealed partial class MainViewModel : ObservableObject { private readonly IRepositoryLister _lister; @@ -17,9 +24,16 @@ public sealed partial class MainViewModel : ObservableObject private readonly IOrganizationLister _orgLister; private readonly Func, IProgress> _progressFactory; private readonly TimeSpan _orgLookupDebounce; + private readonly IActivityLog _log; private readonly Dictionary _itemsByName = new(StringComparer.OrdinalIgnoreCase); private CancellationTokenSource? _orgLoadCts; + /// Breaks the AllSelected <-> item.IsSelected feedback loop while one side updates the other. + private bool _syncingSelection; + + /// First repository name from the last load; makes concrete. + private string? _sampleRepoName; + /// /// Production dependencies by default; pass fakes for testing. The default progress /// factory is , which marshals via the SynchronizationContext @@ -30,18 +44,21 @@ public sealed partial class MainViewModel : ObservableObject IGitClient? git = null, IOrganizationLister? orgLister = null, Func, IProgress>? progressFactory = null, - TimeSpan? orgLookupDebounce = null) + TimeSpan? orgLookupDebounce = null, + IActivityLog? log = null) { _lister = lister ?? new GitHubRepositoryLister(); _git = git ?? new LibGit2GitClient(); _orgLister = orgLister ?? new GitHubOrganizationLister(); _progressFactory = progressFactory ?? (handler => new Progress(handler)); _orgLookupDebounce = orgLookupDebounce ?? TimeSpan.FromMilliseconds(600); + _log = log ?? new FileActivityLog(); Organization = ""; Token = ""; TargetFolder = ""; MaxConcurrency = 8; StatusText = ""; + AllSelected = true; } public ObservableCollection Repos { get; } = new(); @@ -50,11 +67,11 @@ public sealed partial class MainViewModel : ObservableObject public ObservableCollection Organizations { get; } = new(); [ObservableProperty] - [NotifyCanExecuteChangedFor(nameof(SyncCommand))] + [NotifyCanExecuteChangedFor(nameof(LoadReposCommand))] public partial string Organization { get; set; } [ObservableProperty] - [NotifyCanExecuteChangedFor(nameof(SyncCommand))] + [NotifyCanExecuteChangedFor(nameof(LoadReposCommand))] public partial string Token { get; set; } [ObservableProperty] @@ -66,6 +83,8 @@ public sealed partial class MainViewModel : ObservableObject [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(SyncCommand))] + [NotifyCanExecuteChangedFor(nameof(LoadReposCommand))] + [NotifyCanExecuteChangedFor(nameof(RetryFailedCommand))] public partial bool IsRunning { get; set; } [ObservableProperty] @@ -80,6 +99,67 @@ public sealed partial class MainViewModel : ObservableObject [ObservableProperty] public partial bool IsLoadingOrgs { get; set; } + /// True while is listing repositories. + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(LoadReposCommand))] + public partial bool IsLoadingRepos { get; set; } + + /// When set, repositories are placed under TargetFolder\Organization. + [ObservableProperty] + public partial bool CreateOrgSubfolder { get; set; } + + /// Header checkbox state: setting it checks or unchecks every row. + [ObservableProperty] + public partial bool AllSelected { get; set; } + + /// True once a sync run has finished (successfully, canceled, or faulted). + [ObservableProperty] + public partial bool HasCompletedRun { get; set; } + + /// Column the table is currently sorted by, or null for load order. + [ObservableProperty] + public partial string? SortColumn { get; set; } + + [ObservableProperty] + public partial bool SortDescending { get; set; } + + /// Root folder repositories are placed under: TargetFolder, or TargetFolder\Organization. + public string EffectiveTargetRoot + { + get + { + string folder = TargetFolder.Trim(); + if (folder.Length == 0) + { + return ""; + } + string org = Organization.Trim(); + return CreateOrgSubfolder && org.Length > 0 ? Path.Combine(folder, org) : folder; + } + } + + /// Example final path for one repository, e.g. C:\src\acme\my-repo. + public string TargetPreview + { + get + { + string root = EffectiveTargetRoot; + return root.Length == 0 ? "" : Path.Combine(root, _sampleRepoName ?? "my-repo"); + } + } + + partial void OnTargetFolderChanged(string value) => NotifyTargetPathChanged(); + + partial void OnOrganizationChanged(string value) => NotifyTargetPathChanged(); + + partial void OnCreateOrgSubfolderChanged(bool value) => NotifyTargetPathChanged(); + + private void NotifyTargetPathChanged() + { + OnPropertyChanged(nameof(EffectiveTargetRoot)); + OnPropertyChanged(nameof(TargetPreview)); + } + // 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(); @@ -157,23 +237,151 @@ public sealed partial class MainViewModel : ObservableObject partial void OnMaxConcurrencyChanged(int value) => OnPropertyChanged(nameof(MaxConcurrencyValue)); + // ---------------------------------------------------------------- selection + + /// Applies a header-checkbox change to every row. + partial void OnAllSelectedChanged(bool value) + { + if (_syncingSelection) + { + return; // being recomputed from an item change; do not push back down + } + + _syncingSelection = true; + try + { + foreach (RepoItemViewModel item in Repos) + { + item.IsSelected = value; + } + } + finally + { + _syncingSelection = false; + } + SyncCommand.NotifyCanExecuteChanged(); + } + + private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(RepoItemViewModel.IsSelected)) + { + return; + } + if (!_syncingSelection) + { + UpdateAllSelectedFromItems(); + } + SyncCommand.NotifyCanExecuteChanged(); + } + + private void UpdateAllSelectedFromItems() + { + bool all = Repos.All(r => r.IsSelected); + if (AllSelected != all) + { + _syncingSelection = true; + try + { + AllSelected = all; + } + finally + { + _syncingSelection = false; + } + } + } + + // ---------------------------------------------------------------- load + + private bool CanLoadRepos() => + !IsRunning + && !IsLoadingRepos + && !string.IsNullOrWhiteSpace(Organization) + && !string.IsNullOrWhiteSpace(Token); + + /// Phase one: lists the organization's repositories and fills the table, all selected. + [RelayCommand(CanExecute = nameof(CanLoadRepos))] + private async Task LoadReposAsync() + { + IsLoadingRepos = true; + try + { + string organization = Organization.Trim(); + StatusText = "Loading repositories..."; + _log.Info($"Loading repositories for organization '{organization}'."); + + var descriptors = (await _lister + .ListOrganizationRepositoriesAsync(organization, Token.Trim())) + // Names key the row dictionary; a duplicate (possible from pagination + // shifts) must not produce two rows fighting over one folder. + .DistinctBy(r => r.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + foreach (RepoItemViewModel existing in Repos) + { + existing.PropertyChanged -= OnItemPropertyChanged; + } + Repos.Clear(); + _itemsByName.Clear(); + + foreach (RepoDescriptor descriptor in descriptors) + { + var item = new RepoItemViewModel(descriptor); + item.PropertyChanged += OnItemPropertyChanged; + _itemsByName[descriptor.Name] = item; + Repos.Add(item); + } + + TotalCount = Repos.Count; + CompletedCount = 0; + SortColumn = null; + SortDescending = false; + _sampleRepoName = Repos.Count > 0 ? Repos[0].Name : null; + UpdateAllSelectedFromItems(); + StatusText = $"{Repos.Count} repositories loaded."; + _log.Info($"Loaded {Repos.Count} repositories for organization '{organization}'."); + } + catch (Exception ex) + { + StatusText = ex.Message; + _log.Error($"Loading repositories failed: {ex.Message}", ex); + } + finally + { + IsLoadingRepos = false; + NotifyTargetPathChanged(); + SyncCommand.NotifyCanExecuteChanged(); + RetryFailedCommand.NotifyCanExecuteChanged(); + } + } + + // ---------------------------------------------------------------- sync + private bool CanSync() => !IsRunning - && !string.IsNullOrWhiteSpace(Organization) - && !string.IsNullOrWhiteSpace(Token) - && !string.IsNullOrWhiteSpace(TargetFolder); + && !string.IsNullOrWhiteSpace(TargetFolder) + && Repos.Any(r => r.IsSelected); + /// Phase two: clones or updates the selected repositories. [RelayCommand(CanExecute = nameof(CanSync), IncludeCancelCommand = true)] private async Task SyncAsync(CancellationToken cancellationToken) { IsRunning = true; try { - Repos.Clear(); - _itemsByName.Clear(); - CompletedCount = 0; - TotalCount = 0; - StatusText = "Listing repositories..."; + var selected = Repos.Where(r => r.IsSelected).ToList(); + foreach (RepoItemViewModel item in selected) + { + item.Status = SyncStatus.Queued; + item.Error = null; + item.Percent = null; + } + RecomputeCompletedCount(); + + string targetRoot = EffectiveTargetRoot; + StatusText = $"Syncing {selected.Count} repositories..."; + _log.Info($"Sync started: {selected.Count} repositories into '{targetRoot}'."); // Constructed on the UI thread: Progress captures the WinUI // SynchronizationContext, so HandleProgress always runs on the UI thread. @@ -181,37 +389,108 @@ public sealed partial class MainViewModel : ObservableObject var engine = new OrgSyncEngine(_lister, _git); var request = new SyncRequest( - Organization.Trim(), Token.Trim(), TargetFolder.Trim(), MaxConcurrency); + Organization.Trim(), Token.Trim(), targetRoot, MaxConcurrency); + IReadOnlyList descriptors = selected.Select(r => r.Descriptor).ToList(); - SyncSummary summary = await engine.SyncAsync(request, progress, cancellationToken); + SyncSummary summary = await engine.SyncAsync(request, descriptors, progress, cancellationToken); StatusText = summary.WasCanceled ? $"Canceled: {summary.Cloned} cloned, {summary.Updated} updated, {summary.Failed} failed, {summary.Canceled} canceled of {summary.Total}." : $"Finished: {summary.Cloned} cloned, {summary.Updated} updated, {summary.Failed} failed, {summary.Canceled} canceled of {summary.Total}."; + _log.Info($"Sync finished: {summary.Cloned} cloned, {summary.Updated} updated, " + + $"{summary.Failed} failed, {summary.Canceled} canceled of {summary.Total}."); } catch (OperationCanceledException) { StatusText = "Canceled"; + _log.Info("Sync canceled."); } catch (Exception ex) { StatusText = ex.Message; + _log.Error($"Sync failed: {ex.Message}", ex); } finally { + HasCompletedRun = true; IsRunning = false; } } - /// Applies one engine progress report to the list. Runs on the UI thread. + // ---------------------------------------------------------------- retry + + private bool CanRetryFailed() => + !IsRunning && Repos.Any(r => r.Status == SyncStatus.Failed); + + /// Selects exactly the failed repositories and runs the same sync path again. + [RelayCommand(CanExecute = nameof(CanRetryFailed))] + private async Task RetryFailedAsync() + { + foreach (RepoItemViewModel item in Repos) + { + item.IsSelected = item.Status == SyncStatus.Failed; + } + // Executing SyncCommand itself keeps SyncCancelCommand working for retry runs. + await SyncCommand.ExecuteAsync(null); + } + + // ---------------------------------------------------------------- sorting + + /// + /// Sorts the table by a column ("Name", "Status", "Branch", or "Archived"); a second + /// click on the same column flips the direction. The re-order is stable and in place. + /// + [RelayCommand] + private void Sort(string column) + { + if (column is not ("Name" or "Status" or "Branch" or "Archived")) + { + return; + } + + if (SortColumn == column) + { + SortDescending = !SortDescending; + } + else + { + SortColumn = column; + SortDescending = false; + } + + List sorted = column switch + { + "Name" => OrderRepos(r => r.Name, StringComparer.OrdinalIgnoreCase), + "Status" => OrderRepos(r => r.Status, Comparer.Default), + "Branch" => OrderRepos(r => r.BranchText, StringComparer.OrdinalIgnoreCase), + _ => OrderRepos(r => r.IsArchived, Comparer.Default), + }; + + // Move (rather than clear + re-add) so list controls keep row containers stable. + for (int target = 0; target < sorted.Count; target++) + { + int current = Repos.IndexOf(sorted[target]); + if (current != target) + { + Repos.Move(current, target); + } + } + } + + /// OrderBy/OrderByDescending are both stable: equal keys keep their current order. + private List OrderRepos(Func key, IComparer comparer) + => SortDescending + ? Repos.OrderByDescending(key, comparer).ToList() + : Repos.OrderBy(key, comparer).ToList(); + + // ---------------------------------------------------------------- progress + + /// Applies one engine progress report to the table. Runs on the UI thread. private void HandleProgress(RepoProgress report) { if (!_itemsByName.TryGetValue(report.RepoName, out RepoItemViewModel? item)) { - item = new RepoItemViewModel(report.RepoName); - _itemsByName[report.RepoName] = item; - Repos.Add(item); - TotalCount = Repos.Count; + return; // rows are created at load time; ignore anything unknown } if (report.Status != SyncStatus.Queued) @@ -221,17 +500,27 @@ public sealed partial class MainViewModel : ObservableObject item.Percent = report.Percent; } + if (report.Status == SyncStatus.Failed) + { + _log.Error($"{report.RepoName} failed: {report.Error}"); + } + if (report.Status is SyncStatus.Done or SyncStatus.Failed or SyncStatus.Canceled) { - int completed = 0; - foreach (RepoItemViewModel repo in Repos) - { - if (repo.Status is SyncStatus.Done or SyncStatus.Failed or SyncStatus.Canceled) - { - completed++; - } - } - CompletedCount = completed; + RecomputeCompletedCount(); } } + + private void RecomputeCompletedCount() + { + int completed = 0; + foreach (RepoItemViewModel repo in Repos) + { + if (repo.Status is SyncStatus.Done or SyncStatus.Failed or SyncStatus.Canceled) + { + completed++; + } + } + CompletedCount = completed; + } } diff --git a/gclo.ViewModels/RepoItemViewModel.cs b/gclo.ViewModels/RepoItemViewModel.cs index 0ac8bf8..658d012 100644 --- a/gclo.ViewModels/RepoItemViewModel.cs +++ b/gclo.ViewModels/RepoItemViewModel.cs @@ -1,19 +1,29 @@ +using System; using CommunityToolkit.Mvvm.ComponentModel; using gclo.Engine; namespace gclo.ViewModels; -/// One row in the repository status list. +/// One row in the repository table: the descriptor plus selection and live sync state. public sealed partial class RepoItemViewModel : ObservableObject { - public RepoItemViewModel(string name) + public RepoItemViewModel(RepoDescriptor descriptor) { - Name = name; + ArgumentNullException.ThrowIfNull(descriptor); + Descriptor = descriptor; Status = SyncStatus.Queued; + IsSelected = true; } - /// Repository name; never changes after construction. - public string Name { get; } + /// The repository this row represents; never changes after construction. + public RepoDescriptor Descriptor { get; } + + /// Repository name; used as the local folder name. + public string Name => Descriptor.Name; + + /// Whether this repository participates in the next sync. + [ObservableProperty] + public partial bool IsSelected { get; set; } [ObservableProperty] public partial SyncStatus Status { get; set; } @@ -24,6 +34,21 @@ public sealed partial class RepoItemViewModel : ObservableObject [ObservableProperty] public partial double? Percent { get; set; } + /// Default branch name, or empty for an empty repository. + public string BranchText => Descriptor.DefaultBranch ?? ""; + + /// Whether the repository is archived (still cloneable, read-only). + public bool IsArchived => Descriptor.IsArchived; + + /// True while a git operation is in flight and a progress bar should show. + public bool ShowProgress => Status is SyncStatus.Cloning or SyncStatus.Pulling; + + /// True when progress has no percentage (pulls report none). + public bool IsIndeterminate => Status == SyncStatus.Pulling; + + /// Clone progress in [0, 1] for determinate progress bars. + public double ProgressValue => Percent ?? 0; + /// Human-readable status, including clone percentage when known. public string StatusText => Status switch { @@ -40,9 +65,18 @@ public sealed partial class RepoItemViewModel : ObservableObject /// True when there is an error message to show inline. public bool HasError => !string.IsNullOrEmpty(Error); - partial void OnStatusChanged(SyncStatus value) => OnPropertyChanged(nameof(StatusText)); + partial void OnStatusChanged(SyncStatus value) + { + OnPropertyChanged(nameof(StatusText)); + OnPropertyChanged(nameof(ShowProgress)); + OnPropertyChanged(nameof(IsIndeterminate)); + } - partial void OnPercentChanged(double? value) => OnPropertyChanged(nameof(StatusText)); + partial void OnPercentChanged(double? value) + { + OnPropertyChanged(nameof(StatusText)); + OnPropertyChanged(nameof(ProgressValue)); + } partial void OnErrorChanged(string? value) => OnPropertyChanged(nameof(HasError)); } diff --git a/gclo/LogViewerDialog.xaml b/gclo/LogViewerDialog.xaml new file mode 100644 index 0000000..e19b836 --- /dev/null +++ b/gclo/LogViewerDialog.xaml @@ -0,0 +1,34 @@ + + + + + + 800 + + + + + + diff --git a/gclo/LogViewerDialog.xaml.cs b/gclo/LogViewerDialog.xaml.cs new file mode 100644 index 0000000..728436c --- /dev/null +++ b/gclo/LogViewerDialog.xaml.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Microsoft.UI.Xaml.Controls; +using gclo.Engine; + +namespace gclo +{ + /// + /// Read-only viewer for the tail of today's activity log, with a shortcut to the + /// logs folder. The caller must set before + /// calling ShowAsync. + /// + public sealed partial class LogViewerDialog : ContentDialog + { + private const int TailLineCount = 400; + + private readonly IActivityLog _log; + + public LogViewerDialog(IActivityLog log) + { + _log = log; + InitializeComponent(); + LogText.Text = ReadTail(log.CurrentLogFilePath); + + // Recent entries matter most: start scrolled to the end of the tail. + Opened += (_, _) => + { + LogScroll.UpdateLayout(); + LogScroll.ChangeView(null, LogScroll.ScrollableHeight, null, true); + }; + } + + /// + /// Returns the last lines of , + /// or a placeholder when the file is missing or unreadable. Never throws: the + /// viewer is diagnostics UI and must not crash the app over a bad log file. + /// + private static string ReadTail(string path) + { + try + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + return "No log entries yet."; + } + + // Share ReadWrite: the activity log may append while the dialog reads. + using var stream = new FileStream( + path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using var reader = new StreamReader(stream); + + var tail = new Queue(TailLineCount); + while (reader.ReadLine() is string line) + { + if (tail.Count == TailLineCount) + { + tail.Dequeue(); + } + tail.Enqueue(line); + } + + return tail.Count == 0 + ? "No log entries yet." + : string.Join(Environment.NewLine, tail); + } + catch (Exception ex) + { + return "Could not read the log file." + Environment.NewLine + ex.Message; + } + } + + private async void OpenLogsFolder_Click(ContentDialog sender, ContentDialogButtonClickEventArgs args) + { + string directory = _log.LogDirectory; + if (Directory.Exists(directory)) + { + await Windows.System.Launcher.LaunchFolderPathAsync(directory); + } + } + } +} diff --git a/gclo/MainWindow.xaml b/gclo/MainWindow.xaml index 22f5639..5d3013a 100644 --- a/gclo/MainWindow.xaml +++ b/gclo/MainWindow.xaml @@ -26,6 +26,9 @@ + + + @@ -40,6 +43,9 @@ + + + @@ -63,7 +69,7 @@ HorizontalAlignment="Stretch" Header="Organization or account" IsEditable="True" - ItemsSource="{x:Bind ViewModel.Organizations}" + ItemsSource="{x:Bind ViewModel.Organizations, Mode=OneTime}" PlaceholderText="paste a token first, or type an org" Text="{x:Bind ViewModel.Organization, Mode=TwoWay}" /> - + - - + -