Productionize: tests, CLI, CI/CD, self-update #1
No files matched your search
@@ -0,0 +1,2 @@
|
||||
# gclo is maintained by a single owner; all changes route to them for review.
|
||||
* @KofTwentyTwo
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- PRs target the `dev` branch (see CONTRIBUTING.md). -->
|
||||
|
||||
## What
|
||||
|
||||
<!-- What does this PR change? -->
|
||||
|
||||
## Why
|
||||
|
||||
<!-- Why is the change needed? Link the issue if one exists (e.g. "Fixes #42"). -->
|
||||
|
||||
## 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
|
||||
@@ -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:
|
||||
- "*"
|
||||
@@ -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: ["*"]
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -0,0 +1,157 @@
|
||||
# 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
|
||||
|
||||
# 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.
|
||||
# ----------------------------------------------------------------------
|
||||
- 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 <Setup.exe url>` (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 '<packId>-<channel>-Setup.exe'.
|
||||
$setupUrl = "$env:REPO_URL/releases/download/$env:GITHUB_REF_NAME/gclo-stable-Setup.exe"
|
||||
.\wingetcreate.exe update KofTwentyTwo.gclo --urls $setupUrl --version ${{ steps.version.outputs.version }} --submit --token $env:WINGET_TOKEN
|
||||
@@ -480,3 +480,6 @@ $RECYCLE.BIN/
|
||||
|
||||
# Vim temporary swap files
|
||||
*.swp
|
||||
|
||||
# WinUI packaged apps need their publish profiles in source control
|
||||
!**/Properties/PublishProfiles/*.pubxml
|
||||
+43
-8
@@ -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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gclo
|
||||
# gclo — Git Clone Large Organizations
|
||||
|
||||
**Clone an entire GitHub organization. Keep it up to date. In parallel.**
|
||||
|
||||
@@ -6,95 +6,148 @@
|
||||
[](https://github.com/KofTwentyTwo/gclo)
|
||||
[](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) 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.
|
||||
|
||||
<!-- TODO: screenshot —  -->
|
||||
<!-- SCREENSHOT (hero): main window mid-sync — token and org filled in, overall progress bar partway,
|
||||
per-repo list showing a mix of Cloning (with %), Pulling, and Done. Tracked in issue #10. -->
|
||||
|
||||
## 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
|
||||
|
||||
<!-- SCREENSHOT: empty main window on first launch — token box, org dropdown, target folder,
|
||||
Parallel spinner, Sync/Cancel buttons. Tracked in issue #10. -->
|
||||
|
||||
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.
|
||||
|
||||
<!-- SCREENSHOT: org dropdown open, personal account at the top followed by orgs. Tracked in issue #10. -->
|
||||
|
||||
3. **Choose a target folder** — type a path or click **Browse...**. Each repository syncs into `<target>\<repo>`.
|
||||
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.
|
||||
|
||||
<!-- SCREENSHOT: sync in progress — several repos Cloning with percentages, progress bar advancing. Tracked in issue #10. -->
|
||||
|
||||
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).
|
||||
|
||||
<!-- SCREENSHOT: Settings dialog — default target folder, default parallelism, theme. Tracked in issue #10. -->
|
||||
|
||||
**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 <value>` 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.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` | 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 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
|
||||
|
||||
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 +162,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 |
|
||||
+42
@@ -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 <value>` option; tokens are accepted only via environment variable (`--token-env`, default `GITHUB_TOKEN`), a file (`--token-file`), or standard input (`--token-stdin`). See [docs/CLI.md](docs/CLI.md).
|
||||
- **Secret-scanning push protection is enabled** on this repository, so credentials cannot be accidentally committed and pushed.
|
||||
|
||||
If you find any code path where a token can end up on disk, in a log, in process output, or on a command line, that is a vulnerability — please report it through the channel above.
|
||||
@@ -0,0 +1,13 @@
|
||||
# gclo — development tracking
|
||||
|
||||
**Development is tracked in [GitHub Issues](https://github.com/KofTwentyTwo/gclo/issues).**
|
||||
|
||||
- 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)
|
||||
|
||||
Labels: `priority` (work first), `needs-owner` (blocked on the owner), `deferred`
|
||||
(agreed follow-up, unscheduled), `quality`, `release`, `refactor`.
|
||||
|
||||
This file intentionally holds no task content — the issue tracker is the single
|
||||
source of truth.
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
# 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
|
||||
(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 <name> --target <folder> [--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 `<target>\<repo>`. Repositories that are
|
||||
already valid git repositories locally are fetched and fast-forwarded instead.
|
||||
Repositories fail independently — one failure never stops the rest. Up to
|
||||
`--parallel` git operations run at once (default 8).
|
||||
|
||||
`--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 <value>` option?**
|
||||
> Command-line arguments are visible to every other process on the machine —
|
||||
> Task Manager, `ps`, `wmic process`, `/proc/<pid>/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
|
||||
```
|
||||
@@ -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 <stable|dev>`, 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 <your GitHub PAT>
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace gclo.Cli;
|
||||
|
||||
/// <summary>One failed repository in the JSON summary.</summary>
|
||||
internal sealed record SyncFailure(string Repo, string Error);
|
||||
|
||||
/// <summary>The single-line JSON document printed by 'gclo sync --json'.</summary>
|
||||
internal sealed record SyncJsonResult(
|
||||
int Total,
|
||||
int Cloned,
|
||||
int Updated,
|
||||
int Failed,
|
||||
int Canceled,
|
||||
bool WasCanceled,
|
||||
IReadOnlyList<SyncFailure> Failures);
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated System.Text.Json serialization: no runtime reflection, so the
|
||||
/// project stays free of trim/AOT warnings.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||
[JsonSerializable(typeof(SyncJsonResult))]
|
||||
[JsonSerializable(typeof(IReadOnlyList<string>))]
|
||||
internal sealed partial class CliJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,94 @@
|
||||
namespace gclo.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// A command-line usage error. The message is printed to stderr followed by a
|
||||
/// help hint, and the process exits with code 2.
|
||||
/// </summary>
|
||||
internal sealed class CliUsageException : Exception
|
||||
{
|
||||
public CliUsageException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A fatal runtime error (missing token, rejected token, organization not found,
|
||||
/// unusable target folder). The message is printed to stderr and the process
|
||||
/// exits with code 2.
|
||||
/// </summary>
|
||||
internal sealed class CliErrorException : Exception
|
||||
{
|
||||
public CliErrorException(string message, Exception? inner = null) : base(message, inner)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sequential reader over a command's arguments supporting both
|
||||
/// '--name value' and '--name=value' forms.
|
||||
/// </summary>
|
||||
internal sealed class OptionReader
|
||||
{
|
||||
private readonly string[] _args;
|
||||
private int _index;
|
||||
private string? _inlineValue;
|
||||
|
||||
public OptionReader(string[] args) => _args = args;
|
||||
|
||||
/// <summary>The current token, without any inline '=value' part.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The value for <see cref="Current"/>: 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).
|
||||
/// </summary>
|
||||
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++];
|
||||
}
|
||||
|
||||
/// <summary>Rejects '--name=value' written for a flag that takes no value.</summary>
|
||||
public void RejectValue()
|
||||
{
|
||||
if (_inlineValue is not null)
|
||||
{
|
||||
throw new CliUsageException($"Option '{Current}' does not take a value.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Text.Json;
|
||||
using gclo.Engine;
|
||||
|
||||
namespace gclo.Cli;
|
||||
|
||||
/// <summary>'gclo orgs': list the account and organization logins a token can see.</summary>
|
||||
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 <VAR> Read the token from environment variable VAR
|
||||
(default: GITHUB_TOKEN when no token option is given).
|
||||
--token-file <path> Read the token from the first 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 <value>' 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<int> 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'.");
|
||||
}
|
||||
}
|
||||
|
||||
// One activity log per invocation; the token itself is never written to it.
|
||||
var log = new FileActivityLog();
|
||||
log.Info($"orgs started: json={json}");
|
||||
|
||||
try
|
||||
{
|
||||
string token = tokenOptions.Resolve();
|
||||
|
||||
IReadOnlyList<string> 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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace gclo.Cli;
|
||||
|
||||
/// <summary>Entry point: command dispatch, Ctrl+C wiring, and top-level error handling.</summary>
|
||||
internal static class Program
|
||||
{
|
||||
private const string RootHelp = """
|
||||
gclo (Git Clone Large Organizations) - clone or update every repository of a GitHub organization or user account.
|
||||
|
||||
Usage:
|
||||
gclo sync --org <name> --target <folder> [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 <value>' 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 <VAR> read it from environment variable VAR
|
||||
--token-file <path> 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<int> 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<int> 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<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||
?? assembly.GetName().Version?.ToString()
|
||||
?? "unknown";
|
||||
|
||||
// Strip the '+<metadata>' 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using gclo.Engine;
|
||||
|
||||
namespace gclo.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Prints one line per repository status transition and records failures for the
|
||||
/// final summary. Clone percent updates arrive as repeated
|
||||
/// <see cref="SyncStatus.Cloning"/> snapshots and are suppressed — only actual
|
||||
/// status transitions print. Failure lines go to stderr so they survive
|
||||
/// '--quiet' and stdout redirection; everything else goes to stdout.
|
||||
/// </summary>
|
||||
internal sealed class ProgressPrinter : IProgress<RepoProgress>
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, SyncStatus> _lastStatus = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly List<SyncFailure> _failures = new();
|
||||
private readonly bool _printProgress;
|
||||
private readonly bool _printFailures;
|
||||
|
||||
/// <param name="printProgress">Write non-failure transitions to stdout (off for --quiet and --json).</param>
|
||||
/// <param name="printFailures">Write failure lines to stderr (off for --json, which carries failures in the document).</param>
|
||||
public ProgressPrinter(bool printProgress, bool printFailures)
|
||||
{
|
||||
_printProgress = printProgress;
|
||||
_printFailures = printFailures;
|
||||
}
|
||||
|
||||
/// <summary>Failed repositories in the order they failed.</summary>
|
||||
public IReadOnlyList<SyncFailure> 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using System.Text.Json;
|
||||
using gclo.Engine;
|
||||
|
||||
namespace gclo.Cli;
|
||||
|
||||
/// <summary>'gclo sync': clone or fast-forward every repository of an organization.</summary>
|
||||
internal static class SyncCommand
|
||||
{
|
||||
private const string HelpText = """
|
||||
Usage: gclo sync --org <name> --target <folder> [options]
|
||||
|
||||
Clones every repository of <name> into <folder>\<repo>. Repositories that
|
||||
already exist locally are fetched and fast-forwarded instead. Repositories
|
||||
fail independently; one failure never stops the rest.
|
||||
|
||||
Options:
|
||||
--org <name> GitHub organization or user account to sync (required).
|
||||
--target <folder> Local root folder; each repository becomes a subfolder
|
||||
(required; created if missing).
|
||||
--parallel <N> Maximum simultaneous git operations (default 8).
|
||||
--token-env <VAR> Read the token from environment variable VAR
|
||||
(default: GITHUB_TOKEN when no token option is given).
|
||||
--token-file <path> Read the token from the first 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, '<name> <status>', with
|
||||
statuses Queued, Cloning, Pulling, Done, Canceled on stdout and
|
||||
'<name> Failed <error>' 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 <value>' 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<int> 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.");
|
||||
}
|
||||
|
||||
// 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}");
|
||||
|
||||
try
|
||||
{
|
||||
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 (Exception ex)
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private sealed class FailureLoggingProgress : IProgress<RepoProgress>
|
||||
{
|
||||
private readonly ProgressPrinter _printer;
|
||||
private readonly IActivityLog _log;
|
||||
|
||||
public FailureLoggingProgress(ProgressPrinter printer, IActivityLog log)
|
||||
{
|
||||
_printer = printer;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public void Report(RepoProgress value)
|
||||
{
|
||||
if (value.Status == SyncStatus.Failed)
|
||||
{
|
||||
_log.Error($"{value.RepoName} failed: {value.Error ?? "unknown error"}");
|
||||
}
|
||||
_printer.Report(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
namespace gclo.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed class TokenOptions
|
||||
{
|
||||
/// <summary>Environment variable consulted when no token option is given.</summary>
|
||||
public const string DefaultVariable = "GITHUB_TOKEN";
|
||||
|
||||
private string? _envVariable;
|
||||
private string? _filePath;
|
||||
private bool _useStdin;
|
||||
|
||||
/// <summary>Consumes the reader's current option when it is a token option.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the token from the single configured source, or from the
|
||||
/// <see cref="DefaultVariable"/> environment variable when none was given.
|
||||
/// Throws with an actionable message when the token is missing or empty.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>gclo</AssemblyName>
|
||||
<RootNamespace>gclo.Cli</RootNamespace>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<Version>0.1.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\gclo.Engine\gclo.Engine.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,191 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using gclo.Engine;
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="FileActivityLog"/> (line format, daily file naming, lazy
|
||||
/// directory creation, swallowing IO failures, thread safety) and
|
||||
/// <see cref="NullActivityLog"/> (pure no-op).
|
||||
/// </summary>
|
||||
public sealed class ActivityLogTests : IDisposable
|
||||
{
|
||||
/// <summary>"yyyy-MM-dd HH:mm:ss.fff [LEVEL] " prefix every entry line must carry.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,23 @@ public sealed class FakeGitClient : IGitClient
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An <see cref="IOrganizationLister"/> whose behavior is configured with a delegate.</summary>
|
||||
public sealed class FakeOrganizationLister : IOrganizationLister
|
||||
{
|
||||
private int _calls;
|
||||
|
||||
public Func<string, CancellationToken, Task<IReadOnlyList<string>>> Handler { get; set; }
|
||||
= (_, _) => Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
|
||||
|
||||
public int Calls => _calls;
|
||||
|
||||
public Task<IReadOnlyList<string>> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref _calls);
|
||||
return Handler(token, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records every report synchronously into a thread-safe queue.
|
||||
/// Deliberately NOT <see cref="Progress{T}"/>, which posts callbacks to a sync context
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
using gclo.Engine;
|
||||
using LibGit2Sharp;
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for <see cref="LibGit2GitClient"/> 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.
|
||||
/// </summary>
|
||||
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<double>();
|
||||
|
||||
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<LibGit2SharpException>(
|
||||
() => _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_WindowsInvalidPath_ThrowsTypedExceptionAndCleansUp()
|
||||
{
|
||||
// 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");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
|
||||
() => _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));
|
||||
}
|
||||
|
||||
[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<OperationCanceledException>(
|
||||
() => _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<NonFastForwardException>(
|
||||
() => _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<InvalidOperationException>(
|
||||
() => _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);
|
||||
|
||||
/// <summary>Initializes a non-bare repository under the test root with one commit.</summary>
|
||||
private string CreateSourceRepo(string name = "source")
|
||||
{
|
||||
string path = NewPath(name);
|
||||
Repository.Init(path);
|
||||
CommitFile(path, "readme.txt", "hello from source", "initial commit");
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>Writes a file into the working tree, stages it, and commits; returns the new SHA.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same approach as the class under test: pack files under .git are written read-only
|
||||
/// on Windows, so clear attributes before deleting.
|
||||
/// </summary>
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
using System.Diagnostics;
|
||||
using gclo.Engine;
|
||||
using gclo.ViewModels;
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Headless tests for <see cref="MainViewModel"/> using a synchronous progress factory
|
||||
/// (the production default, <see cref="Progress{T}"/>, posts asynchronously and would
|
||||
/// make assertions racy) and a near-zero org-lookup debounce.
|
||||
/// </summary>
|
||||
public sealed class MainViewModelTests
|
||||
{
|
||||
private readonly FakeRepositoryLister _lister = new();
|
||||
private readonly FakeGitClient _git = new();
|
||||
private readonly FakeOrganizationLister _orgs = new();
|
||||
|
||||
/// <summary>Synchronous progress: handler runs inline on the reporting thread.</summary>
|
||||
private sealed class SyncProgress(Action<RepoProgress> handler) : IProgress<RepoProgress>
|
||||
{
|
||||
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),
|
||||
new NullActivityLog());
|
||||
|
||||
private static RepoDescriptor Repo(string name, string? branch = "main", bool archived = false)
|
||||
=> new(name, $"https://example.test/acme/{name}.git", branch, archived);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task<MainViewModel> 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<bool> 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 LoadReposCommand_RequiresOrganizationAndToken()
|
||||
{
|
||||
var vm = CreateViewModel();
|
||||
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.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 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()
|
||||
{
|
||||
_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 = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie"));
|
||||
|
||||
await vm.SyncCommand.ExecuteAsync(null);
|
||||
|
||||
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_CancelCommand_CancelsRun_AndReportsCanceled()
|
||||
{
|
||||
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 = 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}"));
|
||||
|
||||
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<string?>();
|
||||
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]
|
||||
public async Task TokenChange_PopulatesOrganizations_AfterDebounce()
|
||||
{
|
||||
_orgs.Handler = (_, _) => Task.FromResult<IReadOnlyList<string>>(["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<IReadOnlyList<string>>(
|
||||
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<IReadOnlyList<string>>(["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<IReadOnlyList<string>>([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_ExposesDescriptorFields_AndProgressShape()
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using gclo.Engine;
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <see cref="OrgSyncEngine.SyncAsync(SyncRequest, IReadOnlyList{RepoDescriptor},
|
||||
/// IProgress{RepoProgress}?, CancellationToken)"/> 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).
|
||||
/// </summary>
|
||||
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<string> { "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<RepoDescriptor>(), _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<ArgumentNullException>(
|
||||
() => CreateEngine().SyncAsync(CreateRequest(), (IReadOnlyList<RepoDescriptor>)null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using gclo.Engine;
|
||||
using LibGit2Sharp;
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for Windows path validation on clone/pull: paths that are legal in git but
|
||||
/// invalid on Windows must surface as a structured <see cref="InvalidRepositoryPathsException"/>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<InvalidRepositoryPathsException>(
|
||||
() => _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<InvalidRepositoryPathsException>(
|
||||
() => _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);
|
||||
|
||||
/// <summary>Runs the validator over a tree containing exactly the given entry names.</summary>
|
||||
private IReadOnlyList<InvalidPathInfo> 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));
|
||||
}
|
||||
|
||||
/// <summary>Builds a repo whose single commit holds the given forged entry names.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Adds a commit on top of HEAD introducing a forged entry name.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Creates a repo containing one file at a deep path exceeding legacy MAX_PATH.</summary>
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\gclo.Engine\gclo.Engine.csproj" />
|
||||
<ProjectReference Include="..\gclo.ViewModels\gclo.ViewModels.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace gclo.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IActivityLog"/> 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.
|
||||
/// </summary>
|
||||
public sealed class FileActivityLog : IActivityLog
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
|
||||
/// <param name="directory">
|
||||
/// Folder for the log files; defaults to %LOCALAPPDATA%\gclo\logs when null or blank.
|
||||
/// </param>
|
||||
public FileActivityLog(string? directory = null)
|
||||
{
|
||||
LogDirectory = string.IsNullOrWhiteSpace(directory)
|
||||
? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"gclo", "logs")
|
||||
: directory;
|
||||
}
|
||||
|
||||
public string LogDirectory { get; }
|
||||
|
||||
/// <summary>Today's log file; entries roll over to a new file at midnight.</summary>
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An <see cref="IActivityLog"/> that discards everything.</summary>
|
||||
public sealed class NullActivityLog : IActivityLog
|
||||
{
|
||||
public string LogDirectory => "";
|
||||
|
||||
public string CurrentLogFilePath => "";
|
||||
|
||||
public void Info(string message)
|
||||
{
|
||||
}
|
||||
|
||||
public void Error(string message, Exception? exception = null)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace gclo.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface IActivityLog
|
||||
{
|
||||
/// <summary>Records an informational event.</summary>
|
||||
void Info(string message);
|
||||
|
||||
/// <summary>Records a failure, with the exception's full text when one is given.</summary>
|
||||
void Error(string message, Exception? exception = null);
|
||||
|
||||
/// <summary>Folder that holds the log files.</summary>
|
||||
string LogDirectory { get; }
|
||||
|
||||
/// <summary>Full path of the file new entries are currently appended to.</summary>
|
||||
string CurrentLogFilePath { get; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace gclo.Engine;
|
||||
|
||||
/// <summary>One repository path that cannot be created on a Windows file system.</summary>
|
||||
/// <param name="RepoPath">Path inside the repository, forward-slash separated.</param>
|
||||
/// <param name="Reason">Human-readable reason the path is invalid on Windows.</param>
|
||||
/// <param name="SuggestedName">A sanitized replacement for the offending segment, when one can be derived.</param>
|
||||
public sealed record InvalidPathInfo(string RepoPath, string Reason, string? SuggestedName);
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace gclo.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public sealed class InvalidRepositoryPathsException : Exception
|
||||
{
|
||||
public IReadOnlyList<InvalidPathInfo> Paths { get; }
|
||||
|
||||
public InvalidRepositoryPathsException(IReadOnlyList<InvalidPathInfo> paths)
|
||||
: base(BuildMessage(paths))
|
||||
{
|
||||
Paths = paths;
|
||||
}
|
||||
|
||||
private static string BuildMessage(IReadOnlyList<InvalidPathInfo> 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.";
|
||||
}
|
||||
}
|
||||
@@ -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 =>
|
||||
|
||||
@@ -18,6 +18,7 @@ public sealed class OrgSyncEngine
|
||||
_git = git ?? throw new ArgumentNullException(nameof(git));
|
||||
}
|
||||
|
||||
/// <summary>Lists the organization's repositories, then syncs all of them.</summary>
|
||||
public async Task<SyncSummary> SyncAsync(
|
||||
SyncRequest request,
|
||||
IProgress<RepoProgress>? 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="SyncAsync(SyncRequest, IProgress{RepoProgress}?, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
public async Task<SyncSummary> SyncAsync(
|
||||
SyncRequest request,
|
||||
IReadOnlyList<RepoDescriptor> repositories,
|
||||
IProgress<RepoProgress>? 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.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using LibGit2Sharp;
|
||||
|
||||
namespace gclo.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class WindowsPathValidator
|
||||
{
|
||||
private const int MaxSegmentLength = 255;
|
||||
|
||||
private static readonly char[] InvalidChars = Path.GetInvalidFileNameChars();
|
||||
|
||||
private static readonly HashSet<string> 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",
|
||||
};
|
||||
|
||||
/// <summary>Returns every Windows-invalid path in <paramref name="tree"/>; empty when all are fine.</summary>
|
||||
public static IReadOnlyList<InvalidPathInfo> Validate(Tree tree)
|
||||
{
|
||||
var invalid = new List<InvalidPathInfo>();
|
||||
// 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<string, string>(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,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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Maps the persisted theme string to a XAML theme ("System"/unknown => Default).</summary>
|
||||
public static ElementTheme ToElementTheme(string theme) => theme switch
|
||||
{
|
||||
"Light" => ElementTheme.Light,
|
||||
"Dark" => ElementTheme.Dark,
|
||||
_ => ElementTheme.Default,
|
||||
};
|
||||
|
||||
private void Sanitize()
|
||||
{
|
||||
@@ -0,0 +1,526 @@
|
||||
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;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using gclo.Engine;
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Drives the org-sync UI in two phases: <see cref="LoadReposCommand"/> fills the repository
|
||||
/// table, then <see cref="SyncCommand"/> clones or updates the selected subset with live
|
||||
/// per-repo progress. <see cref="RetryFailedCommand"/> re-runs just the failures.
|
||||
/// </summary>
|
||||
public sealed partial class MainViewModel : ObservableObject
|
||||
{
|
||||
private readonly IRepositoryLister _lister;
|
||||
private readonly IGitClient _git;
|
||||
private readonly IOrganizationLister _orgLister;
|
||||
private readonly Func<Action<RepoProgress>, IProgress<RepoProgress>> _progressFactory;
|
||||
private readonly TimeSpan _orgLookupDebounce;
|
||||
private readonly IActivityLog _log;
|
||||
private readonly Dictionary<string, RepoItemViewModel> _itemsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private CancellationTokenSource? _orgLoadCts;
|
||||
|
||||
/// <summary>Breaks the AllSelected <-> item.IsSelected feedback loop while one side updates the other.</summary>
|
||||
private bool _syncingSelection;
|
||||
|
||||
/// <summary>First repository name from the last load; makes <see cref="TargetPreview"/> concrete.</summary>
|
||||
private string? _sampleRepoName;
|
||||
|
||||
/// <summary>
|
||||
/// Production dependencies by default; pass fakes for testing. The default progress
|
||||
/// factory is <see cref="Progress{T}"/>, which marshals via the SynchronizationContext
|
||||
/// captured at construction (the UI thread in the app); tests inject a synchronous one.
|
||||
/// </summary>
|
||||
public MainViewModel(
|
||||
IRepositoryLister? lister = null,
|
||||
IGitClient? git = null,
|
||||
IOrganizationLister? orgLister = null,
|
||||
Func<Action<RepoProgress>, IProgress<RepoProgress>>? progressFactory = null,
|
||||
TimeSpan? orgLookupDebounce = null,
|
||||
IActivityLog? log = null)
|
||||
{
|
||||
_lister = lister ?? new GitHubRepositoryLister();
|
||||
_git = git ?? new LibGit2GitClient();
|
||||
_orgLister = orgLister ?? new GitHubOrganizationLister();
|
||||
_progressFactory = progressFactory ?? (handler => new Progress<RepoProgress>(handler));
|
||||
_orgLookupDebounce = orgLookupDebounce ?? TimeSpan.FromMilliseconds(600);
|
||||
_log = log ?? new FileActivityLog();
|
||||
Organization = "";
|
||||
Token = "";
|
||||
TargetFolder = "";
|
||||
MaxConcurrency = 8;
|
||||
StatusText = "";
|
||||
AllSelected = true;
|
||||
}
|
||||
|
||||
public ObservableCollection<RepoItemViewModel> Repos { get; } = new();
|
||||
|
||||
/// <summary>Organizations discovered from the current token; feeds the org dropdown.</summary>
|
||||
public ObservableCollection<string> Organizations { get; } = new();
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(LoadReposCommand))]
|
||||
public partial string Organization { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(LoadReposCommand))]
|
||||
public partial string Token { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SyncCommand))]
|
||||
public partial string TargetFolder { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial int MaxConcurrency { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SyncCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(LoadReposCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(RetryFailedCommand))]
|
||||
public partial bool IsRunning { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string StatusText { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial int CompletedCount { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial int TotalCount { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsLoadingOrgs { get; set; }
|
||||
|
||||
/// <summary>True while <see cref="LoadReposCommand"/> is listing repositories.</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(LoadReposCommand))]
|
||||
public partial bool IsLoadingRepos { get; set; }
|
||||
|
||||
/// <summary>When set, repositories are placed under TargetFolder\Organization.</summary>
|
||||
[ObservableProperty]
|
||||
public partial bool CreateOrgSubfolder { get; set; }
|
||||
|
||||
/// <summary>Header checkbox state: setting it checks or unchecks every row.</summary>
|
||||
[ObservableProperty]
|
||||
public partial bool AllSelected { get; set; }
|
||||
|
||||
/// <summary>True once a sync run has finished (successfully, canceled, or faulted).</summary>
|
||||
[ObservableProperty]
|
||||
public partial bool HasCompletedRun { get; set; }
|
||||
|
||||
/// <summary>Column the table is currently sorted by, or null for load order.</summary>
|
||||
[ObservableProperty]
|
||||
public partial string? SortColumn { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool SortDescending { get; set; }
|
||||
|
||||
/// <summary>Root folder repositories are placed under: TargetFolder, or TargetFolder\Organization.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Example final path for one repository, e.g. C:\src\acme\my-repo.</summary>
|
||||
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();
|
||||
|
||||
/// <summary>Debounced: each token edit cancels the previous lookup.</summary>
|
||||
private async Task RefreshOrganizationsAsync()
|
||||
{
|
||||
_orgLoadCts?.Cancel();
|
||||
var cts = _orgLoadCts = new CancellationTokenSource();
|
||||
|
||||
string token = Token.Trim();
|
||||
if (token.Length < 10)
|
||||
{
|
||||
Organizations.Clear();
|
||||
return; // not plausibly a complete PAT yet
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(_orgLookupDebounce, cts.Token); // debounce keystrokes / rapid pastes
|
||||
IsLoadingOrgs = true;
|
||||
var orgs = await _orgLister.ListOrganizationsAsync(token, cts.Token);
|
||||
cts.Token.ThrowIfCancellationRequested();
|
||||
|
||||
Organizations.Clear();
|
||||
foreach (string org in orgs)
|
||||
{
|
||||
Organizations.Add(org);
|
||||
}
|
||||
StatusText = orgs.Count == 0
|
||||
? "Token accepted, but it cannot list organizations (fine-grained PAT or missing read:org?). Type an organization or account name manually."
|
||||
: $"Found {orgs.Count} organizations and accounts.";
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// superseded by a newer token edit
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_orgLoadCts == cts)
|
||||
{
|
||||
StatusText = ex.Message;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_orgLoadCts == cts)
|
||||
{
|
||||
IsLoadingOrgs = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Double-typed view of <see cref="MaxConcurrency"/> for NumberBox.Value, which binds a double.
|
||||
/// </summary>
|
||||
public double MaxConcurrencyValue
|
||||
{
|
||||
get => MaxConcurrency;
|
||||
set
|
||||
{
|
||||
int clamped = double.IsNaN(value) ? 8 : (int)Math.Clamp(Math.Round(value), 1, 64);
|
||||
if (MaxConcurrency != clamped)
|
||||
{
|
||||
MaxConcurrency = clamped; // OnMaxConcurrencyChanged notifies MaxConcurrencyValue too
|
||||
}
|
||||
else
|
||||
{
|
||||
// The int did not change (e.g. the box was cleared to NaN): push the
|
||||
// canonical value back so the control redisplays it.
|
||||
OnPropertyChanged(nameof(MaxConcurrencyValue));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnMaxConcurrencyChanged(int value) => OnPropertyChanged(nameof(MaxConcurrencyValue));
|
||||
|
||||
// ---------------------------------------------------------------- selection
|
||||
|
||||
/// <summary>Applies a header-checkbox change to every row.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>Phase one: lists the organization's repositories and fills the table, all selected.</summary>
|
||||
[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(TargetFolder)
|
||||
&& Repos.Any(r => r.IsSelected);
|
||||
|
||||
/// <summary>Phase two: clones or updates the selected repositories.</summary>
|
||||
[RelayCommand(CanExecute = nameof(CanSync), IncludeCancelCommand = true)]
|
||||
private async Task SyncAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
IsRunning = true;
|
||||
try
|
||||
{
|
||||
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<T> captures the WinUI
|
||||
// SynchronizationContext, so HandleProgress always runs on the UI thread.
|
||||
var progress = _progressFactory(HandleProgress);
|
||||
|
||||
var engine = new OrgSyncEngine(_lister, _git);
|
||||
var request = new SyncRequest(
|
||||
Organization.Trim(), Token.Trim(), targetRoot, MaxConcurrency);
|
||||
IReadOnlyList<RepoDescriptor> descriptors = selected.Select(r => r.Descriptor).ToList();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- retry
|
||||
|
||||
private bool CanRetryFailed() =>
|
||||
!IsRunning && Repos.Any(r => r.Status == SyncStatus.Failed);
|
||||
|
||||
/// <summary>Selects exactly the failed repositories and runs the same sync path again.</summary>
|
||||
[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
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<RepoItemViewModel> sorted = column switch
|
||||
{
|
||||
"Name" => OrderRepos(r => r.Name, StringComparer.OrdinalIgnoreCase),
|
||||
"Status" => OrderRepos(r => r.Status, Comparer<SyncStatus>.Default),
|
||||
"Branch" => OrderRepos(r => r.BranchText, StringComparer.OrdinalIgnoreCase),
|
||||
_ => OrderRepos(r => r.IsArchived, Comparer<bool>.Default),
|
||||
};
|
||||
|
||||
// Move (rather than clear + re-add) so list controls keep row containers stable.
|
||||
for (int target = 0; target < sorted.Count; target++)
|
||||
{
|
||||
int current = Repos.IndexOf(sorted[target]);
|
||||
if (current != target)
|
||||
{
|
||||
Repos.Move(current, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>OrderBy/OrderByDescending are both stable: equal keys keep their current order.</summary>
|
||||
private List<RepoItemViewModel> OrderRepos<TKey>(Func<RepoItemViewModel, TKey> key, IComparer<TKey> comparer)
|
||||
=> SortDescending
|
||||
? Repos.OrderByDescending(key, comparer).ToList()
|
||||
: Repos.OrderBy(key, comparer).ToList();
|
||||
|
||||
// ---------------------------------------------------------------- progress
|
||||
|
||||
/// <summary>Applies one engine progress report to the table. Runs on the UI thread.</summary>
|
||||
private void HandleProgress(RepoProgress report)
|
||||
{
|
||||
if (!_itemsByName.TryGetValue(report.RepoName, out RepoItemViewModel? item))
|
||||
{
|
||||
return; // rows are created at load time; ignore anything unknown
|
||||
}
|
||||
|
||||
if (report.Status != SyncStatus.Queued)
|
||||
{
|
||||
item.Status = report.Status;
|
||||
item.Error = report.Error;
|
||||
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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using gclo.Engine;
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
/// <summary>One row in the repository table: the descriptor plus selection and live sync state.</summary>
|
||||
public sealed partial class RepoItemViewModel : ObservableObject
|
||||
{
|
||||
public RepoItemViewModel(RepoDescriptor descriptor)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
Descriptor = descriptor;
|
||||
Status = SyncStatus.Queued;
|
||||
IsSelected = true;
|
||||
}
|
||||
|
||||
/// <summary>The repository this row represents; never changes after construction.</summary>
|
||||
public RepoDescriptor Descriptor { get; }
|
||||
|
||||
/// <summary>Repository name; used as the local folder name.</summary>
|
||||
public string Name => Descriptor.Name;
|
||||
|
||||
/// <summary>Whether this repository participates in the next sync.</summary>
|
||||
[ObservableProperty]
|
||||
public partial bool IsSelected { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial SyncStatus Status { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string? Error { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial double? Percent { get; set; }
|
||||
|
||||
/// <summary>Default branch name, or empty for an empty repository.</summary>
|
||||
public string BranchText => Descriptor.DefaultBranch ?? "";
|
||||
|
||||
/// <summary>Whether the repository is archived (still cloneable, read-only).</summary>
|
||||
public bool IsArchived => Descriptor.IsArchived;
|
||||
|
||||
/// <summary>True while a git operation is in flight and a progress bar should show.</summary>
|
||||
public bool ShowProgress => Status is SyncStatus.Cloning or SyncStatus.Pulling;
|
||||
|
||||
/// <summary>True when progress has no percentage (pulls report none).</summary>
|
||||
public bool IsIndeterminate => Status == SyncStatus.Pulling;
|
||||
|
||||
/// <summary>Clone progress in [0, 1] for determinate progress bars.</summary>
|
||||
public double ProgressValue => Percent ?? 0;
|
||||
|
||||
/// <summary>Human-readable status, including clone percentage when known.</summary>
|
||||
public string StatusText => Status switch
|
||||
{
|
||||
SyncStatus.Queued => "Queued",
|
||||
SyncStatus.Cloning when Percent is double p => $"Cloning {p * 100:0}%",
|
||||
SyncStatus.Cloning => "Cloning",
|
||||
SyncStatus.Pulling => "Pulling",
|
||||
SyncStatus.Done => "Done",
|
||||
SyncStatus.Failed => "Failed",
|
||||
SyncStatus.Canceled => "Canceled",
|
||||
_ => Status.ToString(),
|
||||
};
|
||||
|
||||
/// <summary>True when there is an error message to show inline.</summary>
|
||||
public bool HasError => !string.IsNullOrEmpty(Error);
|
||||
|
||||
partial void OnStatusChanged(SyncStatus value)
|
||||
{
|
||||
OnPropertyChanged(nameof(StatusText));
|
||||
OnPropertyChanged(nameof(ShowProgress));
|
||||
OnPropertyChanged(nameof(IsIndeterminate));
|
||||
}
|
||||
|
||||
partial void OnPercentChanged(double? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(StatusText));
|
||||
OnPropertyChanged(nameof(ProgressValue));
|
||||
}
|
||||
|
||||
partial void OnErrorChanged(string? value) => OnPropertyChanged(nameof(HasError));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\gclo.Engine\gclo.Engine.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -4,8 +4,10 @@
|
||||
<Platform Name="x64" />
|
||||
<Platform Name="x86" />
|
||||
</Configurations>
|
||||
<Project Path="gclo.Cli/gclo.Cli.csproj" />
|
||||
<Project Path="gclo.Engine.Tests/gclo.Engine.Tests.csproj" />
|
||||
<Project Path="gclo.Engine/gclo.Engine.csproj" />
|
||||
<Project Path="gclo.ViewModels/gclo.ViewModels.csproj" />
|
||||
<Project Path="gclo/gclo.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
<!-- App identity -->
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="gclo" Style="{ThemeResource SubtitleTextBlockStyle}" />
|
||||
<TextBlock Text="gclo — Git Clone Large Organizations" Style="{ThemeResource SubtitleTextBlockStyle}" />
|
||||
<TextBlock
|
||||
Text="Clone and update every repository in a GitHub organization."
|
||||
TextWrapping="Wrap" />
|
||||
@@ -57,6 +57,9 @@
|
||||
<TextBlock Style="{ThemeResource CaptionTextBlockStyle}" TextWrapping="Wrap">
|
||||
<Run FontWeight="SemiBold" Text="CommunityToolkit.Mvvm" /><Run Text=" — MIT License" />
|
||||
</TextBlock>
|
||||
<TextBlock Style="{ThemeResource CaptionTextBlockStyle}" TextWrapping="Wrap">
|
||||
<Run FontWeight="SemiBold" Text="Velopack" /><Run Text=" — MIT License" />
|
||||
</TextBlock>
|
||||
<TextBlock Style="{ThemeResource CaptionTextBlockStyle}" TextWrapping="Wrap">
|
||||
<Run FontWeight="SemiBold" Text="Windows App SDK / WinUI 3" /><Run Text=" — MIT License, with Microsoft binary components" />
|
||||
</TextBlock>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ContentDialog
|
||||
x:Class="gclo.LogViewerDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
Title="Activity log"
|
||||
CloseButtonText="Close"
|
||||
SecondaryButtonText="Open logs folder"
|
||||
SecondaryButtonClick="OpenLogsFolder_Click"
|
||||
DefaultButton="Close">
|
||||
|
||||
<ContentDialog.Resources>
|
||||
<!-- Log lines are wide; loosen the stock 548px dialog cap. -->
|
||||
<x:Double x:Key="ContentDialogMaxWidth">800</x:Double>
|
||||
</ContentDialog.Resources>
|
||||
|
||||
<ScrollViewer
|
||||
x:Name="LogScroll"
|
||||
MinWidth="480"
|
||||
MaxHeight="420"
|
||||
HorizontalScrollBarVisibility="Auto"
|
||||
HorizontalScrollMode="Auto"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<TextBlock
|
||||
x:Name="LogText"
|
||||
FontFamily="Consolas"
|
||||
FontSize="12"
|
||||
IsTextSelectionEnabled="True"
|
||||
TextWrapping="NoWrap" />
|
||||
</ScrollViewer>
|
||||
</ContentDialog>
|
||||
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using gclo.Engine;
|
||||
|
||||
namespace gclo
|
||||
{
|
||||
/// <summary>
|
||||
/// Read-only viewer for the tail of today's activity log, with a shortcut to the
|
||||
/// logs folder. The caller must set <see cref="ContentDialog.XamlRoot"/> before
|
||||
/// calling ShowAsync.
|
||||
/// </summary>
|
||||
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);
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the last <see cref="TailLineCount"/> lines of <paramref name="path"/>,
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+187
-26
@@ -26,8 +26,12 @@
|
||||
<MenuFlyoutSeparator />
|
||||
<MenuFlyoutItem Text="Exit" Click="ExitMenuItem_Click" />
|
||||
</MenuBarItem>
|
||||
<MenuBarItem Title="View">
|
||||
<MenuFlyoutItem Text="Activity log…" Click="ActivityLogMenuItem_Click" />
|
||||
</MenuBarItem>
|
||||
<MenuBarItem Title="Help">
|
||||
<MenuFlyoutItem Text="View on GitHub" Click="GitHubMenuItem_Click" />
|
||||
<MenuFlyoutItem Text="Check for updates…" Click="CheckForUpdatesMenuItem_Click" />
|
||||
<MenuFlyoutSeparator />
|
||||
<MenuFlyoutItem Text="About gclo…" Click="AboutMenuItem_Click">
|
||||
<MenuFlyoutItem.KeyboardAccelerators>
|
||||
@@ -39,6 +43,9 @@
|
||||
|
||||
<Grid Grid.Row="1" Padding="20" RowSpacing="12">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
@@ -62,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}" />
|
||||
<ProgressRing
|
||||
@@ -74,14 +81,13 @@
|
||||
IsActive="{x:Bind ViewModel.IsLoadingOrgs, Mode=OneWay}" />
|
||||
</Grid>
|
||||
|
||||
<!-- Target folder + concurrency + actions -->
|
||||
<!-- Target folder + subfolder option + concurrency -->
|
||||
<Grid Grid.Row="1" ColumnSpacing="12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox
|
||||
Grid.Column="0"
|
||||
@@ -93,30 +99,64 @@
|
||||
VerticalAlignment="Bottom"
|
||||
Content="Browse..."
|
||||
Click="BrowseButton_Click" />
|
||||
<NumberBox
|
||||
<CheckBox
|
||||
Grid.Column="2"
|
||||
MinWidth="0"
|
||||
VerticalAlignment="Bottom"
|
||||
Content="{x:Bind OrgSubfolderLabel(ViewModel.Organization), Mode=OneWay}"
|
||||
IsChecked="{x:Bind ViewModel.CreateOrgSubfolder, Mode=TwoWay}" />
|
||||
<NumberBox
|
||||
Grid.Column="3"
|
||||
Header="Parallel"
|
||||
Minimum="1"
|
||||
Maximum="64"
|
||||
SpinButtonPlacementMode="Inline"
|
||||
Value="{x:Bind ViewModel.MaxConcurrencyValue, Mode=TwoWay}" />
|
||||
<Button
|
||||
Grid.Column="3"
|
||||
MinWidth="90"
|
||||
VerticalAlignment="Bottom"
|
||||
Style="{StaticResource AccentButtonStyle}"
|
||||
Content="Sync"
|
||||
Command="{x:Bind ViewModel.SyncCommand}" />
|
||||
<Button
|
||||
Grid.Column="4"
|
||||
MinWidth="90"
|
||||
VerticalAlignment="Bottom"
|
||||
Content="Cancel"
|
||||
Command="{x:Bind ViewModel.SyncCancelCommand}" />
|
||||
</Grid>
|
||||
|
||||
<!-- Where the clones will actually land -->
|
||||
<TextBlock
|
||||
Grid.Row="2"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
TextWrapping="Wrap">
|
||||
<Run Text="Repositories will be created like:" />
|
||||
<Run FontWeight="SemiBold" Text="{x:Bind ViewModel.TargetPreview, Mode=OneWay}" />
|
||||
</TextBlock>
|
||||
|
||||
<!-- Actions -->
|
||||
<StackPanel Grid.Row="3" Orientation="Horizontal" Spacing="12">
|
||||
<Button
|
||||
MinWidth="110"
|
||||
Style="{StaticResource AccentButtonStyle}"
|
||||
Content="Load repos"
|
||||
Command="{x:Bind ViewModel.LoadReposCommand, Mode=OneTime}" />
|
||||
<ProgressRing
|
||||
Width="20"
|
||||
Height="20"
|
||||
VerticalAlignment="Center"
|
||||
IsActive="{x:Bind ViewModel.IsLoadingRepos, Mode=OneWay}" />
|
||||
<Button
|
||||
MinWidth="110"
|
||||
Content="Sync selected"
|
||||
Command="{x:Bind ViewModel.SyncCommand, Mode=OneTime}" />
|
||||
<Button
|
||||
MinWidth="90"
|
||||
Content="Cancel"
|
||||
Command="{x:Bind ViewModel.SyncCancelCommand, Mode=OneTime}" />
|
||||
<Button
|
||||
MinWidth="110"
|
||||
Content="Retry failed"
|
||||
Command="{x:Bind ViewModel.RetryFailedCommand, Mode=OneTime}"
|
||||
Visibility="{x:Bind AnyFailedVisibility(ViewModel.CompletedCount, ViewModel.IsRunning), Mode=OneWay}" />
|
||||
<Button
|
||||
MinWidth="110"
|
||||
Content="Open folder"
|
||||
Click="OpenFolderButton_Click"
|
||||
Visibility="{x:Bind local:StatusFormat.VisibleIf(ViewModel.HasCompletedRun), Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Overall progress -->
|
||||
<Grid Grid.Row="2" ColumnSpacing="12">
|
||||
<Grid Grid.Row="4" ColumnSpacing="12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
@@ -136,26 +176,147 @@
|
||||
Text="{x:Bind local:StatusFormat.Fraction(ViewModel.CompletedCount, ViewModel.TotalCount), Mode=OneWay}" />
|
||||
</Grid>
|
||||
|
||||
<!-- Per-repo status list -->
|
||||
<!-- Table header: fixed column widths mirrored by the row template below -->
|
||||
<Grid Grid.Row="5" Padding="12,0,12,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="40" />
|
||||
<ColumnDefinition Width="2*" />
|
||||
<ColumnDefinition Width="160" />
|
||||
<ColumnDefinition Width="120" />
|
||||
<ColumnDefinition Width="80" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<CheckBox
|
||||
Grid.Column="0"
|
||||
MinWidth="0"
|
||||
Padding="0"
|
||||
VerticalAlignment="Center"
|
||||
ToolTipService.ToolTip="Select all"
|
||||
IsChecked="{x:Bind ViewModel.AllSelected, Mode=TwoWay}" />
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="0,4,0,4"
|
||||
FontWeight="SemiBold"
|
||||
Content="{x:Bind SortHeader('Name', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Command="{x:Bind ViewModel.SortCommand, Mode=OneTime}"
|
||||
CommandParameter="Name" />
|
||||
<Button
|
||||
Grid.Column="2"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="0,4,0,4"
|
||||
FontWeight="SemiBold"
|
||||
Content="{x:Bind SortHeader('Status', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Command="{x:Bind ViewModel.SortCommand, Mode=OneTime}"
|
||||
CommandParameter="Status" />
|
||||
<Button
|
||||
Grid.Column="3"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="0,4,0,4"
|
||||
FontWeight="SemiBold"
|
||||
Content="{x:Bind SortHeader('Branch', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Command="{x:Bind ViewModel.SortCommand, Mode=OneTime}"
|
||||
CommandParameter="Branch" />
|
||||
<Button
|
||||
Grid.Column="4"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="0,4,0,4"
|
||||
FontWeight="SemiBold"
|
||||
Content="{x:Bind SortHeader('Archived', ViewModel.SortColumn, ViewModel.SortDescending), Mode=OneWay}"
|
||||
Command="{x:Bind ViewModel.SortCommand, Mode=OneTime}"
|
||||
CommandParameter="Archived" />
|
||||
</Grid>
|
||||
|
||||
<!-- Per-repo table body -->
|
||||
<ListView
|
||||
Grid.Row="3"
|
||||
ItemsSource="{x:Bind ViewModel.Repos}"
|
||||
Grid.Row="6"
|
||||
ItemsSource="{x:Bind ViewModel.Repos, Mode=OneTime}"
|
||||
SelectionMode="None">
|
||||
<ListView.ItemContainerStyle>
|
||||
<Style TargetType="ListViewItem">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="Padding" Value="12,0,12,0" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
</Style>
|
||||
</ListView.ItemContainerStyle>
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:RepoItemViewModel">
|
||||
<StackPanel Padding="0,6">
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<TextBlock Text="{x:Bind Name}" FontWeight="SemiBold" />
|
||||
<Grid Padding="0,6,0,6">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="40" />
|
||||
<ColumnDefinition Width="2*" />
|
||||
<ColumnDefinition Width="160" />
|
||||
<ColumnDefinition Width="120" />
|
||||
<ColumnDefinition Width="80" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<CheckBox
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
MinWidth="0"
|
||||
Padding="0"
|
||||
VerticalAlignment="Center"
|
||||
IsChecked="{x:Bind IsSelected, Mode=TwoWay}" />
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Text="{x:Bind Name, Mode=OneTime}" />
|
||||
<StackPanel
|
||||
Grid.Row="0"
|
||||
Grid.Column="2"
|
||||
Margin="0,0,12,0"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="2">
|
||||
<TextBlock
|
||||
Text="{x:Bind StatusText, Mode=OneWay}"
|
||||
Foreground="{x:Bind local:StatusFormat.BrushFor(Status), Mode=OneWay}" />
|
||||
<ProgressBar
|
||||
Maximum="1"
|
||||
Value="{x:Bind ProgressValue, Mode=OneWay}"
|
||||
IsIndeterminate="{x:Bind IsIndeterminate, Mode=OneWay}"
|
||||
Visibility="{x:Bind local:StatusFormat.VisibleIf(ShowProgress), Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
<TextBlock
|
||||
Text="{x:Bind Error, Mode=OneWay}"
|
||||
Grid.Row="0"
|
||||
Grid.Column="3"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind BranchText, Mode=OneTime}" />
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
Grid.Column="4"
|
||||
VerticalAlignment="Center"
|
||||
Style="{ThemeResource CaptionTextBlockStyle}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="Archived"
|
||||
Visibility="{x:Bind local:StatusFormat.VisibleIf(IsArchived), Mode=OneTime}" />
|
||||
<TextBlock
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="5"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="Crimson"
|
||||
Text="{x:Bind Error, Mode=OneWay}"
|
||||
Visibility="{x:Bind local:StatusFormat.VisibleIf(HasError), Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
|
||||
+124
-4
@@ -1,26 +1,37 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using gclo.Engine;
|
||||
using gclo.Services;
|
||||
using gclo.ViewModels;
|
||||
|
||||
namespace gclo
|
||||
{
|
||||
/// <summary>Main (and only) window: org sync inputs, progress, and per-repo status list.</summary>
|
||||
/// <summary>
|
||||
/// Main (and only) window: sync inputs, the two-phase Load/Sync actions, overall
|
||||
/// progress, and the per-repo table.
|
||||
/// </summary>
|
||||
public sealed partial class MainWindow : Window
|
||||
{
|
||||
private const string RepoUrl = "https://github.com/KofTwentyTwo/gclo";
|
||||
|
||||
private readonly AppSettings _settings;
|
||||
private readonly UpdateService _updateService = new();
|
||||
|
||||
// Shared with the view model so the log viewer shows the same file the VM writes to.
|
||||
private readonly IActivityLog _log;
|
||||
|
||||
public MainViewModel ViewModel { get; }
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
ViewModel = new MainViewModel();
|
||||
_log = new FileActivityLog();
|
||||
ViewModel = new MainViewModel(log: _log);
|
||||
InitializeComponent();
|
||||
|
||||
Title = "gclo - GitHub organization sync";
|
||||
Title = "gclo — Git Clone Large Organizations";
|
||||
|
||||
_settings = AppSettings.Load();
|
||||
ApplySettings();
|
||||
@@ -46,7 +57,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))
|
||||
@@ -56,6 +67,38 @@ namespace gclo
|
||||
ViewModel.MaxConcurrency = _settings.DefaultMaxConcurrency;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Label for the org-subfolder checkbox; names the actual organization once one is chosen.
|
||||
/// </summary>
|
||||
public string OrgSubfolderLabel(string organization)
|
||||
=> string.IsNullOrWhiteSpace(organization)
|
||||
? "Create org subfolder"
|
||||
: $"Create {organization.Trim()} subfolder";
|
||||
|
||||
/// <summary>
|
||||
/// Header text for a sortable table column, with an arrow on the active sort column.
|
||||
/// </summary>
|
||||
public string SortHeader(string column, string? sortColumn, bool sortDescending)
|
||||
=> column == sortColumn ? column + (sortDescending ? " ▼" : " ▲") : column;
|
||||
|
||||
/// <summary>
|
||||
/// Visible while any repo in the table is Failed. The parameters are not read; they
|
||||
/// are the x:Bind re-evaluation triggers — CompletedCount changes whenever a repo
|
||||
/// finishes (including failures) and IsRunning changes when a run starts or ends,
|
||||
/// which together cover every point where the set of failed items can change.
|
||||
/// </summary>
|
||||
public Visibility AnyFailedVisibility(int completedCount, bool isRunning)
|
||||
{
|
||||
foreach (RepoItemViewModel repo in ViewModel.Repos)
|
||||
{
|
||||
if (repo.Status == SyncStatus.Failed)
|
||||
{
|
||||
return Visibility.Visible;
|
||||
}
|
||||
}
|
||||
return Visibility.Collapsed;
|
||||
}
|
||||
|
||||
private async void SettingsMenuItem_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dialog = new SettingsDialog(_settings) { XamlRoot = Content.XamlRoot };
|
||||
@@ -68,6 +111,12 @@ namespace gclo
|
||||
|
||||
private void ExitMenuItem_Click(object sender, RoutedEventArgs e) => Close();
|
||||
|
||||
private async void ActivityLogMenuItem_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dialog = new LogViewerDialog(_log) { XamlRoot = Content.XamlRoot };
|
||||
await dialog.ShowAsync();
|
||||
}
|
||||
|
||||
private async void GitHubMenuItem_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
await Windows.System.Launcher.LaunchUriAsync(new Uri(RepoUrl));
|
||||
@@ -79,6 +128,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);
|
||||
|
||||
@@ -103,5 +214,14 @@ namespace gclo
|
||||
ViewModel.TargetFolder = folder.Path;
|
||||
}
|
||||
}
|
||||
|
||||
private async void OpenFolderButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
string root = ViewModel.EffectiveTargetRoot;
|
||||
if (Directory.Exists(root))
|
||||
{
|
||||
await Windows.System.Launcher.LaunchFolderPathAsync(root);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using Velopack;
|
||||
|
||||
namespace gclo
|
||||
{
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>VelopackApp.Run</c>, and may exit the process. In non-Velopack contexts (F5,
|
||||
/// loose builds, MSIX packages) that call is inert and the app starts normally.
|
||||
/// </summary>
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||
-->
|
||||
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup>
|
||||
<PublishProtocol>FileSystem</PublishProtocol>
|
||||
<Platform>ARM64</Platform>
|
||||
<RuntimeIdentifier>win-arm64</RuntimeIdentifier>
|
||||
<PublishDir>bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\</PublishDir>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>False</PublishSingleFile>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||
-->
|
||||
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup>
|
||||
<PublishProtocol>FileSystem</PublishProtocol>
|
||||
<Platform>x64</Platform>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<PublishDir>bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\</PublishDir>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>False</PublishSingleFile>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||
-->
|
||||
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup>
|
||||
<PublishProtocol>FileSystem</PublishProtocol>
|
||||
<Platform>x86</Platform>
|
||||
<RuntimeIdentifier>win-x86</RuntimeIdentifier>
|
||||
<PublishDir>bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\</PublishDir>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>False</PublishSingleFile>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,140 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Velopack;
|
||||
using Velopack.Sources;
|
||||
|
||||
namespace gclo.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Result of an update check. <see cref="AvailableVersion"/> is the newer version found,
|
||||
/// or null when already up to date; <see cref="IsDownloaded"/> is true when that update is
|
||||
/// already staged locally (e.g. by a previous session) and only needs a restart to apply;
|
||||
/// <see cref="Error"/> is non-null when the check itself failed.
|
||||
/// </summary>
|
||||
public sealed record UpdateCheckResult(string? AvailableVersion, bool IsDownloaded, string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="IsSupported"/> is false. Every member is guarded — failures are
|
||||
/// reported as strings and must never crash the app over something as optional as an update.
|
||||
/// </summary>
|
||||
public sealed class UpdateService
|
||||
{
|
||||
private const string RepoUrl = "https://github.com/KofTwentyTwo/gclo";
|
||||
|
||||
private UpdateManager? _manager;
|
||||
private UpdateInfo? _pendingUpdate;
|
||||
|
||||
/// <summary>True when the app was installed by Velopack and can check for and apply updates.</summary>
|
||||
public bool IsSupported
|
||||
{
|
||||
get
|
||||
{
|
||||
try
|
||||
{
|
||||
return GetManager().IsInstalled;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The installed version as Velopack sees it, or null outside installed builds.</summary>
|
||||
public string? CurrentVersion
|
||||
{
|
||||
get
|
||||
{
|
||||
try
|
||||
{
|
||||
return GetManager().CurrentVersion?.ToString();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks GitHub releases for a newer version and remembers it for
|
||||
/// <see cref="DownloadAndApplyAsync"/>. Never throws: failures come back in
|
||||
/// <see cref="UpdateCheckResult.Error"/>.
|
||||
/// </summary>
|
||||
public async Task<UpdateCheckResult> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the update found by the last successful <see cref="CheckAsync"/>, applies it,
|
||||
/// and restarts the app. On success this never returns (the process exits to restart);
|
||||
/// otherwise it returns an error message. Never throws.
|
||||
/// </summary>
|
||||
/// <param name="progress">Optional download progress callback, called with 0-100.</param>
|
||||
/// <param name="cancellationToken">Optional token to abandon the download.</param>
|
||||
public async Task<string?> DownloadAndApplyAsync(Action<int>? 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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".
|
||||
/// </summary>
|
||||
private UpdateManager GetManager() =>
|
||||
_manager ??= new UpdateManager(new GithubSource(RepoUrl, null, false));
|
||||
}
|
||||
@@ -27,6 +27,14 @@ public static class StatusFormat
|
||||
public static Visibility VisibleIf(bool value)
|
||||
=> value ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
/// <summary>Maps the persisted theme string to a XAML theme ("System"/unknown => Default).</summary>
|
||||
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}";
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using gclo.Engine;
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
/// <summary>Drives the org-sync UI: inputs, the Sync/Cancel commands, and live per-repo progress.</summary>
|
||||
public sealed partial class MainViewModel : ObservableObject
|
||||
{
|
||||
private readonly IRepositoryLister _lister;
|
||||
private readonly IGitClient _git;
|
||||
private readonly IOrganizationLister _orgLister;
|
||||
private readonly Dictionary<string, RepoItemViewModel> _itemsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private CancellationTokenSource? _orgLoadCts;
|
||||
|
||||
/// <summary>Production dependencies by default; pass fakes for testing.</summary>
|
||||
public MainViewModel(IRepositoryLister? lister = null, IGitClient? git = null, IOrganizationLister? orgLister = null)
|
||||
{
|
||||
_lister = lister ?? new GitHubRepositoryLister();
|
||||
_git = git ?? new LibGit2GitClient();
|
||||
_orgLister = orgLister ?? new GitHubOrganizationLister();
|
||||
Organization = "";
|
||||
Token = "";
|
||||
TargetFolder = "";
|
||||
MaxConcurrency = 8;
|
||||
StatusText = "";
|
||||
}
|
||||
|
||||
public ObservableCollection<RepoItemViewModel> Repos { get; } = new();
|
||||
|
||||
/// <summary>Organizations discovered from the current token; feeds the org dropdown.</summary>
|
||||
public ObservableCollection<string> Organizations { get; } = new();
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SyncCommand))]
|
||||
public partial string Organization { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SyncCommand))]
|
||||
public partial string Token { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SyncCommand))]
|
||||
public partial string TargetFolder { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial int MaxConcurrency { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SyncCommand))]
|
||||
public partial bool IsRunning { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string StatusText { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial int CompletedCount { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial int TotalCount { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsLoadingOrgs { get; set; }
|
||||
|
||||
// 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();
|
||||
|
||||
/// <summary>Debounced: each token edit cancels the previous lookup.</summary>
|
||||
private async Task RefreshOrganizationsAsync()
|
||||
{
|
||||
_orgLoadCts?.Cancel();
|
||||
var cts = _orgLoadCts = new CancellationTokenSource();
|
||||
|
||||
string token = Token.Trim();
|
||||
if (token.Length < 10)
|
||||
{
|
||||
Organizations.Clear();
|
||||
return; // not plausibly a complete PAT yet
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(600, cts.Token); // debounce keystrokes / rapid pastes
|
||||
IsLoadingOrgs = true;
|
||||
var orgs = await _orgLister.ListOrganizationsAsync(token, cts.Token);
|
||||
cts.Token.ThrowIfCancellationRequested();
|
||||
|
||||
Organizations.Clear();
|
||||
foreach (string org in orgs)
|
||||
{
|
||||
Organizations.Add(org);
|
||||
}
|
||||
StatusText = orgs.Count == 0
|
||||
? "Token accepted, but it cannot list organizations (fine-grained PAT or missing read:org?). Type an organization or account name manually."
|
||||
: $"Found {orgs.Count} organizations and accounts.";
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// superseded by a newer token edit
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_orgLoadCts == cts)
|
||||
{
|
||||
StatusText = ex.Message;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_orgLoadCts == cts)
|
||||
{
|
||||
IsLoadingOrgs = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Double-typed view of <see cref="MaxConcurrency"/> for NumberBox.Value, which binds a double.
|
||||
/// </summary>
|
||||
public double MaxConcurrencyValue
|
||||
{
|
||||
get => MaxConcurrency;
|
||||
set
|
||||
{
|
||||
int clamped = double.IsNaN(value) ? 8 : (int)Math.Clamp(Math.Round(value), 1, 64);
|
||||
if (MaxConcurrency != clamped)
|
||||
{
|
||||
MaxConcurrency = clamped; // OnMaxConcurrencyChanged notifies MaxConcurrencyValue too
|
||||
}
|
||||
else
|
||||
{
|
||||
// The int did not change (e.g. the box was cleared to NaN): push the
|
||||
// canonical value back so the control redisplays it.
|
||||
OnPropertyChanged(nameof(MaxConcurrencyValue));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnMaxConcurrencyChanged(int value) => OnPropertyChanged(nameof(MaxConcurrencyValue));
|
||||
|
||||
private bool CanSync() =>
|
||||
!IsRunning
|
||||
&& !string.IsNullOrWhiteSpace(Organization)
|
||||
&& !string.IsNullOrWhiteSpace(Token)
|
||||
&& !string.IsNullOrWhiteSpace(TargetFolder);
|
||||
|
||||
[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...";
|
||||
|
||||
// Constructed on the UI thread: Progress<T> captures the WinUI
|
||||
// SynchronizationContext, so HandleProgress always runs on the UI thread.
|
||||
var progress = new Progress<RepoProgress>(HandleProgress);
|
||||
|
||||
var engine = new OrgSyncEngine(_lister, _git);
|
||||
var request = new SyncRequest(
|
||||
Organization.Trim(), Token.Trim(), TargetFolder.Trim(), MaxConcurrency);
|
||||
|
||||
SyncSummary summary = await engine.SyncAsync(request, 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}.";
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
StatusText = "Canceled";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusText = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Applies one engine progress report to the list. Runs on the UI thread.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
if (report.Status != SyncStatus.Queued)
|
||||
{
|
||||
item.Status = report.Status;
|
||||
item.Error = report.Error;
|
||||
item.Percent = report.Percent;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using gclo.Engine;
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
/// <summary>One row in the repository status list.</summary>
|
||||
public sealed partial class RepoItemViewModel : ObservableObject
|
||||
{
|
||||
public RepoItemViewModel(string name)
|
||||
{
|
||||
Name = name;
|
||||
Status = SyncStatus.Queued;
|
||||
}
|
||||
|
||||
/// <summary>Repository name; never changes after construction.</summary>
|
||||
public string Name { get; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial SyncStatus Status { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string? Error { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial double? Percent { get; set; }
|
||||
|
||||
/// <summary>Human-readable status, including clone percentage when known.</summary>
|
||||
public string StatusText => Status switch
|
||||
{
|
||||
SyncStatus.Queued => "Queued",
|
||||
SyncStatus.Cloning when Percent is double p => $"Cloning {p * 100:0}%",
|
||||
SyncStatus.Cloning => "Cloning",
|
||||
SyncStatus.Pulling => "Pulling",
|
||||
SyncStatus.Done => "Done",
|
||||
SyncStatus.Failed => "Failed",
|
||||
SyncStatus.Canceled => "Canceled",
|
||||
_ => Status.ToString(),
|
||||
};
|
||||
|
||||
/// <summary>True when there is an error message to show inline.</summary>
|
||||
public bool HasError => !string.IsNullOrEmpty(Error);
|
||||
|
||||
partial void OnStatusChanged(SyncStatus value) => OnPropertyChanged(nameof(StatusText));
|
||||
|
||||
partial void OnPercentChanged(double? value) => OnPropertyChanged(nameof(StatusText));
|
||||
|
||||
partial void OnErrorChanged(string? value) => OnPropertyChanged(nameof(HasError));
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
<WinUISDKReferences>false</WinUISDKReferences>
|
||||
<EnableMsixTooling>true</EnableMsixTooling>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- Velopack must run before any XAML starts, so the app supplies its own Main (Program.cs). -->
|
||||
<DefineConstants>$(DefineConstants);DISABLE_XAML_GENERATED_MAIN</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -40,9 +42,11 @@
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.2270" />
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.2.0" />
|
||||
<PackageReference Include="Velopack" Version="1.2.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\gclo.Engine\gclo.Engine.csproj" />
|
||||
<ProjectReference Include="..\gclo.ViewModels\gclo.ViewModels.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
|
||||
Reference in new issue
Block a user